From e000fe98a09d9a7e3a254d486ad3ce1eda621739 Mon Sep 17 00:00:00 2001 From: Dyuman Date: Mon, 18 Mar 2024 11:07:37 +0100 Subject: [PATCH 01/73] New release v3.0.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1c9b284..ccd7e3f 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name='pyreason', - version='2.1.1', + version='3.0.0', author='Dyuman Aditya', author_email='dyuman.aditya@gmail.com', description='An explainable inference software supporting annotated, real valued, graph based and temporal logic', From c6e27f6c87d9a65c968d0e6e281becfab01ba8e2 Mon Sep 17 00:00:00 2001 From: Dyuman Date: Mon, 18 Mar 2024 11:13:29 +0100 Subject: [PATCH 02/73] modified the way node clauses with unseen variables are grounded --- pyreason/scripts/interpretation/interpretation.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 779ce68..e089c2e 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -799,7 +799,7 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n # The groundings for node clauses are either the target node, neighbors of the target node, or an existing subset of nodes if clause_type == 'node': clause_var_1 = clause_variables[0] - subset = get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, neighbors) + subset = get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes) subsets[clause_var_1] = get_qualified_components_node_clause(interpretations_node, subset, clause_label, clause_bnd) @@ -848,8 +848,8 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n # It's a node comparison if len(clause_variables) == 2: clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] - subset_1 = get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, neighbors) - subset_2 = get_node_rule_node_clause_subset(clause_var_2, target_node, subsets, neighbors) + subset_1 = get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes) + subset_2 = get_node_rule_node_clause_subset(clause_var_2, target_node, subsets, nodes) # 1, 2 qualified_nodes_for_comparison_1, numbers_1 = get_qualified_components_node_comparison_clause(interpretations_node, subset_1, clause_label, clause_bnd) @@ -962,6 +962,10 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n else: edges_to_be_added[1].append(target) + # Add qualified nodes/edges to trace + + # Add annotations to annotation function variable + # node/edge, annotations, qualified nodes, qualified edges, edges to be added applicable_rules.append((target_node, annotations, qualified_nodes, qualified_edges, edges_to_be_added)) @@ -1197,12 +1201,12 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e @numba.njit(cache=True) -def get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, neighbors): +def get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes): # The groundings for node clauses are either the target node, neighbors of the target node, or an existing subset of nodes if clause_var_1 == '__target': subset = numba.typed.List([target_node]) else: - subset = neighbors[target_node] if clause_var_1 not in subsets else subsets[clause_var_1] + subset = nodes if clause_var_1 not in subsets else subsets[clause_var_1] return subset From 137a02c6b34d9848e877c78fe82385dccc5439b4 Mon Sep 17 00:00:00 2001 From: Dyuman Date: Sat, 13 Apr 2024 21:22:38 +0200 Subject: [PATCH 03/73] added refinement for node/edge rule clauses --- .../scripts/interpretation/interpretation.py | 630 +++++++++++++----- 1 file changed, 445 insertions(+), 185 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index e089c2e..93dc843 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -15,6 +15,12 @@ list_of_nodes = numba.types.ListType(node_type) list_of_edges = numba.types.ListType(edge_type) +# Type for storing clause data +clause_data = numba.types.Tuple((numba.types.string, label.label_type, numba.types.ListType(numba.types.string))) + +# Type for storing refine clause data +refine_data = numba.types.Tuple((numba.types.string, numba.types.string, numba.types.int8)) + # Type for facts to be applied facts_to_be_applied_node_type = numba.types.Tuple((numba.types.uint16, node_type, label.label_type, interval.interval_type, numba.types.boolean, numba.types.boolean)) facts_to_be_applied_edge_type = numba.types.Tuple((numba.types.uint16, edge_type, label.label_type, interval.interval_type, numba.types.boolean, numba.types.boolean)) @@ -135,7 +141,7 @@ def _init_interpretations_node(nodes, available_labels, specific_labels): interpretations[n].world[l] = interval.closed(0.0, 1.0) return interpretations - + @staticmethod @numba.njit(cache=True) def _init_interpretations_edge(edges, available_labels, specific_labels): @@ -149,7 +155,7 @@ def _init_interpretations_edge(edges, available_labels, specific_labels): interpretations[e].world[l] = interval.closed(0.0, 1.0) return interpretations - + @staticmethod @numba.njit(cache=True) def _init_convergence(convergence_bound_threshold, convergence_threshold): @@ -273,7 +279,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data rule_trace_node.append((numba.types.uint16(t), numba.types.uint16(fp_cnt), comp, p1, interpretations_node[comp].world[p1])) if atom_trace: _update_rule_trace(rule_trace_node_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), interpretations_node[comp].world[p1], facts_to_be_applied_node_trace[i]) - + else: # Check for inconsistencies (multiple facts) if check_consistent_node(interpretations_node, comp, (l, bnd)): @@ -650,14 +656,14 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Break, apply immediate rule then come back to check for more applicable rules if immediate_edge_rule_fire: break - + # Go through all the rules and go back to applying the rules if we came here because of an immediate rule where delta_t>0 if immediate_rule_applied and not (immediate_node_rule_fire or immediate_edge_rule_fire): immediate_rule_applied = False in_loop = True update = False continue - + # Check for convergence after each timestep (perfect convergence or convergence specified by user) # Check number of changed interpretations or max bound change # User specified convergence @@ -757,6 +763,10 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n # We return a list of tuples which specify the target nodes/edges that have made the rule body true applicable_rules = numba.typed.List.empty_list(node_applicable_rule_type) + + # Create pre-allocated data structure so that parallel code does not need to use "append" to be threadsafe + # One array for each node, then condense into a single list later + applicable_rules_threadsafe = numba.typed.List([numba.typed.List.empty_list(node_applicable_rule_type) for _ in nodes]) # Return empty list if rule is not node rule and if we are not inferring edges if rule_type != 'node' and rule_edges[0] == '': @@ -781,6 +791,7 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + clause_type_and_variables = numba.typed.List.empty_list(clause_data) satisfaction = True for i, clause in enumerate(clauses): @@ -791,10 +802,6 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n clause_bnd = clause[3] clause_operator = clause[4] - # Unpack thresholds - # This value is total/available - threshold_quantifier_type = thresholds[i][1][1] - # This is a node clause # The groundings for node clauses are either the target node, neighbors of the target node, or an existing subset of nodes if clause_type == 'node': @@ -803,16 +810,8 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n subsets[clause_var_1] = get_qualified_components_node_clause(interpretations_node, subset, clause_label, clause_bnd) - if atom_trace: - qualified_nodes.append(numba.typed.List(subsets[clause_var_1])) - qualified_edges.append(numba.typed.List.empty_list(edge_type)) - - # Add annotations if necessary - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qn in subsets[clause_var_1]: - a.append(interpretations_node[qn].world[clause_label]) - annotations.append(a) + # Save data for annotations and atom trace + clause_type_and_variables.append(('node', clause_label, numba.typed.List([clause_var_1]))) # This is an edge clause elif clause_type == 'edge': @@ -824,16 +823,9 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n subsets[clause_var_1] = qe[0] subsets[clause_var_2] = qe[1] - if atom_trace: - qualified_nodes.append(numba.typed.List.empty_list(node_type)) - qualified_edges.append(numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2]))) - - # Add annotations if necessary - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qe in numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2])): - a.append(interpretations_edge[qe].world[clause_label]) - annotations.append(a) + # Save data for annotations and atom trace + clause_type_and_variables.append(('edge', clause_label, numba.typed.List([clause_var_1, clause_var_2]))) + else: # This is a comparison clause # Make sure there is at least one ground atom such that pred-num(x) : [1,1] or pred-num(x,y) : [1,1] @@ -877,19 +869,10 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n # Update subsets with final qualified nodes subsets[clause_var_1] = qualified_nodes_1 subsets[clause_var_2] = qualified_nodes_2 - qualified_comparison_nodes = numba.typed.List(qualified_nodes_1) - qualified_comparison_nodes.extend(qualified_nodes_2) - if atom_trace: - qualified_nodes.append(qualified_comparison_nodes) - qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # Save data for annotations and atom trace + clause_type_and_variables.append(('node-comparison', clause_label, numba.typed.List([clause_var_1, clause_var_2]))) - # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qn in qualified_comparison_nodes: - a.append(interval.closed(1, 1)) - annotations.append(a) # Edge comparison. Compare stage else: satisfaction, qualified_nodes_1_source, qualified_nodes_1_target, qualified_nodes_2_source, qualified_nodes_2_target = compare_numbers_edge_predicate(numbers_1, numbers_2, clause_operator, @@ -903,39 +886,19 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n subsets[clause_var_2_source] = qualified_nodes_2_source subsets[clause_var_2_target] = qualified_nodes_2_target - qualified_comparison_nodes_1 = numba.typed.List(zip(qualified_nodes_1_source, qualified_nodes_1_target)) - qualified_comparison_nodes_2 = numba.typed.List(zip(qualified_nodes_2_source, qualified_nodes_2_target)) - qualified_comparison_nodes = numba.typed.List(qualified_comparison_nodes_1) - qualified_comparison_nodes.extend(qualified_comparison_nodes_2) - - if atom_trace: - qualified_nodes.append(numba.typed.List.empty_list(node_type)) - qualified_edges.append(qualified_comparison_nodes) - - # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qe in qualified_comparison_nodes: - a.append(interval.closed(1, 1)) - annotations.append(a) + # Save data for annotations and atom trace + clause_type_and_variables.append(('edge-comparison', clause_label, numba.typed.List([clause_var_1_source, clause_var_1_target, clause_var_2_source, clause_var_2_target]))) # Non comparison clause else: - if threshold_quantifier_type == 'total': - if clause_type == 'node': - neigh_len = len(subset) - else: - neigh_len = sum([len(l) for l in subset_target]) - - # Available is all neighbors that have a particular label with bound inside [0,1] - elif threshold_quantifier_type == 'available': - if clause_type == 'node': - neigh_len = len(get_qualified_components_node_clause(interpretations_node, subset, clause_label, interval.closed(0,1))) - else: - neigh_len = len(get_qualified_components_edge_clause(interpretations_edge, subset_source, subset_target, clause_label, interval.closed(0,1), reverse_graph)[0]) + if clause_type == 'node': + satisfaction = check_node_clause_satisfaction(interpretations_node, subsets, subset, clause_var_1, clause_label, thresholds[i]) and satisfaction + else: + satisfaction = check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, clause_label, thresholds[i], reverse_graph) and satisfaction - qualified_neigh_len = len(subsets[clause_var_1]) - satisfaction = _satisfies_threshold(neigh_len, qualified_neigh_len, thresholds[i]) and satisfaction + # Refine subsets based on any updates + if satisfaction: + satisfaction = refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_node, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph) and satisfaction # Exit loop if even one clause is not satisfied if not satisfaction: @@ -962,12 +925,87 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n else: edges_to_be_added[1].append(target) - # Add qualified nodes/edges to trace + # Loop through the clause data and setup final annotations and trace variables + # 1. Add qualified nodes/edges to trace + # 2. Add annotations to annotation function variable + for i, clause in enumerate(clause_type_and_variables): + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + # 1. + if atom_trace: + qualified_nodes.append(numba.typed.List(subsets[clause_var_1])) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qn in subsets[clause_var_1]: + a.append(interpretations_node[qn].world[clause_label]) + annotations.append(a) - # Add annotations to annotation function variable + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables + # 1. + if atom_trace: + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + qualified_edges.append(numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2]))) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qe in numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2])): + a.append(interpretations_edge[qe].world[clause_label]) + annotations.append(a) + + elif clause_type == 'node-comparison': + clause_var_1, clause_var_2 = clause_variables + qualified_nodes_1 = subsets[clause_var_1] + qualified_nodes_2 = subsets[clause_var_2] + qualified_comparison_nodes = numba.typed.List(qualified_nodes_1) + qualified_comparison_nodes.extend(qualified_nodes_2) + # 1. + if atom_trace: + qualified_nodes.append(qualified_comparison_nodes) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # 2. + # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qn in qualified_comparison_nodes: + a.append(interval.closed(1, 1)) + annotations.append(a) + + elif clause_type == 'edge-comparison': + clause_var_1_source, clause_var_1_target, clause_var_2_source, clause_var_2_target = clause_variables + qualified_nodes_1_source = subsets[clause_var_1_source] + qualified_nodes_1_target = subsets[clause_var_1_target] + qualified_nodes_2_source = subsets[clause_var_2_source] + qualified_nodes_2_target = subsets[clause_var_2_target] + qualified_comparison_nodes_1 = numba.typed.List(zip(qualified_nodes_1_source, qualified_nodes_1_target)) + qualified_comparison_nodes_2 = numba.typed.List(zip(qualified_nodes_2_source, qualified_nodes_2_target)) + qualified_comparison_nodes = numba.typed.List(qualified_comparison_nodes_1) + qualified_comparison_nodes.extend(qualified_comparison_nodes_2) + # 1. + if atom_trace: + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + qualified_edges.append(qualified_comparison_nodes) + # 2. + # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qe in qualified_comparison_nodes: + a.append(interval.closed(1, 1)) + annotations.append(a) # node/edge, annotations, qualified nodes, qualified edges, edges to be added - applicable_rules.append((target_node, annotations, qualified_nodes, qualified_edges, edges_to_be_added)) + applicable_rules_threadsafe[piter] = numba.typed.List([(target_node, annotations, qualified_nodes, qualified_edges, edges_to_be_added)]) + + # Merge all threadsafe rules into one single array + for applicable_rule in applicable_rules_threadsafe: + if len(applicable_rule) > 0: + applicable_rules.append(applicable_rule[0]) return applicable_rules @@ -983,6 +1021,10 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e # We return a list of tuples which specify the target nodes/edges that have made the rule body true applicable_rules = numba.typed.List.empty_list(edge_applicable_rule_type) + + # Create pre-allocated data structure so that parallel code does not need to use "append" to be threadsafe + # One array for each node, then condense into a single list later + applicable_rules_threadsafe = numba.typed.List([numba.typed.List.empty_list(edge_applicable_rule_type) for _ in edges]) # Return empty list if rule is not node rule if rule_type != 'edge': @@ -1007,6 +1049,7 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + clause_type_and_variables = numba.typed.List.empty_list(clause_data) satisfaction = True for i, clause in enumerate(clauses): @@ -1017,27 +1060,16 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e clause_bnd = clause[3] clause_operator = clause[4] - # Unpack thresholds - # This value is total/available - threshold_quantifier_type = thresholds[i][1][1] - # This is a node clause # The groundings for node clauses are either the source, target, neighbors of the source node, or an existing subset of nodes if clause_type == 'node': clause_var_1 = clause_variables[0] - subset = get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, neighbors) - + subset = get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, nodes) + subsets[clause_var_1] = get_qualified_components_node_clause(interpretations_node, subset, clause_label, clause_bnd) - if atom_trace: - qualified_nodes.append(numba.typed.List(subsets[clause_var_1])) - qualified_edges.append(numba.typed.List.empty_list(edge_type)) - # Add annotations if necessary - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qn in subsets[clause_var_1]: - a.append(interpretations_node[qn].world[clause_label]) - annotations.append(a) + # Save data for annotations and atom trace + clause_type_and_variables.append(('node', clause_label, numba.typed.List([clause_var_1]))) # This is an edge clause elif clause_type == 'edge': @@ -1049,17 +1081,9 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e subsets[clause_var_1] = qe[0] subsets[clause_var_2] = qe[1] - if atom_trace: - qualified_nodes.append(numba.typed.List.empty_list(node_type)) - qualified_edges.append(numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2]))) - - # Add annotations if necessary - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qe in numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2])): - a.append(interpretations_edge[qe].world[clause_label]) - annotations.append(a) - + # Save data for annotations and atom trace + clause_type_and_variables.append(('edge', clause_label, numba.typed.List([clause_var_1, clause_var_2]))) + else: # This is a comparison clause # Make sure there is at least one ground atom such that pred-num(x) : [1,1] or pred-num(x,y) : [1,1] @@ -1070,17 +1094,17 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e # 2. get qualified nodes/edges as well as number associated for second predicate # 3. if there's no number in steps 1 or 2 return false clause # 4. do comparison with each qualified component from step 1 with each qualified component in step 2 - + # It's a node comparison if len(clause_variables) == 2: clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] - subset_1 = get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, neighbors) - subset_2 = get_edge_rule_node_clause_subset(clause_var_2, target_edge, subsets, neighbors) - + subset_1 = get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, nodes) + subset_2 = get_edge_rule_node_clause_subset(clause_var_2, target_edge, subsets, nodes) + # 1, 2 qualified_nodes_for_comparison_1, numbers_1 = get_qualified_components_node_comparison_clause(interpretations_node, subset_1, clause_label, clause_bnd) qualified_nodes_for_comparison_2, numbers_2 = get_qualified_components_node_comparison_clause(interpretations_node, subset_2, clause_label, clause_bnd) - + # It's an edge comparison elif len(clause_variables) == 4: clause_var_1_source, clause_var_1_target, clause_var_2_source, clause_var_2_target = clause_variables[0], clause_variables[1], clause_variables[2], clause_variables[3] @@ -1103,19 +1127,10 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e # Update subsets with final qualified nodes subsets[clause_var_1] = qualified_nodes_1 subsets[clause_var_2] = qualified_nodes_2 - qualified_comparison_nodes = numba.typed.List(qualified_nodes_1) - qualified_comparison_nodes.extend(qualified_nodes_2) - if atom_trace: - qualified_nodes.append(qualified_comparison_nodes) - qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # Save data for annotations and atom trace + clause_type_and_variables.append(('node-comparison', clause_label, numba.typed.List([clause_var_1, clause_var_2]))) - # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qn in qualified_comparison_nodes: - a.append(interval.closed(1, 1)) - annotations.append(a) # Edge comparison. Compare stage else: satisfaction, qualified_nodes_1_source, qualified_nodes_1_target, qualified_nodes_2_source, qualified_nodes_2_target = compare_numbers_edge_predicate(numbers_1, numbers_2, clause_operator, @@ -1129,75 +1144,298 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e subsets[clause_var_2_source] = qualified_nodes_2_source subsets[clause_var_2_target] = qualified_nodes_2_target + # Save data for annotations and atom trace + clause_type_and_variables.append(('edge-comparison', clause_label, numba.typed.List([clause_var_1_source, clause_var_1_target, clause_var_2_source, clause_var_2_target]))) + + # Non comparison clause + else: + if clause_type == 'node': + satisfaction = check_node_clause_satisfaction(interpretations_node, subsets, subset, clause_var_1, clause_label, thresholds[i]) and satisfaction + else: + satisfaction = check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, clause_label, thresholds[i], reverse_graph) and satisfaction + + # Refine subsets based on any updates + if satisfaction: + satisfaction = refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_edge, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph) and satisfaction + + # Exit loop if even one clause is not satisfied + if not satisfaction: + break + + # Here we are done going through each clause of the rule + # If all clauses we're satisfied, proceed to collect annotations and prepare edges to be added + if satisfaction: + # Loop through the clause data and setup final annotations and trace variables + # 1. Add qualified nodes/edges to trace + # 2. Add annotations to annotation function variable + for i, clause in enumerate(clause_type_and_variables): + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + # 1. + if atom_trace: + qualified_nodes.append(numba.typed.List(subsets[clause_var_1])) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qn in subsets[clause_var_1]: + a.append(interpretations_node[qn].world[clause_label]) + annotations.append(a) + + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables + # 1. + if atom_trace: + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + qualified_edges.append(numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2]))) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qe in numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2])): + a.append(interpretations_edge[qe].world[clause_label]) + annotations.append(a) + + elif clause_type == 'node-comparison': + clause_var_1, clause_var_2 = clause_variables + qualified_nodes_1 = subsets[clause_var_1] + qualified_nodes_2 = subsets[clause_var_2] + qualified_comparison_nodes = numba.typed.List(qualified_nodes_1) + qualified_comparison_nodes.extend(qualified_nodes_2) + # 1. + if atom_trace: + qualified_nodes.append(qualified_comparison_nodes) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # 2. + # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qn in qualified_comparison_nodes: + a.append(interval.closed(1, 1)) + annotations.append(a) + + elif clause_type == 'edge-comparison': + clause_var_1_source, clause_var_1_target, clause_var_2_source, clause_var_2_target = clause_variables + qualified_nodes_1_source = subsets[clause_var_1_source] + qualified_nodes_1_target = subsets[clause_var_1_target] + qualified_nodes_2_source = subsets[clause_var_2_source] + qualified_nodes_2_target = subsets[clause_var_2_target] qualified_comparison_nodes_1 = numba.typed.List(zip(qualified_nodes_1_source, qualified_nodes_1_target)) qualified_comparison_nodes_2 = numba.typed.List(zip(qualified_nodes_2_source, qualified_nodes_2_target)) qualified_comparison_nodes = numba.typed.List(qualified_comparison_nodes_1) qualified_comparison_nodes.extend(qualified_comparison_nodes_2) - + # 1. if atom_trace: qualified_nodes.append(numba.typed.List.empty_list(node_type)) qualified_edges.append(qualified_comparison_nodes) - + # 2. # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations if ann_fn != '': a = numba.typed.List.empty_list(interval.interval_type) for qe in qualified_comparison_nodes: a.append(interval.closed(1, 1)) annotations.append(a) - - # Non comparison clause - else: - if threshold_quantifier_type == 'total': - if clause_type == 'node': - neigh_len = len(subset) - else: - neigh_len = sum([len(l) for l in subset_target]) - - # Available is all neighbors that have a particular label with bound inside [0,1] - elif threshold_quantifier_type == 'available': - if clause_type == 'node': - neigh_len = len(get_qualified_components_node_clause(interpretations_node, subset, clause_label, interval.closed(0, 1))) - else: - neigh_len = len(get_qualified_components_edge_clause(interpretations_edge, subset_source, subset_target, clause_label, interval.closed(0, 1), reverse_graph)[0]) - - qualified_neigh_len = len(subsets[clause_var_1]) - satisfaction = _satisfies_threshold(neigh_len, qualified_neigh_len, thresholds[i]) and satisfaction - - # Exit loop if even one clause is not satisfied - if not satisfaction: - break + # node/edge, annotations, qualified nodes, qualified edges, edges to be added + applicable_rules_threadsafe[piter] = numba.typed.List([(target_edge, annotations, qualified_nodes, qualified_edges, edges_to_be_added)]) - # Here we are done going through each clause of the rule - # If all clauses we're satisfied, proceed to collect annotations and prepare edges to be added - if satisfaction: - # Collect edges to be added - source, target, _ = rule_edges + # Merge all threadsafe rules into one single array + for applicable_rule in applicable_rules_threadsafe: + if len(applicable_rule) > 0: + applicable_rules.append(applicable_rule[0]) - # Edges to be added - if source != '' and target != '': - # Check if edge nodes are source/target - if source == '__source': - edges_to_be_added[0].append(target_edge[0]) - elif source == '__target': - edges_to_be_added[0].append(target_edge[1]) - elif source in subsets: - edges_to_be_added[0].extend(subsets[source]) - else: - edges_to_be_added[0].append(source) + return applicable_rules - if target == '__source': - edges_to_be_added[1].append(target_edge[0]) - elif target == '__target': - edges_to_be_added[1].append(target_edge[1]) - elif target in subsets: - edges_to_be_added[1].extend(subsets[target]) - else: - edges_to_be_added[1].append(target) - # node/edge, annotations, qualified nodes, qualified edges, edges to be added - applicable_rules.append((target_edge, annotations, qualified_nodes, qualified_edges, edges_to_be_added)) +@numba.njit(cache=True) +def refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_node, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph): + # Loop through all clauses till clause i-1 and update subsets recursively + # Then check if the clause still satisfies the thresholds + clause = clauses[i] + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + clause_bnd = clause[3] + clause_operator = clause[4] + + # Keep track of the variables that were refined (start with clause_variables) and variables that need refining + satisfaction = True + all_variables_refined = numba.typed.List(clause_variables) + variables_just_refined = numba.typed.List(clause_variables) + new_variables_refined = numba.typed.List.empty_list(numba.types.string) + while len(variables_just_refined) > 0: + for j in range(i): + c = clauses[j] + c_type = c[0] + c_label = c[1] + c_variables = c[2] + c_bnd = c[3] + c_operator = c[4] + + # If it is an edge clause or edge comparison clause, check if any of clause_variables are in c_variables + # If yes, then update the variable that is with it in the clause + if c_type == 'edge' or (c_type == 'comparison' and len(c_variables) > 2): + for v in variables_just_refined: + for k, cv in enumerate(c_variables): + if cv == v: + # Find which variable needs to be refined, 1st or 2nd. + # 2nd variable needs refining + if k == 0: + refine_idx = 1 + refine_v = c_variables[1] + # 1st variable needs refining + elif k == 1: + refine_idx = 0 + refine_v = c_variables[0] + # 2nd variable needs refining + elif k == 2: + refine_idx = 1 + refine_v = c_variables[3] + # 1st variable needs refining + else: + refine_idx = 0 + refine_v = c_variables[2] - return applicable_rules + # Refine the variable + if refine_v not in all_variables_refined: + new_variables_refined.append(refine_v) + + if c_type == 'edge': + clause_var_1, clause_var_2 = (refine_v, cv) if refine_idx == 0 else (cv, refine_v) + del subsets[refine_v] + subset_source, subset_target = get_node_rule_edge_clause_subset(clause_var_1, clause_var_2, target_node, subsets, neighbors, reverse_neighbors, nodes) + + # Get qualified edges + qe = get_qualified_components_edge_clause(interpretations_edge, subset_source, subset_target, c_label, c_bnd, reverse_graph) + subsets[clause_var_1] = qe[0] + subsets[clause_var_2] = qe[1] + + # Check if we still satisfy the clause + satisfaction = check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, c_label, thresholds[j], reverse_graph) and satisfaction + else: + # We do not support refinement for comparison clauses + pass + + if not satisfaction: + return satisfaction + + variables_just_refined = numba.typed.List(new_variables_refined) + all_variables_refined.extend(new_variables_refined) + new_variables_refined.clear() + + return satisfaction + + +@numba.njit(cache=True) +def refine_subsets_edge_rule(interpretations_edge, clauses, i, subsets, target_edge, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph): + # Loop through all clauses till clause i-1 and update subsets recursively + # Then check if the clause still satisfies the thresholds + clause = clauses[i] + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + clause_bnd = clause[3] + clause_operator = clause[4] + + # Keep track of the variables that were refined (start with clause_variables) and variables that need refining + satisfaction = True + all_variables_refined = numba.typed.List(clause_variables) + variables_just_refined = numba.typed.List(clause_variables) + new_variables_refined = numba.typed.List.empty_list(numba.types.string) + while len(variables_just_refined) > 0: + for j in range(i): + c = clauses[j] + c_type = c[0] + c_label = c[1] + c_variables = c[2] + c_bnd = c[3] + c_operator = c[4] + + # If it is an edge clause or edge comparison clause, check if any of clause_variables are in c_variables + # If yes, then update the variable that is with it in the clause + if c_type == 'edge' or (c_type == 'comparison' and len(c_variables) > 2): + for v in variables_just_refined: + for k, cv in enumerate(c_variables): + if cv == v: + # Find which variable needs to be refined, 1st or 2nd. + # 2nd variable needs refining + if k == 0: + refine_idx = 1 + refine_v = c_variables[1] + # 1st variable needs refining + elif k == 1: + refine_idx = 0 + refine_v = c_variables[0] + # 2nd variable needs refining + elif k == 2: + refine_idx = 1 + refine_v = c_variables[3] + # 1st variable needs refining + else: + refine_idx = 0 + refine_v = c_variables[2] + + # Refine the variable + if refine_v not in all_variables_refined: + new_variables_refined.append(refine_v) + + if c_type == 'edge': + clause_var_1, clause_var_2 = (refine_v, cv) if refine_idx == 0 else (cv, refine_v) + del subsets[refine_v] + subset_source, subset_target = get_edge_rule_edge_clause_subset(clause_var_1, clause_var_2, target_edge, subsets, neighbors, reverse_neighbors, nodes) + + # Get qualified edges + qe = get_qualified_components_edge_clause(interpretations_edge, subset_source, subset_target, c_label, c_bnd, reverse_graph) + subsets[clause_var_1] = qe[0] + subsets[clause_var_2] = qe[1] + + # Check if we still satisfy the clause + satisfaction = check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, c_label, thresholds[j], reverse_graph) and satisfaction + else: + # We do not support refinement for comparison clauses + pass + + if not satisfaction: + return satisfaction + + variables_just_refined = numba.typed.List(new_variables_refined) + all_variables_refined.extend(new_variables_refined) + new_variables_refined.clear() + + return satisfaction + + +@numba.njit(cache=True) +def check_node_clause_satisfaction(interpretations_node, subsets, subset, clause_var_1, clause_label, threshold): + threshold_quantifier_type = threshold[1][1] + if threshold_quantifier_type == 'total': + neigh_len = len(subset) + + # Available is all neighbors that have a particular label with bound inside [0,1] + elif threshold_quantifier_type == 'available': + neigh_len = len(get_qualified_components_node_clause(interpretations_node, subset, clause_label, interval.closed(0, 1))) + + # Only take length of clause_var_1 because length of subsets of var_1 and var_2 are supposed to be equal + qualified_neigh_len = len(subsets[clause_var_1]) + satisfaction = _satisfies_threshold(neigh_len, qualified_neigh_len, threshold) + return satisfaction + + +@numba.njit(cache=True) +def check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, clause_label, threshold, reverse_graph): + threshold_quantifier_type = threshold[1][1] + if threshold_quantifier_type == 'total': + neigh_len = sum([len(l) for l in subset_target]) + + # Available is all neighbors that have a particular label with bound inside [0,1] + elif threshold_quantifier_type == 'available': + neigh_len = len(get_qualified_components_edge_clause(interpretations_edge, subset_source, subset_target, clause_label, interval.closed(0, 1), reverse_graph)[0]) + + qualified_neigh_len = len(subsets[clause_var_1]) + satisfaction = _satisfies_threshold(neigh_len, qualified_neigh_len, threshold) + return satisfaction @numba.njit(cache=True) @@ -1206,7 +1444,8 @@ def get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes): if clause_var_1 == '__target': subset = numba.typed.List([target_node]) else: - subset = nodes if clause_var_1 not in subsets else subsets[clause_var_1] + nodes_without_target = numba.typed.List([n for n in nodes if n != target_node]) + subset = nodes_without_target if clause_var_1 not in subsets else subsets[clause_var_1] return subset @@ -1238,10 +1477,10 @@ def get_node_rule_edge_clause_subset(clause_var_1, clause_var_2, target_node, su subset_target = numba.typed.List([numba.typed.List([target_node]) for _ in subset_source]) # Case 2: - # We replace Y by all nodes and Z by the neighbors of each of these nodes + # We replace Y by all nodes (except target_node) and Z by the neighbors of each of these nodes elif clause_var_1 not in subsets and clause_var_2 not in subsets: - subset_source = numba.typed.List(nodes) - subset_target = numba.typed.List([neighbors[n] for n in subset_source]) + subset_source = numba.typed.List([n for n in nodes if n != target_node]) + subset_target = numba.typed.List([numba.typed.List([nn for nn in neighbors[n] if nn != target_node]) for n in subset_source]) # Case 3: # We replace Y by the sources of Z @@ -1252,32 +1491,43 @@ def get_node_rule_edge_clause_subset(clause_var_1, clause_var_2, target_node, su for n in subsets[clause_var_2]: sources = reverse_neighbors[n] for source in sources: - subset_source.append(source) - subset_target.append(numba.typed.List([n])) + if source != target_node: + subset_source.append(source) + subset_target.append(numba.typed.List([n])) # Case 4: # We replace Z by the neighbors of Y elif clause_var_1 in subsets and clause_var_2 not in subsets: subset_source = subsets[clause_var_1] - subset_target = numba.typed.List([neighbors[n] for n in subset_source]) + subset_target = numba.typed.List([numba.typed.List([nn for nn in neighbors[n] if nn != target_node]) for n in subset_source]) # Case 5: else: subset_source = subsets[clause_var_1] subset_target = numba.typed.List([subsets[clause_var_2] for _ in subset_source]) + # If any of the subsets are empty return them in the correct type + if len(subset_source) == 0: + subset_source = numba.typed.List.empty_list(node_type) + subset_target = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) + # If any sub lists in subset target are empty, add correct type for empty list + for i, t in enumerate(subset_target): + if len(t) == 0: + subset_target[i] = numba.typed.List.empty_list(node_type) + return subset_source, subset_target @numba.njit(cache=True) -def get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, neighbors): +def get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, nodes): # The groundings for node clauses are either the source, target, neighbors of the source node, or an existing subset of nodes if clause_var_1 == '__source': subset = numba.typed.List([target_edge[0]]) elif clause_var_1 == '__target': subset = numba.typed.List([target_edge[1]]) else: - subset = neighbors[target_edge[0]] if clause_var_1 not in subsets else subsets[clause_var_1] + nodes_without_target_or_source = numba.typed.List([n for n in nodes if n != target_edge[0] and n != target_edge[1]]) + subset = nodes_without_target_or_source if clause_var_1 not in subsets else subsets[clause_var_1] return subset @@ -1328,10 +1578,10 @@ def get_edge_rule_edge_clause_subset(clause_var_1, clause_var_2, target_edge, su subset_target = numba.typed.List([numba.typed.List([target_edge[1]]) for _ in subset_source]) # Case 2: - # We replace Y by all nodes and Z by the neighbors of each of these nodes + # We replace Y by all nodes (except source/target) and Z by the neighbors of each of these nodes elif clause_var_1 not in subsets and clause_var_2 not in subsets: - subset_source = numba.typed.List(nodes) - subset_target = numba.typed.List([neighbors[n] for n in subset_source]) + subset_source = numba.typed.List([n for n in nodes if n != target_edge[0] and n != target_edge[1]]) + subset_target = numba.typed.List([numba.typed.List([nn for nn in neighbors[n] if nn != target_edge[0] and nn != target_edge[1]]) for n in subset_source]) # Case 3: # We replace Y by the sources of Z @@ -1342,20 +1592,30 @@ def get_edge_rule_edge_clause_subset(clause_var_1, clause_var_2, target_edge, su for n in subsets[clause_var_2]: sources = reverse_neighbors[n] for source in sources: - subset_source.append(source) - subset_target.append(numba.typed.List([n])) + if source != target_edge[0] and source != target_edge[1]: + subset_source.append(source) + subset_target.append(numba.typed.List([n])) # Case 4: # We replace Z by the neighbors of Y elif clause_var_1 in subsets and clause_var_2 not in subsets: subset_source = subsets[clause_var_1] - subset_target = numba.typed.List([neighbors[n] for n in subset_source]) + subset_target = numba.typed.List([numba.typed.List([nn for nn in neighbors[n] if nn != target_edge[0] and nn != target_edge[1]]) for n in subset_source]) # Case 5: else: subset_source = subsets[clause_var_1] subset_target = numba.typed.List([subsets[clause_var_2] for _ in subset_source]) + # If any of the subsets are empty return them in the correct type + if len(subset_source) == 0: + subset_source = numba.typed.List.empty_list(node_type) + subset_target = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) + # If any sub lists in subset target are empty, add correct type for empty list + for i, t in enumerate(subset_target): + if len(t) == 0: + subset_target[i] = numba.typed.List.empty_list(node_type) + return subset_source, subset_target @@ -1364,7 +1624,7 @@ def get_qualified_components_node_clause(interpretations_node, candidates, l, bn # Get all the qualified neighbors for a particular clause qualified_nodes = numba.typed.List.empty_list(node_type) for n in candidates: - if is_satisfied_node(interpretations_node, n, (l, bnd)): + if is_satisfied_node(interpretations_node, n, (l, bnd)) and n not in qualified_nodes: qualified_nodes.append(n) return qualified_nodes @@ -1672,7 +1932,7 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat updated_bnds.append(world.world[p2]) if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, p1, interval.closed(lower, upper))) - + # Gather convergence data change = 0 if updated: @@ -1688,7 +1948,7 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat change = max(change, max_delta) else: change = 1 + ip_update_cnt - + return (updated, change) except: return (False, 0) @@ -1697,7 +1957,7 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat @numba.njit(cache=True) def _update_rule_trace(rule_trace, qn, qe, prev_bnd, name): rule_trace.append((qn, qe, prev_bnd.copy(), name)) - + @numba.njit(cache=True) def are_satisfied_node(interpretations, comp, nas): @@ -1864,7 +2124,7 @@ def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, at world.world[p1].set_static(True) if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, p1, interval.closed(0,1))) - # Add inconsistent predicates to a list + # Add inconsistent predicates to a list @numba.njit(cache=True) From ed630a876d937c0c32346eb64d5c52edb2ae275a Mon Sep 17 00:00:00 2001 From: Dyuman Date: Sat, 13 Apr 2024 21:25:47 +0200 Subject: [PATCH 04/73] fixed bug for interpretation dict returning empty when reason timestep not specified --- pyreason/scripts/interpretation/interpretation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 93dc843..79f0fb4 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -722,7 +722,7 @@ def get_interpretation_dict(self): # Initialize interpretations for each time and node and edge interpretations = {} - for t in range(self.tmax+1): + for t in range(len(interpretations)): interpretations[t] = {} for node in self.nodes: interpretations[t][node] = InterpretationDict() @@ -736,7 +736,7 @@ def get_interpretation_dict(self): # If canonical, update all following timesteps as well if self. canonical: - for t in range(time+1, self.tmax+1): + for t in range(time+1, len(interpretations)): interpretations[t][node][l._value] = (bnd.lower, bnd.upper) # Update interpretation edges @@ -746,7 +746,7 @@ def get_interpretation_dict(self): # If canonical, update all following timesteps as well if self. canonical: - for t in range(time+1, self.tmax+1): + for t in range(time+1, len(interpretations)): interpretations[t][edge][l._value] = (bnd.lower, bnd.upper) return interpretations From 0f3619870d08267f6d4c21e9f9aceae39a921ca6 Mon Sep 17 00:00:00 2001 From: Dyuman Date: Sat, 13 Apr 2024 21:27:05 +0200 Subject: [PATCH 05/73] parallel interpretation --- .../interpretation/interpretation_parallel.py | 632 ++++++++++++------ 1 file changed, 439 insertions(+), 193 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index 230b641..0aa7059 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -15,6 +15,12 @@ list_of_nodes = numba.types.ListType(node_type) list_of_edges = numba.types.ListType(edge_type) +# Type for storing clause data +clause_data = numba.types.Tuple((numba.types.string, label.label_type, numba.types.ListType(numba.types.string))) + +# Type for storing refine clause data +refine_data = numba.types.Tuple((numba.types.string, numba.types.string, numba.types.int8)) + # Type for facts to be applied facts_to_be_applied_node_type = numba.types.Tuple((numba.types.uint16, node_type, label.label_type, interval.interval_type, numba.types.boolean, numba.types.boolean)) facts_to_be_applied_edge_type = numba.types.Tuple((numba.types.uint16, edge_type, label.label_type, interval.interval_type, numba.types.boolean, numba.types.boolean)) @@ -135,7 +141,7 @@ def _init_interpretations_node(nodes, available_labels, specific_labels): interpretations[n].world[l] = interval.closed(0.0, 1.0) return interpretations - + @staticmethod @numba.njit(cache=False) def _init_interpretations_edge(edges, available_labels, specific_labels): @@ -149,7 +155,7 @@ def _init_interpretations_edge(edges, available_labels, specific_labels): interpretations[e].world[l] = interval.closed(0.0, 1.0) return interpretations - + @staticmethod @numba.njit(cache=False) def _init_convergence(convergence_bound_threshold, convergence_threshold): @@ -273,7 +279,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data rule_trace_node.append((numba.types.uint16(t), numba.types.uint16(fp_cnt), comp, p1, interpretations_node[comp].world[p1])) if atom_trace: _update_rule_trace(rule_trace_node_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), interpretations_node[comp].world[p1], facts_to_be_applied_node_trace[i]) - + else: # Check for inconsistencies (multiple facts) if check_consistent_node(interpretations_node, comp, (l, bnd)): @@ -650,14 +656,14 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Break, apply immediate rule then come back to check for more applicable rules if immediate_edge_rule_fire: break - + # Go through all the rules and go back to applying the rules if we came here because of an immediate rule where delta_t>0 if immediate_rule_applied and not (immediate_node_rule_fire or immediate_edge_rule_fire): immediate_rule_applied = False in_loop = True update = False continue - + # Check for convergence after each timestep (perfect convergence or convergence specified by user) # Check number of changed interpretations or max bound change # User specified convergence @@ -716,7 +722,7 @@ def get_interpretation_dict(self): # Initialize interpretations for each time and node and edge interpretations = {} - for t in range(self.tmax+1): + for t in range(len(interpretations)): interpretations[t] = {} for node in self.nodes: interpretations[t][node] = InterpretationDict() @@ -730,7 +736,7 @@ def get_interpretation_dict(self): # If canonical, update all following timesteps as well if self. canonical: - for t in range(time+1, self.tmax+1): + for t in range(time+1, len(interpretations)): interpretations[t][node][l._value] = (bnd.lower, bnd.upper) # Update interpretation edges @@ -740,7 +746,7 @@ def get_interpretation_dict(self): # If canonical, update all following timesteps as well if self. canonical: - for t in range(time+1, self.tmax+1): + for t in range(time+1, len(interpretations)): interpretations[t][edge][l._value] = (bnd.lower, bnd.upper) return interpretations @@ -757,7 +763,7 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n # We return a list of tuples which specify the target nodes/edges that have made the rule body true applicable_rules = numba.typed.List.empty_list(node_applicable_rule_type) - + # Create pre-allocated data structure so that parallel code does not need to use "append" to be threadsafe # One array for each node, then condense into a single list later applicable_rules_threadsafe = numba.typed.List([numba.typed.List.empty_list(node_applicable_rule_type) for _ in nodes]) @@ -785,6 +791,7 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + clause_type_and_variables = numba.typed.List.empty_list(clause_data) satisfaction = True for i, clause in enumerate(clauses): @@ -795,28 +802,16 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n clause_bnd = clause[3] clause_operator = clause[4] - # Unpack thresholds - # This value is total/available - threshold_quantifier_type = thresholds[i][1][1] - # This is a node clause # The groundings for node clauses are either the target node, neighbors of the target node, or an existing subset of nodes if clause_type == 'node': clause_var_1 = clause_variables[0] - subset = get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, neighbors) + subset = get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes) subsets[clause_var_1] = get_qualified_components_node_clause(interpretations_node, subset, clause_label, clause_bnd) - if atom_trace: - qualified_nodes.append(numba.typed.List(subsets[clause_var_1])) - qualified_edges.append(numba.typed.List.empty_list(edge_type)) - - # Add annotations if necessary - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qn in subsets[clause_var_1]: - a.append(interpretations_node[qn].world[clause_label]) - annotations.append(a) + # Save data for annotations and atom trace + clause_type_and_variables.append(('node', clause_label, numba.typed.List([clause_var_1]))) # This is an edge clause elif clause_type == 'edge': @@ -828,16 +823,9 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n subsets[clause_var_1] = qe[0] subsets[clause_var_2] = qe[1] - if atom_trace: - qualified_nodes.append(numba.typed.List.empty_list(node_type)) - qualified_edges.append(numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2]))) - - # Add annotations if necessary - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qe in numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2])): - a.append(interpretations_edge[qe].world[clause_label]) - annotations.append(a) + # Save data for annotations and atom trace + clause_type_and_variables.append(('edge', clause_label, numba.typed.List([clause_var_1, clause_var_2]))) + else: # This is a comparison clause # Make sure there is at least one ground atom such that pred-num(x) : [1,1] or pred-num(x,y) : [1,1] @@ -852,8 +840,8 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n # It's a node comparison if len(clause_variables) == 2: clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] - subset_1 = get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, neighbors) - subset_2 = get_node_rule_node_clause_subset(clause_var_2, target_node, subsets, neighbors) + subset_1 = get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes) + subset_2 = get_node_rule_node_clause_subset(clause_var_2, target_node, subsets, nodes) # 1, 2 qualified_nodes_for_comparison_1, numbers_1 = get_qualified_components_node_comparison_clause(interpretations_node, subset_1, clause_label, clause_bnd) @@ -881,19 +869,10 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n # Update subsets with final qualified nodes subsets[clause_var_1] = qualified_nodes_1 subsets[clause_var_2] = qualified_nodes_2 - qualified_comparison_nodes = numba.typed.List(qualified_nodes_1) - qualified_comparison_nodes.extend(qualified_nodes_2) - if atom_trace: - qualified_nodes.append(qualified_comparison_nodes) - qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # Save data for annotations and atom trace + clause_type_and_variables.append(('node-comparison', clause_label, numba.typed.List([clause_var_1, clause_var_2]))) - # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qn in qualified_comparison_nodes: - a.append(interval.closed(1, 1)) - annotations.append(a) # Edge comparison. Compare stage else: satisfaction, qualified_nodes_1_source, qualified_nodes_1_target, qualified_nodes_2_source, qualified_nodes_2_target = compare_numbers_edge_predicate(numbers_1, numbers_2, clause_operator, @@ -907,39 +886,19 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n subsets[clause_var_2_source] = qualified_nodes_2_source subsets[clause_var_2_target] = qualified_nodes_2_target - qualified_comparison_nodes_1 = numba.typed.List(zip(qualified_nodes_1_source, qualified_nodes_1_target)) - qualified_comparison_nodes_2 = numba.typed.List(zip(qualified_nodes_2_source, qualified_nodes_2_target)) - qualified_comparison_nodes = numba.typed.List(qualified_comparison_nodes_1) - qualified_comparison_nodes.extend(qualified_comparison_nodes_2) - - if atom_trace: - qualified_nodes.append(numba.typed.List.empty_list(node_type)) - qualified_edges.append(qualified_comparison_nodes) - - # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qe in qualified_comparison_nodes: - a.append(interval.closed(1, 1)) - annotations.append(a) + # Save data for annotations and atom trace + clause_type_and_variables.append(('edge-comparison', clause_label, numba.typed.List([clause_var_1_source, clause_var_1_target, clause_var_2_source, clause_var_2_target]))) # Non comparison clause else: - if threshold_quantifier_type == 'total': - if clause_type == 'node': - neigh_len = len(subset) - else: - neigh_len = sum([len(l) for l in subset_target]) - - # Available is all neighbors that have a particular label with bound inside [0,1] - elif threshold_quantifier_type == 'available': - if clause_type == 'node': - neigh_len = len(get_qualified_components_node_clause(interpretations_node, subset, clause_label, interval.closed(0,1))) - else: - neigh_len = len(get_qualified_components_edge_clause(interpretations_edge, subset_source, subset_target, clause_label, interval.closed(0,1), reverse_graph)[0]) + if clause_type == 'node': + satisfaction = check_node_clause_satisfaction(interpretations_node, subsets, subset, clause_var_1, clause_label, thresholds[i]) and satisfaction + else: + satisfaction = check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, clause_label, thresholds[i], reverse_graph) and satisfaction - qualified_neigh_len = len(subsets[clause_var_1]) - satisfaction = _satisfies_threshold(neigh_len, qualified_neigh_len, thresholds[i]) and satisfaction + # Refine subsets based on any updates + if satisfaction: + satisfaction = refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_node, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph) and satisfaction # Exit loop if even one clause is not satisfied if not satisfaction: @@ -966,9 +925,83 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n else: edges_to_be_added[1].append(target) + # Loop through the clause data and setup final annotations and trace variables + # 1. Add qualified nodes/edges to trace + # 2. Add annotations to annotation function variable + for i, clause in enumerate(clause_type_and_variables): + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + # 1. + if atom_trace: + qualified_nodes.append(numba.typed.List(subsets[clause_var_1])) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qn in subsets[clause_var_1]: + a.append(interpretations_node[qn].world[clause_label]) + annotations.append(a) + + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables + # 1. + if atom_trace: + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + qualified_edges.append(numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2]))) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qe in numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2])): + a.append(interpretations_edge[qe].world[clause_label]) + annotations.append(a) + + elif clause_type == 'node-comparison': + clause_var_1, clause_var_2 = clause_variables + qualified_nodes_1 = subsets[clause_var_1] + qualified_nodes_2 = subsets[clause_var_2] + qualified_comparison_nodes = numba.typed.List(qualified_nodes_1) + qualified_comparison_nodes.extend(qualified_nodes_2) + # 1. + if atom_trace: + qualified_nodes.append(qualified_comparison_nodes) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # 2. + # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qn in qualified_comparison_nodes: + a.append(interval.closed(1, 1)) + annotations.append(a) + + elif clause_type == 'edge-comparison': + clause_var_1_source, clause_var_1_target, clause_var_2_source, clause_var_2_target = clause_variables + qualified_nodes_1_source = subsets[clause_var_1_source] + qualified_nodes_1_target = subsets[clause_var_1_target] + qualified_nodes_2_source = subsets[clause_var_2_source] + qualified_nodes_2_target = subsets[clause_var_2_target] + qualified_comparison_nodes_1 = numba.typed.List(zip(qualified_nodes_1_source, qualified_nodes_1_target)) + qualified_comparison_nodes_2 = numba.typed.List(zip(qualified_nodes_2_source, qualified_nodes_2_target)) + qualified_comparison_nodes = numba.typed.List(qualified_comparison_nodes_1) + qualified_comparison_nodes.extend(qualified_comparison_nodes_2) + # 1. + if atom_trace: + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + qualified_edges.append(qualified_comparison_nodes) + # 2. + # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qe in qualified_comparison_nodes: + a.append(interval.closed(1, 1)) + annotations.append(a) + # node/edge, annotations, qualified nodes, qualified edges, edges to be added applicable_rules_threadsafe[piter] = numba.typed.List([(target_node, annotations, qualified_nodes, qualified_edges, edges_to_be_added)]) - + # Merge all threadsafe rules into one single array for applicable_rule in applicable_rules_threadsafe: if len(applicable_rule) > 0: @@ -988,7 +1021,7 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e # We return a list of tuples which specify the target nodes/edges that have made the rule body true applicable_rules = numba.typed.List.empty_list(edge_applicable_rule_type) - + # Create pre-allocated data structure so that parallel code does not need to use "append" to be threadsafe # One array for each node, then condense into a single list later applicable_rules_threadsafe = numba.typed.List([numba.typed.List.empty_list(edge_applicable_rule_type) for _ in edges]) @@ -1016,6 +1049,7 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + clause_type_and_variables = numba.typed.List.empty_list(clause_data) satisfaction = True for i, clause in enumerate(clauses): @@ -1026,27 +1060,16 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e clause_bnd = clause[3] clause_operator = clause[4] - # Unpack thresholds - # This value is total/available - threshold_quantifier_type = thresholds[i][1][1] - # This is a node clause # The groundings for node clauses are either the source, target, neighbors of the source node, or an existing subset of nodes if clause_type == 'node': clause_var_1 = clause_variables[0] - subset = get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, neighbors) - + subset = get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, nodes) + subsets[clause_var_1] = get_qualified_components_node_clause(interpretations_node, subset, clause_label, clause_bnd) - if atom_trace: - qualified_nodes.append(numba.typed.List(subsets[clause_var_1])) - qualified_edges.append(numba.typed.List.empty_list(edge_type)) - # Add annotations if necessary - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qn in subsets[clause_var_1]: - a.append(interpretations_node[qn].world[clause_label]) - annotations.append(a) + # Save data for annotations and atom trace + clause_type_and_variables.append(('node', clause_label, numba.typed.List([clause_var_1]))) # This is an edge clause elif clause_type == 'edge': @@ -1058,17 +1081,9 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e subsets[clause_var_1] = qe[0] subsets[clause_var_2] = qe[1] - if atom_trace: - qualified_nodes.append(numba.typed.List.empty_list(node_type)) - qualified_edges.append(numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2]))) - - # Add annotations if necessary - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qe in numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2])): - a.append(interpretations_edge[qe].world[clause_label]) - annotations.append(a) - + # Save data for annotations and atom trace + clause_type_and_variables.append(('edge', clause_label, numba.typed.List([clause_var_1, clause_var_2]))) + else: # This is a comparison clause # Make sure there is at least one ground atom such that pred-num(x) : [1,1] or pred-num(x,y) : [1,1] @@ -1079,17 +1094,17 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e # 2. get qualified nodes/edges as well as number associated for second predicate # 3. if there's no number in steps 1 or 2 return false clause # 4. do comparison with each qualified component from step 1 with each qualified component in step 2 - + # It's a node comparison if len(clause_variables) == 2: clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] - subset_1 = get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, neighbors) - subset_2 = get_edge_rule_node_clause_subset(clause_var_2, target_edge, subsets, neighbors) - + subset_1 = get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, nodes) + subset_2 = get_edge_rule_node_clause_subset(clause_var_2, target_edge, subsets, nodes) + # 1, 2 qualified_nodes_for_comparison_1, numbers_1 = get_qualified_components_node_comparison_clause(interpretations_node, subset_1, clause_label, clause_bnd) qualified_nodes_for_comparison_2, numbers_2 = get_qualified_components_node_comparison_clause(interpretations_node, subset_2, clause_label, clause_bnd) - + # It's an edge comparison elif len(clause_variables) == 4: clause_var_1_source, clause_var_1_target, clause_var_2_source, clause_var_2_target = clause_variables[0], clause_variables[1], clause_variables[2], clause_variables[3] @@ -1112,19 +1127,10 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e # Update subsets with final qualified nodes subsets[clause_var_1] = qualified_nodes_1 subsets[clause_var_2] = qualified_nodes_2 - qualified_comparison_nodes = numba.typed.List(qualified_nodes_1) - qualified_comparison_nodes.extend(qualified_nodes_2) - if atom_trace: - qualified_nodes.append(qualified_comparison_nodes) - qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # Save data for annotations and atom trace + clause_type_and_variables.append(('node-comparison', clause_label, numba.typed.List([clause_var_1, clause_var_2]))) - # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qn in qualified_comparison_nodes: - a.append(interval.closed(1, 1)) - annotations.append(a) # Edge comparison. Compare stage else: satisfaction, qualified_nodes_1_source, qualified_nodes_1_target, qualified_nodes_2_source, qualified_nodes_2_target = compare_numbers_edge_predicate(numbers_1, numbers_2, clause_operator, @@ -1138,40 +1144,20 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e subsets[clause_var_2_source] = qualified_nodes_2_source subsets[clause_var_2_target] = qualified_nodes_2_target - qualified_comparison_nodes_1 = numba.typed.List(zip(qualified_nodes_1_source, qualified_nodes_1_target)) - qualified_comparison_nodes_2 = numba.typed.List(zip(qualified_nodes_2_source, qualified_nodes_2_target)) - qualified_comparison_nodes = numba.typed.List(qualified_comparison_nodes_1) - qualified_comparison_nodes.extend(qualified_comparison_nodes_2) + # Save data for annotations and atom trace + clause_type_and_variables.append(('edge-comparison', clause_label, numba.typed.List([clause_var_1_source, clause_var_1_target, clause_var_2_source, clause_var_2_target]))) - if atom_trace: - qualified_nodes.append(numba.typed.List.empty_list(node_type)) - qualified_edges.append(qualified_comparison_nodes) - - # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations - if ann_fn != '': - a = numba.typed.List.empty_list(interval.interval_type) - for qe in qualified_comparison_nodes: - a.append(interval.closed(1, 1)) - annotations.append(a) - # Non comparison clause else: - if threshold_quantifier_type == 'total': - if clause_type == 'node': - neigh_len = len(subset) - else: - neigh_len = sum([len(l) for l in subset_target]) - - # Available is all neighbors that have a particular label with bound inside [0,1] - elif threshold_quantifier_type == 'available': - if clause_type == 'node': - neigh_len = len(get_qualified_components_node_clause(interpretations_node, subset, clause_label, interval.closed(0, 1))) - else: - neigh_len = len(get_qualified_components_edge_clause(interpretations_edge, subset_source, subset_target, clause_label, interval.closed(0, 1), reverse_graph)[0]) - - qualified_neigh_len = len(subsets[clause_var_1]) - satisfaction = _satisfies_threshold(neigh_len, qualified_neigh_len, thresholds[i]) and satisfaction - + if clause_type == 'node': + satisfaction = check_node_clause_satisfaction(interpretations_node, subsets, subset, clause_var_1, clause_label, thresholds[i]) and satisfaction + else: + satisfaction = check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, clause_label, thresholds[i], reverse_graph) and satisfaction + + # Refine subsets based on any updates + if satisfaction: + satisfaction = refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_edge, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph) and satisfaction + # Exit loop if even one clause is not satisfied if not satisfaction: break @@ -1179,30 +1165,79 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e # Here we are done going through each clause of the rule # If all clauses we're satisfied, proceed to collect annotations and prepare edges to be added if satisfaction: - # Collect edges to be added - source, target, _ = rule_edges + # Loop through the clause data and setup final annotations and trace variables + # 1. Add qualified nodes/edges to trace + # 2. Add annotations to annotation function variable + for i, clause in enumerate(clause_type_and_variables): + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + # 1. + if atom_trace: + qualified_nodes.append(numba.typed.List(subsets[clause_var_1])) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qn in subsets[clause_var_1]: + a.append(interpretations_node[qn].world[clause_label]) + annotations.append(a) - # Edges to be added - if source != '' and target != '': - # Check if edge nodes are source/target - if source == '__source': - edges_to_be_added[0].append(target_edge[0]) - elif source == '__target': - edges_to_be_added[0].append(target_edge[1]) - elif source in subsets: - edges_to_be_added[0].extend(subsets[source]) - else: - edges_to_be_added[0].append(source) + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables + # 1. + if atom_trace: + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + qualified_edges.append(numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2]))) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qe in numba.typed.List(zip(subsets[clause_var_1], subsets[clause_var_2])): + a.append(interpretations_edge[qe].world[clause_label]) + annotations.append(a) - if target == '__source': - edges_to_be_added[1].append(target_edge[0]) - elif target == '__target': - edges_to_be_added[1].append(target_edge[1]) - elif target in subsets: - edges_to_be_added[1].extend(subsets[target]) - else: - edges_to_be_added[1].append(target) + elif clause_type == 'node-comparison': + clause_var_1, clause_var_2 = clause_variables + qualified_nodes_1 = subsets[clause_var_1] + qualified_nodes_2 = subsets[clause_var_2] + qualified_comparison_nodes = numba.typed.List(qualified_nodes_1) + qualified_comparison_nodes.extend(qualified_nodes_2) + # 1. + if atom_trace: + qualified_nodes.append(qualified_comparison_nodes) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # 2. + # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qn in qualified_comparison_nodes: + a.append(interval.closed(1, 1)) + annotations.append(a) + elif clause_type == 'edge-comparison': + clause_var_1_source, clause_var_1_target, clause_var_2_source, clause_var_2_target = clause_variables + qualified_nodes_1_source = subsets[clause_var_1_source] + qualified_nodes_1_target = subsets[clause_var_1_target] + qualified_nodes_2_source = subsets[clause_var_2_source] + qualified_nodes_2_target = subsets[clause_var_2_target] + qualified_comparison_nodes_1 = numba.typed.List(zip(qualified_nodes_1_source, qualified_nodes_1_target)) + qualified_comparison_nodes_2 = numba.typed.List(zip(qualified_nodes_2_source, qualified_nodes_2_target)) + qualified_comparison_nodes = numba.typed.List(qualified_comparison_nodes_1) + qualified_comparison_nodes.extend(qualified_comparison_nodes_2) + # 1. + if atom_trace: + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + qualified_edges.append(qualified_comparison_nodes) + # 2. + # Add annotations for comparison clause. For now, we don't distinguish between LHS and RHS annotations + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + for qe in qualified_comparison_nodes: + a.append(interval.closed(1, 1)) + annotations.append(a) # node/edge, annotations, qualified nodes, qualified edges, edges to be added applicable_rules_threadsafe[piter] = numba.typed.List([(target_edge, annotations, qualified_nodes, qualified_edges, edges_to_be_added)]) @@ -1215,12 +1250,202 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e @numba.njit(cache=False) -def get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, neighbors): +def refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_node, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph): + # Loop through all clauses till clause i-1 and update subsets recursively + # Then check if the clause still satisfies the thresholds + clause = clauses[i] + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + clause_bnd = clause[3] + clause_operator = clause[4] + + # Keep track of the variables that were refined (start with clause_variables) and variables that need refining + satisfaction = True + all_variables_refined = numba.typed.List(clause_variables) + variables_just_refined = numba.typed.List(clause_variables) + new_variables_refined = numba.typed.List.empty_list(numba.types.string) + while len(variables_just_refined) > 0: + for j in range(i): + c = clauses[j] + c_type = c[0] + c_label = c[1] + c_variables = c[2] + c_bnd = c[3] + c_operator = c[4] + + # If it is an edge clause or edge comparison clause, check if any of clause_variables are in c_variables + # If yes, then update the variable that is with it in the clause + if c_type == 'edge' or (c_type == 'comparison' and len(c_variables) > 2): + for v in variables_just_refined: + for k, cv in enumerate(c_variables): + if cv == v: + # Find which variable needs to be refined, 1st or 2nd. + # 2nd variable needs refining + if k == 0: + refine_idx = 1 + refine_v = c_variables[1] + # 1st variable needs refining + elif k == 1: + refine_idx = 0 + refine_v = c_variables[0] + # 2nd variable needs refining + elif k == 2: + refine_idx = 1 + refine_v = c_variables[3] + # 1st variable needs refining + else: + refine_idx = 0 + refine_v = c_variables[2] + + # Refine the variable + if refine_v not in all_variables_refined: + new_variables_refined.append(refine_v) + + if c_type == 'edge': + clause_var_1, clause_var_2 = (refine_v, cv) if refine_idx == 0 else (cv, refine_v) + del subsets[refine_v] + subset_source, subset_target = get_node_rule_edge_clause_subset(clause_var_1, clause_var_2, target_node, subsets, neighbors, reverse_neighbors, nodes) + + # Get qualified edges + qe = get_qualified_components_edge_clause(interpretations_edge, subset_source, subset_target, c_label, c_bnd, reverse_graph) + subsets[clause_var_1] = qe[0] + subsets[clause_var_2] = qe[1] + + # Check if we still satisfy the clause + satisfaction = check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, c_label, thresholds[j], reverse_graph) and satisfaction + else: + # We do not support refinement for comparison clauses + pass + + if not satisfaction: + return satisfaction + + variables_just_refined = numba.typed.List(new_variables_refined) + all_variables_refined.extend(new_variables_refined) + new_variables_refined.clear() + + return satisfaction + + +@numba.njit(cache=False) +def refine_subsets_edge_rule(interpretations_edge, clauses, i, subsets, target_edge, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph): + # Loop through all clauses till clause i-1 and update subsets recursively + # Then check if the clause still satisfies the thresholds + clause = clauses[i] + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + clause_bnd = clause[3] + clause_operator = clause[4] + + # Keep track of the variables that were refined (start with clause_variables) and variables that need refining + satisfaction = True + all_variables_refined = numba.typed.List(clause_variables) + variables_just_refined = numba.typed.List(clause_variables) + new_variables_refined = numba.typed.List.empty_list(numba.types.string) + while len(variables_just_refined) > 0: + for j in range(i): + c = clauses[j] + c_type = c[0] + c_label = c[1] + c_variables = c[2] + c_bnd = c[3] + c_operator = c[4] + + # If it is an edge clause or edge comparison clause, check if any of clause_variables are in c_variables + # If yes, then update the variable that is with it in the clause + if c_type == 'edge' or (c_type == 'comparison' and len(c_variables) > 2): + for v in variables_just_refined: + for k, cv in enumerate(c_variables): + if cv == v: + # Find which variable needs to be refined, 1st or 2nd. + # 2nd variable needs refining + if k == 0: + refine_idx = 1 + refine_v = c_variables[1] + # 1st variable needs refining + elif k == 1: + refine_idx = 0 + refine_v = c_variables[0] + # 2nd variable needs refining + elif k == 2: + refine_idx = 1 + refine_v = c_variables[3] + # 1st variable needs refining + else: + refine_idx = 0 + refine_v = c_variables[2] + + # Refine the variable + if refine_v not in all_variables_refined: + new_variables_refined.append(refine_v) + + if c_type == 'edge': + clause_var_1, clause_var_2 = (refine_v, cv) if refine_idx == 0 else (cv, refine_v) + del subsets[refine_v] + subset_source, subset_target = get_edge_rule_edge_clause_subset(clause_var_1, clause_var_2, target_edge, subsets, neighbors, reverse_neighbors, nodes) + + # Get qualified edges + qe = get_qualified_components_edge_clause(interpretations_edge, subset_source, subset_target, c_label, c_bnd, reverse_graph) + subsets[clause_var_1] = qe[0] + subsets[clause_var_2] = qe[1] + + # Check if we still satisfy the clause + satisfaction = check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, c_label, thresholds[j], reverse_graph) and satisfaction + else: + # We do not support refinement for comparison clauses + pass + + if not satisfaction: + return satisfaction + + variables_just_refined = numba.typed.List(new_variables_refined) + all_variables_refined.extend(new_variables_refined) + new_variables_refined.clear() + + return satisfaction + + +@numba.njit(cache=False) +def check_node_clause_satisfaction(interpretations_node, subsets, subset, clause_var_1, clause_label, threshold): + threshold_quantifier_type = threshold[1][1] + if threshold_quantifier_type == 'total': + neigh_len = len(subset) + + # Available is all neighbors that have a particular label with bound inside [0,1] + elif threshold_quantifier_type == 'available': + neigh_len = len(get_qualified_components_node_clause(interpretations_node, subset, clause_label, interval.closed(0, 1))) + + # Only take length of clause_var_1 because length of subsets of var_1 and var_2 are supposed to be equal + qualified_neigh_len = len(subsets[clause_var_1]) + satisfaction = _satisfies_threshold(neigh_len, qualified_neigh_len, threshold) + return satisfaction + + +@numba.njit(cache=False) +def check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, clause_label, threshold, reverse_graph): + threshold_quantifier_type = threshold[1][1] + if threshold_quantifier_type == 'total': + neigh_len = sum([len(l) for l in subset_target]) + + # Available is all neighbors that have a particular label with bound inside [0,1] + elif threshold_quantifier_type == 'available': + neigh_len = len(get_qualified_components_edge_clause(interpretations_edge, subset_source, subset_target, clause_label, interval.closed(0, 1), reverse_graph)[0]) + + qualified_neigh_len = len(subsets[clause_var_1]) + satisfaction = _satisfies_threshold(neigh_len, qualified_neigh_len, threshold) + return satisfaction + + +@numba.njit(cache=False) +def get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes): # The groundings for node clauses are either the target node, neighbors of the target node, or an existing subset of nodes if clause_var_1 == '__target': subset = numba.typed.List([target_node]) else: - subset = neighbors[target_node] if clause_var_1 not in subsets else subsets[clause_var_1] + nodes_without_target = numba.typed.List([n for n in nodes if n != target_node]) + subset = nodes_without_target if clause_var_1 not in subsets else subsets[clause_var_1] return subset @@ -1252,10 +1477,10 @@ def get_node_rule_edge_clause_subset(clause_var_1, clause_var_2, target_node, su subset_target = numba.typed.List([numba.typed.List([target_node]) for _ in subset_source]) # Case 2: - # We replace Y by all nodes and Z by the neighbors of each of these nodes + # We replace Y by all nodes (except target_node) and Z by the neighbors of each of these nodes elif clause_var_1 not in subsets and clause_var_2 not in subsets: - subset_source = numba.typed.List(nodes) - subset_target = numba.typed.List([neighbors[n] for n in subset_source]) + subset_source = numba.typed.List([n for n in nodes if n != target_node]) + subset_target = numba.typed.List([numba.typed.List([nn for nn in neighbors[n] if nn != target_node]) for n in subset_source]) # Case 3: # We replace Y by the sources of Z @@ -1266,32 +1491,43 @@ def get_node_rule_edge_clause_subset(clause_var_1, clause_var_2, target_node, su for n in subsets[clause_var_2]: sources = reverse_neighbors[n] for source in sources: - subset_source.append(source) - subset_target.append(numba.typed.List([n])) + if source != target_node: + subset_source.append(source) + subset_target.append(numba.typed.List([n])) # Case 4: # We replace Z by the neighbors of Y elif clause_var_1 in subsets and clause_var_2 not in subsets: subset_source = subsets[clause_var_1] - subset_target = numba.typed.List([neighbors[n] for n in subset_source]) + subset_target = numba.typed.List([numba.typed.List([nn for nn in neighbors[n] if nn != target_node]) for n in subset_source]) # Case 5: else: subset_source = subsets[clause_var_1] subset_target = numba.typed.List([subsets[clause_var_2] for _ in subset_source]) + # If any of the subsets are empty return them in the correct type + if len(subset_source) == 0: + subset_source = numba.typed.List.empty_list(node_type) + subset_target = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) + # If any sub lists in subset target are empty, add correct type for empty list + for i, t in enumerate(subset_target): + if len(t) == 0: + subset_target[i] = numba.typed.List.empty_list(node_type) + return subset_source, subset_target @numba.njit(cache=False) -def get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, neighbors): +def get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, nodes): # The groundings for node clauses are either the source, target, neighbors of the source node, or an existing subset of nodes if clause_var_1 == '__source': subset = numba.typed.List([target_edge[0]]) elif clause_var_1 == '__target': subset = numba.typed.List([target_edge[1]]) else: - subset = neighbors[target_edge[0]] if clause_var_1 not in subsets else subsets[clause_var_1] + nodes_without_target_or_source = numba.typed.List([n for n in nodes if n != target_edge[0] and n != target_edge[1]]) + subset = nodes_without_target_or_source if clause_var_1 not in subsets else subsets[clause_var_1] return subset @@ -1342,10 +1578,10 @@ def get_edge_rule_edge_clause_subset(clause_var_1, clause_var_2, target_edge, su subset_target = numba.typed.List([numba.typed.List([target_edge[1]]) for _ in subset_source]) # Case 2: - # We replace Y by all nodes and Z by the neighbors of each of these nodes + # We replace Y by all nodes (except source/target) and Z by the neighbors of each of these nodes elif clause_var_1 not in subsets and clause_var_2 not in subsets: - subset_source = numba.typed.List(nodes) - subset_target = numba.typed.List([neighbors[n] for n in subset_source]) + subset_source = numba.typed.List([n for n in nodes if n != target_edge[0] and n != target_edge[1]]) + subset_target = numba.typed.List([numba.typed.List([nn for nn in neighbors[n] if nn != target_edge[0] and nn != target_edge[1]]) for n in subset_source]) # Case 3: # We replace Y by the sources of Z @@ -1356,20 +1592,30 @@ def get_edge_rule_edge_clause_subset(clause_var_1, clause_var_2, target_edge, su for n in subsets[clause_var_2]: sources = reverse_neighbors[n] for source in sources: - subset_source.append(source) - subset_target.append(numba.typed.List([n])) + if source != target_edge[0] and source != target_edge[1]: + subset_source.append(source) + subset_target.append(numba.typed.List([n])) # Case 4: # We replace Z by the neighbors of Y elif clause_var_1 in subsets and clause_var_2 not in subsets: subset_source = subsets[clause_var_1] - subset_target = numba.typed.List([neighbors[n] for n in subset_source]) + subset_target = numba.typed.List([numba.typed.List([nn for nn in neighbors[n] if nn != target_edge[0] and nn != target_edge[1]]) for n in subset_source]) # Case 5: else: subset_source = subsets[clause_var_1] subset_target = numba.typed.List([subsets[clause_var_2] for _ in subset_source]) + # If any of the subsets are empty return them in the correct type + if len(subset_source) == 0: + subset_source = numba.typed.List.empty_list(node_type) + subset_target = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) + # If any sub lists in subset target are empty, add correct type for empty list + for i, t in enumerate(subset_target): + if len(t) == 0: + subset_target[i] = numba.typed.List.empty_list(node_type) + return subset_source, subset_target @@ -1378,7 +1624,7 @@ def get_qualified_components_node_clause(interpretations_node, candidates, l, bn # Get all the qualified neighbors for a particular clause qualified_nodes = numba.typed.List.empty_list(node_type) for n in candidates: - if is_satisfied_node(interpretations_node, n, (l, bnd)): + if is_satisfied_node(interpretations_node, n, (l, bnd)) and n not in qualified_nodes: qualified_nodes.append(n) return qualified_nodes @@ -1686,7 +1932,7 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat updated_bnds.append(world.world[p2]) if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, p1, interval.closed(lower, upper))) - + # Gather convergence data change = 0 if updated: @@ -1702,7 +1948,7 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat change = max(change, max_delta) else: change = 1 + ip_update_cnt - + return (updated, change) except: return (False, 0) @@ -1711,7 +1957,7 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat @numba.njit(cache=False) def _update_rule_trace(rule_trace, qn, qe, prev_bnd, name): rule_trace.append((qn, qe, prev_bnd.copy(), name)) - + @numba.njit(cache=False) def are_satisfied_node(interpretations, comp, nas): @@ -1878,7 +2124,7 @@ def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, at world.world[p1].set_static(True) if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, p1, interval.closed(0,1))) - # Add inconsistent predicates to a list + # Add inconsistent predicates to a list @numba.njit(cache=False) From 6d412193ef115aa1553e1ddc554a510ed3385dcc Mon Sep 17 00:00:00 2001 From: Dyuman Date: Sat, 13 Apr 2024 21:32:58 +0200 Subject: [PATCH 06/73] bug --- pyreason/scripts/interpretation/interpretation.py | 2 +- pyreason/scripts/interpretation/interpretation_parallel.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 79f0fb4..3166750 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -1156,7 +1156,7 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e # Refine subsets based on any updates if satisfaction: - satisfaction = refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_edge, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph) and satisfaction + satisfaction = refine_subsets_edge_rule(interpretations_edge, clauses, i, subsets, target_edge, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph) and satisfaction # Exit loop if even one clause is not satisfied if not satisfaction: diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index 0aa7059..cfd7a9f 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -1156,7 +1156,7 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e # Refine subsets based on any updates if satisfaction: - satisfaction = refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_edge, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph) and satisfaction + satisfaction = refine_subsets_edge_rule(interpretations_edge, clauses, i, subsets, target_edge, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph) and satisfaction # Exit loop if even one clause is not satisfied if not satisfaction: From 7fdf73c1a4c363445b1c202ea4729eff1ca5679b Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Mon, 27 May 2024 11:20:28 +0200 Subject: [PATCH 07/73] Update setup.py --- setup.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/setup.py b/setup.py index 0c519e7..ccd7e3f 100644 --- a/setup.py +++ b/setup.py @@ -8,11 +8,7 @@ setup( name='pyreason', -<<<<<<< v3.0.0 version='3.0.0', -======= - version='2.3.0', ->>>>>>> main author='Dyuman Aditya', author_email='dyuman.aditya@gmail.com', description='An explainable inference software supporting annotated, real valued, graph based and temporal logic', From b01a80394334feb62a1ca63ec9b06934d1390056 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sat, 15 Jun 2024 11:24:20 +0200 Subject: [PATCH 08/73] new grounding methods, fixed annotations and speedups to be tested --- .../scripts/interpretation/interpretation.py | 515 +++++++++++++++++- .../numba_wrapper/numba_types/rule_type.py | 26 +- pyreason/scripts/rules/rule_internal.py | 6 +- pyreason/scripts/utils/rule_parser.py | 28 +- tests/test_hello_world.py | 4 +- tests/test_hello_world_parallel.py | 4 +- 6 files changed, 543 insertions(+), 40 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 3166750..575a47d 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -579,8 +579,9 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Only go through if the rule can be applied within the given timesteps, or we're running until convergence delta_t = rule.get_delta() if t + delta_t <= tmax or tmax == -1 or again: - applicable_node_rules = _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, neighbors, reverse_neighbors, atom_trace, reverse_graph, nodes_to_skip[i]) - applicable_edge_rules = _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, reverse_graph, edges_to_skip[i]) + # applicable_node_rules = _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, neighbors, reverse_neighbors, atom_trace, reverse_graph, nodes_to_skip[i]) + # applicable_edge_rules = _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, reverse_graph, edges_to_skip[i]) + applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace) # Loop through applicable rules and add them to the rules to be applied for later or next fp operation for applicable_rule in applicable_node_rules: @@ -752,6 +753,332 @@ def get_interpretation_dict(self): return interpretations +@numba.njit(cache=True) +def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace): + # Extract rule params + rule_type = rule.get_type() + head_variables = rule.get_head_variables() + clauses = rule.get_clauses() + thresholds = rule.get_thresholds() + ann_fn = rule.get_annotation_function() + rule_edges = rule.get_edges() + + if rule_type == 'node': + head_var_1 = head_variables[0] + else: + head_var_1, head_var_2 = head_variables[0], head_variables[1] + + # We return a list of tuples which specify the target nodes/edges that have made the rule body true + applicable_rules_node = numba.typed.List.empty_list(node_applicable_rule_type) + applicable_rules_edge = numba.typed.List.empty_list(edge_applicable_rule_type) + + # Grounding procedure + # 1. Go through each clause and check which variables have not been initialized in groundings + # 2. Check satisfaction of variables based on the predicate in the clause + + # Grounding variable that maps variables in the body to a list of grounded nodes + # Grounding edges that maps edge variables to a list of edges + groundings = numba.typed.Dict.empty(key_type=numba.types.string, value_type=list_of_nodes) + groundings_edges = numba.typed.Dict.empty(key_type=edge_type, value_type=list_of_edges) + + # Dependency graph that keeps track of the connections between the variables in the body + dependency_graph_neighbors = numba.typed.Dict.empty(key_type=node_type, value_type=list_of_nodes) + dependency_graph_reverse_neighbors = numba.typed.Dict.empty(key_type=node_type, value_type=list_of_nodes) + + satisfaction = True + for i, clause in enumerate(clauses): + # Unpack clause variables + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + clause_bnd = clause[3] + clause_operator = clause[4] + + # This is a node clause + if clause_type == 'node': + clause_var_1 = clause_variables[0] + + # Get subset of nodes that can be used to ground the variable + grounding = get_rule_node_clause_grounding(clause_var_1, groundings, nodes) + + # Narrow subset based on predicate + qualified_groundings = get_qualified_node_groundings(interpretations_node, grounding, clause_label, clause_bnd) + groundings[clause_var_1] = qualified_groundings + + # Check satisfaction of those nodes wrt the threshold + satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction + + # This is an edge clause + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + + # Get subset of edges that can be used to ground the variables + grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes) + + # Narrow subset based on predicate (save the edges that are qualified to use for finding future groundings faster) + qualified_groundings = get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, clause_bnd) + + # Check satisfaction of those edges wrt the threshold + satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction + + # Update the groundings + groundings[clause_var_1] = numba.typed.List.empty_list(node_type) + groundings[clause_var_2] = numba.typed.List.empty_list(node_type) + for e in qualified_groundings: + if e[0] not in groundings[clause_var_1]: + groundings[clause_var_1].append(e[0]) + if e[1] not in groundings[clause_var_2]: + groundings[clause_var_2].append(e[1]) + + # Update the edge groundings (to use later for grounding other clauses with the same variables) + groundings_edges[(clause_var_1, clause_var_2)] = qualified_groundings + + # Update dependency graph + # Add a connection between clause_var_1 -> clause_var_2 and vice versa + if clause_var_1 not in dependency_graph_neighbors: + dependency_graph_neighbors[clause_var_1] = numba.typed.List([clause_var_2]) + elif clause_var_2 not in dependency_graph_neighbors[clause_var_1]: + dependency_graph_neighbors[clause_var_1].append(clause_var_2) + if clause_var_2 not in dependency_graph_reverse_neighbors: + dependency_graph_reverse_neighbors[clause_var_2] = numba.typed.List([clause_var_1]) + elif clause_var_1 not in dependency_graph_reverse_neighbors[clause_var_2]: + dependency_graph_reverse_neighbors[clause_var_2].append(clause_var_1) + + # This is a comparison clause + else: + pass + + # Refine the subsets based on any updates + if satisfaction: + refine_groundings(clause_variables, groundings, groundings_edges, dependency_graph_neighbors, dependency_graph_reverse_neighbors) + + # If satisfaction is false, break + if not satisfaction: + break + + # All clauses of the rule have been satisfied, now add elements to trace and setup any edges that need to be added to the graph + if satisfaction: + # Check if all clauses are satisfied again in case the refining process changed anything + for i, clause in enumerate(clauses): + # Unpack clause variables + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, groundings[clause_var_1], groundings[clause_var_1], clause_label, thresholds[i]) and satisfaction + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, groundings_edges[(clause_var_1, clause_var_2)], groundings_edges[(clause_var_1, clause_var_2)], clause_label, thresholds[i]) and satisfaction + + # If satisfaction is still true, then continue to setup any edges to be added and annotations + # Fill out the rules to be applied lists + if satisfaction: + # Setup edges to be added and fill rules to be applied + # Setup traces and inputs for annotation function + # Loop through the clause data and setup final annotations and trace variables + # Three cases: 1.node rule, 2. edge rule with infer edges, 3. edge rule + if rule_type == 'node': + # Loop through all the head variable groundings and add it to the rules to be applied + # Loop through the clauses and add appropriate trace data and annotations + for head_grounding in groundings[head_var_1]: + qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) + qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) + annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) + edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + for i, clause in enumerate(clauses): + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + + # 1. + if atom_trace: + if clause_var_1 == head_var_1: + qualified_nodes.append(numba.typed.List([head_grounding])) + else: + qualified_nodes.append(numba.typed.List(groundings[clause_var_1])) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1: + a.append(interpretations_node[head_grounding].world[clause_label]) + else: + for qn in groundings[clause_var_1]: + a.append(interpretations_node[qn].world[clause_label]) + annotations.append(a) + + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + # 1. + if atom_trace: + # Cases: Both equal, one equal, none equal + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + if clause_var_1 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_grounding]) + qualified_edges.append(es) + elif clause_var_2 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_grounding]) + qualified_edges.append(es) + else: + qualified_edges.append(numba.typed.List(groundings_edges[(clause_var_1, clause_var_2)])) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_2 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[1] == head_grounding: + a.append(interpretations_edge[e].world[clause_label]) + else: + for qe in groundings_edges[(clause_var_1, clause_var_2)]: + a.append(interpretations_edge[qe].world[clause_label]) + annotations.append(a) + else: + # Comparison clause (we do not handle for now) + pass + + # For each grounding add a rule to be applied + applicable_rules_node.append((head_grounding, annotations, qualified_nodes, qualified_edges, edges_to_be_added)) + + elif rule_type == 'edge': + head_var_1 = head_variables[0] + head_var_2 = head_variables[1] + head_var_1_groundings = groundings[head_var_1] + head_var_2_groundings = groundings[head_var_2] + + source, target, _ = rule_edges + infer_edges = True if source != '' and target != '' else False + + # Prepare the edges that we will loop over. + # For infer edges we loop over each combination pair + # Else we loop over the valid edges in the graph + valid_edge_groundings = numba.typed.List.empty_list(edge_type) + for g1 in head_var_1_groundings: + for g2 in head_var_2_groundings: + if infer_edges: + valid_edge_groundings.append((g1, g2)) + else: + if (g1, g2) in edges: + valid_edge_groundings.append((g1, g2)) + + # Loop through the head variable groundings + for valid_e in valid_edge_groundings: + head_var_1_grounding, head_var_2_grounding = valid_e[0], valid_e[1] + qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) + qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) + annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) + edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + + if infer_edges: + edges_to_be_added[0].append(head_var_1_grounding) + edges_to_be_added[1].append(head_var_2_grounding) + + for i, clause in enumerate(clauses): + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + # 1. + if atom_trace: + if clause_var_1 == head_var_1: + qualified_nodes.append(numba.typed.List([head_var_1_grounding])) + elif clause_var_1 == head_var_2: + qualified_nodes.append(numba.typed.List([head_var_2_grounding])) + else: + qualified_nodes.append(numba.typed.List(groundings[clause_var_1])) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1: + a.append(interpretations_node[head_var_1_grounding].world[clause_label]) + elif clause_var_1 == head_var_2: + a.append(interpretations_node[head_var_2_grounding].world[clause_label]) + else: + for qn in groundings[clause_var_1]: + a.append(interpretations_node[qn].world[clause_label]) + annotations.append(a) + + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + # 1. + if atom_trace: + # Cases: + # 1. Both equal (cv1 = hv1 and cv2 = hv2 or cv1 = hv2 and cv2 = hv1) + # 2. One equal (cv1 = hv1 or cv2 = hv1 or cv1 = hv2 or cv2 = hv2) + # 3. None equal + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + if clause_var_1 == head_var_1 and clause_var_2 == head_var_2: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_1_grounding and e[1] == head_var_2_grounding]) + qualified_edges.append(es) + elif clause_var_1 == head_var_2 and clause_var_2 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_2_grounding and e[1] == head_var_1_grounding]) + qualified_edges.append(es) + elif clause_var_1 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_1_grounding]) + qualified_edges.append(es) + elif clause_var_1 == head_var_2: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_2_grounding]) + qualified_edges.append(es) + elif clause_var_2 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_var_1_grounding]) + qualified_edges.append(es) + elif clause_var_2 == head_var_2: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_var_2_grounding]) + qualified_edges.append(es) + else: + qualified_edges.append(numba.typed.List(groundings_edges[(clause_var_1, clause_var_2)])) + + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1 and clause_var_2 == head_var_2: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_var_1_grounding and e[1] == head_var_2_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_1 == head_var_2 and clause_var_2 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_var_2_grounding and e[1] == head_var_1_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_1 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_var_1_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_1 == head_var_2: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_var_2_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_2 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[1] == head_var_1_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_2 == head_var_2: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[1] == head_var_2_grounding: + a.append(interpretations_edge[e].world[clause_label]) + else: + for qe in groundings_edges[(clause_var_1, clause_var_2)]: + a.append(interpretations_edge[qe].world[clause_label]) + annotations.append(a) + + # For each grounding combination add a rule to be applied + e = (head_var_1_grounding, head_var_2_grounding) + applicable_rules_edge.append((e, annotations, qualified_nodes, qualified_edges, edges_to_be_added)) + + # Return the applicable rules + return applicable_rules_node, applicable_rules_edge + + @numba.njit(cache=True) def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, neighbors, reverse_neighbors, atom_trace, reverse_graph, nodes_to_skip): # Extract rule params @@ -1249,8 +1576,63 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e return applicable_rules +@numba.njit(cache=True) +def refine_groundings(clause_variables, groundings, groundings_edges, dependency_graph_neighbors, dependency_graph_reverse_neighbors): + # Loop through the dependency graph and refine the groundings that have connections + all_variables_refined = numba.typed.List(clause_variables) + variables_just_refined = numba.typed.List(clause_variables) + new_variables_refined = numba.typed.List.empty_list(numba.types.string) + while len(variables_just_refined) > 0: + for refined_variable in variables_just_refined: + # Refine all the neighbors of the refined variable + if refined_variable in dependency_graph_neighbors: + for neighbor in dependency_graph_neighbors[refined_variable]: + old_edge_groundings = groundings_edges[(refined_variable, neighbor)] + new_node_groundings = groundings[refined_variable] + + # Delete old groundings for the variable being refined + del groundings[neighbor] + groundings[neighbor] = numba.typed.List.empty_list(node_type) + + # Update the edge groundings and node groundings + qualified_groundings = numba.typed.List([edge for edge in old_edge_groundings if edge[0] in new_node_groundings]) + for e in qualified_groundings: + if e[1] not in groundings[neighbor]: + groundings[neighbor].append(e[1]) + groundings_edges[(refined_variable, neighbor)] = qualified_groundings + + # Add the neighbor to the list of refined variables so that we can refine for all its neighbors + if neighbor not in all_variables_refined: + new_variables_refined.append(neighbor) + + if refined_variable in dependency_graph_reverse_neighbors: + for reverse_neighbor in dependency_graph_reverse_neighbors[refined_variable]: + old_edge_groundings = groundings_edges[(reverse_neighbor, refined_variable)] + new_node_groundings = groundings[refined_variable] + + # Delete old groundings for the variable being refined + del groundings[reverse_neighbor] + groundings[reverse_neighbor] = numba.typed.List.empty_list(node_type) + + # Update the edge groundings and node groundings + qualified_groundings = numba.typed.List([edge for edge in old_edge_groundings if edge[1] in new_node_groundings]) + for e in qualified_groundings: + if e[0] not in groundings[reverse_neighbor]: + groundings[reverse_neighbor].append(e[0]) + groundings_edges[(reverse_neighbor, refined_variable)] = qualified_groundings + + # Add the neighbor to the list of refined variables so that we can refine for all its neighbors + if reverse_neighbor not in all_variables_refined: + new_variables_refined.append(reverse_neighbor) + + variables_just_refined = numba.typed.List(new_variables_refined) + all_variables_refined.extend(new_variables_refined) + new_variables_refined.clear() + + @numba.njit(cache=True) def refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_node, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph): + """NOTE: DEPRECATED""" # Loop through all clauses till clause i-1 and update subsets recursively # Then check if the clause still satisfies the thresholds clause = clauses[i] @@ -1330,6 +1712,7 @@ def refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_n @numba.njit(cache=True) def refine_subsets_edge_rule(interpretations_edge, clauses, i, subsets, target_edge, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph): + """NOTE: DEPRECATED""" # Loop through all clauses till clause i-1 and update subsets recursively # Then check if the clause still satisfies the thresholds clause = clauses[i] @@ -1407,8 +1790,39 @@ def refine_subsets_edge_rule(interpretations_edge, clauses, i, subsets, target_e return satisfaction +@numba.njit(cache=True) +def check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_grounding, clause_label, threshold): + threshold_quantifier_type = threshold[1][1] + if threshold_quantifier_type == 'total': + neigh_len = len(grounding) + + # Available is all neighbors that have a particular label with bound inside [0,1] + elif threshold_quantifier_type == 'available': + neigh_len = len(get_qualified_node_groundings(interpretations_node, grounding, clause_label, interval.closed(0, 1))) + + qualified_neigh_len = len(qualified_grounding) + satisfaction = _satisfies_threshold(neigh_len, qualified_neigh_len, threshold) + return satisfaction + + +@numba.njit(cache=True) +def check_edge_grounding_threshold_satisfaction(interpretations_edge, grounding, qualified_grounding, clause_label, threshold): + threshold_quantifier_type = threshold[1][1] + if threshold_quantifier_type == 'total': + neigh_len = len(grounding) + + # Available is all neighbors that have a particular label with bound inside [0,1] + elif threshold_quantifier_type == 'available': + neigh_len = len(get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, interval.closed(0, 1))) + + qualified_neigh_len = len(qualified_grounding) + satisfaction = _satisfies_threshold(neigh_len, qualified_neigh_len, threshold) + return satisfaction + + @numba.njit(cache=True) def check_node_clause_satisfaction(interpretations_node, subsets, subset, clause_var_1, clause_label, threshold): + """NOTE: DEPRECATED""" threshold_quantifier_type = threshold[1][1] if threshold_quantifier_type == 'total': neigh_len = len(subset) @@ -1425,6 +1839,7 @@ def check_node_clause_satisfaction(interpretations_node, subsets, subset, clause @numba.njit(cache=True) def check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, clause_label, threshold, reverse_graph): + """NOTE: DEPRECATED""" threshold_quantifier_type = threshold[1][1] if threshold_quantifier_type == 'total': neigh_len = sum([len(l) for l in subset_target]) @@ -1438,8 +1853,61 @@ def check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, return satisfaction +@numba.njit(cache=True) +def get_rule_node_clause_grounding(clause_var_1, groundings, nodes): + # The groundings for a node clause can be either a previous grounding or all possible nodes + grounding = numba.typed.List(nodes) if clause_var_1 not in groundings else groundings[clause_var_1] + return grounding + + +@numba.njit(cache=True) +def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes): + # There are 4 cases for predicate(Y,Z): + # 1. Both predicate variables Y and Z have not been encountered before + # 2. The source variable Y has not been encountered before but the target variable Z has + # 3. The target variable Z has not been encountered before but the source variable Y has + # 4. Both predicate variables Y and Z have been encountered before + edge_groundings = numba.typed.List.empty_list(edge_type) + + # Case 1: + # We replace Y by all nodes and Z by the neighbors of each of these nodes + if clause_var_1 not in groundings and clause_var_2 not in groundings: + for n in nodes: + es = numba.typed.List([(n, nn) for nn in neighbors[n]]) + edge_groundings.extend(es) + + # Case 2: + # We replace Y by the sources of Z + elif clause_var_1 not in groundings and clause_var_2 in groundings: + for n in groundings[clause_var_2]: + es = numba.typed.List([(nn, n) for nn in reverse_neighbors[n]]) + edge_groundings.extend(es) + + # Case 3: + # We replace Z by the neighbors of Y + elif clause_var_1 in groundings and clause_var_2 not in groundings: + for n in groundings[clause_var_1]: + es = numba.typed.List([(n, nn) for nn in neighbors[n]]) + edge_groundings.extend(es) + + # Case 4: + # We have seen both variables before + else: + # # We have already seen these two variables in an edge clause + # if (clause_var_1, clause_var_2) in groundings_edges: + # edge_groundings = groundings_edges[(clause_var_1, clause_var_2)] + # # We have seen both these variables but not in an edge clause together + # else: + for n in groundings[clause_var_1]: + es = numba.typed.List([(n, nn) for nn in neighbors[n] if nn in groundings[clause_var_2]]) + edge_groundings.extend(es) + + return edge_groundings + + @numba.njit(cache=True) def get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes): + """NOTE: DEPRECATED""" # The groundings for node clauses are either the target node, neighbors of the target node, or an existing subset of nodes if clause_var_1 == '__target': subset = numba.typed.List([target_node]) @@ -1451,6 +1919,7 @@ def get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes): @numba.njit(cache=True) def get_node_rule_edge_clause_subset(clause_var_1, clause_var_2, target_node, subsets, neighbors, reverse_neighbors, nodes): + """NOTE: DEPRECATED""" # There are 5 cases for predicate(Y,Z): # 1. Either one or both of Y, Z are the target node # 2. Both predicate variables Y and Z have not been encountered before @@ -1520,6 +1989,7 @@ def get_node_rule_edge_clause_subset(clause_var_1, clause_var_2, target_node, su @numba.njit(cache=True) def get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, nodes): + """NOTE: DEPRECATED""" # The groundings for node clauses are either the source, target, neighbors of the source node, or an existing subset of nodes if clause_var_1 == '__source': subset = numba.typed.List([target_edge[0]]) @@ -1533,6 +2003,7 @@ def get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, nodes): @numba.njit(cache=True) def get_edge_rule_edge_clause_subset(clause_var_1, clause_var_2, target_edge, subsets, neighbors, reverse_neighbors, nodes): + """NOTE: DEPRECATED""" # There are 5 cases for predicate(Y,Z): # 1. Either one or both of Y, Z are the source or target node # 2. Both predicate variables Y and Z have not been encountered before @@ -1619,8 +2090,31 @@ def get_edge_rule_edge_clause_subset(clause_var_1, clause_var_2, target_edge, su return subset_source, subset_target +@numba.njit(cache=True) +def get_qualified_node_groundings(interpretations_node, grounding, clause_l, clause_bnd): + # Filter the grounding by the predicate and bound of the clause + qualified_groundings = numba.typed.List.empty_list(node_type) + for n in grounding: + if is_satisfied_node(interpretations_node, n, (clause_l, clause_bnd)): + qualified_groundings.append(n) + + return qualified_groundings + + +@numba.njit(cache=True) +def get_qualified_edge_groundings(interpretations_edge, grounding, clause_l, clause_bnd): + # Filter the grounding by the predicate and bound of the clause + qualified_groundings = numba.typed.List.empty_list(edge_type) + for e in grounding: + if is_satisfied_edge(interpretations_edge, e, (clause_l, clause_bnd)): + qualified_groundings.append(e) + + return qualified_groundings + + @numba.njit(cache=True) def get_qualified_components_node_clause(interpretations_node, candidates, l, bnd): + """NOTE: DEPRECATED""" # Get all the qualified neighbors for a particular clause qualified_nodes = numba.typed.List.empty_list(node_type) for n in candidates: @@ -1632,6 +2126,7 @@ def get_qualified_components_node_clause(interpretations_node, candidates, l, bn @numba.njit(cache=True) def get_qualified_components_node_comparison_clause(interpretations_node, candidates, l, bnd): + """NOTE: DEPRECATED""" # Get all the qualified neighbors for a particular comparison clause and return them along with the number associated qualified_nodes = numba.typed.List.empty_list(node_type) qualified_nodes_numbers = numba.typed.List.empty_list(numba.types.float64) @@ -1646,6 +2141,7 @@ def get_qualified_components_node_comparison_clause(interpretations_node, candid @numba.njit(cache=True) def get_qualified_components_edge_clause(interpretations_edge, candidates_source, candidates_target, l, bnd, reverse_graph): + """NOTE: DEPRECATED""" # Get all the qualified sources and targets for a particular clause qualified_nodes_source = numba.typed.List.empty_list(node_type) qualified_nodes_target = numba.typed.List.empty_list(node_type) @@ -1661,6 +2157,7 @@ def get_qualified_components_edge_clause(interpretations_edge, candidates_source @numba.njit(cache=True) def get_qualified_components_edge_comparison_clause(interpretations_edge, candidates_source, candidates_target, l, bnd, reverse_graph): + """NOTE: DEPRECATED""" # Get all the qualified sources and targets for a particular clause qualified_nodes_source = numba.typed.List.empty_list(node_type) qualified_nodes_target = numba.typed.List.empty_list(node_type) @@ -1679,6 +2176,7 @@ def get_qualified_components_edge_comparison_clause(interpretations_edge, candid @numba.njit(cache=True) def compare_numbers_node_predicate(numbers_1, numbers_2, op, qualified_nodes_1, qualified_nodes_2): + """NOTE: DEPRECATED""" result = False final_qualified_nodes_1 = numba.typed.List.empty_list(node_type) final_qualified_nodes_2 = numba.typed.List.empty_list(node_type) @@ -1711,6 +2209,7 @@ def compare_numbers_node_predicate(numbers_1, numbers_2, op, qualified_nodes_1, @numba.njit(cache=True) def compare_numbers_edge_predicate(numbers_1, numbers_2, op, qualified_nodes_1a, qualified_nodes_1b, qualified_nodes_2a, qualified_nodes_2b): + """NOTE: DEPRECATED""" result = False final_qualified_nodes_1a = numba.typed.List.empty_list(node_type) final_qualified_nodes_1b = numba.typed.List.empty_list(node_type) @@ -1962,15 +2461,15 @@ def _update_rule_trace(rule_trace, qn, qe, prev_bnd, name): @numba.njit(cache=True) def are_satisfied_node(interpretations, comp, nas): result = True - for (label, interval) in nas: - result = result and is_satisfied_node(interpretations, comp, (label, interval)) + for (l, bnd) in nas: + result = result and is_satisfied_node(interpretations, comp, (l, bnd)) return result @numba.njit(cache=True) def is_satisfied_node(interpretations, comp, na): result = False - if (not (na[0] is None or na[1] is None)): + if not (na[0] is None or na[1] is None): # This is to prevent a key error in case the label is a specific label try: world = interpretations[comp] @@ -2012,15 +2511,15 @@ def is_satisfied_node_comparison(interpretations, comp, na): @numba.njit(cache=True) def are_satisfied_edge(interpretations, comp, nas): result = True - for (label, interval) in nas: - result = result and is_satisfied_edge(interpretations, comp, (label, interval)) + for (l, bnd) in nas: + result = result and is_satisfied_edge(interpretations, comp, (l, bnd)) return result @numba.njit(cache=True) def is_satisfied_edge(interpretations, comp, na): result = False - if (not (na[0] is None or na[1] is None)): + if not (na[0] is None or na[1] is None): # This is to prevent a key error in case the label is a specific label try: world = interpretations[comp] diff --git a/pyreason/scripts/numba_wrapper/numba_types/rule_type.py b/pyreason/scripts/numba_wrapper/numba_types/rule_type.py index 970d710..e4247ca 100755 --- a/pyreason/scripts/numba_wrapper/numba_types/rule_type.py +++ b/pyreason/scripts/numba_wrapper/numba_types/rule_type.py @@ -32,8 +32,8 @@ def typeof_rule(val, c): # Construct object from Numba functions (Doesn't work. We don't need this currently) @type_callable(Rule) def type_rule(context): - def typer(rule_name, type, target, delta, clauses, bnd, thresholds, ann_fn, weights, edges, static, immediate_rule): - if isinstance(rule_name, types.UnicodeType) and isinstance(type, types.UnicodeType) and isinstance(target, label.LabelType) and isinstance(delta, types.Integer) and isinstance(clauses, (types.NoneType, types.ListType)) and isinstance(bnd, interval.IntervalType) and isinstance(thresholds, types.ListType) and isinstance(ann_fn, types.UnicodeType) and isinstance(weights, types.Array) and isinstance(edges, types.Tuple) and isinstance(static, types.Boolean) and isinstance(immediate_rule, types.Boolean): + def typer(rule_name, type, target, head_variables, delta, clauses, bnd, thresholds, ann_fn, weights, edges, static, immediate_rule): + if isinstance(rule_name, types.UnicodeType) and isinstance(type, types.UnicodeType) and isinstance(target, label.LabelType) and isinstance(head_variables, types.ListType) and isinstance(delta, types.Integer) and isinstance(clauses, (types.NoneType, types.ListType)) and isinstance(bnd, interval.IntervalType) and isinstance(thresholds, types.ListType) and isinstance(ann_fn, types.UnicodeType) and isinstance(weights, types.Array) and isinstance(edges, types.Tuple) and isinstance(static, types.Boolean) and isinstance(immediate_rule, types.Boolean): return rule_type return typer @@ -46,6 +46,7 @@ def __init__(self, dmm, fe_type): ('rule_name', types.string), ('type', types.string), ('target', label.label_type), + ('head_variables', types.ListType(types.string)), ('delta', types.uint16), ('clauses', types.ListType(types.Tuple((types.string, label.label_type, types.ListType(types.string), interval.interval_type, types.string)))), ('bnd', interval.interval_type), @@ -63,6 +64,7 @@ def __init__(self, dmm, fe_type): make_attribute_wrapper(RuleType, 'rule_name', 'rule_name') make_attribute_wrapper(RuleType, 'type', 'type') make_attribute_wrapper(RuleType, 'target', 'target') +make_attribute_wrapper(RuleType, 'head_variables', 'head_variables') make_attribute_wrapper(RuleType, 'delta', 'delta') make_attribute_wrapper(RuleType, 'clauses', 'clauses') make_attribute_wrapper(RuleType, 'bnd', 'bnd') @@ -75,16 +77,18 @@ def __init__(self, dmm, fe_type): # Implement constructor -@lower_builtin(Rule, types.string, types.string, label.label_type, types.uint16, types.ListType(types.Tuple((types.string, label.label_type, types.ListType(types.string), interval.interval_type, types.string))), interval.interval_type, types.ListType(types.ListType(types.Tuple((types.string, types.string, types.float64)))), types.string, types.float64[::1], types.Tuple((types.string, types.string, label.label_type)), types.boolean, types.boolean) +@lower_builtin(Rule, types.string, types.string, label.label_type, types.ListType(types.string), types.uint16, types.ListType(types.Tuple((types.string, label.label_type, types.ListType(types.string), interval.interval_type, types.string))), interval.interval_type, types.ListType(types.ListType(types.Tuple((types.string, types.string, types.float64)))), types.string, types.float64[::1], types.Tuple((types.string, types.string, label.label_type)), types.boolean, types.boolean) def impl_rule(context, builder, sig, args): typ = sig.return_type - rule_name, type, target, delta, clauses, bnd, thresholds, ann_fn, weights, edges, static, immediate_rule = args + rule_name, type, target, head_variables, delta, clauses, bnd, thresholds, ann_fn, weights, edges, static, immediate_rule = args + context.nrt.incref(builder, types.ListType(types.string), head_variables) context.nrt.incref(builder, types.ListType(types.Tuple((types.string, label.label_type, types.ListType(types.string), interval.interval_type, types.string))), clauses) context.nrt.incref(builder, types.ListType(types.Tuple((types.string, types.UniTuple(types.string, 2), types.float64))), thresholds) rule = cgutils.create_struct_proxy(typ)(context, builder) rule.rule_name = rule_name rule.type = type rule.target = target + rule.head_variables = head_variables rule.delta = delta rule.clauses = clauses rule.bnd = bnd @@ -119,6 +123,13 @@ def getter(rule): return getter +@overload_method(RuleType, "get_head_variables") +def get_head_variables(rule): + def getter(rule): + return rule.head_variables + return getter + + @overload_method(RuleType, "get_delta") def get_delta(rule): def getter(rule): @@ -188,6 +199,7 @@ def unbox_rule(typ, obj, c): name_obj = c.pyapi.object_getattr_string(obj, "_rule_name") type_obj = c.pyapi.object_getattr_string(obj, "_type") target_obj = c.pyapi.object_getattr_string(obj, "_target") + head_variables_obj = c.pyapi.object_getattr_string(obj, "_head_variables") delta_obj = c.pyapi.object_getattr_string(obj, "_delta") clauses_obj = c.pyapi.object_getattr_string(obj, "_clauses") bnd_obj = c.pyapi.object_getattr_string(obj, "_bnd") @@ -201,6 +213,7 @@ def unbox_rule(typ, obj, c): rule.rule_name = c.unbox(types.string, name_obj).value rule.type = c.unbox(types.string, type_obj).value rule.target = c.unbox(label.label_type, target_obj).value + rule.head_variables = c.unbox(types.ListType(types.string), head_variables_obj).value rule.delta = c.unbox(types.uint16, delta_obj).value rule.clauses = c.unbox(types.ListType(types.Tuple((types.string, label.label_type, types.ListType(types.string), interval.interval_type, types.string))), clauses_obj).value rule.bnd = c.unbox(interval.interval_type, bnd_obj).value @@ -213,6 +226,7 @@ def unbox_rule(typ, obj, c): c.pyapi.decref(name_obj) c.pyapi.decref(type_obj) c.pyapi.decref(target_obj) + c.pyapi.decref(head_variables_obj) c.pyapi.decref(delta_obj) c.pyapi.decref(clauses_obj) c.pyapi.decref(bnd_obj) @@ -233,6 +247,7 @@ def box_rule(typ, val, c): name_obj = c.box(types.string, rule.rule_name) type_obj = c.box(types.string, rule.type) target_obj = c.box(label.label_type, rule.target) + head_variables_obj = c.box(types.ListType(types.string), rule.head_variables) delta_obj = c.box(types.uint16, rule.delta) clauses_obj = c.box(types.ListType(types.Tuple((types.string, label.label_type, types.ListType(types.string), interval.interval_type, types.string))), rule.clauses) bnd_obj = c.box(interval.interval_type, rule.bnd) @@ -242,10 +257,11 @@ def box_rule(typ, val, c): edges_obj = c.box(types.Tuple((types.string, types.string, label.label_type)), rule.edges) static_obj = c.box(types.boolean, rule.static) immediate_rule_obj = c.box(types.boolean, rule.immediate_rule) - res = c.pyapi.call_function_objargs(class_obj, (name_obj, type_obj, target_obj, delta_obj, clauses_obj, bnd_obj, thresholds_obj, ann_fn_obj, weights_obj, edges_obj, static_obj, immediate_rule_obj)) + res = c.pyapi.call_function_objargs(class_obj, (name_obj, type_obj, target_obj, head_variables_obj, delta_obj, clauses_obj, bnd_obj, thresholds_obj, ann_fn_obj, weights_obj, edges_obj, static_obj, immediate_rule_obj)) c.pyapi.decref(name_obj) c.pyapi.decref(type_obj) c.pyapi.decref(target_obj) + c.pyapi.decref(head_variables_obj) c.pyapi.decref(delta_obj) c.pyapi.decref(clauses_obj) c.pyapi.decref(ann_fn_obj) diff --git a/pyreason/scripts/rules/rule_internal.py b/pyreason/scripts/rules/rule_internal.py index 69de3ca..6704407 100755 --- a/pyreason/scripts/rules/rule_internal.py +++ b/pyreason/scripts/rules/rule_internal.py @@ -1,9 +1,10 @@ class Rule: - def __init__(self, rule_name, rule_type, target, delta, clauses, bnd, thresholds, ann_fn, weights, edges, static, immediate_rule): + def __init__(self, rule_name, rule_type, target, head_variables, delta, clauses, bnd, thresholds, ann_fn, weights, edges, static, immediate_rule): self._rule_name = rule_name self._type = rule_type self._target = target + self._head_variables = head_variables self._delta = delta self._clauses = clauses self._bnd = bnd @@ -23,6 +24,9 @@ def get_rule_type(self): def get_target(self): return self._target + def get_head_variables(self): + return self._head_variables + def get_delta(self): return self._delta diff --git a/pyreason/scripts/utils/rule_parser.py b/pyreason/scripts/utils/rule_parser.py index fc2db4b..34858e4 100644 --- a/pyreason/scripts/utils/rule_parser.py +++ b/pyreason/scripts/utils/rule_parser.py @@ -123,25 +123,6 @@ def parse_rule(rule_text: str, name: str, infer_edges: bool = False, set_static: if rule_type == 'node': infer_edges = False - # Replace the variables in the body with source/target if they match the variables in the head - # If infer_edges is true, then we consider all rules to be node rules, we infer the 2nd variable of the target predicate from the rule body - # Else we consider the rule to be an edge rule and replace variables with source/target - # Node rules with possibility of adding edges - if infer_edges or len(head_variables) == 1: - head_source_variable = head_variables[0] - for i in range(len(body_variables)): - for j in range(len(body_variables[i])): - if body_variables[i][j] == head_source_variable: - body_variables[i][j] = '__target' - # Edge rule, no edges to be added - elif len(head_variables) == 2: - for i in range(len(body_variables)): - for j in range(len(body_variables[i])): - if body_variables[i][j] == head_variables[0]: - body_variables[i][j] = '__source' - elif body_variables[i][j] == head_variables[1]: - body_variables[i][j] = '__target' - # Start setting up clauses # clauses = [c1, c2, c3, c4] # thresholds = [t1, t2, t3, t4] @@ -174,15 +155,18 @@ def parse_rule(rule_text: str, name: str, infer_edges: bool = False, set_static: # Assert that there are two variables in the head of the rule if we infer edges # Add edges between head variables if necessary if infer_edges: - var = '__target' if head_variables[0] == head_variables[1] else head_variables[1] - edges = ('__target', var, target) + # var = '__target' if head_variables[0] == head_variables[1] else head_variables[1] + # edges = ('__target', var, target) + edges = (head_variables[0], head_variables[1], target) else: edges = ('', '', label.Label('')) weights = np.ones(len(body_predicates), dtype=np.float64) weights = np.append(weights, 0) - r = rule.Rule(name, rule_type, target, numba.types.uint16(t), clauses, target_bound, thresholds, ann_fn, weights, edges, set_static, immediate_rule) + head_variables = numba.typed.List(head_variables) + + r = rule.Rule(name, rule_type, target, head_variables, numba.types.uint16(t), clauses, target_bound, thresholds, ann_fn, weights, edges, set_static, immediate_rule) return r diff --git a/tests/test_hello_world.py b/tests/test_hello_world.py index c221345..e4557c8 100644 --- a/tests/test_hello_world.py +++ b/tests/test_hello_world.py @@ -25,8 +25,8 @@ def test_hello_world(): print() assert len(dataframes[0]) == 1, 'At t=0 there should be one popular person' - assert len(dataframes[1]) == 2, 'At t=0 there should be two popular people' - assert len(dataframes[2]) == 3, 'At t=0 there should be three popular people' + assert len(dataframes[1]) == 2, 'At t=1 there should be two popular people' + assert len(dataframes[2]) == 3, 'At t=2 there should be three popular people' # Mary should be popular in all three timesteps assert 'Mary' in dataframes[0]['component'].values and dataframes[0].iloc[0].popular == [1, 1], 'Mary should have popular bounds [1,1] for t=0 timesteps' diff --git a/tests/test_hello_world_parallel.py b/tests/test_hello_world_parallel.py index cd16111..43839ad 100644 --- a/tests/test_hello_world_parallel.py +++ b/tests/test_hello_world_parallel.py @@ -26,8 +26,8 @@ def test_hello_world_parallel(): print() assert len(dataframes[0]) == 1, 'At t=0 there should be one popular person' - assert len(dataframes[1]) == 2, 'At t=0 there should be two popular people' - assert len(dataframes[2]) == 3, 'At t=0 there should be three popular people' + assert len(dataframes[1]) == 2, 'At t=1 there should be two popular people' + assert len(dataframes[2]) == 3, 'At t=2 there should be three popular people' # Mary should be popular in all three timesteps assert 'Mary' in dataframes[0]['component'].values and dataframes[0].iloc[0].popular == [1, 1], 'Mary should have popular bounds [1,1] for t=0 timesteps' From bd87afe243e7705629597112865b0e4aa2f9f51c Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Mon, 17 Jun 2024 19:58:54 +0200 Subject: [PATCH 09/73] fixed test case for custom thresholds --- docs/group-chat-example.md | 6 +++--- media/group_chat_graph.png | Bin 25111 -> 27533 bytes .../scripts/interpretation/interpretation.py | 17 ++++++++++++++++ pyreason/scripts/threshold/threshold.py | 2 +- tests/group_chat_graph.graphml | 19 ++++++++---------- tests/test_custom_thresholds.py | 4 +++- 6 files changed, 32 insertions(+), 16 deletions(-) diff --git a/docs/group-chat-example.md b/docs/group-chat-example.md index c2aaa0c..6f34a8d 100755 --- a/docs/group-chat-example.md +++ b/docs/group-chat-example.md @@ -3,7 +3,7 @@ Here is an example that utilizes custom thresholds. The following graph represents a network of People and a Text Message in their group chat. - + In this case, we want to know when a text message has been viewed by all members of the group chat. @@ -14,7 +14,7 @@ First, lets create the group chat. import networkx as nx # Create an empty graph -G = nx.Graph() +G = nx.DiGraph() # Add nodes nodes = ["TextMessage", "Zach", "Justin", "Michelle", "Amy"] @@ -35,7 +35,7 @@ G.add_edges_from(edges) Considering that we only want a text message to be considered viewed by all if it has been viewed by everyone that can view it, we define the rule as follows: ```text -ViewedByAll(x) <- HaveAccess(x,y), Viewed(y) +ViewedByAll(y) <- HaveAccess(x,y), Viewed(x) ``` The `head` of the rule is `ViewedByAll(x)` and the body is `HaveAccess(x,y), Viewed(y)`. The head and body are separated by an arrow which means the rule will start evaluating from diff --git a/media/group_chat_graph.png b/media/group_chat_graph.png index dc9afac6763429e1b323bd3e326aded687c29a4d..9da3f58f91212038a7bcc23f221eb188bdc6c7cc 100644 GIT binary patch literal 27533 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 MV6Z+*YuC~L0r|x>rT_o{ literal 25111 zcmc$`c{G;&+dX_qG%1NtrZQ$I88T#QAVZ?0P|8e7<_r-U6bYe{xgwGf${fm+IV36b z*kD#6;pHLVJU_#6?7feDeEbhwTTUv@RN_v1gZ&MNxON9`<~T}>P-<-X>C@xqvINh*bVd&7rUFkT7pz@t`)C~CJz^Y}r zVme*`^ix6}^-fzcm(-N+6Yes+GwE_D#f=Q0HqGIt_Y1-fHu5ZCy+?|Rou=it z-*i7xTFJAvo=iaG$uLQ$ocX<MOHge-1MeYGK`fTn|%cK4n1#iU67AGy+mYn_5Q}d~`r#v0C zU&dw|*|;K}K-{b?20vL{x18-pI$y%cS+-*xH;%Mi`dECH!9Hm@XVJZqROOSqPaRQK z=ACN}+rz7peg5--=EOsb6Zcz-*1b-*yy{$IOttP*V3Gqvx^n>@Ma?B~**3x6k�& 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 ZQ^cOt{3Z@qk3$+5fg8Dg6< - - - + + - - 1 + 1 - 1 - - - - 1 + 1 - 1 + 1 + + + 1 diff --git a/tests/test_custom_thresholds.py b/tests/test_custom_thresholds.py index b982bf3..b16620d 100644 --- a/tests/test_custom_thresholds.py +++ b/tests/test_custom_thresholds.py @@ -25,7 +25,7 @@ def test_custom_thresholds(): pr.add_rule( pr.Rule( - "ViewedByAll(x) <- HaveAccess(x,y), Viewed(y)", + "ViewedByAll(y) <- HaveAccess(x,y), Viewed(x)", "viewed_by_all_rule", custom_thresholds=user_defined_thresholds, ) @@ -60,3 +60,5 @@ def test_custom_thresholds(): 1, 1, ], "TextMessage should have ViewedByAll bounds [1,1] for t=2 timesteps" + +test_custom_thresholds() \ No newline at end of file From 369f1fc6d18f705a2a2bf2531cdd10fe1b50c4ab Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Mon, 17 Jun 2024 20:13:04 +0200 Subject: [PATCH 10/73] removed print statements from interpretation.py --- pyreason/scripts/interpretation/interpretation.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 4a60476..f004c59 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -812,11 +812,6 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Check satisfaction of those nodes wrt the threshold satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction - print('sat:', satisfaction) - print('grounding:', grounding) - print('qualified_groundings:', qualified_groundings) - print() - # This is an edge clause elif clause_type == 'edge': clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] @@ -853,14 +848,6 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, elif clause_var_1 not in dependency_graph_reverse_neighbors[clause_var_2]: dependency_graph_reverse_neighbors[clause_var_2].append(clause_var_1) - print('sat:', satisfaction) - print('groundings:', grounding) - print('qualified_groundings:', qualified_groundings) - print('grounding edges:', groundings_edges[(clause_var_1, clause_var_2)]) - print('grounding 1', groundings[clause_var_1]) - print('grounding 2', groundings[clause_var_2]) - print() - # This is a comparison clause else: pass From af73caed39f98d1c5f769baf4d92a7d81d376aa3 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Mon, 17 Jun 2024 20:14:17 +0200 Subject: [PATCH 11/73] removed print statements from interpretation.py --- pyreason/scripts/interpretation/interpretation.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index f004c59..575a47d 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -794,10 +794,6 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, clause_bnd = clause[3] clause_operator = clause[4] - print('clause_type:', clause_type) - print('clause_label:', clause_label) - print('clause_variables:', clause_variables) - # This is a node clause if clause_type == 'node': clause_var_1 = clause_variables[0] From 8c750bff34b0005ea9eba9071724724b47c94e04 Mon Sep 17 00:00:00 2001 From: jpatil14 Date: Tue, 18 Jun 2024 00:23:50 -0700 Subject: [PATCH 12/73] Added unit test for 4 types of anyburl rules. --- tests/knowledge_graph_test_subset.graphml | 71 ++++++++++++ tests/test_anyBurl_infer_edges_rules.py | 132 ++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 tests/knowledge_graph_test_subset.graphml create mode 100644 tests/test_anyBurl_infer_edges_rules.py diff --git a/tests/knowledge_graph_test_subset.graphml b/tests/knowledge_graph_test_subset.graphml new file mode 100644 index 0000000..72e5c23 --- /dev/null +++ b/tests/knowledge_graph_test_subset.graphml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + \ No newline at end of file diff --git a/tests/test_anyBurl_infer_edges_rules.py b/tests/test_anyBurl_infer_edges_rules.py new file mode 100644 index 0000000..7b3ae35 --- /dev/null +++ b/tests/test_anyBurl_infer_edges_rules.py @@ -0,0 +1,132 @@ +import pyreason as pr + +def test_anyBurl_rule_1(): + graph_path = 'knowledge_graph_test_subset.graphml' + pr.reset() + pr.reset_rules() + # Modify pyreason settings to make verbose and to save the rule trace to a file + pr.settings.verbose = True + pr.settings.atom_trace = True + pr.settings.memory_profile = False + pr.settings.canonical = True + pr.settings.inconsistency_check = False + pr.settings.static_graph_facts = False + pr.settings.output_to_file = False + pr.settings.store_interpretation_changes = True + pr.settings.save_graph_attributes_to_trace = True + # Load all the files into pyreason + pr.load_graphml(graph_path) + pr.add_rule(pr.Rule('isConnectedTo(A, Y) <-1 isConnectedTo(Y, B), Amsterdam_Airport_Schiphol(B), Vnukovo_International_Airport(A)', 'connected_rule_1', infer_edges=True)) + + # Run the program for two timesteps to see the diffusion take place + interpretation = pr.reason(timesteps=1) + # pr.save_rule_trace(interpretation) + + # Display the changes in the interpretation for each timestep + dataframes = pr.filter_and_sort_edges(interpretation, ['isConnectedTo']) + for t, df in enumerate(dataframes): + print(f'TIMESTEP - {t}') + print(df) + print() + assert len(dataframes) == 2, 'Pyreason should run exactly 2 fixpoint operations' + assert len(dataframes[1]) == 1, 'At t=1 there should be only 1 new isConnectedTo atom' + assert ('Vnukovo_International_Airport', 'Riga_International_Airport') in dataframes[1]['component'].values.tolist() and dataframes[1]['isConnectedTo'].iloc[0] == [1, 1], '(Vnukovo_International_Airport, Riga_International_Airport) should have isConnectedTo bounds [1,1] for t=1 timesteps' + +def test_anyBurl_rule_2(): + graph_path = 'knowledge_graph_test_subset.graphml' + pr.reset() + pr.reset_rules() + # Modify pyreason settings to make verbose and to save the rule trace to a file + pr.settings.verbose = True + pr.settings.atom_trace = True + pr.settings.memory_profile = False + pr.settings.canonical = True + pr.settings.inconsistency_check = False + pr.settings.static_graph_facts = False + pr.settings.output_to_file = False + pr.settings.store_interpretation_changes = True + pr.settings.save_graph_attributes_to_trace = True + # Load all the files into pyreason + pr.load_graphml(graph_path) + + pr.add_rule(pr.Rule('isConnectedTo(Y, A) <-1 isConnectedTo(Y, B), Amsterdam_Airport_Schiphol(B), Vnukovo_International_Airport(A)', 'connected_rule_2', infer_edges=True)) + + # Run the program for two timesteps to see the diffusion take place + interpretation = pr.reason(timesteps=1) + # pr.save_rule_trace(interpretation) + + # Display the changes in the interpretation for each timestep + dataframes = pr.filter_and_sort_edges(interpretation, ['isConnectedTo']) + for t, df in enumerate(dataframes): + print(f'TIMESTEP - {t}') + print(df) + print() + assert len(dataframes) == 2, 'Pyreason should run exactly 2 fixpoint operations' + assert len(dataframes[1]) == 1, 'At t=1 there should be only 1 new isConnectedTo atom' + assert ('Riga_International_Airport', 'Vnukovo_International_Airport') in dataframes[1]['component'].values.tolist() and dataframes[1]['isConnectedTo'].iloc[0] == [1, 1], '(Riga_International_Airport, Vnukovo_International_Airport) should have isConnectedTo bounds [1,1] for t=1 timesteps' + +def test_anyBurl_rule_3(): + graph_path = 'knowledge_graph_test_subset.graphml' + pr.reset() + pr.reset_rules() + # Modify pyreason settings to make verbose and to save the rule trace to a file + pr.settings.verbose = True + pr.settings.atom_trace = True + pr.settings.memory_profile = False + pr.settings.canonical = True + pr.settings.inconsistency_check = False + pr.settings.static_graph_facts = False + pr.settings.output_to_file = False + pr.settings.store_interpretation_changes = True + pr.settings.save_graph_attributes_to_trace = True + # Load all the files into pyreason + pr.load_graphml(graph_path) + + pr.add_rule(pr.Rule('isConnectedTo(A, Y) <-1 isConnectedTo(B, Y), Amsterdam_Airport_Schiphol(B), Vnukovo_International_Airport(A)', 'connected_rule_3', infer_edges=True)) + + # Run the program for two timesteps to see the diffusion take place + interpretation = pr.reason(timesteps=1) + # pr.save_rule_trace(interpretation) + + # Display the changes in the interpretation for each timestep + dataframes = pr.filter_and_sort_edges(interpretation, ['isConnectedTo']) + for t, df in enumerate(dataframes): + print(f'TIMESTEP - {t}') + print(df) + print() + assert len(dataframes) == 2, 'Pyreason should run exactly 1 fixpoint operations' + assert len(dataframes[1]) == 1, 'At t=1 there should be only 1 new isConnectedTo atom' + assert ('Vnukovo_International_Airport', 'Yali') in dataframes[1]['component'].values.tolist() and dataframes[1]['isConnectedTo'].iloc[0] == [1, 1], '(Vnukovo_International_Airport, Yali) should have isConnectedTo bounds [1,1] for t=1 timesteps' + +def test_anyBurl_rule_4(): + graph_path = 'knowledge_graph_test_subset.graphml' + pr.reset() + pr.reset_rules() + # Modify pyreason settings to make verbose and to save the rule trace to a file + pr.settings.verbose = True + pr.settings.atom_trace = True + pr.settings.memory_profile = False + pr.settings.canonical = True + pr.settings.inconsistency_check = False + pr.settings.static_graph_facts = False + pr.settings.output_to_file = False + pr.settings.store_interpretation_changes = True + pr.settings.save_graph_attributes_to_trace = True + # Load all the files into pyreason + pr.load_graphml(graph_path) + + pr.add_rule(pr.Rule('isConnectedTo(Y, A) <-1 isConnectedTo(B, Y), Amsterdam_Airport_Schiphol(B), Vnukovo_International_Airport(A)', 'connected_rule_4', infer_edges=True)) + + # Run the program for two timesteps to see the diffusion take place + interpretation = pr.reason(timesteps=1) + # pr.save_rule_trace(interpretation) + + # Display the changes in the interpretation for each timestep + dataframes = pr.filter_and_sort_edges(interpretation, ['isConnectedTo']) + for t, df in enumerate(dataframes): + print(f'TIMESTEP - {t}') + print(df) + print() + assert len(dataframes) == 2, 'Pyreason should run exactly 1 fixpoint operations' + assert len(dataframes[1]) == 1, 'At t=1 there should be only 1 new isConnectedTo atom' + assert ('Yali', 'Vnukovo_International_Airport') in dataframes[1]['component'].values.tolist() and dataframes[1]['isConnectedTo'].iloc[0] == [1, 1], '(Yali, Vnukovo_International_Airport) should have isConnectedTo bounds [1,1] for t=1 timesteps' \ No newline at end of file From 2a23a8ba87b20b22ef127d1952f5f687f5df6e12 Mon Sep 17 00:00:00 2001 From: jpatil14 Date: Tue, 18 Jun 2024 00:45:03 -0700 Subject: [PATCH 13/73] Added unit test for 4 types of anyburl rules: Updated graphml path --- tests/test_anyBurl_infer_edges_rules.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_anyBurl_infer_edges_rules.py b/tests/test_anyBurl_infer_edges_rules.py index 7b3ae35..d151de7 100644 --- a/tests/test_anyBurl_infer_edges_rules.py +++ b/tests/test_anyBurl_infer_edges_rules.py @@ -1,7 +1,7 @@ import pyreason as pr def test_anyBurl_rule_1(): - graph_path = 'knowledge_graph_test_subset.graphml' + graph_path = './tests/knowledge_graph_test_subset.graphml' pr.reset() pr.reset_rules() # Modify pyreason settings to make verbose and to save the rule trace to a file @@ -33,7 +33,7 @@ def test_anyBurl_rule_1(): assert ('Vnukovo_International_Airport', 'Riga_International_Airport') in dataframes[1]['component'].values.tolist() and dataframes[1]['isConnectedTo'].iloc[0] == [1, 1], '(Vnukovo_International_Airport, Riga_International_Airport) should have isConnectedTo bounds [1,1] for t=1 timesteps' def test_anyBurl_rule_2(): - graph_path = 'knowledge_graph_test_subset.graphml' + graph_path = './tests/knowledge_graph_test_subset.graphml' pr.reset() pr.reset_rules() # Modify pyreason settings to make verbose and to save the rule trace to a file @@ -66,7 +66,7 @@ def test_anyBurl_rule_2(): assert ('Riga_International_Airport', 'Vnukovo_International_Airport') in dataframes[1]['component'].values.tolist() and dataframes[1]['isConnectedTo'].iloc[0] == [1, 1], '(Riga_International_Airport, Vnukovo_International_Airport) should have isConnectedTo bounds [1,1] for t=1 timesteps' def test_anyBurl_rule_3(): - graph_path = 'knowledge_graph_test_subset.graphml' + graph_path = './tests/knowledge_graph_test_subset.graphml' pr.reset() pr.reset_rules() # Modify pyreason settings to make verbose and to save the rule trace to a file @@ -99,7 +99,7 @@ def test_anyBurl_rule_3(): assert ('Vnukovo_International_Airport', 'Yali') in dataframes[1]['component'].values.tolist() and dataframes[1]['isConnectedTo'].iloc[0] == [1, 1], '(Vnukovo_International_Airport, Yali) should have isConnectedTo bounds [1,1] for t=1 timesteps' def test_anyBurl_rule_4(): - graph_path = 'knowledge_graph_test_subset.graphml' + graph_path = './tests/knowledge_graph_test_subset.graphml' pr.reset() pr.reset_rules() # Modify pyreason settings to make verbose and to save the rule trace to a file From d218a3378f70f0aa2783339416a857745662cf0b Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Tue, 25 Jun 2024 10:49:26 +0200 Subject: [PATCH 14/73] fixed parallel implementation,now works --- .../scripts/interpretation/interpretation.py | 231 ++---- .../interpretation/interpretation_parallel.py | 750 ++++++++++++++---- tests/test_custom_thresholds.py | 2 - 3 files changed, 639 insertions(+), 344 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 575a47d..ff55617 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -43,6 +43,11 @@ numba.types.Tuple((numba.types.ListType(node_type), numba.types.ListType(node_type), label.label_type)) )) +rules_to_be_applied_node_type = numba.types.Tuple((numba.types.uint16, node_type, label.label_type, interval.interval_type, numba.types.boolean, numba.types.boolean)) +rules_to_be_applied_edge_type = numba.types.Tuple((numba.types.uint16, edge_type, label.label_type, interval.interval_type, numba.types.boolean, numba.types.boolean)) +rules_to_be_applied_trace_type = numba.types.Tuple((numba.types.ListType(numba.types.ListType(node_type)), numba.types.ListType(numba.types.ListType(edge_type)), numba.types.string)) +edges_to_be_added_type = numba.types.Tuple((numba.types.ListType(node_type), numba.types.ListType(node_type), label.label_type)) + class Interpretation: available_labels_node = [] @@ -67,12 +72,12 @@ def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, self.prev_reasoning_data = numba.typed.List([0, 0]) # Initialize list of tuples for rules/facts to be applied, along with all the ground atoms that fired the rule. One to One correspondence between rules_to_be_applied_node and rules_to_be_applied_node_trace if atom_trace is true - self.rules_to_be_applied_node_trace = numba.typed.List.empty_list(numba.types.Tuple((numba.types.ListType(numba.types.ListType(node_type)), numba.types.ListType(numba.types.ListType(edge_type)), numba.types.string))) - self.rules_to_be_applied_edge_trace = numba.typed.List.empty_list(numba.types.Tuple((numba.types.ListType(numba.types.ListType(node_type)), numba.types.ListType(numba.types.ListType(edge_type)), numba.types.string))) + self.rules_to_be_applied_node_trace = numba.typed.List.empty_list(rules_to_be_applied_trace_type) + self.rules_to_be_applied_edge_trace = numba.typed.List.empty_list(rules_to_be_applied_trace_type) self.facts_to_be_applied_node_trace = numba.typed.List.empty_list(numba.types.string) self.facts_to_be_applied_edge_trace = numba.typed.List.empty_list(numba.types.string) - self.rules_to_be_applied_node = numba.typed.List.empty_list(numba.types.Tuple((numba.types.uint16, node_type, label.label_type, interval.interval_type, numba.types.boolean, numba.types.boolean))) - self.rules_to_be_applied_edge = numba.typed.List.empty_list(numba.types.Tuple((numba.types.uint16, edge_type, label.label_type, interval.interval_type, numba.types.boolean, numba.types.boolean))) + self.rules_to_be_applied_node = numba.typed.List.empty_list(rules_to_be_applied_node_type) + self.rules_to_be_applied_edge = numba.typed.List.empty_list(rules_to_be_applied_edge_type) self.facts_to_be_applied_node = numba.typed.List.empty_list(facts_to_be_applied_node_type) self.facts_to_be_applied_edge = numba.typed.List.empty_list(facts_to_be_applied_edge_type) self.edges_to_be_added_node_rule = numba.typed.List.empty_list(numba.types.Tuple((numba.types.ListType(node_type), numba.types.ListType(node_type), label.label_type))) @@ -208,7 +213,7 @@ def _start_fp(self, rules, max_facts_time, verbose, again): print('Fixed Point iterations:', fp_cnt) @staticmethod - @numba.njit(cache=True) + @numba.njit(cache=True, parallel=False) def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): t = prev_reasoning_data[0] fp_cnt = prev_reasoning_data[1] @@ -244,18 +249,6 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data bound_delta = 0 update = False - # Parameters for immediate rules - immediate_node_rule_fire = False - immediate_edge_rule_fire = False - immediate_rule_applied = False - # When delta_t = 0, we don't want to check the same rule with the same node/edge after coming back to the fp operator - nodes_to_skip = numba.typed.Dict.empty(key_type=numba.types.int64, value_type=list_of_nodes) - edges_to_skip = numba.typed.Dict.empty(key_type=numba.types.int64, value_type=list_of_edges) - # Initialize the above - for i in range(len(rules)): - nodes_to_skip[i] = numba.typed.List.empty_list(node_type) - edges_to_skip[i] = numba.typed.List.empty_list(edge_type) - # Start by applying facts # Nodes facts_to_be_applied_node_new.clear() @@ -388,50 +381,25 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Nodes rules_to_remove_idx.clear() for idx, i in enumerate(rules_to_be_applied_node): - # If we are coming here from an immediate rule firing with delta_t=0 we have to apply that one rule. Which was just added to the list to_be_applied - if immediate_node_rule_fire and rules_to_be_applied_node[-1][4]: - i = rules_to_be_applied_node[-1] - idx = len(rules_to_be_applied_node) - 1 - - if i[0]==t: + if i[0] == t: comp, l, bnd, immediate, set_static = i[1], i[2], i[3], i[4], i[5] - sources, targets, edge_l = edges_to_be_added_node_rule[idx] - edges_added, changes = _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, edge_l, interpretations_node, interpretations_edge) - changes_cnt += changes - - # Update bound for newly added edges. Use bnd to update all edges if label is specified, else use bnd to update normally - if edge_l.value!='': - for e in edges_added: - if check_consistent_edge(interpretations_edge, e, (edge_l, bnd)): - override = True if update_mode == 'override' else False - u, changes = _update_edge(interpretations_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=override) - - update = u or update - - # Update convergence params - if convergence_mode=='delta_bound': - bound_delta = max(bound_delta, changes) - else: - changes_cnt += changes - # Resolve inconsistency - else: - if inconsistency_check: - resolve_inconsistency_edge(interpretations_edge, e, (edge_l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_edge, rule_trace_edge_atoms, store_interpretation_changes) - else: - u, changes = _update_edge(interpretations_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) - - update = u or update + # Check for inconsistencies + if check_consistent_node(interpretations_node, comp, (l, bnd)): + override = True if update_mode == 'override' else False + u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=override) - # Update convergence params - if convergence_mode=='delta_bound': - bound_delta = max(bound_delta, changes) - else: - changes_cnt += changes + update = u or update + # Update convergence params + if convergence_mode=='delta_bound': + bound_delta = max(bound_delta, changes) + else: + changes_cnt += changes + # Resolve inconsistency else: - # Check for inconsistencies - if check_consistent_node(interpretations_node, comp, (l, bnd)): - override = True if update_mode == 'override' else False - u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=override) + if inconsistency_check: + resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_node, rule_trace_node_atoms, store_interpretation_changes) + else: + u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=True) update = u or update # Update convergence params @@ -439,33 +407,10 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data bound_delta = max(bound_delta, changes) else: changes_cnt += changes - # Resolve inconsistency - else: - if inconsistency_check: - resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_node, rule_trace_node_atoms, store_interpretation_changes) - else: - u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=True) - - update = u or update - # Update convergence params - if convergence_mode=='delta_bound': - bound_delta = max(bound_delta, changes) - else: - changes_cnt += changes # Delete rules that have been applied from list by adding index to list rules_to_remove_idx.append(idx) - # Break out of the apply rules loop if a rule is immediate. Then we go to the fp operator and check for other applicable rules then come back - if immediate: - # If delta_t=0 we want to apply one rule and go back to the fp operator - # If delta_t>0 we want to come back here and apply the rest of the rules - if immediate_edge_rule_fire: - break - elif not immediate_edge_rule_fire and u: - immediate_rule_applied = True - break - # Remove from rules to be applied and edges to be applied lists after coming out from loop rules_to_be_applied_node[:] = numba.typed.List([rules_to_be_applied_node[i] for i in range(len(rules_to_be_applied_node)) if i not in rules_to_remove_idx]) edges_to_be_added_node_rule[:] = numba.typed.List([edges_to_be_added_node_rule[i] for i in range(len(edges_to_be_added_node_rule)) if i not in rules_to_remove_idx]) @@ -475,22 +420,14 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Edges rules_to_remove_idx.clear() for idx, i in enumerate(rules_to_be_applied_edge): - # If we broke from above loop to apply more rules, then break from here - if immediate_rule_applied and not immediate_edge_rule_fire: - break - # If we are coming here from an immediate rule firing with delta_t=0 we have to apply that one rule. Which was just added to the list to_be_applied - if immediate_edge_rule_fire and rules_to_be_applied_edge[-1][4]: - i = rules_to_be_applied_edge[-1] - idx = len(rules_to_be_applied_edge) - 1 - - if i[0]==t: + if i[0] == t: comp, l, bnd, immediate, set_static = i[1], i[2], i[3], i[4], i[5] sources, targets, edge_l = edges_to_be_added_edge_rule[idx] edges_added, changes = _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, edge_l, interpretations_node, interpretations_edge) changes_cnt += changes # Update bound for newly added edges. Use bnd to update all edges if label is specified, else use bnd to update normally - if edge_l.value!='': + if edge_l.value != '': for e in edges_added: if check_consistent_edge(interpretations_edge, e, (edge_l, bnd)): override = True if update_mode == 'override' else False @@ -547,16 +484,6 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Delete rules that have been applied from list by adding the index to list rules_to_remove_idx.append(idx) - # Break out of the apply rules loop if a rule is immediate. Then we go to the fp operator and check for other applicable rules then come back - if immediate: - # If t=0 we want to apply one rule and go back to the fp operator - # If t>0 we want to come back here and apply the rest of the rules - if immediate_edge_rule_fire: - break - elif not immediate_edge_rule_fire and u: - immediate_rule_applied = True - break - # Remove from rules to be applied and edges to be applied lists after coming out from loop rules_to_be_applied_edge[:] = numba.typed.List([rules_to_be_applied_edge[i] for i in range(len(rules_to_be_applied_edge)) if i not in rules_to_remove_idx]) edges_to_be_added_edge_rule[:] = numba.typed.List([edges_to_be_added_edge_rule[i] for i in range(len(edges_to_be_added_edge_rule)) if i not in rules_to_remove_idx]) @@ -566,15 +493,20 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Fixed point # if update or immediate_node_rule_fire or immediate_edge_rule_fire or immediate_rule_applied: if update: - # Increase fp operator count only if not an immediate rule - if not (immediate_node_rule_fire or immediate_edge_rule_fire): - fp_cnt += 1 + # Increase fp operator count + fp_cnt += 1 - for i in range(len(rules)): + # Lists or threadsafe operations (when parallel is on) + rules_to_be_applied_node_threadsafe = numba.typed.List([numba.typed.List.empty_list(rules_to_be_applied_node_type) for _ in range(len(rules))]) + rules_to_be_applied_edge_threadsafe = numba.typed.List([numba.typed.List.empty_list(rules_to_be_applied_edge_type) for _ in range(len(rules))]) + if atom_trace: + rules_to_be_applied_node_trace_threadsafe = numba.typed.List([numba.typed.List.empty_list(rules_to_be_applied_trace_type) for _ in range(len(rules))]) + rules_to_be_applied_edge_trace_threadsafe = numba.typed.List([numba.typed.List.empty_list(rules_to_be_applied_trace_type) for _ in range(len(rules))]) + edges_to_be_added_edge_rule_threadsafe = numba.typed.List([numba.typed.List.empty_list(edges_to_be_added_type) for _ in range(len(rules))]) + + for i in prange(len(rules)): rule = rules[i] immediate_rule = rule.is_immediate_rule() - immediate_node_rule_fire = False - immediate_edge_rule_fire = False # Only go through if the rule can be applied within the given timesteps, or we're running until convergence delta_t = rule.get_delta() @@ -585,41 +517,20 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Loop through applicable rules and add them to the rules to be applied for later or next fp operation for applicable_rule in applicable_node_rules: - n, annotations, qualified_nodes, qualified_edges, edges_to_add = applicable_rule + n, annotations, qualified_nodes, qualified_edges, _ = applicable_rule # If there is an edge to add or the predicate doesn't exist or the interpretation is not static - if len(edges_to_add[0]) > 0 or rule.get_target() not in interpretations_node[n].world or not interpretations_node[n].world[rule.get_target()].is_static(): + if rule.get_target() not in interpretations_node[n].world or not interpretations_node[n].world[rule.get_target()].is_static(): bnd = annotate(annotation_functions, rule, annotations, rule.get_weights()) # Bound annotations in between 0 and 1 bnd_l = min(max(bnd[0], 0), 1) bnd_u = min(max(bnd[1], 0), 1) bnd = interval.closed(bnd_l, bnd_u) max_rules_time = max(max_rules_time, t + delta_t) - edges_to_be_added_node_rule.append(edges_to_add) - rules_to_be_applied_node.append((numba.types.uint16(t + delta_t), n, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) + rules_to_be_applied_node_threadsafe[i].append((numba.types.uint16(t + delta_t), n, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) + # rules_to_be_applied_node.append((numba.types.uint16(t + delta_t), n, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) if atom_trace: - rules_to_be_applied_node_trace.append((qualified_nodes, qualified_edges, rule.get_name())) - - # We apply a rule on a node/edge only once in each timestep to prevent it from being added to the to_be_added list continuously (this will improve performance - # It's possible to have an annotation function that keeps changing the value of a node/edge. Do this only for delta_t>0 - if delta_t != 0: - nodes_to_skip[i].append(n) - - # Handle loop parameters for the next (maybe) fp operation - # If it is a t=0 rule or an immediate rule we want to go back for another fp operation to check for new rules that may fire - # Next fp operation we will skip this rule on this node because anyway there won't be an update - if delta_t == 0: - in_loop = True - update = False - if immediate_rule and delta_t == 0: - # immediate_rule_fire becomes True because we still need to check for more eligible rules, we're not done. - in_loop = True - update = True - immediate_node_rule_fire = True - break - - # Break, apply immediate rule then come back to check for more applicable rules - if immediate_node_rule_fire: - break + rules_to_be_applied_node_trace_threadsafe[i].append((qualified_nodes, qualified_edges, rule.get_name())) + # rules_to_be_applied_node_trace.append((qualified_nodes, qualified_edges, rule.get_name())) for applicable_rule in applicable_edge_rules: e, annotations, qualified_nodes, qualified_edges, edges_to_add = applicable_rule @@ -631,39 +542,27 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data bnd_u = min(max(bnd[1], 0), 1) bnd = interval.closed(bnd_l, bnd_u) max_rules_time = max(max_rules_time, t+delta_t) - edges_to_be_added_edge_rule.append(edges_to_add) - rules_to_be_applied_edge.append((numba.types.uint16(t+delta_t), e, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) + # edges_to_be_added_edge_rule.append(edges_to_add) + edges_to_be_added_edge_rule_threadsafe[i].append(edges_to_add) + # rules_to_be_applied_edge.append((numba.types.uint16(t+delta_t), e, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) + rules_to_be_applied_edge_threadsafe[i].append((numba.types.uint16(t+delta_t), e, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) if atom_trace: - rules_to_be_applied_edge_trace.append((qualified_nodes, qualified_edges, rule.get_name())) - - # We apply a rule on a node/edge only once in each timestep to prevent it from being added to the to_be_added list continuously (this will improve performance - # It's possible to have an annotation function that keeps changing the value of a node/edge. Do this only for delta_t>0 - if delta_t != 0: - edges_to_skip[i].append(e) - - # Handle loop parameters for the next (maybe) fp operation - # If it is a t=0 rule or an immediate rule we want to go back for another fp operation to check for new rules that may fire - # Next fp operation we will skip this rule on this node because anyway there won't be an update - if delta_t == 0: - in_loop = True - update = False - if immediate_rule and delta_t == 0: - # immediate_rule_fire becomes True because we still need to check for more eligible rules, we're not done. - in_loop = True - update = True - immediate_edge_rule_fire = True - break - - # Break, apply immediate rule then come back to check for more applicable rules - if immediate_edge_rule_fire: - break - - # Go through all the rules and go back to applying the rules if we came here because of an immediate rule where delta_t>0 - if immediate_rule_applied and not (immediate_node_rule_fire or immediate_edge_rule_fire): - immediate_rule_applied = False - in_loop = True - update = False - continue + # rules_to_be_applied_edge_trace.append((qualified_nodes, qualified_edges, rule.get_name())) + rules_to_be_applied_edge_trace_threadsafe[i].append((qualified_nodes, qualified_edges, rule.get_name())) + + # Update lists after parallel run + for i in range(len(rules)): + if len(rules_to_be_applied_node_threadsafe[i]) > 0: + rules_to_be_applied_node.extend(rules_to_be_applied_node_threadsafe[i]) + if len(rules_to_be_applied_edge_threadsafe[i]) > 0: + rules_to_be_applied_edge.extend(rules_to_be_applied_edge_threadsafe[i]) + if atom_trace: + if len(rules_to_be_applied_node_trace_threadsafe[i]) > 0: + rules_to_be_applied_node_trace.extend(rules_to_be_applied_node_trace_threadsafe[i]) + if len(rules_to_be_applied_edge_trace_threadsafe[i]) > 0: + rules_to_be_applied_edge_trace.extend(rules_to_be_applied_edge_trace_threadsafe[i]) + if len(edges_to_be_added_edge_rule_threadsafe[i]) > 0: + edges_to_be_added_edge_rule.extend(edges_to_be_added_edge_rule_threadsafe[i]) # Check for convergence after each timestep (perfect convergence or convergence specified by user) # Check number of changed interpretations or max bound change diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index cfd7a9f..098dd08 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -43,6 +43,11 @@ numba.types.Tuple((numba.types.ListType(node_type), numba.types.ListType(node_type), label.label_type)) )) +rules_to_be_applied_node_type = numba.types.Tuple((numba.types.uint16, node_type, label.label_type, interval.interval_type, numba.types.boolean, numba.types.boolean)) +rules_to_be_applied_edge_type = numba.types.Tuple((numba.types.uint16, edge_type, label.label_type, interval.interval_type, numba.types.boolean, numba.types.boolean)) +rules_to_be_applied_trace_type = numba.types.Tuple((numba.types.ListType(numba.types.ListType(node_type)), numba.types.ListType(numba.types.ListType(edge_type)), numba.types.string)) +edges_to_be_added_type = numba.types.Tuple((numba.types.ListType(node_type), numba.types.ListType(node_type), label.label_type)) + class Interpretation: available_labels_node = [] @@ -67,12 +72,12 @@ def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, self.prev_reasoning_data = numba.typed.List([0, 0]) # Initialize list of tuples for rules/facts to be applied, along with all the ground atoms that fired the rule. One to One correspondence between rules_to_be_applied_node and rules_to_be_applied_node_trace if atom_trace is true - self.rules_to_be_applied_node_trace = numba.typed.List.empty_list(numba.types.Tuple((numba.types.ListType(numba.types.ListType(node_type)), numba.types.ListType(numba.types.ListType(edge_type)), numba.types.string))) - self.rules_to_be_applied_edge_trace = numba.typed.List.empty_list(numba.types.Tuple((numba.types.ListType(numba.types.ListType(node_type)), numba.types.ListType(numba.types.ListType(edge_type)), numba.types.string))) + self.rules_to_be_applied_node_trace = numba.typed.List.empty_list(rules_to_be_applied_trace_type) + self.rules_to_be_applied_edge_trace = numba.typed.List.empty_list(rules_to_be_applied_trace_type) self.facts_to_be_applied_node_trace = numba.typed.List.empty_list(numba.types.string) self.facts_to_be_applied_edge_trace = numba.typed.List.empty_list(numba.types.string) - self.rules_to_be_applied_node = numba.typed.List.empty_list(numba.types.Tuple((numba.types.uint16, node_type, label.label_type, interval.interval_type, numba.types.boolean, numba.types.boolean))) - self.rules_to_be_applied_edge = numba.typed.List.empty_list(numba.types.Tuple((numba.types.uint16, edge_type, label.label_type, interval.interval_type, numba.types.boolean, numba.types.boolean))) + self.rules_to_be_applied_node = numba.typed.List.empty_list(rules_to_be_applied_node_type) + self.rules_to_be_applied_edge = numba.typed.List.empty_list(rules_to_be_applied_edge_type) self.facts_to_be_applied_node = numba.typed.List.empty_list(facts_to_be_applied_node_type) self.facts_to_be_applied_edge = numba.typed.List.empty_list(facts_to_be_applied_edge_type) self.edges_to_be_added_node_rule = numba.typed.List.empty_list(numba.types.Tuple((numba.types.ListType(node_type), numba.types.ListType(node_type), label.label_type))) @@ -208,7 +213,7 @@ def _start_fp(self, rules, max_facts_time, verbose, again): print('Fixed Point iterations:', fp_cnt) @staticmethod - @numba.njit(cache=False) + @numba.njit(cache=False, parallel=True) def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): t = prev_reasoning_data[0] fp_cnt = prev_reasoning_data[1] @@ -244,18 +249,6 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data bound_delta = 0 update = False - # Parameters for immediate rules - immediate_node_rule_fire = False - immediate_edge_rule_fire = False - immediate_rule_applied = False - # When delta_t = 0, we don't want to check the same rule with the same node/edge after coming back to the fp operator - nodes_to_skip = numba.typed.Dict.empty(key_type=numba.types.int64, value_type=list_of_nodes) - edges_to_skip = numba.typed.Dict.empty(key_type=numba.types.int64, value_type=list_of_edges) - # Initialize the above - for i in range(len(rules)): - nodes_to_skip[i] = numba.typed.List.empty_list(node_type) - edges_to_skip[i] = numba.typed.List.empty_list(edge_type) - # Start by applying facts # Nodes facts_to_be_applied_node_new.clear() @@ -388,50 +381,25 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Nodes rules_to_remove_idx.clear() for idx, i in enumerate(rules_to_be_applied_node): - # If we are coming here from an immediate rule firing with delta_t=0 we have to apply that one rule. Which was just added to the list to_be_applied - if immediate_node_rule_fire and rules_to_be_applied_node[-1][4]: - i = rules_to_be_applied_node[-1] - idx = len(rules_to_be_applied_node) - 1 - - if i[0]==t: + if i[0] == t: comp, l, bnd, immediate, set_static = i[1], i[2], i[3], i[4], i[5] - sources, targets, edge_l = edges_to_be_added_node_rule[idx] - edges_added, changes = _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, edge_l, interpretations_node, interpretations_edge) - changes_cnt += changes - - # Update bound for newly added edges. Use bnd to update all edges if label is specified, else use bnd to update normally - if edge_l.value!='': - for e in edges_added: - if check_consistent_edge(interpretations_edge, e, (edge_l, bnd)): - override = True if update_mode == 'override' else False - u, changes = _update_edge(interpretations_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=override) - - update = u or update - - # Update convergence params - if convergence_mode=='delta_bound': - bound_delta = max(bound_delta, changes) - else: - changes_cnt += changes - # Resolve inconsistency - else: - if inconsistency_check: - resolve_inconsistency_edge(interpretations_edge, e, (edge_l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_edge, rule_trace_edge_atoms, store_interpretation_changes) - else: - u, changes = _update_edge(interpretations_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) - - update = u or update + # Check for inconsistencies + if check_consistent_node(interpretations_node, comp, (l, bnd)): + override = True if update_mode == 'override' else False + u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=override) - # Update convergence params - if convergence_mode=='delta_bound': - bound_delta = max(bound_delta, changes) - else: - changes_cnt += changes + update = u or update + # Update convergence params + if convergence_mode=='delta_bound': + bound_delta = max(bound_delta, changes) + else: + changes_cnt += changes + # Resolve inconsistency else: - # Check for inconsistencies - if check_consistent_node(interpretations_node, comp, (l, bnd)): - override = True if update_mode == 'override' else False - u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=override) + if inconsistency_check: + resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_node, rule_trace_node_atoms, store_interpretation_changes) + else: + u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=True) update = u or update # Update convergence params @@ -439,33 +407,10 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data bound_delta = max(bound_delta, changes) else: changes_cnt += changes - # Resolve inconsistency - else: - if inconsistency_check: - resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_node, rule_trace_node_atoms, store_interpretation_changes) - else: - u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=True) - - update = u or update - # Update convergence params - if convergence_mode=='delta_bound': - bound_delta = max(bound_delta, changes) - else: - changes_cnt += changes # Delete rules that have been applied from list by adding index to list rules_to_remove_idx.append(idx) - # Break out of the apply rules loop if a rule is immediate. Then we go to the fp operator and check for other applicable rules then come back - if immediate: - # If delta_t=0 we want to apply one rule and go back to the fp operator - # If delta_t>0 we want to come back here and apply the rest of the rules - if immediate_edge_rule_fire: - break - elif not immediate_edge_rule_fire and u: - immediate_rule_applied = True - break - # Remove from rules to be applied and edges to be applied lists after coming out from loop rules_to_be_applied_node[:] = numba.typed.List([rules_to_be_applied_node[i] for i in range(len(rules_to_be_applied_node)) if i not in rules_to_remove_idx]) edges_to_be_added_node_rule[:] = numba.typed.List([edges_to_be_added_node_rule[i] for i in range(len(edges_to_be_added_node_rule)) if i not in rules_to_remove_idx]) @@ -475,22 +420,14 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Edges rules_to_remove_idx.clear() for idx, i in enumerate(rules_to_be_applied_edge): - # If we broke from above loop to apply more rules, then break from here - if immediate_rule_applied and not immediate_edge_rule_fire: - break - # If we are coming here from an immediate rule firing with delta_t=0 we have to apply that one rule. Which was just added to the list to_be_applied - if immediate_edge_rule_fire and rules_to_be_applied_edge[-1][4]: - i = rules_to_be_applied_edge[-1] - idx = len(rules_to_be_applied_edge) - 1 - - if i[0]==t: + if i[0] == t: comp, l, bnd, immediate, set_static = i[1], i[2], i[3], i[4], i[5] sources, targets, edge_l = edges_to_be_added_edge_rule[idx] edges_added, changes = _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, edge_l, interpretations_node, interpretations_edge) changes_cnt += changes # Update bound for newly added edges. Use bnd to update all edges if label is specified, else use bnd to update normally - if edge_l.value!='': + if edge_l.value != '': for e in edges_added: if check_consistent_edge(interpretations_edge, e, (edge_l, bnd)): override = True if update_mode == 'override' else False @@ -547,16 +484,6 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Delete rules that have been applied from list by adding the index to list rules_to_remove_idx.append(idx) - # Break out of the apply rules loop if a rule is immediate. Then we go to the fp operator and check for other applicable rules then come back - if immediate: - # If t=0 we want to apply one rule and go back to the fp operator - # If t>0 we want to come back here and apply the rest of the rules - if immediate_edge_rule_fire: - break - elif not immediate_edge_rule_fire and u: - immediate_rule_applied = True - break - # Remove from rules to be applied and edges to be applied lists after coming out from loop rules_to_be_applied_edge[:] = numba.typed.List([rules_to_be_applied_edge[i] for i in range(len(rules_to_be_applied_edge)) if i not in rules_to_remove_idx]) edges_to_be_added_edge_rule[:] = numba.typed.List([edges_to_be_added_edge_rule[i] for i in range(len(edges_to_be_added_edge_rule)) if i not in rules_to_remove_idx]) @@ -566,59 +493,44 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Fixed point # if update or immediate_node_rule_fire or immediate_edge_rule_fire or immediate_rule_applied: if update: - # Increase fp operator count only if not an immediate rule - if not (immediate_node_rule_fire or immediate_edge_rule_fire): - fp_cnt += 1 + # Increase fp operator count + fp_cnt += 1 - for i in range(len(rules)): + # Lists or threadsafe operations (when parallel is on) + rules_to_be_applied_node_threadsafe = numba.typed.List([numba.typed.List.empty_list(rules_to_be_applied_node_type) for _ in range(len(rules))]) + rules_to_be_applied_edge_threadsafe = numba.typed.List([numba.typed.List.empty_list(rules_to_be_applied_edge_type) for _ in range(len(rules))]) + if atom_trace: + rules_to_be_applied_node_trace_threadsafe = numba.typed.List([numba.typed.List.empty_list(rules_to_be_applied_trace_type) for _ in range(len(rules))]) + rules_to_be_applied_edge_trace_threadsafe = numba.typed.List([numba.typed.List.empty_list(rules_to_be_applied_trace_type) for _ in range(len(rules))]) + edges_to_be_added_edge_rule_threadsafe = numba.typed.List([numba.typed.List.empty_list(edges_to_be_added_type) for _ in range(len(rules))]) + + for i in prange(len(rules)): rule = rules[i] immediate_rule = rule.is_immediate_rule() - immediate_node_rule_fire = False - immediate_edge_rule_fire = False # Only go through if the rule can be applied within the given timesteps, or we're running until convergence delta_t = rule.get_delta() if t + delta_t <= tmax or tmax == -1 or again: - applicable_node_rules = _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, neighbors, reverse_neighbors, atom_trace, reverse_graph, nodes_to_skip[i]) - applicable_edge_rules = _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, reverse_graph, edges_to_skip[i]) + # applicable_node_rules = _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, neighbors, reverse_neighbors, atom_trace, reverse_graph, nodes_to_skip[i]) + # applicable_edge_rules = _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, reverse_graph, edges_to_skip[i]) + applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace) # Loop through applicable rules and add them to the rules to be applied for later or next fp operation for applicable_rule in applicable_node_rules: - n, annotations, qualified_nodes, qualified_edges, edges_to_add = applicable_rule + n, annotations, qualified_nodes, qualified_edges, _ = applicable_rule # If there is an edge to add or the predicate doesn't exist or the interpretation is not static - if len(edges_to_add[0]) > 0 or rule.get_target() not in interpretations_node[n].world or not interpretations_node[n].world[rule.get_target()].is_static(): + if rule.get_target() not in interpretations_node[n].world or not interpretations_node[n].world[rule.get_target()].is_static(): bnd = annotate(annotation_functions, rule, annotations, rule.get_weights()) # Bound annotations in between 0 and 1 bnd_l = min(max(bnd[0], 0), 1) bnd_u = min(max(bnd[1], 0), 1) bnd = interval.closed(bnd_l, bnd_u) max_rules_time = max(max_rules_time, t + delta_t) - edges_to_be_added_node_rule.append(edges_to_add) - rules_to_be_applied_node.append((numba.types.uint16(t + delta_t), n, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) + rules_to_be_applied_node_threadsafe[i].append((numba.types.uint16(t + delta_t), n, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) + # rules_to_be_applied_node.append((numba.types.uint16(t + delta_t), n, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) if atom_trace: - rules_to_be_applied_node_trace.append((qualified_nodes, qualified_edges, rule.get_name())) - - # We apply a rule on a node/edge only once in each timestep to prevent it from being added to the to_be_added list continuously (this will improve performance - # It's possible to have an annotation function that keeps changing the value of a node/edge. Do this only for delta_t>0 - if delta_t != 0: - nodes_to_skip[i].append(n) - - # Handle loop parameters for the next (maybe) fp operation - # If it is a t=0 rule or an immediate rule we want to go back for another fp operation to check for new rules that may fire - # Next fp operation we will skip this rule on this node because anyway there won't be an update - if delta_t == 0: - in_loop = True - update = False - if immediate_rule and delta_t == 0: - # immediate_rule_fire becomes True because we still need to check for more eligible rules, we're not done. - in_loop = True - update = True - immediate_node_rule_fire = True - break - - # Break, apply immediate rule then come back to check for more applicable rules - if immediate_node_rule_fire: - break + rules_to_be_applied_node_trace_threadsafe[i].append((qualified_nodes, qualified_edges, rule.get_name())) + # rules_to_be_applied_node_trace.append((qualified_nodes, qualified_edges, rule.get_name())) for applicable_rule in applicable_edge_rules: e, annotations, qualified_nodes, qualified_edges, edges_to_add = applicable_rule @@ -630,39 +542,27 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data bnd_u = min(max(bnd[1], 0), 1) bnd = interval.closed(bnd_l, bnd_u) max_rules_time = max(max_rules_time, t+delta_t) - edges_to_be_added_edge_rule.append(edges_to_add) - rules_to_be_applied_edge.append((numba.types.uint16(t+delta_t), e, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) + # edges_to_be_added_edge_rule.append(edges_to_add) + edges_to_be_added_edge_rule_threadsafe[i].append(edges_to_add) + # rules_to_be_applied_edge.append((numba.types.uint16(t+delta_t), e, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) + rules_to_be_applied_edge_threadsafe[i].append((numba.types.uint16(t+delta_t), e, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) if atom_trace: - rules_to_be_applied_edge_trace.append((qualified_nodes, qualified_edges, rule.get_name())) - - # We apply a rule on a node/edge only once in each timestep to prevent it from being added to the to_be_added list continuously (this will improve performance - # It's possible to have an annotation function that keeps changing the value of a node/edge. Do this only for delta_t>0 - if delta_t != 0: - edges_to_skip[i].append(e) - - # Handle loop parameters for the next (maybe) fp operation - # If it is a t=0 rule or an immediate rule we want to go back for another fp operation to check for new rules that may fire - # Next fp operation we will skip this rule on this node because anyway there won't be an update - if delta_t == 0: - in_loop = True - update = False - if immediate_rule and delta_t == 0: - # immediate_rule_fire becomes True because we still need to check for more eligible rules, we're not done. - in_loop = True - update = True - immediate_edge_rule_fire = True - break - - # Break, apply immediate rule then come back to check for more applicable rules - if immediate_edge_rule_fire: - break - - # Go through all the rules and go back to applying the rules if we came here because of an immediate rule where delta_t>0 - if immediate_rule_applied and not (immediate_node_rule_fire or immediate_edge_rule_fire): - immediate_rule_applied = False - in_loop = True - update = False - continue + # rules_to_be_applied_edge_trace.append((qualified_nodes, qualified_edges, rule.get_name())) + rules_to_be_applied_edge_trace_threadsafe[i].append((qualified_nodes, qualified_edges, rule.get_name())) + + # Update lists after parallel run + for i in range(len(rules)): + if len(rules_to_be_applied_node_threadsafe[i]) > 0: + rules_to_be_applied_node.extend(rules_to_be_applied_node_threadsafe[i]) + if len(rules_to_be_applied_edge_threadsafe[i]) > 0: + rules_to_be_applied_edge.extend(rules_to_be_applied_edge_threadsafe[i]) + if atom_trace: + if len(rules_to_be_applied_node_trace_threadsafe[i]) > 0: + rules_to_be_applied_node_trace.extend(rules_to_be_applied_node_trace_threadsafe[i]) + if len(rules_to_be_applied_edge_trace_threadsafe[i]) > 0: + rules_to_be_applied_edge_trace.extend(rules_to_be_applied_edge_trace_threadsafe[i]) + if len(edges_to_be_added_edge_rule_threadsafe[i]) > 0: + edges_to_be_added_edge_rule.extend(edges_to_be_added_edge_rule_threadsafe[i]) # Check for convergence after each timestep (perfect convergence or convergence specified by user) # Check number of changed interpretations or max bound change @@ -752,7 +652,333 @@ def get_interpretation_dict(self): return interpretations -@numba.njit(cache=False, parallel=True) +@numba.njit(cache=False) +def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace): + # Extract rule params + rule_type = rule.get_type() + head_variables = rule.get_head_variables() + clauses = rule.get_clauses() + thresholds = rule.get_thresholds() + ann_fn = rule.get_annotation_function() + rule_edges = rule.get_edges() + + if rule_type == 'node': + head_var_1 = head_variables[0] + else: + head_var_1, head_var_2 = head_variables[0], head_variables[1] + + # We return a list of tuples which specify the target nodes/edges that have made the rule body true + applicable_rules_node = numba.typed.List.empty_list(node_applicable_rule_type) + applicable_rules_edge = numba.typed.List.empty_list(edge_applicable_rule_type) + + # Grounding procedure + # 1. Go through each clause and check which variables have not been initialized in groundings + # 2. Check satisfaction of variables based on the predicate in the clause + + # Grounding variable that maps variables in the body to a list of grounded nodes + # Grounding edges that maps edge variables to a list of edges + groundings = numba.typed.Dict.empty(key_type=numba.types.string, value_type=list_of_nodes) + groundings_edges = numba.typed.Dict.empty(key_type=edge_type, value_type=list_of_edges) + + # Dependency graph that keeps track of the connections between the variables in the body + dependency_graph_neighbors = numba.typed.Dict.empty(key_type=node_type, value_type=list_of_nodes) + dependency_graph_reverse_neighbors = numba.typed.Dict.empty(key_type=node_type, value_type=list_of_nodes) + + satisfaction = True + for i, clause in enumerate(clauses): + # Unpack clause variables + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + clause_bnd = clause[3] + clause_operator = clause[4] + + # This is a node clause + if clause_type == 'node': + clause_var_1 = clause_variables[0] + + # Get subset of nodes that can be used to ground the variable + grounding = get_rule_node_clause_grounding(clause_var_1, groundings, nodes) + + # Narrow subset based on predicate + qualified_groundings = get_qualified_node_groundings(interpretations_node, grounding, clause_label, clause_bnd) + groundings[clause_var_1] = qualified_groundings + + # Check satisfaction of those nodes wrt the threshold + satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction + + # This is an edge clause + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + + # Get subset of edges that can be used to ground the variables + grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes) + + # Narrow subset based on predicate (save the edges that are qualified to use for finding future groundings faster) + qualified_groundings = get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, clause_bnd) + + # Check satisfaction of those edges wrt the threshold + satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction + + # Update the groundings + groundings[clause_var_1] = numba.typed.List.empty_list(node_type) + groundings[clause_var_2] = numba.typed.List.empty_list(node_type) + for e in qualified_groundings: + if e[0] not in groundings[clause_var_1]: + groundings[clause_var_1].append(e[0]) + if e[1] not in groundings[clause_var_2]: + groundings[clause_var_2].append(e[1]) + + # Update the edge groundings (to use later for grounding other clauses with the same variables) + groundings_edges[(clause_var_1, clause_var_2)] = qualified_groundings + + # Update dependency graph + # Add a connection between clause_var_1 -> clause_var_2 and vice versa + if clause_var_1 not in dependency_graph_neighbors: + dependency_graph_neighbors[clause_var_1] = numba.typed.List([clause_var_2]) + elif clause_var_2 not in dependency_graph_neighbors[clause_var_1]: + dependency_graph_neighbors[clause_var_1].append(clause_var_2) + if clause_var_2 not in dependency_graph_reverse_neighbors: + dependency_graph_reverse_neighbors[clause_var_2] = numba.typed.List([clause_var_1]) + elif clause_var_1 not in dependency_graph_reverse_neighbors[clause_var_2]: + dependency_graph_reverse_neighbors[clause_var_2].append(clause_var_1) + + # This is a comparison clause + else: + pass + + # Refine the subsets based on any updates + if satisfaction: + refine_groundings(clause_variables, groundings, groundings_edges, dependency_graph_neighbors, dependency_graph_reverse_neighbors) + + # If satisfaction is false, break + if not satisfaction: + break + + # All clauses of the rule have been satisfied, now add elements to trace and setup any edges that need to be added to the graph + if satisfaction: + # Check if all clauses are satisfied again in case the refining process changed anything + for i, clause in enumerate(clauses): + # Unpack clause variables + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, groundings[clause_var_1], groundings[clause_var_1], clause_label, thresholds[i]) and satisfaction + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, groundings_edges[(clause_var_1, clause_var_2)], groundings_edges[(clause_var_1, clause_var_2)], clause_label, thresholds[i]) and satisfaction + + # If satisfaction is still true, then continue to setup any edges to be added and annotations + # Fill out the rules to be applied lists + if satisfaction: + # Setup edges to be added and fill rules to be applied + # Setup traces and inputs for annotation function + # Loop through the clause data and setup final annotations and trace variables + # Three cases: 1.node rule, 2. edge rule with infer edges, 3. edge rule + if rule_type == 'node': + # Loop through all the head variable groundings and add it to the rules to be applied + # Loop through the clauses and add appropriate trace data and annotations + for head_grounding in groundings[head_var_1]: + qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) + qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) + annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) + edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + for i, clause in enumerate(clauses): + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + + # 1. + if atom_trace: + if clause_var_1 == head_var_1: + qualified_nodes.append(numba.typed.List([head_grounding])) + else: + qualified_nodes.append(numba.typed.List(groundings[clause_var_1])) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1: + a.append(interpretations_node[head_grounding].world[clause_label]) + else: + for qn in groundings[clause_var_1]: + a.append(interpretations_node[qn].world[clause_label]) + annotations.append(a) + + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + # 1. + if atom_trace: + # Cases: Both equal, one equal, none equal + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + if clause_var_1 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_grounding]) + qualified_edges.append(es) + elif clause_var_2 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_grounding]) + qualified_edges.append(es) + else: + qualified_edges.append(numba.typed.List(groundings_edges[(clause_var_1, clause_var_2)])) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_2 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[1] == head_grounding: + a.append(interpretations_edge[e].world[clause_label]) + else: + for qe in groundings_edges[(clause_var_1, clause_var_2)]: + a.append(interpretations_edge[qe].world[clause_label]) + annotations.append(a) + else: + # Comparison clause (we do not handle for now) + pass + + # For each grounding add a rule to be applied + applicable_rules_node.append((head_grounding, annotations, qualified_nodes, qualified_edges, edges_to_be_added)) + + elif rule_type == 'edge': + head_var_1 = head_variables[0] + head_var_2 = head_variables[1] + head_var_1_groundings = groundings[head_var_1] + head_var_2_groundings = groundings[head_var_2] + + source, target, _ = rule_edges + infer_edges = True if source != '' and target != '' else False + + # Prepare the edges that we will loop over. + # For infer edges we loop over each combination pair + # Else we loop over the valid edges in the graph + valid_edge_groundings = numba.typed.List.empty_list(edge_type) + for g1 in head_var_1_groundings: + for g2 in head_var_2_groundings: + if infer_edges: + valid_edge_groundings.append((g1, g2)) + else: + if (g1, g2) in edges: + valid_edge_groundings.append((g1, g2)) + + # Loop through the head variable groundings + for valid_e in valid_edge_groundings: + head_var_1_grounding, head_var_2_grounding = valid_e[0], valid_e[1] + qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) + qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) + annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) + edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + + if infer_edges: + edges_to_be_added[0].append(head_var_1_grounding) + edges_to_be_added[1].append(head_var_2_grounding) + + for i, clause in enumerate(clauses): + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + # 1. + if atom_trace: + if clause_var_1 == head_var_1: + qualified_nodes.append(numba.typed.List([head_var_1_grounding])) + elif clause_var_1 == head_var_2: + qualified_nodes.append(numba.typed.List([head_var_2_grounding])) + else: + qualified_nodes.append(numba.typed.List(groundings[clause_var_1])) + qualified_edges.append(numba.typed.List.empty_list(edge_type)) + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1: + a.append(interpretations_node[head_var_1_grounding].world[clause_label]) + elif clause_var_1 == head_var_2: + a.append(interpretations_node[head_var_2_grounding].world[clause_label]) + else: + for qn in groundings[clause_var_1]: + a.append(interpretations_node[qn].world[clause_label]) + annotations.append(a) + + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + # 1. + if atom_trace: + # Cases: + # 1. Both equal (cv1 = hv1 and cv2 = hv2 or cv1 = hv2 and cv2 = hv1) + # 2. One equal (cv1 = hv1 or cv2 = hv1 or cv1 = hv2 or cv2 = hv2) + # 3. None equal + qualified_nodes.append(numba.typed.List.empty_list(node_type)) + if clause_var_1 == head_var_1 and clause_var_2 == head_var_2: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_1_grounding and e[1] == head_var_2_grounding]) + qualified_edges.append(es) + elif clause_var_1 == head_var_2 and clause_var_2 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_2_grounding and e[1] == head_var_1_grounding]) + qualified_edges.append(es) + elif clause_var_1 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_1_grounding]) + qualified_edges.append(es) + elif clause_var_1 == head_var_2: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_2_grounding]) + qualified_edges.append(es) + elif clause_var_2 == head_var_1: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_var_1_grounding]) + qualified_edges.append(es) + elif clause_var_2 == head_var_2: + es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_var_2_grounding]) + qualified_edges.append(es) + else: + qualified_edges.append(numba.typed.List(groundings_edges[(clause_var_1, clause_var_2)])) + + # 2. + if ann_fn != '': + a = numba.typed.List.empty_list(interval.interval_type) + if clause_var_1 == head_var_1 and clause_var_2 == head_var_2: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_var_1_grounding and e[1] == head_var_2_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_1 == head_var_2 and clause_var_2 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_var_2_grounding and e[1] == head_var_1_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_1 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_var_1_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_1 == head_var_2: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[0] == head_var_2_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_2 == head_var_1: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[1] == head_var_1_grounding: + a.append(interpretations_edge[e].world[clause_label]) + elif clause_var_2 == head_var_2: + for e in groundings_edges[(clause_var_1, clause_var_2)]: + if e[1] == head_var_2_grounding: + a.append(interpretations_edge[e].world[clause_label]) + else: + for qe in groundings_edges[(clause_var_1, clause_var_2)]: + a.append(interpretations_edge[qe].world[clause_label]) + annotations.append(a) + + # For each grounding combination add a rule to be applied + e = (head_var_1_grounding, head_var_2_grounding) + applicable_rules_edge.append((e, annotations, qualified_nodes, qualified_edges, edges_to_be_added)) + + # Return the applicable rules + return applicable_rules_node, applicable_rules_edge + + +@numba.njit(cache=False) def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, neighbors, reverse_neighbors, atom_trace, reverse_graph, nodes_to_skip): # Extract rule params rule_type = rule.get_type() @@ -1010,7 +1236,7 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n return applicable_rules -@numba.njit(cache=False, parallel=True) +@numba.njit(cache=False) def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, reverse_graph, edges_to_skip): # Extract rule params rule_type = rule.get_type() @@ -1249,8 +1475,63 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e return applicable_rules +@numba.njit(cache=False) +def refine_groundings(clause_variables, groundings, groundings_edges, dependency_graph_neighbors, dependency_graph_reverse_neighbors): + # Loop through the dependency graph and refine the groundings that have connections + all_variables_refined = numba.typed.List(clause_variables) + variables_just_refined = numba.typed.List(clause_variables) + new_variables_refined = numba.typed.List.empty_list(numba.types.string) + while len(variables_just_refined) > 0: + for refined_variable in variables_just_refined: + # Refine all the neighbors of the refined variable + if refined_variable in dependency_graph_neighbors: + for neighbor in dependency_graph_neighbors[refined_variable]: + old_edge_groundings = groundings_edges[(refined_variable, neighbor)] + new_node_groundings = groundings[refined_variable] + + # Delete old groundings for the variable being refined + del groundings[neighbor] + groundings[neighbor] = numba.typed.List.empty_list(node_type) + + # Update the edge groundings and node groundings + qualified_groundings = numba.typed.List([edge for edge in old_edge_groundings if edge[0] in new_node_groundings]) + for e in qualified_groundings: + if e[1] not in groundings[neighbor]: + groundings[neighbor].append(e[1]) + groundings_edges[(refined_variable, neighbor)] = qualified_groundings + + # Add the neighbor to the list of refined variables so that we can refine for all its neighbors + if neighbor not in all_variables_refined: + new_variables_refined.append(neighbor) + + if refined_variable in dependency_graph_reverse_neighbors: + for reverse_neighbor in dependency_graph_reverse_neighbors[refined_variable]: + old_edge_groundings = groundings_edges[(reverse_neighbor, refined_variable)] + new_node_groundings = groundings[refined_variable] + + # Delete old groundings for the variable being refined + del groundings[reverse_neighbor] + groundings[reverse_neighbor] = numba.typed.List.empty_list(node_type) + + # Update the edge groundings and node groundings + qualified_groundings = numba.typed.List([edge for edge in old_edge_groundings if edge[1] in new_node_groundings]) + for e in qualified_groundings: + if e[0] not in groundings[reverse_neighbor]: + groundings[reverse_neighbor].append(e[0]) + groundings_edges[(reverse_neighbor, refined_variable)] = qualified_groundings + + # Add the neighbor to the list of refined variables so that we can refine for all its neighbors + if reverse_neighbor not in all_variables_refined: + new_variables_refined.append(reverse_neighbor) + + variables_just_refined = numba.typed.List(new_variables_refined) + all_variables_refined.extend(new_variables_refined) + new_variables_refined.clear() + + @numba.njit(cache=False) def refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_node, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph): + """NOTE: DEPRECATED""" # Loop through all clauses till clause i-1 and update subsets recursively # Then check if the clause still satisfies the thresholds clause = clauses[i] @@ -1330,6 +1611,7 @@ def refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_n @numba.njit(cache=False) def refine_subsets_edge_rule(interpretations_edge, clauses, i, subsets, target_edge, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph): + """NOTE: DEPRECATED""" # Loop through all clauses till clause i-1 and update subsets recursively # Then check if the clause still satisfies the thresholds clause = clauses[i] @@ -1407,8 +1689,39 @@ def refine_subsets_edge_rule(interpretations_edge, clauses, i, subsets, target_e return satisfaction +@numba.njit(cache=False) +def check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_grounding, clause_label, threshold): + threshold_quantifier_type = threshold[1][1] + if threshold_quantifier_type == 'total': + neigh_len = len(grounding) + + # Available is all neighbors that have a particular label with bound inside [0,1] + elif threshold_quantifier_type == 'available': + neigh_len = len(get_qualified_node_groundings(interpretations_node, grounding, clause_label, interval.closed(0, 1))) + + qualified_neigh_len = len(qualified_grounding) + satisfaction = _satisfies_threshold(neigh_len, qualified_neigh_len, threshold) + return satisfaction + + +@numba.njit(cache=False) +def check_edge_grounding_threshold_satisfaction(interpretations_edge, grounding, qualified_grounding, clause_label, threshold): + threshold_quantifier_type = threshold[1][1] + if threshold_quantifier_type == 'total': + neigh_len = len(grounding) + + # Available is all neighbors that have a particular label with bound inside [0,1] + elif threshold_quantifier_type == 'available': + neigh_len = len(get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, interval.closed(0, 1))) + + qualified_neigh_len = len(qualified_grounding) + satisfaction = _satisfies_threshold(neigh_len, qualified_neigh_len, threshold) + return satisfaction + + @numba.njit(cache=False) def check_node_clause_satisfaction(interpretations_node, subsets, subset, clause_var_1, clause_label, threshold): + """NOTE: DEPRECATED""" threshold_quantifier_type = threshold[1][1] if threshold_quantifier_type == 'total': neigh_len = len(subset) @@ -1425,6 +1738,7 @@ def check_node_clause_satisfaction(interpretations_node, subsets, subset, clause @numba.njit(cache=False) def check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, clause_label, threshold, reverse_graph): + """NOTE: DEPRECATED""" threshold_quantifier_type = threshold[1][1] if threshold_quantifier_type == 'total': neigh_len = sum([len(l) for l in subset_target]) @@ -1438,8 +1752,61 @@ def check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, return satisfaction +@numba.njit(cache=False) +def get_rule_node_clause_grounding(clause_var_1, groundings, nodes): + # The groundings for a node clause can be either a previous grounding or all possible nodes + grounding = numba.typed.List(nodes) if clause_var_1 not in groundings else groundings[clause_var_1] + return grounding + + +@numba.njit(cache=False) +def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes): + # There are 4 cases for predicate(Y,Z): + # 1. Both predicate variables Y and Z have not been encountered before + # 2. The source variable Y has not been encountered before but the target variable Z has + # 3. The target variable Z has not been encountered before but the source variable Y has + # 4. Both predicate variables Y and Z have been encountered before + edge_groundings = numba.typed.List.empty_list(edge_type) + + # Case 1: + # We replace Y by all nodes and Z by the neighbors of each of these nodes + if clause_var_1 not in groundings and clause_var_2 not in groundings: + for n in nodes: + es = numba.typed.List([(n, nn) for nn in neighbors[n]]) + edge_groundings.extend(es) + + # Case 2: + # We replace Y by the sources of Z + elif clause_var_1 not in groundings and clause_var_2 in groundings: + for n in groundings[clause_var_2]: + es = numba.typed.List([(nn, n) for nn in reverse_neighbors[n]]) + edge_groundings.extend(es) + + # Case 3: + # We replace Z by the neighbors of Y + elif clause_var_1 in groundings and clause_var_2 not in groundings: + for n in groundings[clause_var_1]: + es = numba.typed.List([(n, nn) for nn in neighbors[n]]) + edge_groundings.extend(es) + + # Case 4: + # We have seen both variables before + else: + # # We have already seen these two variables in an edge clause + # if (clause_var_1, clause_var_2) in groundings_edges: + # edge_groundings = groundings_edges[(clause_var_1, clause_var_2)] + # # We have seen both these variables but not in an edge clause together + # else: + for n in groundings[clause_var_1]: + es = numba.typed.List([(n, nn) for nn in neighbors[n] if nn in groundings[clause_var_2]]) + edge_groundings.extend(es) + + return edge_groundings + + @numba.njit(cache=False) def get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes): + """NOTE: DEPRECATED""" # The groundings for node clauses are either the target node, neighbors of the target node, or an existing subset of nodes if clause_var_1 == '__target': subset = numba.typed.List([target_node]) @@ -1451,6 +1818,7 @@ def get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes): @numba.njit(cache=False) def get_node_rule_edge_clause_subset(clause_var_1, clause_var_2, target_node, subsets, neighbors, reverse_neighbors, nodes): + """NOTE: DEPRECATED""" # There are 5 cases for predicate(Y,Z): # 1. Either one or both of Y, Z are the target node # 2. Both predicate variables Y and Z have not been encountered before @@ -1520,6 +1888,7 @@ def get_node_rule_edge_clause_subset(clause_var_1, clause_var_2, target_node, su @numba.njit(cache=False) def get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, nodes): + """NOTE: DEPRECATED""" # The groundings for node clauses are either the source, target, neighbors of the source node, or an existing subset of nodes if clause_var_1 == '__source': subset = numba.typed.List([target_edge[0]]) @@ -1533,6 +1902,7 @@ def get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, nodes): @numba.njit(cache=False) def get_edge_rule_edge_clause_subset(clause_var_1, clause_var_2, target_edge, subsets, neighbors, reverse_neighbors, nodes): + """NOTE: DEPRECATED""" # There are 5 cases for predicate(Y,Z): # 1. Either one or both of Y, Z are the source or target node # 2. Both predicate variables Y and Z have not been encountered before @@ -1619,8 +1989,31 @@ def get_edge_rule_edge_clause_subset(clause_var_1, clause_var_2, target_edge, su return subset_source, subset_target +@numba.njit(cache=False) +def get_qualified_node_groundings(interpretations_node, grounding, clause_l, clause_bnd): + # Filter the grounding by the predicate and bound of the clause + qualified_groundings = numba.typed.List.empty_list(node_type) + for n in grounding: + if is_satisfied_node(interpretations_node, n, (clause_l, clause_bnd)): + qualified_groundings.append(n) + + return qualified_groundings + + +@numba.njit(cache=False) +def get_qualified_edge_groundings(interpretations_edge, grounding, clause_l, clause_bnd): + # Filter the grounding by the predicate and bound of the clause + qualified_groundings = numba.typed.List.empty_list(edge_type) + for e in grounding: + if is_satisfied_edge(interpretations_edge, e, (clause_l, clause_bnd)): + qualified_groundings.append(e) + + return qualified_groundings + + @numba.njit(cache=False) def get_qualified_components_node_clause(interpretations_node, candidates, l, bnd): + """NOTE: DEPRECATED""" # Get all the qualified neighbors for a particular clause qualified_nodes = numba.typed.List.empty_list(node_type) for n in candidates: @@ -1632,6 +2025,7 @@ def get_qualified_components_node_clause(interpretations_node, candidates, l, bn @numba.njit(cache=False) def get_qualified_components_node_comparison_clause(interpretations_node, candidates, l, bnd): + """NOTE: DEPRECATED""" # Get all the qualified neighbors for a particular comparison clause and return them along with the number associated qualified_nodes = numba.typed.List.empty_list(node_type) qualified_nodes_numbers = numba.typed.List.empty_list(numba.types.float64) @@ -1646,6 +2040,7 @@ def get_qualified_components_node_comparison_clause(interpretations_node, candid @numba.njit(cache=False) def get_qualified_components_edge_clause(interpretations_edge, candidates_source, candidates_target, l, bnd, reverse_graph): + """NOTE: DEPRECATED""" # Get all the qualified sources and targets for a particular clause qualified_nodes_source = numba.typed.List.empty_list(node_type) qualified_nodes_target = numba.typed.List.empty_list(node_type) @@ -1661,6 +2056,7 @@ def get_qualified_components_edge_clause(interpretations_edge, candidates_source @numba.njit(cache=False) def get_qualified_components_edge_comparison_clause(interpretations_edge, candidates_source, candidates_target, l, bnd, reverse_graph): + """NOTE: DEPRECATED""" # Get all the qualified sources and targets for a particular clause qualified_nodes_source = numba.typed.List.empty_list(node_type) qualified_nodes_target = numba.typed.List.empty_list(node_type) @@ -1679,6 +2075,7 @@ def get_qualified_components_edge_comparison_clause(interpretations_edge, candid @numba.njit(cache=False) def compare_numbers_node_predicate(numbers_1, numbers_2, op, qualified_nodes_1, qualified_nodes_2): + """NOTE: DEPRECATED""" result = False final_qualified_nodes_1 = numba.typed.List.empty_list(node_type) final_qualified_nodes_2 = numba.typed.List.empty_list(node_type) @@ -1711,6 +2108,7 @@ def compare_numbers_node_predicate(numbers_1, numbers_2, op, qualified_nodes_1, @numba.njit(cache=False) def compare_numbers_edge_predicate(numbers_1, numbers_2, op, qualified_nodes_1a, qualified_nodes_1b, qualified_nodes_2a, qualified_nodes_2b): + """NOTE: DEPRECATED""" result = False final_qualified_nodes_1a = numba.typed.List.empty_list(node_type) final_qualified_nodes_1b = numba.typed.List.empty_list(node_type) @@ -1962,15 +2360,15 @@ def _update_rule_trace(rule_trace, qn, qe, prev_bnd, name): @numba.njit(cache=False) def are_satisfied_node(interpretations, comp, nas): result = True - for (label, interval) in nas: - result = result and is_satisfied_node(interpretations, comp, (label, interval)) + for (l, bnd) in nas: + result = result and is_satisfied_node(interpretations, comp, (l, bnd)) return result @numba.njit(cache=False) def is_satisfied_node(interpretations, comp, na): result = False - if (not (na[0] is None or na[1] is None)): + if not (na[0] is None or na[1] is None): # This is to prevent a key error in case the label is a specific label try: world = interpretations[comp] @@ -2012,15 +2410,15 @@ def is_satisfied_node_comparison(interpretations, comp, na): @numba.njit(cache=False) def are_satisfied_edge(interpretations, comp, nas): result = True - for (label, interval) in nas: - result = result and is_satisfied_edge(interpretations, comp, (label, interval)) + for (l, bnd) in nas: + result = result and is_satisfied_edge(interpretations, comp, (l, bnd)) return result @numba.njit(cache=False) def is_satisfied_edge(interpretations, comp, na): result = False - if (not (na[0] is None or na[1] is None)): + if not (na[0] is None or na[1] is None): # This is to prevent a key error in case the label is a specific label try: world = interpretations[comp] diff --git a/tests/test_custom_thresholds.py b/tests/test_custom_thresholds.py index b16620d..a3a1776 100644 --- a/tests/test_custom_thresholds.py +++ b/tests/test_custom_thresholds.py @@ -60,5 +60,3 @@ def test_custom_thresholds(): 1, 1, ], "TextMessage should have ViewedByAll bounds [1,1] for t=2 timesteps" - -test_custom_thresholds() \ No newline at end of file From 3dc55425dbee41694de324d0894f3cebc502bfc3 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Tue, 25 Jun 2024 13:21:44 +0200 Subject: [PATCH 15/73] dummy push --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0e7700e..18d141c 100755 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Check out the [PyReason Hello World](https://pyreason.readthedocs.io/en/latest/t ## 1. Introduction PyReason is a graphical inference tool that uses a set of logical rules and facts (initial conditions) to reason over graph structures. To get more details, refer to the paper/video/hello-world-example mentioned above. - + ## 2. Documentation All API documentation and code examples can be found on [ReadTheDocs](https://pyreason.readthedocs.io/en/latest/) From 7c6ac8c850113d0670f054eaefd7a666fa261449 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Wed, 26 Jun 2024 15:37:43 +0200 Subject: [PATCH 16/73] Fixed interpretation.py bug --- .../scripts/interpretation/interpretation.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index ff55617..b07ba7f 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -511,8 +511,6 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Only go through if the rule can be applied within the given timesteps, or we're running until convergence delta_t = rule.get_delta() if t + delta_t <= tmax or tmax == -1 or again: - # applicable_node_rules = _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, neighbors, reverse_neighbors, atom_trace, reverse_graph, nodes_to_skip[i]) - # applicable_edge_rules = _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, reverse_graph, edges_to_skip[i]) applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace) # Loop through applicable rules and add them to the rules to be applied for later or next fp operation @@ -527,10 +525,13 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data bnd = interval.closed(bnd_l, bnd_u) max_rules_time = max(max_rules_time, t + delta_t) rules_to_be_applied_node_threadsafe[i].append((numba.types.uint16(t + delta_t), n, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) - # rules_to_be_applied_node.append((numba.types.uint16(t + delta_t), n, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) if atom_trace: rules_to_be_applied_node_trace_threadsafe[i].append((qualified_nodes, qualified_edges, rule.get_name())) - # rules_to_be_applied_node_trace.append((qualified_nodes, qualified_edges, rule.get_name())) + + # If delta_t is zero we apply the rules and check if more are applicable + if delta_t == 0: + in_loop = True + update = False for applicable_rule in applicable_edge_rules: e, annotations, qualified_nodes, qualified_edges, edges_to_add = applicable_rule @@ -550,6 +551,11 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # rules_to_be_applied_edge_trace.append((qualified_nodes, qualified_edges, rule.get_name())) rules_to_be_applied_edge_trace_threadsafe[i].append((qualified_nodes, qualified_edges, rule.get_name())) + # If delta_t is zero we apply the rules and check if more are applicable + if delta_t == 0: + in_loop = True + update = False + # Update lists after parallel run for i in range(len(rules)): if len(rules_to_be_applied_node_threadsafe[i]) > 0: @@ -567,14 +573,14 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Check for convergence after each timestep (perfect convergence or convergence specified by user) # Check number of changed interpretations or max bound change # User specified convergence - if convergence_mode=='delta_interpretation': + if convergence_mode == 'delta_interpretation': if changes_cnt <= convergence_delta: if verbose: print(f'\nConverged at time: {t} with {int(changes_cnt)} changes from the previous interpretation') # Be consistent with time returned when we don't converge t += 1 break - elif convergence_mode=='delta_bound': + elif convergence_mode == 'delta_bound': if bound_delta <= convergence_delta: if verbose: print(f'\nConverged at time: {t} with {float_to_str(bound_delta)} as the maximum bound change from the previous interpretation') @@ -584,8 +590,8 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Perfect convergence # Make sure there are no rules to be applied, and no facts that will be applied in the future. We do this by checking the max time any rule/fact is applicable # If no more rules/facts to be applied - elif convergence_mode=='perfect_convergence': - if t>=max_facts_time and t>=max_rules_time: + elif convergence_mode == 'perfect_convergence': + if t>=max_facts_time and t >= max_rules_time: if verbose: print(f'\nConverged at time: {t}') # Be consistent with time returned when we don't converge From 54e4446eab48be877480cd0b6cb4d66ee127b789 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Wed, 26 Jun 2024 15:44:18 +0200 Subject: [PATCH 17/73] added fix to parallel interpretation code --- .../interpretation/interpretation_parallel.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index 098dd08..bbe72cf 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -511,8 +511,6 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Only go through if the rule can be applied within the given timesteps, or we're running until convergence delta_t = rule.get_delta() if t + delta_t <= tmax or tmax == -1 or again: - # applicable_node_rules = _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, neighbors, reverse_neighbors, atom_trace, reverse_graph, nodes_to_skip[i]) - # applicable_edge_rules = _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, reverse_graph, edges_to_skip[i]) applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace) # Loop through applicable rules and add them to the rules to be applied for later or next fp operation @@ -527,10 +525,13 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data bnd = interval.closed(bnd_l, bnd_u) max_rules_time = max(max_rules_time, t + delta_t) rules_to_be_applied_node_threadsafe[i].append((numba.types.uint16(t + delta_t), n, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) - # rules_to_be_applied_node.append((numba.types.uint16(t + delta_t), n, rule.get_target(), bnd, immediate_rule, rule.is_static_rule())) if atom_trace: rules_to_be_applied_node_trace_threadsafe[i].append((qualified_nodes, qualified_edges, rule.get_name())) - # rules_to_be_applied_node_trace.append((qualified_nodes, qualified_edges, rule.get_name())) + + # If delta_t is zero we apply the rules and check if more are applicable + if delta_t == 0: + in_loop = True + update = False for applicable_rule in applicable_edge_rules: e, annotations, qualified_nodes, qualified_edges, edges_to_add = applicable_rule @@ -550,6 +551,11 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # rules_to_be_applied_edge_trace.append((qualified_nodes, qualified_edges, rule.get_name())) rules_to_be_applied_edge_trace_threadsafe[i].append((qualified_nodes, qualified_edges, rule.get_name())) + # If delta_t is zero we apply the rules and check if more are applicable + if delta_t == 0: + in_loop = True + update = False + # Update lists after parallel run for i in range(len(rules)): if len(rules_to_be_applied_node_threadsafe[i]) > 0: @@ -567,14 +573,14 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Check for convergence after each timestep (perfect convergence or convergence specified by user) # Check number of changed interpretations or max bound change # User specified convergence - if convergence_mode=='delta_interpretation': + if convergence_mode == 'delta_interpretation': if changes_cnt <= convergence_delta: if verbose: print(f'\nConverged at time: {t} with {int(changes_cnt)} changes from the previous interpretation') # Be consistent with time returned when we don't converge t += 1 break - elif convergence_mode=='delta_bound': + elif convergence_mode == 'delta_bound': if bound_delta <= convergence_delta: if verbose: print(f'\nConverged at time: {t} with {float_to_str(bound_delta)} as the maximum bound change from the previous interpretation') @@ -584,8 +590,8 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Perfect convergence # Make sure there are no rules to be applied, and no facts that will be applied in the future. We do this by checking the max time any rule/fact is applicable # If no more rules/facts to be applied - elif convergence_mode=='perfect_convergence': - if t>=max_facts_time and t>=max_rules_time: + elif convergence_mode == 'perfect_convergence': + if t>=max_facts_time and t >= max_rules_time: if verbose: print(f'\nConverged at time: {t}') # Be consistent with time returned when we don't converge From 70654d75aaff2285a2e5b36d4cb0eb34bded0e02 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Fri, 19 Jul 2024 15:07:26 +0200 Subject: [PATCH 18/73] fixed bug with rules firing when they shouldn't. The issue was that we were not updating the edge collection groundings --- .../scripts/interpretation/interpretation.py | 117 +++++++++++++----- 1 file changed, 84 insertions(+), 33 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index b07ba7f..7f5a586 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -709,6 +709,11 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Narrow subset based on predicate qualified_groundings = get_qualified_node_groundings(interpretations_node, grounding, clause_label, clause_bnd) groundings[clause_var_1] = qualified_groundings + for c1, c2 in groundings_edges: + if c1 == clause_var_1: + groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[0] in qualified_groundings]) + if c2 == clause_var_1: + groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[1] in qualified_groundings]) # Check satisfaction of those nodes wrt the threshold satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction @@ -761,25 +766,11 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if not satisfaction: break - # All clauses of the rule have been satisfied, now add elements to trace and setup any edges that need to be added to the graph - if satisfaction: - # Check if all clauses are satisfied again in case the refining process changed anything - for i, clause in enumerate(clauses): - # Unpack clause variables - clause_type = clause[0] - clause_label = clause[1] - clause_variables = clause[2] - - if clause_type == 'node': - clause_var_1 = clause_variables[0] - satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, groundings[clause_var_1], groundings[clause_var_1], clause_label, thresholds[i]) and satisfaction - elif clause_type == 'edge': - clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] - satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, groundings_edges[(clause_var_1, clause_var_2)], groundings_edges[(clause_var_1, clause_var_2)], clause_label, thresholds[i]) and satisfaction - - # If satisfaction is still true, then continue to setup any edges to be added and annotations + # If satisfaction is still true, one final refinement to check if each edge pair is valid in edge rules + # Then continue to setup any edges to be added and annotations # Fill out the rules to be applied lists if satisfaction: + # Create temp grounding containers to verify if the head groundings are valid (only for edge rules) # Setup edges to be added and fill rules to be applied # Setup traces and inputs for annotation function # Loop through the clause data and setup final annotations and trace variables @@ -792,6 +783,12 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + + # Check for satisfaction one more time in case the refining process has changed the groundings + satisfaction = check_all_clause_satisfaction(interpretations_node, interpretations_edge, clauses, thresholds, groundings, groundings_edges) + if not satisfaction: + continue + for i, clause in enumerate(clauses): clause_type = clause[0] clause_label = clause[1] @@ -876,12 +873,45 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Loop through the head variable groundings for valid_e in valid_edge_groundings: + satisfaction = True head_var_1_grounding, head_var_2_grounding = valid_e[0], valid_e[1] qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + # Containers to keep track of groundings to make sure that the edge pair is valid + # We do this because we cannot know beforehand the edge matches from source groundings to target groundings + temp_groundings = groundings.copy() + temp_groundings_edges = groundings_edges.copy() + + # Refine the temp groundings for the specific edge head grounding + # We update the edge collection as well depending on if there's a match between the clause variables and head variables + temp_groundings[head_var_1] = numba.typed.List([head_var_1_grounding]) + temp_groundings[head_var_2] = numba.typed.List([head_var_2_grounding]) + for c1, c2 in temp_groundings_edges.keys(): + if c1 == head_var_1 and c2 == head_var_2: + temp_groundings_edges[(c1, c2)] = numba.typed.List([e for e in temp_groundings_edges[(c1, c2)] if e == (head_var_1_grounding, head_var_2_grounding)]) + elif c1 == head_var_2 and c2 == head_var_1: + temp_groundings_edges[(c1, c2)] = numba.typed.List([e for e in temp_groundings_edges[(c1, c2)] if e == (head_var_2_grounding, head_var_1_grounding)]) + elif c1 == head_var_1: + temp_groundings_edges[(c1, c2)] = numba.typed.List([e for e in temp_groundings_edges[(c1, c2)] if e[0] == head_var_1_grounding]) + elif c2 == head_var_1: + temp_groundings_edges[(c1, c2)] = numba.typed.List([e for e in temp_groundings_edges[(c1, c2)] if e[1] == head_var_1_grounding]) + elif c1 == head_var_2: + temp_groundings_edges[(c1, c2)] = numba.typed.List([e for e in temp_groundings_edges[(c1, c2)] if e[0] == head_var_2_grounding]) + elif c2 == head_var_2: + temp_groundings_edges[(c1, c2)] = numba.typed.List([e for e in temp_groundings_edges[(c1, c2)] if e[1] == head_var_2_grounding]) + + refine_groundings(head_variables, temp_groundings, temp_groundings_edges, dependency_graph_neighbors, dependency_graph_reverse_neighbors) + + # Check if the thresholds are still satisfied + # Check if all clauses are satisfied again in case the refining process changed anything + satisfaction = check_all_clause_satisfaction(interpretations_node, interpretations_edge, clauses, thresholds, temp_groundings, temp_groundings_edges) + + if not satisfaction: + continue + if infer_edges: edges_to_be_added[0].append(head_var_1_grounding) edges_to_be_added[1].append(head_var_2_grounding) @@ -900,7 +930,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, elif clause_var_1 == head_var_2: qualified_nodes.append(numba.typed.List([head_var_2_grounding])) else: - qualified_nodes.append(numba.typed.List(groundings[clause_var_1])) + qualified_nodes.append(numba.typed.List(temp_groundings[clause_var_1])) qualified_edges.append(numba.typed.List.empty_list(edge_type)) # 2. if ann_fn != '': @@ -910,7 +940,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, elif clause_var_1 == head_var_2: a.append(interpretations_node[head_var_2_grounding].world[clause_label]) else: - for qn in groundings[clause_var_1]: + for qn in temp_groundings[clause_var_1]: a.append(interpretations_node[qn].world[clause_label]) annotations.append(a) @@ -924,59 +954,61 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # 3. None equal qualified_nodes.append(numba.typed.List.empty_list(node_type)) if clause_var_1 == head_var_1 and clause_var_2 == head_var_2: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_1_grounding and e[1] == head_var_2_grounding]) + es = numba.typed.List([e for e in temp_groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_1_grounding and e[1] == head_var_2_grounding]) qualified_edges.append(es) elif clause_var_1 == head_var_2 and clause_var_2 == head_var_1: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_2_grounding and e[1] == head_var_1_grounding]) + es = numba.typed.List([e for e in temp_groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_2_grounding and e[1] == head_var_1_grounding]) qualified_edges.append(es) elif clause_var_1 == head_var_1: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_1_grounding]) + es = numba.typed.List([e for e in temp_groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_1_grounding]) qualified_edges.append(es) elif clause_var_1 == head_var_2: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_2_grounding]) + es = numba.typed.List([e for e in temp_groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_2_grounding]) qualified_edges.append(es) elif clause_var_2 == head_var_1: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_var_1_grounding]) + es = numba.typed.List([e for e in temp_groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_var_1_grounding]) qualified_edges.append(es) elif clause_var_2 == head_var_2: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_var_2_grounding]) + es = numba.typed.List([e for e in temp_groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_var_2_grounding]) qualified_edges.append(es) else: - qualified_edges.append(numba.typed.List(groundings_edges[(clause_var_1, clause_var_2)])) + qualified_edges.append(numba.typed.List(temp_groundings_edges[(clause_var_1, clause_var_2)])) # 2. if ann_fn != '': a = numba.typed.List.empty_list(interval.interval_type) if clause_var_1 == head_var_1 and clause_var_2 == head_var_2: - for e in groundings_edges[(clause_var_1, clause_var_2)]: + for e in temp_groundings_edges[(clause_var_1, clause_var_2)]: if e[0] == head_var_1_grounding and e[1] == head_var_2_grounding: a.append(interpretations_edge[e].world[clause_label]) elif clause_var_1 == head_var_2 and clause_var_2 == head_var_1: - for e in groundings_edges[(clause_var_1, clause_var_2)]: + for e in temp_groundings_edges[(clause_var_1, clause_var_2)]: if e[0] == head_var_2_grounding and e[1] == head_var_1_grounding: a.append(interpretations_edge[e].world[clause_label]) elif clause_var_1 == head_var_1: - for e in groundings_edges[(clause_var_1, clause_var_2)]: + for e in temp_groundings_edges[(clause_var_1, clause_var_2)]: if e[0] == head_var_1_grounding: a.append(interpretations_edge[e].world[clause_label]) elif clause_var_1 == head_var_2: - for e in groundings_edges[(clause_var_1, clause_var_2)]: + for e in temp_groundings_edges[(clause_var_1, clause_var_2)]: if e[0] == head_var_2_grounding: a.append(interpretations_edge[e].world[clause_label]) elif clause_var_2 == head_var_1: - for e in groundings_edges[(clause_var_1, clause_var_2)]: + for e in temp_groundings_edges[(clause_var_1, clause_var_2)]: if e[1] == head_var_1_grounding: a.append(interpretations_edge[e].world[clause_label]) elif clause_var_2 == head_var_2: - for e in groundings_edges[(clause_var_1, clause_var_2)]: + for e in temp_groundings_edges[(clause_var_1, clause_var_2)]: if e[1] == head_var_2_grounding: a.append(interpretations_edge[e].world[clause_label]) else: - for qe in groundings_edges[(clause_var_1, clause_var_2)]: + for qe in temp_groundings_edges[(clause_var_1, clause_var_2)]: a.append(interpretations_edge[qe].world[clause_label]) annotations.append(a) # For each grounding combination add a rule to be applied + # Only if all the clauses have valid groundings + # if satisfaction: e = (head_var_1_grounding, head_var_2_grounding) applicable_rules_edge.append((e, annotations, qualified_nodes, qualified_edges, edges_to_be_added)) @@ -984,6 +1016,25 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, return applicable_rules_node, applicable_rules_edge +@numba.njit(cache=True) +def check_all_clause_satisfaction(interpretations_node, interpretations_edge, clauses, thresholds, groundings, groundings_edges): + # Check if the thresholds are satisfied for each clause + satisfaction = True + for i, clause in enumerate(clauses): + # Unpack clause variables + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, groundings[clause_var_1], groundings[clause_var_1], clause_label, thresholds[i]) and satisfaction + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, groundings_edges[(clause_var_1, clause_var_2)], groundings_edges[(clause_var_1, clause_var_2)], clause_label, thresholds[i]) and satisfaction + return satisfaction + + @numba.njit(cache=True) def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, neighbors, reverse_neighbors, atom_trace, reverse_graph, nodes_to_skip): # Extract rule params From a717cc85a2354d0cdaeaec303755068acc514a82 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Fri, 19 Jul 2024 15:15:54 +0200 Subject: [PATCH 19/73] updated parallel implementation with the bug fix --- .../interpretation/interpretation_parallel.py | 117 +++++++++++++----- 1 file changed, 84 insertions(+), 33 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index bbe72cf..95521ca 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -709,6 +709,11 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Narrow subset based on predicate qualified_groundings = get_qualified_node_groundings(interpretations_node, grounding, clause_label, clause_bnd) groundings[clause_var_1] = qualified_groundings + for c1, c2 in groundings_edges: + if c1 == clause_var_1: + groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[0] in qualified_groundings]) + if c2 == clause_var_1: + groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[1] in qualified_groundings]) # Check satisfaction of those nodes wrt the threshold satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction @@ -761,25 +766,11 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if not satisfaction: break - # All clauses of the rule have been satisfied, now add elements to trace and setup any edges that need to be added to the graph - if satisfaction: - # Check if all clauses are satisfied again in case the refining process changed anything - for i, clause in enumerate(clauses): - # Unpack clause variables - clause_type = clause[0] - clause_label = clause[1] - clause_variables = clause[2] - - if clause_type == 'node': - clause_var_1 = clause_variables[0] - satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, groundings[clause_var_1], groundings[clause_var_1], clause_label, thresholds[i]) and satisfaction - elif clause_type == 'edge': - clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] - satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, groundings_edges[(clause_var_1, clause_var_2)], groundings_edges[(clause_var_1, clause_var_2)], clause_label, thresholds[i]) and satisfaction - - # If satisfaction is still true, then continue to setup any edges to be added and annotations + # If satisfaction is still true, one final refinement to check if each edge pair is valid in edge rules + # Then continue to setup any edges to be added and annotations # Fill out the rules to be applied lists if satisfaction: + # Create temp grounding containers to verify if the head groundings are valid (only for edge rules) # Setup edges to be added and fill rules to be applied # Setup traces and inputs for annotation function # Loop through the clause data and setup final annotations and trace variables @@ -792,6 +783,12 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + + # Check for satisfaction one more time in case the refining process has changed the groundings + satisfaction = check_all_clause_satisfaction(interpretations_node, interpretations_edge, clauses, thresholds, groundings, groundings_edges) + if not satisfaction: + continue + for i, clause in enumerate(clauses): clause_type = clause[0] clause_label = clause[1] @@ -876,12 +873,45 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Loop through the head variable groundings for valid_e in valid_edge_groundings: + satisfaction = True head_var_1_grounding, head_var_2_grounding = valid_e[0], valid_e[1] qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) annotations = numba.typed.List.empty_list(numba.typed.List.empty_list(interval.interval_type)) edges_to_be_added = (numba.typed.List.empty_list(node_type), numba.typed.List.empty_list(node_type), rule_edges[-1]) + # Containers to keep track of groundings to make sure that the edge pair is valid + # We do this because we cannot know beforehand the edge matches from source groundings to target groundings + temp_groundings = groundings.copy() + temp_groundings_edges = groundings_edges.copy() + + # Refine the temp groundings for the specific edge head grounding + # We update the edge collection as well depending on if there's a match between the clause variables and head variables + temp_groundings[head_var_1] = numba.typed.List([head_var_1_grounding]) + temp_groundings[head_var_2] = numba.typed.List([head_var_2_grounding]) + for c1, c2 in temp_groundings_edges.keys(): + if c1 == head_var_1 and c2 == head_var_2: + temp_groundings_edges[(c1, c2)] = numba.typed.List([e for e in temp_groundings_edges[(c1, c2)] if e == (head_var_1_grounding, head_var_2_grounding)]) + elif c1 == head_var_2 and c2 == head_var_1: + temp_groundings_edges[(c1, c2)] = numba.typed.List([e for e in temp_groundings_edges[(c1, c2)] if e == (head_var_2_grounding, head_var_1_grounding)]) + elif c1 == head_var_1: + temp_groundings_edges[(c1, c2)] = numba.typed.List([e for e in temp_groundings_edges[(c1, c2)] if e[0] == head_var_1_grounding]) + elif c2 == head_var_1: + temp_groundings_edges[(c1, c2)] = numba.typed.List([e for e in temp_groundings_edges[(c1, c2)] if e[1] == head_var_1_grounding]) + elif c1 == head_var_2: + temp_groundings_edges[(c1, c2)] = numba.typed.List([e for e in temp_groundings_edges[(c1, c2)] if e[0] == head_var_2_grounding]) + elif c2 == head_var_2: + temp_groundings_edges[(c1, c2)] = numba.typed.List([e for e in temp_groundings_edges[(c1, c2)] if e[1] == head_var_2_grounding]) + + refine_groundings(head_variables, temp_groundings, temp_groundings_edges, dependency_graph_neighbors, dependency_graph_reverse_neighbors) + + # Check if the thresholds are still satisfied + # Check if all clauses are satisfied again in case the refining process changed anything + satisfaction = check_all_clause_satisfaction(interpretations_node, interpretations_edge, clauses, thresholds, temp_groundings, temp_groundings_edges) + + if not satisfaction: + continue + if infer_edges: edges_to_be_added[0].append(head_var_1_grounding) edges_to_be_added[1].append(head_var_2_grounding) @@ -900,7 +930,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, elif clause_var_1 == head_var_2: qualified_nodes.append(numba.typed.List([head_var_2_grounding])) else: - qualified_nodes.append(numba.typed.List(groundings[clause_var_1])) + qualified_nodes.append(numba.typed.List(temp_groundings[clause_var_1])) qualified_edges.append(numba.typed.List.empty_list(edge_type)) # 2. if ann_fn != '': @@ -910,7 +940,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, elif clause_var_1 == head_var_2: a.append(interpretations_node[head_var_2_grounding].world[clause_label]) else: - for qn in groundings[clause_var_1]: + for qn in temp_groundings[clause_var_1]: a.append(interpretations_node[qn].world[clause_label]) annotations.append(a) @@ -924,59 +954,61 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # 3. None equal qualified_nodes.append(numba.typed.List.empty_list(node_type)) if clause_var_1 == head_var_1 and clause_var_2 == head_var_2: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_1_grounding and e[1] == head_var_2_grounding]) + es = numba.typed.List([e for e in temp_groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_1_grounding and e[1] == head_var_2_grounding]) qualified_edges.append(es) elif clause_var_1 == head_var_2 and clause_var_2 == head_var_1: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_2_grounding and e[1] == head_var_1_grounding]) + es = numba.typed.List([e for e in temp_groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_2_grounding and e[1] == head_var_1_grounding]) qualified_edges.append(es) elif clause_var_1 == head_var_1: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_1_grounding]) + es = numba.typed.List([e for e in temp_groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_1_grounding]) qualified_edges.append(es) elif clause_var_1 == head_var_2: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_2_grounding]) + es = numba.typed.List([e for e in temp_groundings_edges[(clause_var_1, clause_var_2)] if e[0] == head_var_2_grounding]) qualified_edges.append(es) elif clause_var_2 == head_var_1: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_var_1_grounding]) + es = numba.typed.List([e for e in temp_groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_var_1_grounding]) qualified_edges.append(es) elif clause_var_2 == head_var_2: - es = numba.typed.List([e for e in groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_var_2_grounding]) + es = numba.typed.List([e for e in temp_groundings_edges[(clause_var_1, clause_var_2)] if e[1] == head_var_2_grounding]) qualified_edges.append(es) else: - qualified_edges.append(numba.typed.List(groundings_edges[(clause_var_1, clause_var_2)])) + qualified_edges.append(numba.typed.List(temp_groundings_edges[(clause_var_1, clause_var_2)])) # 2. if ann_fn != '': a = numba.typed.List.empty_list(interval.interval_type) if clause_var_1 == head_var_1 and clause_var_2 == head_var_2: - for e in groundings_edges[(clause_var_1, clause_var_2)]: + for e in temp_groundings_edges[(clause_var_1, clause_var_2)]: if e[0] == head_var_1_grounding and e[1] == head_var_2_grounding: a.append(interpretations_edge[e].world[clause_label]) elif clause_var_1 == head_var_2 and clause_var_2 == head_var_1: - for e in groundings_edges[(clause_var_1, clause_var_2)]: + for e in temp_groundings_edges[(clause_var_1, clause_var_2)]: if e[0] == head_var_2_grounding and e[1] == head_var_1_grounding: a.append(interpretations_edge[e].world[clause_label]) elif clause_var_1 == head_var_1: - for e in groundings_edges[(clause_var_1, clause_var_2)]: + for e in temp_groundings_edges[(clause_var_1, clause_var_2)]: if e[0] == head_var_1_grounding: a.append(interpretations_edge[e].world[clause_label]) elif clause_var_1 == head_var_2: - for e in groundings_edges[(clause_var_1, clause_var_2)]: + for e in temp_groundings_edges[(clause_var_1, clause_var_2)]: if e[0] == head_var_2_grounding: a.append(interpretations_edge[e].world[clause_label]) elif clause_var_2 == head_var_1: - for e in groundings_edges[(clause_var_1, clause_var_2)]: + for e in temp_groundings_edges[(clause_var_1, clause_var_2)]: if e[1] == head_var_1_grounding: a.append(interpretations_edge[e].world[clause_label]) elif clause_var_2 == head_var_2: - for e in groundings_edges[(clause_var_1, clause_var_2)]: + for e in temp_groundings_edges[(clause_var_1, clause_var_2)]: if e[1] == head_var_2_grounding: a.append(interpretations_edge[e].world[clause_label]) else: - for qe in groundings_edges[(clause_var_1, clause_var_2)]: + for qe in temp_groundings_edges[(clause_var_1, clause_var_2)]: a.append(interpretations_edge[qe].world[clause_label]) annotations.append(a) # For each grounding combination add a rule to be applied + # Only if all the clauses have valid groundings + # if satisfaction: e = (head_var_1_grounding, head_var_2_grounding) applicable_rules_edge.append((e, annotations, qualified_nodes, qualified_edges, edges_to_be_added)) @@ -984,6 +1016,25 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, return applicable_rules_node, applicable_rules_edge +@numba.njit(cache=False) +def check_all_clause_satisfaction(interpretations_node, interpretations_edge, clauses, thresholds, groundings, groundings_edges): + # Check if the thresholds are satisfied for each clause + satisfaction = True + for i, clause in enumerate(clauses): + # Unpack clause variables + clause_type = clause[0] + clause_label = clause[1] + clause_variables = clause[2] + + if clause_type == 'node': + clause_var_1 = clause_variables[0] + satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, groundings[clause_var_1], groundings[clause_var_1], clause_label, thresholds[i]) and satisfaction + elif clause_type == 'edge': + clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] + satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, groundings_edges[(clause_var_1, clause_var_2)], groundings_edges[(clause_var_1, clause_var_2)], clause_label, thresholds[i]) and satisfaction + return satisfaction + + @numba.njit(cache=False) def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, neighbors, reverse_neighbors, atom_trace, reverse_graph, nodes_to_skip): # Extract rule params From bc2a67c3d12e36b2d479e24970939abd1c263990 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sat, 27 Jul 2024 13:04:16 +0200 Subject: [PATCH 20/73] Merged v3.0.0 into this tests branch --- tests/test_anyBurl_infer_edges_rules.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_anyBurl_infer_edges_rules.py b/tests/test_anyBurl_infer_edges_rules.py index d151de7..6aa3af5 100644 --- a/tests/test_anyBurl_infer_edges_rules.py +++ b/tests/test_anyBurl_infer_edges_rules.py @@ -1,5 +1,6 @@ import pyreason as pr + def test_anyBurl_rule_1(): graph_path = './tests/knowledge_graph_test_subset.graphml' pr.reset() @@ -32,6 +33,7 @@ def test_anyBurl_rule_1(): assert len(dataframes[1]) == 1, 'At t=1 there should be only 1 new isConnectedTo atom' assert ('Vnukovo_International_Airport', 'Riga_International_Airport') in dataframes[1]['component'].values.tolist() and dataframes[1]['isConnectedTo'].iloc[0] == [1, 1], '(Vnukovo_International_Airport, Riga_International_Airport) should have isConnectedTo bounds [1,1] for t=1 timesteps' + def test_anyBurl_rule_2(): graph_path = './tests/knowledge_graph_test_subset.graphml' pr.reset() @@ -65,6 +67,7 @@ def test_anyBurl_rule_2(): assert len(dataframes[1]) == 1, 'At t=1 there should be only 1 new isConnectedTo atom' assert ('Riga_International_Airport', 'Vnukovo_International_Airport') in dataframes[1]['component'].values.tolist() and dataframes[1]['isConnectedTo'].iloc[0] == [1, 1], '(Riga_International_Airport, Vnukovo_International_Airport) should have isConnectedTo bounds [1,1] for t=1 timesteps' + def test_anyBurl_rule_3(): graph_path = './tests/knowledge_graph_test_subset.graphml' pr.reset() @@ -98,6 +101,7 @@ def test_anyBurl_rule_3(): assert len(dataframes[1]) == 1, 'At t=1 there should be only 1 new isConnectedTo atom' assert ('Vnukovo_International_Airport', 'Yali') in dataframes[1]['component'].values.tolist() and dataframes[1]['isConnectedTo'].iloc[0] == [1, 1], '(Vnukovo_International_Airport, Yali) should have isConnectedTo bounds [1,1] for t=1 timesteps' + def test_anyBurl_rule_4(): graph_path = './tests/knowledge_graph_test_subset.graphml' pr.reset() @@ -129,4 +133,4 @@ def test_anyBurl_rule_4(): print() assert len(dataframes) == 2, 'Pyreason should run exactly 1 fixpoint operations' assert len(dataframes[1]) == 1, 'At t=1 there should be only 1 new isConnectedTo atom' - assert ('Yali', 'Vnukovo_International_Airport') in dataframes[1]['component'].values.tolist() and dataframes[1]['isConnectedTo'].iloc[0] == [1, 1], '(Yali, Vnukovo_International_Airport) should have isConnectedTo bounds [1,1] for t=1 timesteps' \ No newline at end of file + assert ('Yali', 'Vnukovo_International_Airport') in dataframes[1]['component'].values.tolist() and dataframes[1]['isConnectedTo'].iloc[0] == [1, 1], '(Yali, Vnukovo_International_Airport) should have isConnectedTo bounds [1,1] for t=1 timesteps' From d5b90186c073459bfa55296e4ced43ed9e04fa0d Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sat, 27 Jul 2024 13:20:28 +0200 Subject: [PATCH 21/73] fixed test failures --- tests/test_anyBurl_infer_edges_rules.py | 3 +++ tests/test_custom_thresholds.py | 2 ++ tests/test_hello_world.py | 2 ++ tests/test_hello_world_parallel.py | 1 + 4 files changed, 8 insertions(+) diff --git a/tests/test_anyBurl_infer_edges_rules.py b/tests/test_anyBurl_infer_edges_rules.py index 6aa3af5..0ae5df7 100644 --- a/tests/test_anyBurl_infer_edges_rules.py +++ b/tests/test_anyBurl_infer_edges_rules.py @@ -48,6 +48,7 @@ def test_anyBurl_rule_2(): pr.settings.output_to_file = False pr.settings.store_interpretation_changes = True pr.settings.save_graph_attributes_to_trace = True + pr.settings.parallel_computing = False # Load all the files into pyreason pr.load_graphml(graph_path) @@ -82,6 +83,7 @@ def test_anyBurl_rule_3(): pr.settings.output_to_file = False pr.settings.store_interpretation_changes = True pr.settings.save_graph_attributes_to_trace = True + pr.settings.parallel_computing = False # Load all the files into pyreason pr.load_graphml(graph_path) @@ -116,6 +118,7 @@ def test_anyBurl_rule_4(): pr.settings.output_to_file = False pr.settings.store_interpretation_changes = True pr.settings.save_graph_attributes_to_trace = True + pr.settings.parallel_computing = False # Load all the files into pyreason pr.load_graphml(graph_path) diff --git a/tests/test_custom_thresholds.py b/tests/test_custom_thresholds.py index a3a1776..5a87c55 100644 --- a/tests/test_custom_thresholds.py +++ b/tests/test_custom_thresholds.py @@ -13,6 +13,8 @@ def test_custom_thresholds(): # Modify pyreason settings to make verbose and to save the rule trace to a file pr.settings.verbose = True # Print info to screen + pr.settings.canonical = False + pr.settings.parallel_computing = False # Load all the files into pyreason pr.load_graphml(graph_path) diff --git a/tests/test_hello_world.py b/tests/test_hello_world.py index dde7167..0848078 100644 --- a/tests/test_hello_world.py +++ b/tests/test_hello_world.py @@ -12,6 +12,8 @@ def test_hello_world(): # Modify pyreason settings to make verbose and to save the rule trace to a file pr.settings.verbose = True # Print info to screen + pr.settings.canonical = False + pr.settings.parallel_computing = False # Load all the files into pyreason pr.load_graphml(graph_path) diff --git a/tests/test_hello_world_parallel.py b/tests/test_hello_world_parallel.py index 146e07c..3920ca3 100644 --- a/tests/test_hello_world_parallel.py +++ b/tests/test_hello_world_parallel.py @@ -13,6 +13,7 @@ def test_hello_world_parallel(): # Modify pyreason settings to make verbose and to save the rule trace to a file pr.settings.verbose = True # Print info to screen pr.settings.parallel_computing = True + pr.settings.canonical = False # Load all the files into pyreason pr.load_graphml(graph_path) From 1eea18b019a92ab5501dae3dd672a1371070997e Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sat, 27 Jul 2024 13:45:09 +0200 Subject: [PATCH 22/73] fixed test failures --- pyreason/pyreason.py | 8 ++++++++ tests/test_custom_thresholds.py | 5 ++--- tests/test_hello_world.py | 5 ++--- tests/test_hello_world_parallel.py | 5 ++--- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/pyreason/pyreason.py b/pyreason/pyreason.py index 48ae672..d128e02 100755 --- a/pyreason/pyreason.py +++ b/pyreason/pyreason.py @@ -399,6 +399,14 @@ def reset_rules(): __rules = None +def reset_settings(): + """ + Resets settings to default + """ + global settings + settings = _Settings() + + # FUNCTIONS def load_graphml(path: str) -> None: """Loads graph from GraphMl file path into program diff --git a/tests/test_custom_thresholds.py b/tests/test_custom_thresholds.py index 5a87c55..4c6b035 100644 --- a/tests/test_custom_thresholds.py +++ b/tests/test_custom_thresholds.py @@ -11,10 +11,9 @@ def test_custom_thresholds(): # Modify the paths based on where you've stored the files we made above graph_path = "./tests/group_chat_graph.graphml" - # Modify pyreason settings to make verbose and to save the rule trace to a file + # Modify pyreason settings to make verbose + pr.reset_settings() pr.settings.verbose = True # Print info to screen - pr.settings.canonical = False - pr.settings.parallel_computing = False # Load all the files into pyreason pr.load_graphml(graph_path) diff --git a/tests/test_hello_world.py b/tests/test_hello_world.py index 0848078..bd2b0ea 100644 --- a/tests/test_hello_world.py +++ b/tests/test_hello_world.py @@ -10,10 +10,9 @@ def test_hello_world(): # Modify the paths based on where you've stored the files we made above graph_path = './tests/friends_graph.graphml' - # Modify pyreason settings to make verbose and to save the rule trace to a file + # Modify pyreason settings to make verbose + pr.reset_settings() pr.settings.verbose = True # Print info to screen - pr.settings.canonical = False - pr.settings.parallel_computing = False # Load all the files into pyreason pr.load_graphml(graph_path) diff --git a/tests/test_hello_world_parallel.py b/tests/test_hello_world_parallel.py index 3920ca3..9f2e4e5 100644 --- a/tests/test_hello_world_parallel.py +++ b/tests/test_hello_world_parallel.py @@ -10,10 +10,9 @@ def test_hello_world_parallel(): # Modify the paths based on where you've stored the files we made above graph_path = './tests/friends_graph.graphml' - # Modify pyreason settings to make verbose and to save the rule trace to a file + # Modify pyreason settings to make verbose + pr.reset_settings() pr.settings.verbose = True # Print info to screen - pr.settings.parallel_computing = True - pr.settings.canonical = False # Load all the files into pyreason pr.load_graphml(graph_path) From 8991cb2e800cc13f5c623f9b5027aabbfb15c059 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sat, 3 Aug 2024 11:37:09 +0200 Subject: [PATCH 23/73] fixed bug with rules firing when they shouldn't --- .../scripts/interpretation/interpretation.py | 16 ++++++++-------- .../interpretation/interpretation_parallel.py | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 7f5a586..796af21 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -1849,14 +1849,14 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun # Case 4: # We have seen both variables before else: - # # We have already seen these two variables in an edge clause - # if (clause_var_1, clause_var_2) in groundings_edges: - # edge_groundings = groundings_edges[(clause_var_1, clause_var_2)] - # # We have seen both these variables but not in an edge clause together - # else: - for n in groundings[clause_var_1]: - es = numba.typed.List([(n, nn) for nn in neighbors[n] if nn in groundings[clause_var_2]]) - edge_groundings.extend(es) + # We have already seen these two variables in an edge clause + if (clause_var_1, clause_var_2) in groundings_edges: + edge_groundings = groundings_edges[(clause_var_1, clause_var_2)] + # We have seen both these variables but not in an edge clause together + else: + for n in groundings[clause_var_1]: + es = numba.typed.List([(n, nn) for nn in neighbors[n] if nn in groundings[clause_var_2]]) + edge_groundings.extend(es) return edge_groundings diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index 95521ca..acee3bc 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -1849,14 +1849,14 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun # Case 4: # We have seen both variables before else: - # # We have already seen these two variables in an edge clause - # if (clause_var_1, clause_var_2) in groundings_edges: - # edge_groundings = groundings_edges[(clause_var_1, clause_var_2)] - # # We have seen both these variables but not in an edge clause together - # else: - for n in groundings[clause_var_1]: - es = numba.typed.List([(n, nn) for nn in neighbors[n] if nn in groundings[clause_var_2]]) - edge_groundings.extend(es) + # We have already seen these two variables in an edge clause + if (clause_var_1, clause_var_2) in groundings_edges: + edge_groundings = groundings_edges[(clause_var_1, clause_var_2)] + # We have seen both these variables but not in an edge clause together + else: + for n in groundings[clause_var_1]: + es = numba.typed.List([(n, nn) for nn in neighbors[n] if nn in groundings[clause_var_2]]) + edge_groundings.extend(es) return edge_groundings From b37810f29d3bd6340f52809b13f7f776e8ef7e00 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 4 Aug 2024 11:26:39 +0200 Subject: [PATCH 24/73] changed facts format. We can now add facts as text --- docs/group-chat-example.md | 8 ++--- docs/hello-world.md | 2 +- pyreason/pyreason.py | 25 ++++++++----- pyreason/scripts/facts/fact.py | 38 +++----------------- pyreason/scripts/facts/fact_edge.py | 3 ++ pyreason/scripts/facts/fact_node.py | 3 ++ pyreason/scripts/rules/rule.py | 2 +- pyreason/scripts/rules/rule_internal.py | 3 ++ pyreason/scripts/utils/fact_parser.py | 47 +++++++++++++++++++++++++ tests/test_custom_thresholds.py | 8 ++--- tests/test_hello_world.py | 2 +- tests/test_hello_world_parallel.py | 2 +- 12 files changed, 89 insertions(+), 54 deletions(-) create mode 100644 pyreason/scripts/utils/fact_parser.py diff --git a/docs/group-chat-example.md b/docs/group-chat-example.md index 6f34a8d..2e29e57 100755 --- a/docs/group-chat-example.md +++ b/docs/group-chat-example.md @@ -79,10 +79,10 @@ We add the facts in PyReason as below: ```python import pyreason as pr -pr.add_fact(pr.Fact("seen-fact-zach", "Zach", "Viewed", [1, 1], 0, 0, static=True)) -pr.add_fact(pr.Fact("seen-fact-justin", "Justin", "Viewed", [1, 1], 0, 0, static=True)) -pr.add_fact(pr.Fact("seen-fact-michelle", "Michelle", "Viewed", [1, 1], 1, 1, static=True)) -pr.add_fact(pr.Fact("seen-fact-amy", "Amy", "Viewed", [1, 1], 2, 2, static=True)) +pr.add_fact(pr.Fact("Viewed(Zach)", "seen-fact-zach", 0, 3)) +pr.add_fact(pr.Fact("Viewed(Justin)", "seen-fact-justin", 0, 3)) +pr.add_fact(pr.Fact("Viewed(Michelle)", "seen-fact-michelle", 1, 3)) +pr.add_fact(pr.Fact("Viewed(Amy)", "seen-fact-amy", 2, 3)) ``` This allows us to specify the component that has an initial condition, the initial condition itself in the form of bounds diff --git a/docs/hello-world.md b/docs/hello-world.md index f2bf131..cc4a75c 100755 --- a/docs/hello-world.md +++ b/docs/hello-world.md @@ -88,7 +88,7 @@ We add a fact in PyReason like so: ```python import pyreason as pr -pr.add_fact(pr.Fact(name='popular-fact', component='Mary', attribute='popular', bound=[1, 1], start_time=0, end_time=2)) +pr.add_fact(pr.Fact(fact_text='popular(Mary) : true', name='popular_fact', start_time=0, end_time=2)) ``` This allows us to specify the component that has an initial condition, the initial condition itself in the form of bounds diff --git a/pyreason/pyreason.py b/pyreason/pyreason.py index d128e02..4cc5978 100755 --- a/pyreason/pyreason.py +++ b/pyreason/pyreason.py @@ -467,6 +467,11 @@ def add_rule(pr_rule: Rule) -> None: # Add to collection of rules if __rules is None: __rules = numba.typed.List.empty_list(rule.rule_type) + + # Generate name for rule if not set + if pr_rule.rule.get_rule_name() is None: + pr_rule.rule.set_rule_name(f'rule_{len(__rules)}') + __rules.append(pr_rule.rule) @@ -495,17 +500,19 @@ def add_fact(pyreason_fact: Fact) -> None: """ global __node_facts, __edge_facts - if pyreason_fact.type == 'node': - f = fact_node.Fact(pyreason_fact.name, pyreason_fact.component, pyreason_fact.label, pyreason_fact.interval, pyreason_fact.t_lower, pyreason_fact.t_upper, pyreason_fact.static) - if __node_facts is None: - __node_facts = numba.typed.List.empty_list(fact_node.fact_type) - __node_facts.append(f) + if __node_facts is None: + __node_facts = numba.typed.List.empty_list(fact_node.fact_type) + if __edge_facts is None: + __edge_facts = numba.typed.List.empty_list(fact_edge.fact_type) + if pyreason_fact.type == 'node': + if pyreason_fact.fact.get_name() is None: + pyreason_fact.fact.set_name(f'fact_{len(__node_facts)+len(__edge_facts)}') + __node_facts.append(pyreason_fact.fact) else: - f = fact_edge.Fact(pyreason_fact.name, pyreason_fact.component, pyreason_fact.label, pyreason_fact.interval, pyreason_fact.t_lower, pyreason_fact.t_upper, pyreason_fact.static) - if __edge_facts is None: - __edge_facts = numba.typed.List.empty_list(fact_edge.fact_type) - __edge_facts.append(f) + if pyreason_fact.fact.get_name() is None: + pyreason_fact.fact.set_name(f'fact_{len(__node_facts)+len(__edge_facts)}') + __edge_facts.append(pyreason_fact.fact) def add_annotation_function(function: Callable) -> None: diff --git a/pyreason/scripts/facts/fact.py b/pyreason/scripts/facts/fact.py index 1004cec..ed368ad 100644 --- a/pyreason/scripts/facts/fact.py +++ b/pyreason/scripts/facts/fact.py @@ -1,23 +1,14 @@ -import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval -import pyreason.scripts.numba_wrapper.numba_types.label_type as label - -from typing import Tuple -from typing import List -from typing import Union +import pyreason.scripts.utils.fact_parser as fact_parser class Fact: - def __init__(self, name: str, component: Union[str, Tuple[str, str]], attribute: str, bound: Union[interval.Interval, List[float]], start_time: int, end_time: int, static: bool = False): + def __init__(self, fact_text: str, name: str = None, start_time: int = 0, end_time: int = 0, static: bool = False): """Define a PyReason fact that can be loaded into the program using `pr.add_fact()` + :param fact_text: The fact in text format. Example: `'pred(x,y) : [0.2, 1]'` or `'pred(x,y) : True'` + :type fact_text: str :param name: The name of the fact. This will appear in the trace so that you know when it was applied :type name: str - :param component: The node or edge that whose attribute you want to change - :type component: str | Tuple[str, str] - :param attribute: The attribute you would like to change for the specified node/edge - :type attribute: str - :param bound: The bound to which you'd like to set the attribute corresponding to the specified node/edge - :type bound: interval.Interval | List[float] :param start_time: The timestep at which this fact becomes active :type start_time: int :param end_time: The last timestep this fact is active @@ -25,23 +16,4 @@ def __init__(self, name: str, component: Union[str, Tuple[str, str]], attribute: :param static: If the fact should be active for the entire program. In which case `start_time` and `end_time` will be ignored :type static: bool """ - self.name = name - self.t_upper = end_time - self.t_lower = start_time - self.component = component - self.label = attribute - self.interval = bound - self.static = static - - # Check if it is a node fact or edge fact - if isinstance(self.component, str): - self.type = 'node' - else: - self.type = 'edge' - - # Set label to correct type - self.label = label.Label(attribute) - - # Set bound to correct type - if isinstance(bound, list): - self.interval = interval.closed(*bound) + self.fact, self.type = fact_parser.parse_fact(fact_text, name, start_time, end_time, static) diff --git a/pyreason/scripts/facts/fact_edge.py b/pyreason/scripts/facts/fact_edge.py index 935d40f..bbeb3e6 100755 --- a/pyreason/scripts/facts/fact_edge.py +++ b/pyreason/scripts/facts/fact_edge.py @@ -12,6 +12,9 @@ def __init__(self, name, component, label, interval, t_lower, t_upper, static=Fa def get_name(self): return self._name + def set_name(self, name): + self._name = name + def get_component(self): return self._component diff --git a/pyreason/scripts/facts/fact_node.py b/pyreason/scripts/facts/fact_node.py index 92e97c8..69e379e 100755 --- a/pyreason/scripts/facts/fact_node.py +++ b/pyreason/scripts/facts/fact_node.py @@ -12,6 +12,9 @@ def __init__(self, name, component, label, interval, t_lower, t_upper, static=Fa def get_name(self): return self._name + def set_name(self, name): + self._name = name + def get_component(self): return self._component diff --git a/pyreason/scripts/rules/rule.py b/pyreason/scripts/rules/rule.py index 73824c4..36e9b0b 100755 --- a/pyreason/scripts/rules/rule.py +++ b/pyreason/scripts/rules/rule.py @@ -9,7 +9,7 @@ class Rule: 1. It is not possible to have weights for different clauses. Weights are 1 by default with bias 0 TODO: Add weights as a parameter """ - def __init__(self, rule_text: str, name: str, infer_edges: bool = False, set_static: bool = False, immediate_rule: bool = False, custom_thresholds=None): + def __init__(self, rule_text: str, name: str = None, infer_edges: bool = False, set_static: bool = False, immediate_rule: bool = False, custom_thresholds=None): """ :param rule_text: The rule in text format :param name: The name of the rule. This will appear in the rule trace diff --git a/pyreason/scripts/rules/rule_internal.py b/pyreason/scripts/rules/rule_internal.py index 6704407..7f1976e 100755 --- a/pyreason/scripts/rules/rule_internal.py +++ b/pyreason/scripts/rules/rule_internal.py @@ -18,6 +18,9 @@ def __init__(self, rule_name, rule_type, target, head_variables, delta, clauses, def get_rule_name(self): return self._rule_name + def set_rule_name(self, rule_name): + self._rule_name = rule_name + def get_rule_type(self): return self._type diff --git a/pyreason/scripts/utils/fact_parser.py b/pyreason/scripts/utils/fact_parser.py new file mode 100644 index 0000000..2c52cb8 --- /dev/null +++ b/pyreason/scripts/utils/fact_parser.py @@ -0,0 +1,47 @@ +import pyreason.scripts.facts.fact_node as fact_node +import pyreason.scripts.facts.fact_edge as fact_edge + +import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval + + +def parse_fact(fact_text, name, t_lower, t_upper, static): + f = fact_text.replace(' ', '') + + # Separate into predicate-component and bound. If there is no bound it means it's true + if ':' in f: + pred_comp, bound = f.split(':') + else: + pred_comp = f + bound = 'True' + + # Check if bound is a boolean or a list of floats + bound = bound.lower() + if bound == 'true': + bound = interval.closed(1, 1) + elif bound == 'false': + bound = interval.closed(0, 0) + else: + bound = [float(b) for b in bound[1:-1].split(',')] + bound = interval.closed(*bound) + + # Split the predicate and component + idx = pred_comp.find('(') + pred = pred_comp[:idx] + component = pred_comp[idx + 1:-1] + + # Check if it is a node or edge fact + if ',' in component: + fact_type = 'edge' + component = tuple(component.split(',')) + else: + fact_type = 'node' + + print(fact_type, component, pred, bound) + + # Create the fact + if fact_type == 'node': + fact = fact_node.Fact(name, component, pred, bound, t_lower, t_upper, static) + else: + fact = fact_edge.Fact(name, component, pred, bound, t_lower, t_upper, static) + + return fact, fact_type diff --git a/tests/test_custom_thresholds.py b/tests/test_custom_thresholds.py index 4c6b035..e1ae437 100644 --- a/tests/test_custom_thresholds.py +++ b/tests/test_custom_thresholds.py @@ -32,10 +32,10 @@ def test_custom_thresholds(): ) ) - pr.add_fact(pr.Fact("seen-fact-zach", "Zach", "Viewed", [1, 1], 0, 3)) - pr.add_fact(pr.Fact("seen-fact-justin", "Justin", "Viewed", [1, 1], 0, 3)) - pr.add_fact(pr.Fact("seen-fact-michelle", "Michelle", "Viewed", [1, 1], 1, 3)) - pr.add_fact(pr.Fact("seen-fact-amy", "Amy", "Viewed", [1, 1], 2, 3)) + pr.add_fact(pr.Fact("Viewed(Zach)", "seen-fact-zach", 0, 3)) + pr.add_fact(pr.Fact("Viewed(Justin)", "seen-fact-justin", 0, 3)) + pr.add_fact(pr.Fact("Viewed(Michelle)", "seen-fact-michelle", 1, 3)) + pr.add_fact(pr.Fact("Viewed(Amy)", "seen-fact-amy", 2, 3)) # Run the program for three timesteps to see the diffusion take place interpretation = pr.reason(timesteps=3) diff --git a/tests/test_hello_world.py b/tests/test_hello_world.py index bd2b0ea..f103d70 100644 --- a/tests/test_hello_world.py +++ b/tests/test_hello_world.py @@ -17,7 +17,7 @@ def test_hello_world(): # Load all the files into pyreason pr.load_graphml(graph_path) pr.add_rule(pr.Rule('popular(x) <-1 popular(y), Friends(x,y), owns(y,z), owns(x,z)', 'popular_rule')) - pr.add_fact(pr.Fact('popular-fact', 'Mary', 'popular', [1, 1], 0, 2)) + pr.add_fact(pr.Fact('popular(Mary)', 'popular_fact', 0, 2)) # Run the program for two timesteps to see the diffusion take place interpretation = pr.reason(timesteps=2) diff --git a/tests/test_hello_world_parallel.py b/tests/test_hello_world_parallel.py index 9f2e4e5..fe47a33 100644 --- a/tests/test_hello_world_parallel.py +++ b/tests/test_hello_world_parallel.py @@ -17,7 +17,7 @@ def test_hello_world_parallel(): # Load all the files into pyreason pr.load_graphml(graph_path) pr.add_rule(pr.Rule('popular(x) <-1 popular(y), Friends(x,y), owns(y,z), owns(x,z)', 'popular_rule')) - pr.add_fact(pr.Fact('popular-fact', 'Mary', 'popular', [1, 1], 0, 2)) + pr.add_fact(pr.Fact('popular(Mary)', 'popular_fact', 0, 2)) # Run the program for two timesteps to see the diffusion take place interpretation = pr.reason(timesteps=2) From 11fc3d60de630415c1e0ab36c495e8cfed4d868d Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 4 Aug 2024 11:29:24 +0200 Subject: [PATCH 25/73] changed facts format. We can now add facts as text --- pyreason/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyreason/__init__.py b/pyreason/__init__.py index 85f8319..0e137b8 100755 --- a/pyreason/__init__.py +++ b/pyreason/__init__.py @@ -20,7 +20,7 @@ settings.verbose = False load_graphml(graph_path) add_rule(Rule('popular(x) <-1 popular(y), Friends(x,y), owns(y,z), owns(x,z)', 'popular_rule')) - add_fact(Fact('popular-fact', 'Mary', 'popular', [1, 1], 0, 2)) + add_fact(Fact('popular(Mary)', 'popular_fact', 0, 2)) reason(timesteps=2) reset() From d3a395011455e2bf7e7707d58e3a61a77d3c4c97 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 4 Aug 2024 12:35:20 +0200 Subject: [PATCH 26/73] fixed bug with new facts --- pyreason/pyreason.py | 14 ++++++++------ pyreason/scripts/facts/fact.py | 11 ++++++++++- pyreason/scripts/interval/interval.py | 1 + pyreason/scripts/utils/fact_parser.py | 15 ++------------- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/pyreason/pyreason.py b/pyreason/pyreason.py index 4cc5978..7e7e3c4 100755 --- a/pyreason/pyreason.py +++ b/pyreason/pyreason.py @@ -506,13 +506,15 @@ def add_fact(pyreason_fact: Fact) -> None: __edge_facts = numba.typed.List.empty_list(fact_edge.fact_type) if pyreason_fact.type == 'node': - if pyreason_fact.fact.get_name() is None: - pyreason_fact.fact.set_name(f'fact_{len(__node_facts)+len(__edge_facts)}') - __node_facts.append(pyreason_fact.fact) + if pyreason_fact.name is None: + pyreason_fact.name = f'fact_{len(__node_facts)+len(__edge_facts)}' + f = fact_node.Fact(pyreason_fact.name, pyreason_fact.component, pyreason_fact.pred, pyreason_fact.bound, pyreason_fact.start_time, pyreason_fact.end_time, pyreason_fact.static) + __node_facts.append(f) else: - if pyreason_fact.fact.get_name() is None: - pyreason_fact.fact.set_name(f'fact_{len(__node_facts)+len(__edge_facts)}') - __edge_facts.append(pyreason_fact.fact) + if pyreason_fact.name is None: + pyreason_fact.name = f'fact_{len(__node_facts)+len(__edge_facts)}' + f = fact_edge.Fact(pyreason_fact.name, pyreason_fact.component, pyreason_fact.pred, pyreason_fact.bound, pyreason_fact.start_time, pyreason_fact.end_time, pyreason_fact.static) + __edge_facts.append(f) def add_annotation_function(function: Callable) -> None: diff --git a/pyreason/scripts/facts/fact.py b/pyreason/scripts/facts/fact.py index ed368ad..44d823d 100644 --- a/pyreason/scripts/facts/fact.py +++ b/pyreason/scripts/facts/fact.py @@ -1,4 +1,5 @@ import pyreason.scripts.utils.fact_parser as fact_parser +import pyreason.scripts.numba_wrapper.numba_types.label_type as label class Fact: @@ -16,4 +17,12 @@ def __init__(self, fact_text: str, name: str = None, start_time: int = 0, end_ti :param static: If the fact should be active for the entire program. In which case `start_time` and `end_time` will be ignored :type static: bool """ - self.fact, self.type = fact_parser.parse_fact(fact_text, name, start_time, end_time, static) + pred, component, bound, fact_type = fact_parser.parse_fact(fact_text) + self.name = name + self.start_time = start_time + self.end_time = end_time + self.static = static + self.pred = label.Label(pred) + self.component = component + self.bound = bound + self.type = fact_type diff --git a/pyreason/scripts/interval/interval.py b/pyreason/scripts/interval/interval.py index a6ab76e..a6b7fb6 100755 --- a/pyreason/scripts/interval/interval.py +++ b/pyreason/scripts/interval/interval.py @@ -2,6 +2,7 @@ from numba import njit import numpy as np + class Interval(structref.StructRefProxy): def __new__(cls, l, u, s=False): return structref.StructRefProxy.__new__(cls, l, u, s, l, u) diff --git a/pyreason/scripts/utils/fact_parser.py b/pyreason/scripts/utils/fact_parser.py index 2c52cb8..a0e1f39 100644 --- a/pyreason/scripts/utils/fact_parser.py +++ b/pyreason/scripts/utils/fact_parser.py @@ -1,10 +1,7 @@ -import pyreason.scripts.facts.fact_node as fact_node -import pyreason.scripts.facts.fact_edge as fact_edge - import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval -def parse_fact(fact_text, name, t_lower, t_upper, static): +def parse_fact(fact_text): f = fact_text.replace(' ', '') # Separate into predicate-component and bound. If there is no bound it means it's true @@ -36,12 +33,4 @@ def parse_fact(fact_text, name, t_lower, t_upper, static): else: fact_type = 'node' - print(fact_type, component, pred, bound) - - # Create the fact - if fact_type == 'node': - fact = fact_node.Fact(name, component, pred, bound, t_lower, t_upper, static) - else: - fact = fact_edge.Fact(name, component, pred, bound, t_lower, t_upper, static) - - return fact, fact_type + return pred, component, bound, fact_type From 7c7447842241eade3e965829a37aa448ebc16e31 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 4 Aug 2024 16:36:17 +0200 Subject: [PATCH 27/73] allow for ground atoms in rules with setting `pr.settings.allow_ground_atoms=True` --- pyreason/pyreason.py | 23 ++++++++++++++++++- .../scripts/interpretation/interpretation.py | 23 +++++++++++++------ .../interpretation/interpretation_parallel.py | 23 +++++++++++++------ pyreason/scripts/program/program.py | 7 +++--- 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/pyreason/pyreason.py b/pyreason/pyreason.py index 7e7e3c4..00be591 100755 --- a/pyreason/pyreason.py +++ b/pyreason/pyreason.py @@ -41,6 +41,7 @@ def __init__(self): self.__store_interpretation_changes = True self.__parallel_computing = False self.__update_mode = 'intersection' + self.__allow_ground_atoms = False @property def verbose(self) -> bool: @@ -167,6 +168,14 @@ def update_mode(self) -> str: """ return self.__update_mode + @property + def allow_ground_atoms(self) -> bool: + """Returns whether rules can have ground atoms or not. Default is False + + :return: bool + """ + return self.__allow_ground_atoms + @verbose.setter def verbose(self, value: bool) -> None: """Set verbose mode. Default is True @@ -354,6 +363,18 @@ def update_mode(self, value: str) -> None: else: self.__update_mode = value + @allow_ground_atoms.setter + def allow_ground_atoms(self, value: bool) -> None: + """Allow ground atoms to be used in rules when possible. Default is False + + :param value: Whether to allow ground atoms or not + :raises TypeError: If not bool raise error + """ + if not isinstance(value, bool): + raise TypeError('value has to be a bool') + else: + self.__allow_ground_atoms = value + # VARIABLES __graph = None @@ -628,7 +649,7 @@ def _reason(timesteps, convergence_threshold, convergence_bound_threshold): annotation_functions = tuple(__annotation_functions) # Setup logical program - __program = Program(__graph, all_node_facts, all_edge_facts, __rules, __ipl, annotation_functions, settings.reverse_digraph, settings.atom_trace, settings.save_graph_attributes_to_trace, settings.canonical, settings.inconsistency_check, settings.store_interpretation_changes, settings.parallel_computing, settings.update_mode) + __program = Program(__graph, all_node_facts, all_edge_facts, __rules, __ipl, annotation_functions, settings.reverse_digraph, settings.atom_trace, settings.save_graph_attributes_to_trace, settings.canonical, settings.inconsistency_check, settings.store_interpretation_changes, settings.parallel_computing, settings.update_mode, settings.allow_ground_atoms) __program.available_labels_node = __node_labels __program.available_labels_edge = __edge_labels __program.specific_node_labels = __specific_node_labels diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 796af21..55ce7b5 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -55,7 +55,7 @@ class Interpretation: specific_node_labels = numba.typed.Dict.empty(key_type=label.label_type, value_type=numba.types.ListType(node_type)) specific_edge_labels = numba.typed.Dict.empty(key_type=label.label_type, value_type=numba.types.ListType(edge_type)) - def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode): + def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_atoms): self.graph = graph self.ipl = ipl self.annotation_functions = annotation_functions @@ -66,6 +66,7 @@ def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, self.inconsistency_check = inconsistency_check self.store_interpretation_changes = store_interpretation_changes self.update_mode = update_mode + self.allow_ground_atoms = allow_ground_atoms # For reasoning and reasoning again (contains previous time and previous fp operation cnt) self.time = 0 @@ -204,7 +205,7 @@ def _init_facts(facts_node, facts_edge, facts_to_be_applied_node, facts_to_be_ap return max_time def _start_fp(self, rules, max_facts_time, verbose, again): - fp_cnt, t = self.reason(self.interpretations_node, self.interpretations_edge, self.tmax, self.prev_reasoning_data, rules, self.nodes, self.edges, self.neighbors, self.reverse_neighbors, self.rules_to_be_applied_node, self.rules_to_be_applied_edge, self.edges_to_be_added_node_rule, self.edges_to_be_added_edge_rule, self.rules_to_be_applied_node_trace, self.rules_to_be_applied_edge_trace, self.facts_to_be_applied_node, self.facts_to_be_applied_edge, self.facts_to_be_applied_node_trace, self.facts_to_be_applied_edge_trace, self.ipl, self.rule_trace_node, self.rule_trace_edge, self.rule_trace_node_atoms, self.rule_trace_edge_atoms, self.reverse_graph, self.atom_trace, self.save_graph_attributes_to_rule_trace, self.canonical, self.inconsistency_check, self.store_interpretation_changes, self.update_mode, max_facts_time, self.annotation_functions, self._convergence_mode, self._convergence_delta, verbose, again) + fp_cnt, t = self.reason(self.interpretations_node, self.interpretations_edge, self.tmax, self.prev_reasoning_data, rules, self.nodes, self.edges, self.neighbors, self.reverse_neighbors, self.rules_to_be_applied_node, self.rules_to_be_applied_edge, self.edges_to_be_added_node_rule, self.edges_to_be_added_edge_rule, self.rules_to_be_applied_node_trace, self.rules_to_be_applied_edge_trace, self.facts_to_be_applied_node, self.facts_to_be_applied_edge, self.facts_to_be_applied_node_trace, self.facts_to_be_applied_edge_trace, self.ipl, self.rule_trace_node, self.rule_trace_edge, self.rule_trace_node_atoms, self.rule_trace_edge_atoms, self.reverse_graph, self.atom_trace, self.save_graph_attributes_to_rule_trace, self.canonical, self.inconsistency_check, self.store_interpretation_changes, self.update_mode, self.allow_ground_atoms, max_facts_time, self.annotation_functions, self._convergence_mode, self._convergence_delta, verbose, again) self.time = t - 1 # If we need to reason again, store the next timestep to start from self.prev_reasoning_data[0] = t @@ -214,7 +215,7 @@ def _start_fp(self, rules, max_facts_time, verbose, again): @staticmethod @numba.njit(cache=True, parallel=False) - def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): + def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_atoms, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): t = prev_reasoning_data[0] fp_cnt = prev_reasoning_data[1] max_rules_time = 0 @@ -511,7 +512,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Only go through if the rule can be applied within the given timesteps, or we're running until convergence delta_t = rule.get_delta() if t + delta_t <= tmax or tmax == -1 or again: - applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace) + applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_atoms) # Loop through applicable rules and add them to the rules to be applied for later or next fp operation for applicable_rule in applicable_node_rules: @@ -659,7 +660,7 @@ def get_interpretation_dict(self): @numba.njit(cache=True) -def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace): +def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_atoms): # Extract rule params rule_type = rule.get_type() head_variables = rule.get_head_variables() @@ -704,7 +705,11 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, clause_var_1 = clause_variables[0] # Get subset of nodes that can be used to ground the variable - grounding = get_rule_node_clause_grounding(clause_var_1, groundings, nodes) + # If we allow ground atoms, we can use the nodes directly + if allow_ground_atoms and clause_var_1 in nodes: + grounding = numba.typed.List([clause_var_1]) + else: + grounding = get_rule_node_clause_grounding(clause_var_1, groundings, nodes) # Narrow subset based on predicate qualified_groundings = get_qualified_node_groundings(interpretations_node, grounding, clause_label, clause_bnd) @@ -723,7 +728,11 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] # Get subset of edges that can be used to ground the variables - grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes) + # If we allow ground atoms, we can use the nodes directly + if allow_ground_atoms and (clause_var_1, clause_var_2) in edges: + grounding = numba.typed.List([(clause_var_1, clause_var_2)]) + else: + grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes) # Narrow subset based on predicate (save the edges that are qualified to use for finding future groundings faster) qualified_groundings = get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, clause_bnd) diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index acee3bc..717f915 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -55,7 +55,7 @@ class Interpretation: specific_node_labels = numba.typed.Dict.empty(key_type=label.label_type, value_type=numba.types.ListType(node_type)) specific_edge_labels = numba.typed.Dict.empty(key_type=label.label_type, value_type=numba.types.ListType(edge_type)) - def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode): + def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_atoms): self.graph = graph self.ipl = ipl self.annotation_functions = annotation_functions @@ -66,6 +66,7 @@ def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, self.inconsistency_check = inconsistency_check self.store_interpretation_changes = store_interpretation_changes self.update_mode = update_mode + self.allow_ground_atoms = allow_ground_atoms # For reasoning and reasoning again (contains previous time and previous fp operation cnt) self.time = 0 @@ -204,7 +205,7 @@ def _init_facts(facts_node, facts_edge, facts_to_be_applied_node, facts_to_be_ap return max_time def _start_fp(self, rules, max_facts_time, verbose, again): - fp_cnt, t = self.reason(self.interpretations_node, self.interpretations_edge, self.tmax, self.prev_reasoning_data, rules, self.nodes, self.edges, self.neighbors, self.reverse_neighbors, self.rules_to_be_applied_node, self.rules_to_be_applied_edge, self.edges_to_be_added_node_rule, self.edges_to_be_added_edge_rule, self.rules_to_be_applied_node_trace, self.rules_to_be_applied_edge_trace, self.facts_to_be_applied_node, self.facts_to_be_applied_edge, self.facts_to_be_applied_node_trace, self.facts_to_be_applied_edge_trace, self.ipl, self.rule_trace_node, self.rule_trace_edge, self.rule_trace_node_atoms, self.rule_trace_edge_atoms, self.reverse_graph, self.atom_trace, self.save_graph_attributes_to_rule_trace, self.canonical, self.inconsistency_check, self.store_interpretation_changes, self.update_mode, max_facts_time, self.annotation_functions, self._convergence_mode, self._convergence_delta, verbose, again) + fp_cnt, t = self.reason(self.interpretations_node, self.interpretations_edge, self.tmax, self.prev_reasoning_data, rules, self.nodes, self.edges, self.neighbors, self.reverse_neighbors, self.rules_to_be_applied_node, self.rules_to_be_applied_edge, self.edges_to_be_added_node_rule, self.edges_to_be_added_edge_rule, self.rules_to_be_applied_node_trace, self.rules_to_be_applied_edge_trace, self.facts_to_be_applied_node, self.facts_to_be_applied_edge, self.facts_to_be_applied_node_trace, self.facts_to_be_applied_edge_trace, self.ipl, self.rule_trace_node, self.rule_trace_edge, self.rule_trace_node_atoms, self.rule_trace_edge_atoms, self.reverse_graph, self.atom_trace, self.save_graph_attributes_to_rule_trace, self.canonical, self.inconsistency_check, self.store_interpretation_changes, self.update_mode, self.allow_ground_atoms, max_facts_time, self.annotation_functions, self._convergence_mode, self._convergence_delta, verbose, again) self.time = t - 1 # If we need to reason again, store the next timestep to start from self.prev_reasoning_data[0] = t @@ -214,7 +215,7 @@ def _start_fp(self, rules, max_facts_time, verbose, again): @staticmethod @numba.njit(cache=False, parallel=True) - def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): + def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_atoms, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): t = prev_reasoning_data[0] fp_cnt = prev_reasoning_data[1] max_rules_time = 0 @@ -511,7 +512,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Only go through if the rule can be applied within the given timesteps, or we're running until convergence delta_t = rule.get_delta() if t + delta_t <= tmax or tmax == -1 or again: - applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace) + applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_atoms) # Loop through applicable rules and add them to the rules to be applied for later or next fp operation for applicable_rule in applicable_node_rules: @@ -659,7 +660,7 @@ def get_interpretation_dict(self): @numba.njit(cache=False) -def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace): +def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_atoms): # Extract rule params rule_type = rule.get_type() head_variables = rule.get_head_variables() @@ -704,7 +705,11 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, clause_var_1 = clause_variables[0] # Get subset of nodes that can be used to ground the variable - grounding = get_rule_node_clause_grounding(clause_var_1, groundings, nodes) + # If we allow ground atoms, we can use the nodes directly + if allow_ground_atoms and clause_var_1 in nodes: + grounding = numba.typed.List([clause_var_1]) + else: + grounding = get_rule_node_clause_grounding(clause_var_1, groundings, nodes) # Narrow subset based on predicate qualified_groundings = get_qualified_node_groundings(interpretations_node, grounding, clause_label, clause_bnd) @@ -723,7 +728,11 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] # Get subset of edges that can be used to ground the variables - grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes) + # If we allow ground atoms, we can use the nodes directly + if allow_ground_atoms and (clause_var_1, clause_var_2) in edges: + grounding = numba.typed.List([(clause_var_1, clause_var_2)]) + else: + grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes) # Narrow subset based on predicate (save the edges that are qualified to use for finding future groundings faster) qualified_groundings = get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, clause_bnd) diff --git a/pyreason/scripts/program/program.py b/pyreason/scripts/program/program.py index 4c992ae..d73d537 100755 --- a/pyreason/scripts/program/program.py +++ b/pyreason/scripts/program/program.py @@ -8,7 +8,7 @@ class Program: specific_node_labels = [] specific_edge_labels = [] - def __init__(self, graph, facts_node, facts_edge, rules, ipl, annotation_functions, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, parallel_computing, update_mode): + def __init__(self, graph, facts_node, facts_edge, rules, ipl, annotation_functions, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, parallel_computing, update_mode, allow_ground_atoms): self._graph = graph self._facts_node = facts_node self._facts_edge = facts_edge @@ -23,6 +23,7 @@ def __init__(self, graph, facts_node, facts_edge, rules, ipl, annotation_functio self._store_interpretation_changes = store_interpretation_changes self._parallel_computing = parallel_computing self._update_mode = update_mode + self._allow_ground_atoms = allow_ground_atoms self.interp = None def reason(self, tmax, convergence_threshold, convergence_bound_threshold, verbose=True): @@ -35,9 +36,9 @@ def reason(self, tmax, convergence_threshold, convergence_bound_threshold, verbo # Instantiate correct interpretation class based on whether we parallelize the code or not. (We cannot parallelize with cache on) if self._parallel_computing: - self.interp = InterpretationParallel(self._graph, self._ipl, self._annotation_functions, self._reverse_graph, self._atom_trace, self._save_graph_attributes_to_rule_trace, self._canonical, self._inconsistency_check, self._store_interpretation_changes, self._update_mode) + self.interp = InterpretationParallel(self._graph, self._ipl, self._annotation_functions, self._reverse_graph, self._atom_trace, self._save_graph_attributes_to_rule_trace, self._canonical, self._inconsistency_check, self._store_interpretation_changes, self._update_mode, self._allow_ground_atoms) else: - self.interp = Interpretation(self._graph, self._ipl, self._annotation_functions, self._reverse_graph, self._atom_trace, self._save_graph_attributes_to_rule_trace, self._canonical, self._inconsistency_check, self._store_interpretation_changes, self._update_mode) + self.interp = Interpretation(self._graph, self._ipl, self._annotation_functions, self._reverse_graph, self._atom_trace, self._save_graph_attributes_to_rule_trace, self._canonical, self._inconsistency_check, self._store_interpretation_changes, self._update_mode, self._allow_ground_atoms) self.interp.start_fp(self._tmax, self._facts_node, self._facts_edge, self._rules, verbose, convergence_threshold, convergence_bound_threshold) return self.interp From b2d78e8e4d16ce79c09447dad217e54715ec8887 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 4 Aug 2024 18:49:10 +0200 Subject: [PATCH 28/73] fixed bug with ground rules and bug with interp_dict return. We also apparently don't need to turn off cache for parallel anymore. --- pyreason/pyreason.py | 5 +- .../scripts/interpretation/interpretation.py | 50 +++++- .../interpretation/interpretation_parallel.py | 158 +++++++++++------- 3 files changed, 148 insertions(+), 65 deletions(-) diff --git a/pyreason/pyreason.py b/pyreason/pyreason.py index 00be591..1041c18 100755 --- a/pyreason/pyreason.py +++ b/pyreason/pyreason.py @@ -5,6 +5,7 @@ import sys import pandas as pd import memory_profiler as mp +import warnings from typing import List, Type, Callable, Tuple from pyreason.scripts.utils.output import Output @@ -602,7 +603,9 @@ def _reason(timesteps, convergence_threshold, convergence_bound_threshold): # Check variables that HAVE to be set. Exceptions if __graph is None: - raise Exception('Graph not loaded. Use `load_graph` to load the graphml file') + __graph = nx.DiGraph() + if settings.verbose: + warnings.warn('Graph not loaded. Use `load_graph` to load the graphml file. Using empty graph') if __rules is None: raise Exception('There are no rules, use `add_rule` or `add_rules_from_file`') diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 55ce7b5..1a928d6 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -254,8 +254,12 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Nodes facts_to_be_applied_node_new.clear() for i in range(len(facts_to_be_applied_node)): - if facts_to_be_applied_node[i][0]==t: + if facts_to_be_applied_node[i][0] == t: comp, l, bnd, static, graph_attribute = facts_to_be_applied_node[i][1], facts_to_be_applied_node[i][2], facts_to_be_applied_node[i][3], facts_to_be_applied_node[i][4], facts_to_be_applied_node[i][5] + # If the component is not in the graph, add it + if comp not in nodes: + _add_node(comp, neighbors, reverse_neighbors, nodes, interpretations_node) + # Check if bnd is static. Then no need to update, just add to rule trace, check if graph attribute and add ipl complement to rule trace as well if l in interpretations_node[comp].world and interpretations_node[comp].world[l].is_static(): # Check if we should even store any of the changes to the rule trace etc. @@ -318,6 +322,10 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data for i in range(len(facts_to_be_applied_edge)): if facts_to_be_applied_edge[i][0]==t: comp, l, bnd, static, graph_attribute = facts_to_be_applied_edge[i][1], facts_to_be_applied_edge[i][2], facts_to_be_applied_edge[i][3], facts_to_be_applied_edge[i][4], facts_to_be_applied_edge[i][5] + # If the component is not in the graph, add it + if comp not in edges: + _add_edge(comp[0], comp[1], neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + # Check if bnd is static. Then no need to update, just add to rule trace, check if graph attribute, and add ipl complement to rule trace as well if l in interpretations_edge[comp].world and interpretations_edge[comp].world[l].is_static(): # Inverse of this is: if not save_graph_attributes_to_rule_trace and graph_attribute @@ -623,13 +631,13 @@ def delete_node(self, node): # This function is useful for pyreason gym, called externally _delete_node(node, self.neighbors, self.reverse_neighbors, self.nodes, self.interpretations_node) - def get_interpretation_dict(self): + def get_dict(self): # This function can be called externally to retrieve a dict of the interpretation values # Only values in the rule trace will be added # Initialize interpretations for each time and node and edge interpretations = {} - for t in range(len(interpretations)): + for t in range(self.time+1): interpretations[t] = {} for node in self.nodes: interpretations[t][node] = InterpretationDict() @@ -643,7 +651,7 @@ def get_interpretation_dict(self): # If canonical, update all following timesteps as well if self. canonical: - for t in range(time+1, len(interpretations)): + for t in range(time+1, self.time+1): interpretations[t][node][l._value] = (bnd.lower, bnd.upper) # Update interpretation edges @@ -653,7 +661,7 @@ def get_interpretation_dict(self): # If canonical, update all following timesteps as well if self. canonical: - for t in range(time+1, len(interpretations)): + for t in range(time+1, self.time+1): interpretations[t][edge][l._value] = (bnd.lower, bnd.upper) return interpretations @@ -787,6 +795,16 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if rule_type == 'node': # Loop through all the head variable groundings and add it to the rules to be applied # Loop through the clauses and add appropriate trace data and annotations + + # If there is no grounding for head_var_1, we treat it as a ground atom and add it to the graph + head_var_1_in_nodes = head_var_1 in nodes + if allow_ground_atoms and head_var_1_in_nodes: + groundings[head_var_1] = numba.typed.List([head_var_1]) + elif head_var_1 not in groundings: + if not head_var_1_in_nodes: + _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + groundings[head_var_1] = numba.typed.List([head_var_1]) + for head_grounding in groundings[head_var_1]: qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) @@ -862,6 +880,28 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, elif rule_type == 'edge': head_var_1 = head_variables[0] head_var_2 = head_variables[1] + + # If there is no grounding for head_var_1 or head_var_2, we treat it as a ground atom and add it to the graph + head_var_1_in_nodes = head_var_1 in nodes + head_var_2_in_nodes = head_var_2 in nodes + if allow_ground_atoms and head_var_1_in_nodes: + groundings[head_var_1] = numba.typed.List([head_var_1]) + if allow_ground_atoms and head_var_2_in_nodes: + groundings[head_var_2] = numba.typed.List([head_var_2]) + + if head_var_1 not in groundings: + if not head_var_1_in_nodes: + _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + groundings[head_var_1] = numba.typed.List([head_var_1]) + if head_var_2 not in groundings: + if not head_var_2_in_nodes: + _add_node(head_var_2, neighbors, reverse_neighbors, nodes, interpretations_node) + groundings[head_var_2] = numba.typed.List([head_var_2]) + + # Artificially connect the head variables with an edge if both of them were not in the graph + if not head_var_1_in_nodes and not head_var_2_in_nodes: + _add_edge(head_var_1, head_var_2, neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + head_var_1_groundings = groundings[head_var_1] head_var_2_groundings = groundings[head_var_2] diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index 717f915..dedce5d 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -119,7 +119,7 @@ def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, self.reverse_neighbors = self._init_reverse_neighbors(self.neighbors) @staticmethod - @numba.njit(cache=False) + @numba.njit(cache=True) def _init_reverse_neighbors(neighbors): reverse_neighbors = numba.typed.Dict.empty(key_type=node_type, value_type=list_of_nodes) for n, neighbor_nodes in neighbors.items(): @@ -135,7 +135,7 @@ def _init_reverse_neighbors(neighbors): return reverse_neighbors @staticmethod - @numba.njit(cache=False) + @numba.njit(cache=True) def _init_interpretations_node(nodes, available_labels, specific_labels): interpretations = numba.typed.Dict.empty(key_type=node_type, value_type=world.world_type) # General labels @@ -149,7 +149,7 @@ def _init_interpretations_node(nodes, available_labels, specific_labels): return interpretations @staticmethod - @numba.njit(cache=False) + @numba.njit(cache=True) def _init_interpretations_edge(edges, available_labels, specific_labels): interpretations = numba.typed.Dict.empty(key_type=edge_type, value_type=world.world_type) # General labels @@ -163,7 +163,7 @@ def _init_interpretations_edge(edges, available_labels, specific_labels): return interpretations @staticmethod - @numba.njit(cache=False) + @numba.njit(cache=True) def _init_convergence(convergence_bound_threshold, convergence_threshold): if convergence_bound_threshold==-1 and convergence_threshold==-1: convergence_mode = 'perfect_convergence' @@ -183,7 +183,7 @@ def start_fp(self, tmax, facts_node, facts_edge, rules, verbose, convergence_thr self._start_fp(rules, max_facts_time, verbose, again) @staticmethod - @numba.njit(cache=False) + @numba.njit(cache=True) def _init_facts(facts_node, facts_edge, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, atom_trace): max_time = 0 for fact in facts_node: @@ -214,7 +214,7 @@ def _start_fp(self, rules, max_facts_time, verbose, again): print('Fixed Point iterations:', fp_cnt) @staticmethod - @numba.njit(cache=False, parallel=True) + @numba.njit(cache=True, parallel=True) def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_atoms, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): t = prev_reasoning_data[0] fp_cnt = prev_reasoning_data[1] @@ -254,8 +254,12 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Nodes facts_to_be_applied_node_new.clear() for i in range(len(facts_to_be_applied_node)): - if facts_to_be_applied_node[i][0]==t: + if facts_to_be_applied_node[i][0] == t: comp, l, bnd, static, graph_attribute = facts_to_be_applied_node[i][1], facts_to_be_applied_node[i][2], facts_to_be_applied_node[i][3], facts_to_be_applied_node[i][4], facts_to_be_applied_node[i][5] + # If the component is not in the graph, add it + if comp not in nodes: + _add_node(comp, neighbors, reverse_neighbors, nodes, interpretations_node) + # Check if bnd is static. Then no need to update, just add to rule trace, check if graph attribute and add ipl complement to rule trace as well if l in interpretations_node[comp].world and interpretations_node[comp].world[l].is_static(): # Check if we should even store any of the changes to the rule trace etc. @@ -318,6 +322,10 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data for i in range(len(facts_to_be_applied_edge)): if facts_to_be_applied_edge[i][0]==t: comp, l, bnd, static, graph_attribute = facts_to_be_applied_edge[i][1], facts_to_be_applied_edge[i][2], facts_to_be_applied_edge[i][3], facts_to_be_applied_edge[i][4], facts_to_be_applied_edge[i][5] + # If the component is not in the graph, add it + if comp not in edges: + _add_edge(comp[0], comp[1], neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + # Check if bnd is static. Then no need to update, just add to rule trace, check if graph attribute, and add ipl complement to rule trace as well if l in interpretations_edge[comp].world and interpretations_edge[comp].world[l].is_static(): # Inverse of this is: if not save_graph_attributes_to_rule_trace and graph_attribute @@ -623,13 +631,13 @@ def delete_node(self, node): # This function is useful for pyreason gym, called externally _delete_node(node, self.neighbors, self.reverse_neighbors, self.nodes, self.interpretations_node) - def get_interpretation_dict(self): + def get_dict(self): # This function can be called externally to retrieve a dict of the interpretation values # Only values in the rule trace will be added # Initialize interpretations for each time and node and edge interpretations = {} - for t in range(len(interpretations)): + for t in range(self.time+1): interpretations[t] = {} for node in self.nodes: interpretations[t][node] = InterpretationDict() @@ -643,7 +651,7 @@ def get_interpretation_dict(self): # If canonical, update all following timesteps as well if self. canonical: - for t in range(time+1, len(interpretations)): + for t in range(time+1, self.time+1): interpretations[t][node][l._value] = (bnd.lower, bnd.upper) # Update interpretation edges @@ -653,13 +661,13 @@ def get_interpretation_dict(self): # If canonical, update all following timesteps as well if self. canonical: - for t in range(time+1, len(interpretations)): + for t in range(time+1, self.time+1): interpretations[t][edge][l._value] = (bnd.lower, bnd.upper) return interpretations -@numba.njit(cache=False) +@numba.njit(cache=True) def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_atoms): # Extract rule params rule_type = rule.get_type() @@ -787,6 +795,16 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if rule_type == 'node': # Loop through all the head variable groundings and add it to the rules to be applied # Loop through the clauses and add appropriate trace data and annotations + + # If there is no grounding for head_var_1, we treat it as a ground atom and add it to the graph + head_var_1_in_nodes = head_var_1 in nodes + if allow_ground_atoms and head_var_1_in_nodes: + groundings[head_var_1] = numba.typed.List([head_var_1]) + elif head_var_1 not in groundings: + if not head_var_1_in_nodes: + _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + groundings[head_var_1] = numba.typed.List([head_var_1]) + for head_grounding in groundings[head_var_1]: qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) @@ -862,6 +880,28 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, elif rule_type == 'edge': head_var_1 = head_variables[0] head_var_2 = head_variables[1] + + # If there is no grounding for head_var_1 or head_var_2, we treat it as a ground atom and add it to the graph + head_var_1_in_nodes = head_var_1 in nodes + head_var_2_in_nodes = head_var_2 in nodes + if allow_ground_atoms and head_var_1_in_nodes: + groundings[head_var_1] = numba.typed.List([head_var_1]) + if allow_ground_atoms and head_var_2_in_nodes: + groundings[head_var_2] = numba.typed.List([head_var_2]) + + if head_var_1 not in groundings: + if not head_var_1_in_nodes: + _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + groundings[head_var_1] = numba.typed.List([head_var_1]) + if head_var_2 not in groundings: + if not head_var_2_in_nodes: + _add_node(head_var_2, neighbors, reverse_neighbors, nodes, interpretations_node) + groundings[head_var_2] = numba.typed.List([head_var_2]) + + # Artificially connect the head variables with an edge if both of them were not in the graph + if not head_var_1_in_nodes and not head_var_2_in_nodes: + _add_edge(head_var_1, head_var_2, neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + head_var_1_groundings = groundings[head_var_1] head_var_2_groundings = groundings[head_var_2] @@ -1025,7 +1065,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, return applicable_rules_node, applicable_rules_edge -@numba.njit(cache=False) +@numba.njit(cache=True) def check_all_clause_satisfaction(interpretations_node, interpretations_edge, clauses, thresholds, groundings, groundings_edges): # Check if the thresholds are satisfied for each clause satisfaction = True @@ -1044,7 +1084,7 @@ def check_all_clause_satisfaction(interpretations_node, interpretations_edge, cl return satisfaction -@numba.njit(cache=False) +@numba.njit(cache=True) def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, neighbors, reverse_neighbors, atom_trace, reverse_graph, nodes_to_skip): # Extract rule params rule_type = rule.get_type() @@ -1302,7 +1342,7 @@ def _ground_node_rule(rule, interpretations_node, interpretations_edge, nodes, n return applicable_rules -@numba.njit(cache=False) +@numba.njit(cache=True) def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, reverse_graph, edges_to_skip): # Extract rule params rule_type = rule.get_type() @@ -1541,7 +1581,7 @@ def _ground_edge_rule(rule, interpretations_node, interpretations_edge, nodes, e return applicable_rules -@numba.njit(cache=False) +@numba.njit(cache=True) def refine_groundings(clause_variables, groundings, groundings_edges, dependency_graph_neighbors, dependency_graph_reverse_neighbors): # Loop through the dependency graph and refine the groundings that have connections all_variables_refined = numba.typed.List(clause_variables) @@ -1595,7 +1635,7 @@ def refine_groundings(clause_variables, groundings, groundings_edges, dependency new_variables_refined.clear() -@numba.njit(cache=False) +@numba.njit(cache=True) def refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_node, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph): """NOTE: DEPRECATED""" # Loop through all clauses till clause i-1 and update subsets recursively @@ -1675,7 +1715,7 @@ def refine_subsets_node_rule(interpretations_edge, clauses, i, subsets, target_n return satisfaction -@numba.njit(cache=False) +@numba.njit(cache=True) def refine_subsets_edge_rule(interpretations_edge, clauses, i, subsets, target_edge, neighbors, reverse_neighbors, nodes, thresholds, reverse_graph): """NOTE: DEPRECATED""" # Loop through all clauses till clause i-1 and update subsets recursively @@ -1755,7 +1795,7 @@ def refine_subsets_edge_rule(interpretations_edge, clauses, i, subsets, target_e return satisfaction -@numba.njit(cache=False) +@numba.njit(cache=True) def check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_grounding, clause_label, threshold): threshold_quantifier_type = threshold[1][1] if threshold_quantifier_type == 'total': @@ -1770,7 +1810,7 @@ def check_node_grounding_threshold_satisfaction(interpretations_node, grounding, return satisfaction -@numba.njit(cache=False) +@numba.njit(cache=True) def check_edge_grounding_threshold_satisfaction(interpretations_edge, grounding, qualified_grounding, clause_label, threshold): threshold_quantifier_type = threshold[1][1] if threshold_quantifier_type == 'total': @@ -1785,7 +1825,7 @@ def check_edge_grounding_threshold_satisfaction(interpretations_edge, grounding, return satisfaction -@numba.njit(cache=False) +@numba.njit(cache=True) def check_node_clause_satisfaction(interpretations_node, subsets, subset, clause_var_1, clause_label, threshold): """NOTE: DEPRECATED""" threshold_quantifier_type = threshold[1][1] @@ -1802,7 +1842,7 @@ def check_node_clause_satisfaction(interpretations_node, subsets, subset, clause return satisfaction -@numba.njit(cache=False) +@numba.njit(cache=True) def check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, subset_target, clause_var_1, clause_label, threshold, reverse_graph): """NOTE: DEPRECATED""" threshold_quantifier_type = threshold[1][1] @@ -1818,14 +1858,14 @@ def check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, return satisfaction -@numba.njit(cache=False) +@numba.njit(cache=True) def get_rule_node_clause_grounding(clause_var_1, groundings, nodes): # The groundings for a node clause can be either a previous grounding or all possible nodes grounding = numba.typed.List(nodes) if clause_var_1 not in groundings else groundings[clause_var_1] return grounding -@numba.njit(cache=False) +@numba.njit(cache=True) def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes): # There are 4 cases for predicate(Y,Z): # 1. Both predicate variables Y and Z have not been encountered before @@ -1870,7 +1910,7 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun return edge_groundings -@numba.njit(cache=False) +@numba.njit(cache=True) def get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes): """NOTE: DEPRECATED""" # The groundings for node clauses are either the target node, neighbors of the target node, or an existing subset of nodes @@ -1882,7 +1922,7 @@ def get_node_rule_node_clause_subset(clause_var_1, target_node, subsets, nodes): return subset -@numba.njit(cache=False) +@numba.njit(cache=True) def get_node_rule_edge_clause_subset(clause_var_1, clause_var_2, target_node, subsets, neighbors, reverse_neighbors, nodes): """NOTE: DEPRECATED""" # There are 5 cases for predicate(Y,Z): @@ -1952,7 +1992,7 @@ def get_node_rule_edge_clause_subset(clause_var_1, clause_var_2, target_node, su return subset_source, subset_target -@numba.njit(cache=False) +@numba.njit(cache=True) def get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, nodes): """NOTE: DEPRECATED""" # The groundings for node clauses are either the source, target, neighbors of the source node, or an existing subset of nodes @@ -1966,7 +2006,7 @@ def get_edge_rule_node_clause_subset(clause_var_1, target_edge, subsets, nodes): return subset -@numba.njit(cache=False) +@numba.njit(cache=True) def get_edge_rule_edge_clause_subset(clause_var_1, clause_var_2, target_edge, subsets, neighbors, reverse_neighbors, nodes): """NOTE: DEPRECATED""" # There are 5 cases for predicate(Y,Z): @@ -2055,7 +2095,7 @@ def get_edge_rule_edge_clause_subset(clause_var_1, clause_var_2, target_edge, su return subset_source, subset_target -@numba.njit(cache=False) +@numba.njit(cache=True) def get_qualified_node_groundings(interpretations_node, grounding, clause_l, clause_bnd): # Filter the grounding by the predicate and bound of the clause qualified_groundings = numba.typed.List.empty_list(node_type) @@ -2066,7 +2106,7 @@ def get_qualified_node_groundings(interpretations_node, grounding, clause_l, cla return qualified_groundings -@numba.njit(cache=False) +@numba.njit(cache=True) def get_qualified_edge_groundings(interpretations_edge, grounding, clause_l, clause_bnd): # Filter the grounding by the predicate and bound of the clause qualified_groundings = numba.typed.List.empty_list(edge_type) @@ -2077,7 +2117,7 @@ def get_qualified_edge_groundings(interpretations_edge, grounding, clause_l, cla return qualified_groundings -@numba.njit(cache=False) +@numba.njit(cache=True) def get_qualified_components_node_clause(interpretations_node, candidates, l, bnd): """NOTE: DEPRECATED""" # Get all the qualified neighbors for a particular clause @@ -2089,7 +2129,7 @@ def get_qualified_components_node_clause(interpretations_node, candidates, l, bn return qualified_nodes -@numba.njit(cache=False) +@numba.njit(cache=True) def get_qualified_components_node_comparison_clause(interpretations_node, candidates, l, bnd): """NOTE: DEPRECATED""" # Get all the qualified neighbors for a particular comparison clause and return them along with the number associated @@ -2104,7 +2144,7 @@ def get_qualified_components_node_comparison_clause(interpretations_node, candid return qualified_nodes, qualified_nodes_numbers -@numba.njit(cache=False) +@numba.njit(cache=True) def get_qualified_components_edge_clause(interpretations_edge, candidates_source, candidates_target, l, bnd, reverse_graph): """NOTE: DEPRECATED""" # Get all the qualified sources and targets for a particular clause @@ -2120,7 +2160,7 @@ def get_qualified_components_edge_clause(interpretations_edge, candidates_source return qualified_nodes_source, qualified_nodes_target -@numba.njit(cache=False) +@numba.njit(cache=True) def get_qualified_components_edge_comparison_clause(interpretations_edge, candidates_source, candidates_target, l, bnd, reverse_graph): """NOTE: DEPRECATED""" # Get all the qualified sources and targets for a particular clause @@ -2139,7 +2179,7 @@ def get_qualified_components_edge_comparison_clause(interpretations_edge, candid return qualified_nodes_source, qualified_nodes_target, qualified_edges_numbers -@numba.njit(cache=False) +@numba.njit(cache=True) def compare_numbers_node_predicate(numbers_1, numbers_2, op, qualified_nodes_1, qualified_nodes_2): """NOTE: DEPRECATED""" result = False @@ -2172,7 +2212,7 @@ def compare_numbers_node_predicate(numbers_1, numbers_2, op, qualified_nodes_1, return result, final_qualified_nodes_1, final_qualified_nodes_2 -@numba.njit(cache=False) +@numba.njit(cache=True) def compare_numbers_edge_predicate(numbers_1, numbers_2, op, qualified_nodes_1a, qualified_nodes_1b, qualified_nodes_2a, qualified_nodes_2b): """NOTE: DEPRECATED""" result = False @@ -2209,7 +2249,7 @@ def compare_numbers_edge_predicate(numbers_1, numbers_2, op, qualified_nodes_1a, return result, final_qualified_nodes_1a, final_qualified_nodes_1b, final_qualified_nodes_2a, final_qualified_nodes_2b -@numba.njit(cache=False) +@numba.njit(cache=True) def _satisfies_threshold(num_neigh, num_qualified_component, threshold): # Checks if qualified neighbors satisfy threshold. This is for one clause if threshold[1][0]=='number': @@ -2241,7 +2281,7 @@ def _satisfies_threshold(num_neigh, num_qualified_component, threshold): return result -@numba.njit(cache=False) +@numba.njit(cache=True) def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_trace, idx, facts_to_be_applied_trace, rule_trace_atoms, store_interpretation_changes, mode, override=False): updated = False # This is to prevent a key error in case the label is a specific label @@ -2330,7 +2370,7 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat return (False, 0) -@numba.njit(cache=False) +@numba.njit(cache=True) def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_trace, idx, facts_to_be_applied_trace, rule_trace_atoms, store_interpretation_changes, mode, override=False): updated = False # This is to prevent a key error in case the label is a specific label @@ -2418,12 +2458,12 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat return (False, 0) -@numba.njit(cache=False) +@numba.njit(cache=True) def _update_rule_trace(rule_trace, qn, qe, prev_bnd, name): rule_trace.append((qn, qe, prev_bnd.copy(), name)) -@numba.njit(cache=False) +@numba.njit(cache=True) def are_satisfied_node(interpretations, comp, nas): result = True for (l, bnd) in nas: @@ -2431,7 +2471,7 @@ def are_satisfied_node(interpretations, comp, nas): return result -@numba.njit(cache=False) +@numba.njit(cache=True) def is_satisfied_node(interpretations, comp, na): result = False if not (na[0] is None or na[1] is None): @@ -2446,7 +2486,7 @@ def is_satisfied_node(interpretations, comp, na): return result -@numba.njit(cache=False) +@numba.njit(cache=True) def is_satisfied_node_comparison(interpretations, comp, na): result = False number = 0 @@ -2473,7 +2513,7 @@ def is_satisfied_node_comparison(interpretations, comp, na): return result, number -@numba.njit(cache=False) +@numba.njit(cache=True) def are_satisfied_edge(interpretations, comp, nas): result = True for (l, bnd) in nas: @@ -2481,7 +2521,7 @@ def are_satisfied_edge(interpretations, comp, nas): return result -@numba.njit(cache=False) +@numba.njit(cache=True) def is_satisfied_edge(interpretations, comp, na): result = False if not (na[0] is None or na[1] is None): @@ -2496,7 +2536,7 @@ def is_satisfied_edge(interpretations, comp, na): return result -@numba.njit(cache=False) +@numba.njit(cache=True) def is_satisfied_edge_comparison(interpretations, comp, na): result = False number = 0 @@ -2523,7 +2563,7 @@ def is_satisfied_edge_comparison(interpretations, comp, na): return result, number -@numba.njit(cache=False) +@numba.njit(cache=True) def annotate(annotation_functions, rule, annotations, weights): func_name = rule.get_annotation_function() if func_name == '': @@ -2536,7 +2576,7 @@ def annotate(annotation_functions, rule, annotations, weights): return annotation -@numba.njit(cache=False) +@numba.njit(cache=True) def check_consistent_node(interpretations, comp, na): world = interpretations[comp] if na[0] in world.world: @@ -2549,7 +2589,7 @@ def check_consistent_node(interpretations, comp, na): return True -@numba.njit(cache=False) +@numba.njit(cache=True) def check_consistent_edge(interpretations, comp, na): world = interpretations[comp] if na[0] in world.world: @@ -2562,7 +2602,7 @@ def check_consistent_edge(interpretations, comp, na): return True -@numba.njit(cache=False) +@numba.njit(cache=True) def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, atom_trace, rule_trace, rule_trace_atoms, store_interpretation_changes): world = interpretations[comp] if store_interpretation_changes: @@ -2591,7 +2631,7 @@ def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, at # Add inconsistent predicates to a list -@numba.njit(cache=False) +@numba.njit(cache=True) def resolve_inconsistency_edge(interpretations, comp, na, ipl, t_cnt, fp_cnt, atom_trace, rule_trace, rule_trace_atoms, store_interpretation_changes): w = interpretations[comp] if store_interpretation_changes: @@ -2619,7 +2659,7 @@ def resolve_inconsistency_edge(interpretations, comp, na, ipl, t_cnt, fp_cnt, at rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, p1, interval.closed(0,1))) -@numba.njit(cache=False) +@numba.njit(cache=True) def _add_node(node, neighbors, reverse_neighbors, nodes, interpretations_node): nodes.append(node) neighbors[node] = numba.typed.List.empty_list(node_type) @@ -2627,7 +2667,7 @@ def _add_node(node, neighbors, reverse_neighbors, nodes, interpretations_node): interpretations_node[node] = world.World(numba.typed.List.empty_list(label.label_type)) -@numba.njit(cache=False) +@numba.njit(cache=True) def _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge): # If not a node, add to list of nodes and initialize neighbors if source not in nodes: @@ -2658,7 +2698,7 @@ def _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, int return edge, new_edge -@numba.njit(cache=False) +@numba.njit(cache=True) def _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge): changes = 0 edges_added = numba.typed.List.empty_list(edge_type) @@ -2670,7 +2710,7 @@ def _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, l, return edges_added, changes -@numba.njit(cache=False) +@numba.njit(cache=True) def _delete_edge(edge, neighbors, reverse_neighbors, edges, interpretations_edge): source, target = edge edges.remove(edge) @@ -2679,7 +2719,7 @@ def _delete_edge(edge, neighbors, reverse_neighbors, edges, interpretations_edge reverse_neighbors[target].remove(source) -@numba.njit(cache=False) +@numba.njit(cache=True) def _delete_node(node, neighbors, reverse_neighbors, nodes, interpretations_node): nodes.remove(node) del interpretations_node[node] @@ -2695,7 +2735,7 @@ def _delete_node(node, neighbors, reverse_neighbors, nodes, interpretations_node reverse_neighbors[n].remove(node) -@numba.njit(cache=False) +@numba.njit(cache=True) def float_to_str(value): number = int(value) decimal = int(value % 1 * 1000) @@ -2703,7 +2743,7 @@ def float_to_str(value): return float_str -@numba.njit(cache=False) +@numba.njit(cache=True) def str_to_float(value): decimal_pos = value.find('.') if decimal_pos != -1: @@ -2716,7 +2756,7 @@ def str_to_float(value): return value -@numba.njit(cache=False) +@numba.njit(cache=True) def str_to_int(value): if value[0] == '-': negative = True From 82bc8cdbed7d108e3c886dcc2129fc9e22884f49 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 4 Aug 2024 18:55:43 +0200 Subject: [PATCH 29/73] added a negation `~` possibility for clauses or heads in rules --- pyreason/scripts/utils/rule_parser.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pyreason/scripts/utils/rule_parser.py b/pyreason/scripts/utils/rule_parser.py index 3e689fc..132f856 100644 --- a/pyreason/scripts/utils/rule_parser.py +++ b/pyreason/scripts/utils/rule_parser.py @@ -33,7 +33,7 @@ def parse_rule(rule_text: str, name: str, custom_thresholds: list, infer_edges: # 2. replace ) by )) and ] by ]] so that we can split without damaging the string # 3. Split with ), and then for each element of list, split with ], and add to new list # 4. Then replace ]] with ] and )) with ) in for loop - # 5. Add :[1,1] to the end of each element if a bound is not specified + # 5. Add :[1,1] or :[0,0] to the end of each element if a bound is not specified # 6. Then split each element with : # 7. Transform bound strings into pr.intervals @@ -54,7 +54,9 @@ def parse_rule(rule_text: str, name: str, custom_thresholds: list, infer_edges: # 5 for i in range(len(split_body)): - if split_body[i][-1] != ']': + if split_body[i][0] == '~': + split_body[i] = split_body[i][1:] + ':[0,0]' + elif split_body[i][-1] != ']': split_body[i] += ':[1,1]' # 6 @@ -79,7 +81,10 @@ def parse_rule(rule_text: str, name: str, custom_thresholds: list, infer_edges: # This means there is no bound or annotation function specified if head[-1] == ')': - head += ':[1,1]' + if head[0] == '~': + head = head[1:] + ':[0,0]' + else: + head += ':[1,1]' head, head_bound = head.split(':') # Check if we have a bound or annotation function From 5f23ada03fda72a3e127db88919dae6031747241 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 4 Aug 2024 19:06:32 +0200 Subject: [PATCH 30/73] added a negation `~` possibility for facts --- pyreason/scripts/utils/fact_parser.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyreason/scripts/utils/fact_parser.py b/pyreason/scripts/utils/fact_parser.py index a0e1f39..afbc354 100644 --- a/pyreason/scripts/utils/fact_parser.py +++ b/pyreason/scripts/utils/fact_parser.py @@ -9,7 +9,10 @@ def parse_fact(fact_text): pred_comp, bound = f.split(':') else: pred_comp = f - bound = 'True' + if pred_comp[0] == '~': + bound = 'False' + else: + bound = 'True' # Check if bound is a boolean or a list of floats bound = bound.lower() From ce616485491c537ac312439d1ba9cba695bce349 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 4 Aug 2024 21:29:45 +0200 Subject: [PATCH 31/73] reset graph function --- pyreason/pyreason.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pyreason/pyreason.py b/pyreason/pyreason.py index 1041c18..2afc8f0 100755 --- a/pyreason/pyreason.py +++ b/pyreason/pyreason.py @@ -421,6 +421,14 @@ def reset_rules(): __rules = None +def reset_graph(): + """ + Resets graph to none + """ + global __graph + __graph = None + + def reset_settings(): """ Resets settings to default @@ -603,7 +611,7 @@ def _reason(timesteps, convergence_threshold, convergence_bound_threshold): # Check variables that HAVE to be set. Exceptions if __graph is None: - __graph = nx.DiGraph() + load_graph(nx.DiGraph()) if settings.verbose: warnings.warn('Graph not loaded. Use `load_graph` to load the graphml file. Using empty graph') if __rules is None: From aae4943f2570bd88eff119b5301be5e06dd312e7 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 4 Aug 2024 23:38:17 +0200 Subject: [PATCH 32/73] added query feature and prevented nodes/edges from being added to graph when not necessary due to ground rules --- .../scripts/interpretation/interpretation.py | 88 +++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 1a928d6..f83fb3f 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -666,6 +666,69 @@ def get_dict(self): return interpretations + def query(self, query, return_bool=True): + """ + This function is used to query the graph after reasoning + :param query: The query string of for `pred(node)` or `pred(edge)` or `pred(node) : [l, u]` + :param return_bool: If True, returns boolean of query, else the bounds associated with it + :return: bool, or bounds + """ + # Parse the query + query = query.replace(' ', '') + + if ':' in query: + pred_comp, bounds = query.split(':') + l, u = bounds.split(',') + l, u = float(l), float(u) + else: + if query[0] == '~': + pred_comp = query[1:] + l, u = 0, 0 + else: + pred_comp = query + l, u = 1, 1 + + bnd = interval.closed(l, u) + + # Split predicate and component + idx = pred_comp.find('(') + pred = label.Label(pred_comp[:idx]) + component = pred_comp[idx + 1:-1] + + if ',' in component: + component = tuple(component.split(',')) + comp_type = 'edge' + else: + comp_type = 'node' + + # Check if the component exists + if comp_type == 'node': + if component not in self.nodes: + return False if return_bool else (0, 0) + else: + if component not in self.edges: + return False if return_bool else (0, 0) + + # Check if the predicate exists + if comp_type == 'node': + if pred not in self.interpretations_node[component].world: + return False if return_bool else (0, 0) + else: + if pred not in self.interpretations_edge[component].world: + return False if return_bool else (0, 0) + + # Check if the bounds are satisfied + if comp_type == 'node': + if self.interpretations_node[component].world[pred] in bnd: + return True if return_bool else (self.interpretations_node[component].world[pred].lower, self.interpretations_node[component].world[pred].upper) + else: + return False if return_bool else (0, 0) + else: + if self.interpretations_edge[component].world[pred] in bnd: + return True if return_bool else (self.interpretations_edge[component].world[pred].lower, self.interpretations_edge[component].world[pred].upper) + else: + return False if return_bool else (0, 0) + @numba.njit(cache=True) def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_atoms): @@ -798,11 +861,12 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # If there is no grounding for head_var_1, we treat it as a ground atom and add it to the graph head_var_1_in_nodes = head_var_1 in nodes + add_head_var_node_to_graph = False if allow_ground_atoms and head_var_1_in_nodes: groundings[head_var_1] = numba.typed.List([head_var_1]) elif head_var_1 not in groundings: if not head_var_1_in_nodes: - _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + add_head_var_node_to_graph = True groundings[head_var_1] = numba.typed.List([head_var_1]) for head_grounding in groundings[head_var_1]: @@ -874,6 +938,10 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Comparison clause (we do not handle for now) pass + # Now that we're sure that the rule is satisfied, we add the head to the graph if needed (only for ground rules) + if add_head_var_node_to_graph: + _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + # For each grounding add a rule to be applied applicable_rules_node.append((head_grounding, annotations, qualified_nodes, qualified_edges, edges_to_be_added)) @@ -884,6 +952,9 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # If there is no grounding for head_var_1 or head_var_2, we treat it as a ground atom and add it to the graph head_var_1_in_nodes = head_var_1 in nodes head_var_2_in_nodes = head_var_2 in nodes + add_head_var_1_node_to_graph = False + add_head_var_2_node_to_graph = False + add_head_edge_to_graph = False if allow_ground_atoms and head_var_1_in_nodes: groundings[head_var_1] = numba.typed.List([head_var_1]) if allow_ground_atoms and head_var_2_in_nodes: @@ -891,16 +962,16 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if head_var_1 not in groundings: if not head_var_1_in_nodes: - _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + add_head_var_1_node_to_graph = True groundings[head_var_1] = numba.typed.List([head_var_1]) if head_var_2 not in groundings: if not head_var_2_in_nodes: - _add_node(head_var_2, neighbors, reverse_neighbors, nodes, interpretations_node) + add_head_var_2_node_to_graph = True groundings[head_var_2] = numba.typed.List([head_var_2]) # Artificially connect the head variables with an edge if both of them were not in the graph if not head_var_1_in_nodes and not head_var_2_in_nodes: - _add_edge(head_var_1, head_var_2, neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + add_head_edge_to_graph = True head_var_1_groundings = groundings[head_var_1] head_var_2_groundings = groundings[head_var_2] @@ -922,7 +993,6 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Loop through the head variable groundings for valid_e in valid_edge_groundings: - satisfaction = True head_var_1_grounding, head_var_2_grounding = valid_e[0], valid_e[1] qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) @@ -1055,6 +1125,14 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, a.append(interpretations_edge[qe].world[clause_label]) annotations.append(a) + # Now that we're sure that the rule is satisfied, we add the head to the graph if needed (only for ground rules) + if add_head_var_1_node_to_graph and head_var_1_grounding == head_var_1: + _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + if add_head_var_2_node_to_graph and head_var_2_grounding == head_var_2: + _add_node(head_var_2, neighbors, reverse_neighbors, nodes, interpretations_node) + if add_head_edge_to_graph and (head_var_1, head_var_2) == (head_var_1_grounding, head_var_2_grounding): + _add_edge(head_var_1, head_var_2, neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + # For each grounding combination add a rule to be applied # Only if all the clauses have valid groundings # if satisfaction: From 7aa31807025f862a8a9b63683ccd1560082de6dd Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 4 Aug 2024 23:40:12 +0200 Subject: [PATCH 33/73] changed `allow_ground_atoms` to `allow_ground_rules` --- pyreason/pyreason.py | 14 ++++++------ .../scripts/interpretation/interpretation.py | 22 +++++++++---------- pyreason/scripts/program/program.py | 8 +++---- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/pyreason/pyreason.py b/pyreason/pyreason.py index 2afc8f0..22ef016 100755 --- a/pyreason/pyreason.py +++ b/pyreason/pyreason.py @@ -42,7 +42,7 @@ def __init__(self): self.__store_interpretation_changes = True self.__parallel_computing = False self.__update_mode = 'intersection' - self.__allow_ground_atoms = False + self.__allow_ground_rules = False @property def verbose(self) -> bool: @@ -170,12 +170,12 @@ def update_mode(self) -> str: return self.__update_mode @property - def allow_ground_atoms(self) -> bool: + def allow_ground_rules(self) -> bool: """Returns whether rules can have ground atoms or not. Default is False :return: bool """ - return self.__allow_ground_atoms + return self.__allow_ground_rules @verbose.setter def verbose(self, value: bool) -> None: @@ -364,8 +364,8 @@ def update_mode(self, value: str) -> None: else: self.__update_mode = value - @allow_ground_atoms.setter - def allow_ground_atoms(self, value: bool) -> None: + @allow_ground_rules.setter + def allow_ground_rules(self, value: bool) -> None: """Allow ground atoms to be used in rules when possible. Default is False :param value: Whether to allow ground atoms or not @@ -374,7 +374,7 @@ def allow_ground_atoms(self, value: bool) -> None: if not isinstance(value, bool): raise TypeError('value has to be a bool') else: - self.__allow_ground_atoms = value + self.__allow_ground_rules = value # VARIABLES @@ -660,7 +660,7 @@ def _reason(timesteps, convergence_threshold, convergence_bound_threshold): annotation_functions = tuple(__annotation_functions) # Setup logical program - __program = Program(__graph, all_node_facts, all_edge_facts, __rules, __ipl, annotation_functions, settings.reverse_digraph, settings.atom_trace, settings.save_graph_attributes_to_trace, settings.canonical, settings.inconsistency_check, settings.store_interpretation_changes, settings.parallel_computing, settings.update_mode, settings.allow_ground_atoms) + __program = Program(__graph, all_node_facts, all_edge_facts, __rules, __ipl, annotation_functions, settings.reverse_digraph, settings.atom_trace, settings.save_graph_attributes_to_trace, settings.canonical, settings.inconsistency_check, settings.store_interpretation_changes, settings.parallel_computing, settings.update_mode, settings.allow_ground_rules) __program.available_labels_node = __node_labels __program.available_labels_edge = __edge_labels __program.specific_node_labels = __specific_node_labels diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index f83fb3f..58f1ce2 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -55,7 +55,7 @@ class Interpretation: specific_node_labels = numba.typed.Dict.empty(key_type=label.label_type, value_type=numba.types.ListType(node_type)) specific_edge_labels = numba.typed.Dict.empty(key_type=label.label_type, value_type=numba.types.ListType(edge_type)) - def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_atoms): + def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_rules): self.graph = graph self.ipl = ipl self.annotation_functions = annotation_functions @@ -66,7 +66,7 @@ def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, self.inconsistency_check = inconsistency_check self.store_interpretation_changes = store_interpretation_changes self.update_mode = update_mode - self.allow_ground_atoms = allow_ground_atoms + self.allow_ground_rules = allow_ground_rules # For reasoning and reasoning again (contains previous time and previous fp operation cnt) self.time = 0 @@ -205,7 +205,7 @@ def _init_facts(facts_node, facts_edge, facts_to_be_applied_node, facts_to_be_ap return max_time def _start_fp(self, rules, max_facts_time, verbose, again): - fp_cnt, t = self.reason(self.interpretations_node, self.interpretations_edge, self.tmax, self.prev_reasoning_data, rules, self.nodes, self.edges, self.neighbors, self.reverse_neighbors, self.rules_to_be_applied_node, self.rules_to_be_applied_edge, self.edges_to_be_added_node_rule, self.edges_to_be_added_edge_rule, self.rules_to_be_applied_node_trace, self.rules_to_be_applied_edge_trace, self.facts_to_be_applied_node, self.facts_to_be_applied_edge, self.facts_to_be_applied_node_trace, self.facts_to_be_applied_edge_trace, self.ipl, self.rule_trace_node, self.rule_trace_edge, self.rule_trace_node_atoms, self.rule_trace_edge_atoms, self.reverse_graph, self.atom_trace, self.save_graph_attributes_to_rule_trace, self.canonical, self.inconsistency_check, self.store_interpretation_changes, self.update_mode, self.allow_ground_atoms, max_facts_time, self.annotation_functions, self._convergence_mode, self._convergence_delta, verbose, again) + fp_cnt, t = self.reason(self.interpretations_node, self.interpretations_edge, self.tmax, self.prev_reasoning_data, rules, self.nodes, self.edges, self.neighbors, self.reverse_neighbors, self.rules_to_be_applied_node, self.rules_to_be_applied_edge, self.edges_to_be_added_node_rule, self.edges_to_be_added_edge_rule, self.rules_to_be_applied_node_trace, self.rules_to_be_applied_edge_trace, self.facts_to_be_applied_node, self.facts_to_be_applied_edge, self.facts_to_be_applied_node_trace, self.facts_to_be_applied_edge_trace, self.ipl, self.rule_trace_node, self.rule_trace_edge, self.rule_trace_node_atoms, self.rule_trace_edge_atoms, self.reverse_graph, self.atom_trace, self.save_graph_attributes_to_rule_trace, self.canonical, self.inconsistency_check, self.store_interpretation_changes, self.update_mode, self.allow_ground_rules, max_facts_time, self.annotation_functions, self._convergence_mode, self._convergence_delta, verbose, again) self.time = t - 1 # If we need to reason again, store the next timestep to start from self.prev_reasoning_data[0] = t @@ -215,7 +215,7 @@ def _start_fp(self, rules, max_facts_time, verbose, again): @staticmethod @numba.njit(cache=True, parallel=False) - def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_atoms, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): + def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_rules, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): t = prev_reasoning_data[0] fp_cnt = prev_reasoning_data[1] max_rules_time = 0 @@ -520,7 +520,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Only go through if the rule can be applied within the given timesteps, or we're running until convergence delta_t = rule.get_delta() if t + delta_t <= tmax or tmax == -1 or again: - applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_atoms) + applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_rules) # Loop through applicable rules and add them to the rules to be applied for later or next fp operation for applicable_rule in applicable_node_rules: @@ -731,7 +731,7 @@ def query(self, query, return_bool=True): @numba.njit(cache=True) -def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_atoms): +def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_rules): # Extract rule params rule_type = rule.get_type() head_variables = rule.get_head_variables() @@ -777,7 +777,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Get subset of nodes that can be used to ground the variable # If we allow ground atoms, we can use the nodes directly - if allow_ground_atoms and clause_var_1 in nodes: + if allow_ground_rules and clause_var_1 in nodes: grounding = numba.typed.List([clause_var_1]) else: grounding = get_rule_node_clause_grounding(clause_var_1, groundings, nodes) @@ -800,7 +800,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Get subset of edges that can be used to ground the variables # If we allow ground atoms, we can use the nodes directly - if allow_ground_atoms and (clause_var_1, clause_var_2) in edges: + if allow_ground_rules and (clause_var_1, clause_var_2) in edges: grounding = numba.typed.List([(clause_var_1, clause_var_2)]) else: grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes) @@ -862,7 +862,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # If there is no grounding for head_var_1, we treat it as a ground atom and add it to the graph head_var_1_in_nodes = head_var_1 in nodes add_head_var_node_to_graph = False - if allow_ground_atoms and head_var_1_in_nodes: + if allow_ground_rules and head_var_1_in_nodes: groundings[head_var_1] = numba.typed.List([head_var_1]) elif head_var_1 not in groundings: if not head_var_1_in_nodes: @@ -955,9 +955,9 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, add_head_var_1_node_to_graph = False add_head_var_2_node_to_graph = False add_head_edge_to_graph = False - if allow_ground_atoms and head_var_1_in_nodes: + if allow_ground_rules and head_var_1_in_nodes: groundings[head_var_1] = numba.typed.List([head_var_1]) - if allow_ground_atoms and head_var_2_in_nodes: + if allow_ground_rules and head_var_2_in_nodes: groundings[head_var_2] = numba.typed.List([head_var_2]) if head_var_1 not in groundings: diff --git a/pyreason/scripts/program/program.py b/pyreason/scripts/program/program.py index d73d537..8adc5e8 100755 --- a/pyreason/scripts/program/program.py +++ b/pyreason/scripts/program/program.py @@ -8,7 +8,7 @@ class Program: specific_node_labels = [] specific_edge_labels = [] - def __init__(self, graph, facts_node, facts_edge, rules, ipl, annotation_functions, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, parallel_computing, update_mode, allow_ground_atoms): + def __init__(self, graph, facts_node, facts_edge, rules, ipl, annotation_functions, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, parallel_computing, update_mode, allow_ground_rules): self._graph = graph self._facts_node = facts_node self._facts_edge = facts_edge @@ -23,7 +23,7 @@ def __init__(self, graph, facts_node, facts_edge, rules, ipl, annotation_functio self._store_interpretation_changes = store_interpretation_changes self._parallel_computing = parallel_computing self._update_mode = update_mode - self._allow_ground_atoms = allow_ground_atoms + self._allow_ground_rules = allow_ground_rules self.interp = None def reason(self, tmax, convergence_threshold, convergence_bound_threshold, verbose=True): @@ -36,9 +36,9 @@ def reason(self, tmax, convergence_threshold, convergence_bound_threshold, verbo # Instantiate correct interpretation class based on whether we parallelize the code or not. (We cannot parallelize with cache on) if self._parallel_computing: - self.interp = InterpretationParallel(self._graph, self._ipl, self._annotation_functions, self._reverse_graph, self._atom_trace, self._save_graph_attributes_to_rule_trace, self._canonical, self._inconsistency_check, self._store_interpretation_changes, self._update_mode, self._allow_ground_atoms) + self.interp = InterpretationParallel(self._graph, self._ipl, self._annotation_functions, self._reverse_graph, self._atom_trace, self._save_graph_attributes_to_rule_trace, self._canonical, self._inconsistency_check, self._store_interpretation_changes, self._update_mode, self._allow_ground_rules) else: - self.interp = Interpretation(self._graph, self._ipl, self._annotation_functions, self._reverse_graph, self._atom_trace, self._save_graph_attributes_to_rule_trace, self._canonical, self._inconsistency_check, self._store_interpretation_changes, self._update_mode, self._allow_ground_atoms) + self.interp = Interpretation(self._graph, self._ipl, self._annotation_functions, self._reverse_graph, self._atom_trace, self._save_graph_attributes_to_rule_trace, self._canonical, self._inconsistency_check, self._store_interpretation_changes, self._update_mode, self._allow_ground_rules) self.interp.start_fp(self._tmax, self._facts_node, self._facts_edge, self._rules, verbose, convergence_threshold, convergence_bound_threshold) return self.interp From b71ee9ee8d6a58d7e9c0dcecd97c52820b484222 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Tue, 6 Aug 2024 15:06:38 +0200 Subject: [PATCH 34/73] bug in fact parser with negations --- pyreason/scripts/utils/fact_parser.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyreason/scripts/utils/fact_parser.py b/pyreason/scripts/utils/fact_parser.py index afbc354..6b3c922 100644 --- a/pyreason/scripts/utils/fact_parser.py +++ b/pyreason/scripts/utils/fact_parser.py @@ -11,6 +11,7 @@ def parse_fact(fact_text): pred_comp = f if pred_comp[0] == '~': bound = 'False' + pred_comp = pred_comp[1:] else: bound = 'True' From d972891e242cb9569d977bfed4a135c5de02a16d Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Wed, 7 Aug 2024 22:39:30 +0200 Subject: [PATCH 35/73] added `forall()` keyword in pyreason rules --- pyreason/scripts/rules/rule.py | 2 ++ pyreason/scripts/utils/rule_parser.py | 34 ++++++++++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/pyreason/scripts/rules/rule.py b/pyreason/scripts/rules/rule.py index 36e9b0b..0583369 100755 --- a/pyreason/scripts/rules/rule.py +++ b/pyreason/scripts/rules/rule.py @@ -16,6 +16,8 @@ def __init__(self, rule_text: str, name: str = None, infer_edges: bool = False, :param infer_edges: Whether to infer new edges after edge rule fires :param set_static: Whether to set the atom in the head as static if the rule fires. The bounds will no longer change :param immediate_rule: Whether the rule is immediate. Immediate rules check for more applicable rules immediately after being applied + :param custom_thresholds: A list of custom thresholds for the rule. If not specified, the default thresholds for ANY are used. It can be a list of + size #of clauses or a map of clause index to threshold """ if custom_thresholds is None: custom_thresholds = [] diff --git a/pyreason/scripts/utils/rule_parser.py b/pyreason/scripts/utils/rule_parser.py index 132f856..25194eb 100644 --- a/pyreason/scripts/utils/rule_parser.py +++ b/pyreason/scripts/utils/rule_parser.py @@ -1,12 +1,14 @@ import numba import numpy as np +from typing import Union import pyreason.scripts.numba_wrapper.numba_types.rule_type as rule import pyreason.scripts.numba_wrapper.numba_types.label_type as label import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval +from pyreason.scripts.threshold.threshold import Threshold -def parse_rule(rule_text: str, name: str, custom_thresholds: list, infer_edges: bool = False, set_static: bool = False, immediate_rule: bool = False) -> rule.Rule: +def parse_rule(rule_text: str, name: str, custom_thresholds: Union[list, dict], infer_edges: bool = False, set_static: bool = False, immediate_rule: bool = False) -> rule.Rule: # First remove all spaces from line r = rule_text.replace(' ', '') @@ -67,6 +69,13 @@ def parse_rule(rule_text: str, name: str, custom_thresholds: list, infer_edges: body_clauses.append(clause) body_bounds.append(bound) + for i, b in enumerate(body_clauses.copy()): + if 'forall(' in b: + if not custom_thresholds: + custom_thresholds = {} + custom_thresholds[i] = Threshold("greater_equal", ("percent", "total"), 100) + body_clauses[i] = b[:-1].replace('forall(', '') + # 7 for i in range(len(body_bounds)): bound = body_bounds[i] @@ -141,18 +150,25 @@ def parse_rule(rule_text: str, name: str, custom_thresholds: list, infer_edges: # gather count of clauses for threshold validation num_clauses = len(body_clauses) - if custom_thresholds and (len(custom_thresholds) != num_clauses): - raise Exception('The length of custom thresholds {} is not equal to number of clauses {}' - .format(len(custom_thresholds), num_clauses)) - + if isinstance(custom_thresholds, list): + if len(custom_thresholds) != num_clauses: + raise Exception(f'The length of custom thresholds {len(custom_thresholds)} is not equal to number of clauses {num_clauses}') + for threshold in custom_thresholds: + thresholds.append(threshold.to_tuple()) + elif isinstance(custom_thresholds, dict): + if max(custom_thresholds.keys()) >= num_clauses: + raise Exception(f'The max clause index in the custom thresholds map {max(custom_thresholds.keys())} is greater than number of clauses {num_clauses}') + for i in range(num_clauses): + if i in custom_thresholds: + thresholds.append(custom_thresholds[i].to_tuple()) + else: + thresholds.append(('greater_equal', ('number', 'total'), 1.0)) + # If no custom thresholds provided, use defaults # otherwise loop through user-defined thresholds and convert to numba compatible format - if not custom_thresholds: + elif not custom_thresholds: for _ in range(num_clauses): thresholds.append(('greater_equal', ('number', 'total'), 1.0)) - else: - for threshold in custom_thresholds: - thresholds.append(threshold.to_tuple()) # # Loop though clauses for body_clause, predicate, variables, bounds in zip(body_clauses, body_predicates, body_variables, body_bounds): From d5c549d61cc00c5ee663cfc38227aa21727e6f17 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Wed, 7 Aug 2024 22:46:50 +0200 Subject: [PATCH 36/73] bug in rule parser --- pyreason/scripts/rules/rule.py | 2 -- pyreason/scripts/utils/rule_parser.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/pyreason/scripts/rules/rule.py b/pyreason/scripts/rules/rule.py index 0583369..0ac1749 100755 --- a/pyreason/scripts/rules/rule.py +++ b/pyreason/scripts/rules/rule.py @@ -19,6 +19,4 @@ def __init__(self, rule_text: str, name: str = None, infer_edges: bool = False, :param custom_thresholds: A list of custom thresholds for the rule. If not specified, the default thresholds for ANY are used. It can be a list of size #of clauses or a map of clause index to threshold """ - if custom_thresholds is None: - custom_thresholds = [] self.rule = rule_parser.parse_rule(rule_text, name, custom_thresholds, infer_edges, set_static, immediate_rule) diff --git a/pyreason/scripts/utils/rule_parser.py b/pyreason/scripts/utils/rule_parser.py index 25194eb..4394bf5 100644 --- a/pyreason/scripts/utils/rule_parser.py +++ b/pyreason/scripts/utils/rule_parser.py @@ -8,7 +8,7 @@ from pyreason.scripts.threshold.threshold import Threshold -def parse_rule(rule_text: str, name: str, custom_thresholds: Union[list, dict], infer_edges: bool = False, set_static: bool = False, immediate_rule: bool = False) -> rule.Rule: +def parse_rule(rule_text: str, name: str, custom_thresholds: Union[None, list, dict], infer_edges: bool = False, set_static: bool = False, immediate_rule: bool = False) -> rule.Rule: # First remove all spaces from line r = rule_text.replace(' ', '') From fec8f92bae95cb791869d947c69eed9a274341dd Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Wed, 7 Aug 2024 23:29:02 +0200 Subject: [PATCH 37/73] bug in thresholds where we should only check thresholds at the end unless it's the default "there exists" threshold --- pyreason/scripts/interpretation/interpretation.py | 10 ++++++++-- pyreason/scripts/utils/rule_parser.py | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 58f1ce2..0d7a7e2 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -792,7 +792,10 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[1] in qualified_groundings]) # Check satisfaction of those nodes wrt the threshold - satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction + # Only check satisfaction if the default threshold is used. This saves us from grounding the rest of the rule + # It doesn't make sense to check any other thresholds because the head could be grounded with multiple nodes/edges + if thresholds[i][1][0] == 'number' and thresholds[i][1][1] == 'total' and thresholds[i][2] == 1.0: + satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction # This is an edge clause elif clause_type == 'edge': @@ -809,7 +812,10 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, qualified_groundings = get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, clause_bnd) # Check satisfaction of those edges wrt the threshold - satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction + # Only check satisfaction if the default threshold is used. This saves us from grounding the rest of the rule + # It doesn't make sense to check any other thresholds because the head could be grounded with multiple nodes/edges + if thresholds[i][1][0] == 'number' and thresholds[i][1][1] == 'total' and thresholds[i][2] == 1.0: + satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction # Update the groundings groundings[clause_var_1] = numba.typed.List.empty_list(node_type) diff --git a/pyreason/scripts/utils/rule_parser.py b/pyreason/scripts/utils/rule_parser.py index 4394bf5..e996426 100644 --- a/pyreason/scripts/utils/rule_parser.py +++ b/pyreason/scripts/utils/rule_parser.py @@ -69,6 +69,7 @@ def parse_rule(rule_text: str, name: str, custom_thresholds: Union[None, list, d body_clauses.append(clause) body_bounds.append(bound) + # Check if there are custom thresholds for the rule such as forall in string form for i, b in enumerate(body_clauses.copy()): if 'forall(' in b: if not custom_thresholds: From 90343a8102398a91551a06b9480464a62a438cc0 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Thu, 8 Aug 2024 23:14:45 +0200 Subject: [PATCH 38/73] not ready for thresholds yet. doesn't work --- pyreason/scripts/interpretation/interpretation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 0d7a7e2..4ea97af 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -794,8 +794,8 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Check satisfaction of those nodes wrt the threshold # Only check satisfaction if the default threshold is used. This saves us from grounding the rest of the rule # It doesn't make sense to check any other thresholds because the head could be grounded with multiple nodes/edges - if thresholds[i][1][0] == 'number' and thresholds[i][1][1] == 'total' and thresholds[i][2] == 1.0: - satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction + # if thresholds[i][1][0] == 'number' and thresholds[i][1][1] == 'total' and thresholds[i][2] == 1.0: + satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction # This is an edge clause elif clause_type == 'edge': @@ -814,8 +814,8 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Check satisfaction of those edges wrt the threshold # Only check satisfaction if the default threshold is used. This saves us from grounding the rest of the rule # It doesn't make sense to check any other thresholds because the head could be grounded with multiple nodes/edges - if thresholds[i][1][0] == 'number' and thresholds[i][1][1] == 'total' and thresholds[i][2] == 1.0: - satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction + # if thresholds[i][1][0] == 'number' and thresholds[i][1][1] == 'total' and thresholds[i][2] == 1.0: + satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction # Update the groundings groundings[clause_var_1] = numba.typed.List.empty_list(node_type) From 15c460d72d9c846dceb453bd45c6c8cee7b5be30 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Thu, 8 Aug 2024 23:48:14 +0200 Subject: [PATCH 39/73] fixed ipl --- pyreason/pyreason.py | 12 ++++++++++++ .../scripts/interpretation/interpretation.py | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/pyreason/pyreason.py b/pyreason/pyreason.py index 22ef016..db5303c 100755 --- a/pyreason/pyreason.py +++ b/pyreason/pyreason.py @@ -489,6 +489,18 @@ def load_inconsistent_predicate_list(path: str) -> None: __ipl = yaml_parser.parse_ipl(path) +def add_inconsistent_predicate(pred1: str, pred2: str) -> None: + """Add an inconsistent predicate pair to the IPL + + :param pred1: First predicate in the inconsistent pair + :param pred2: Second predicate in the inconsistent pair + """ + global __ipl + if __ipl is None: + __ipl = numba.typed.List.empty_list(numba.types.Tuple((label.label_type, label.label_type))) + __ipl.append((label.Label(pred1), label.Label(pred2))) + + def add_rule(pr_rule: Rule) -> None: """Add a rule to pyreason from text format. This format is not as modular as the YAML format. """ diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 4ea97af..c898854 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -2409,7 +2409,9 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat if updated: ip_update_cnt = 0 for p1, p2 in ipl: - if p1==l: + if p1 == l: + if p2 not in world.world: + world.world[p2] = interval.closed(0, 1) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p2], f'IPL: {l.get_value()}') lower = max(world.world[p2].lower, 1 - world.world[p1].upper) @@ -2420,7 +2422,9 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat updated_bnds.append(world.world[p2]) if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, p2, interval.closed(lower, upper))) - if p2==l: + if p2 == l: + if p1 not in world.world: + world.world[p1] = interval.closed(0, 1) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p1], f'IPL: {l.get_value()}') lower = max(world.world[p1].lower, 1 - world.world[p2].upper) @@ -2498,7 +2502,9 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat if updated: ip_update_cnt = 0 for p1, p2 in ipl: - if p1==l: + if p1 == l: + if p2 not in world.world: + world.world[p2] = interval.closed(0, 1) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p2], f'IPL: {l.get_value()}') lower = max(world.world[p2].lower, 1 - world.world[p1].upper) @@ -2509,7 +2515,9 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat updated_bnds.append(world.world[p2]) if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, p2, interval.closed(lower, upper))) - if p2==l: + if p2 == l: + if p1 not in world.world: + world.world[p1] = interval.closed(0, 1) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p1], f'IPL: {l.get_value()}') lower = max(world.world[p1].lower, 1 - world.world[p2].upper) From 9ce7aefde8cfe9e1165402a2b81c983bb70b4a13 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Fri, 23 Aug 2024 22:22:35 +0200 Subject: [PATCH 40/73] bug with annotation function --- pyreason/scripts/interpretation/interpretation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index c898854..cc56acb 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -909,7 +909,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, else: for qn in groundings[clause_var_1]: a.append(interpretations_node[qn].world[clause_label]) - annotations.append(a) + annotations.append(a) elif clause_type == 'edge': clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] @@ -1067,7 +1067,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, else: for qn in temp_groundings[clause_var_1]: a.append(interpretations_node[qn].world[clause_label]) - annotations.append(a) + annotations.append(a) elif clause_type == 'edge': clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] From 36da3aec89800c43cd03806d733a6a332820db41 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Fri, 23 Aug 2024 22:32:24 +0200 Subject: [PATCH 41/73] added str_to_float in query --- pyreason/scripts/interpretation/interpretation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index cc56acb..a073471 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -679,7 +679,7 @@ def query(self, query, return_bool=True): if ':' in query: pred_comp, bounds = query.split(':') l, u = bounds.split(',') - l, u = float(l), float(u) + l, u = str_to_float(l), str_to_float(u) else: if query[0] == '~': pred_comp = query[1:] From 27e440bb094306c263a334f055a2fba1303b4f12 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Fri, 23 Aug 2024 23:10:01 +0200 Subject: [PATCH 42/73] bug in query --- pyreason/scripts/interpretation/interpretation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index a073471..4d3c2b8 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -678,6 +678,7 @@ def query(self, query, return_bool=True): if ':' in query: pred_comp, bounds = query.split(':') + bounds.replace('[', '').replace(']', '') l, u = bounds.split(',') l, u = str_to_float(l), str_to_float(u) else: From 8aaee9f8ab07f21cc73c3e83f4ca2ad001613454 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Fri, 23 Aug 2024 23:21:12 +0200 Subject: [PATCH 43/73] new test case for annotation functions --- tests/test_annotation_function.py | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_annotation_function.py diff --git a/tests/test_annotation_function.py b/tests/test_annotation_function.py new file mode 100644 index 0000000..22deb10 --- /dev/null +++ b/tests/test_annotation_function.py @@ -0,0 +1,36 @@ +# Test if annotation functions work +import pyreason as pr +import numba +import numpy as np + + +@numba.njit +def probability_func(annotations, weights): + prob_A = annotations[0][0].lower + prob_B = annotations[1][0].lower + union_prob = prob_A + prob_B + union_prob = np.round(union_prob, 3) + return union_prob, 1 + + +def test_annotation_function(): + # Reset PyReason + pr.reset() + pr.reset_rules() + + pr.settings.allow_ground_rules = True + + pr.add_fact(pr.Fact('P(A) : [0.01, 1]')) + pr.add_fact(pr.Fact('P(B) : [0.2, 1]')) + pr.add_annotation_function(probability_func) + pr.add_rule(pr.Rule('union_probability(A, B):probability_func <- P(A):[0, 1], P(B):[0, 1]', infer_edges=True)) + + interpretation = pr.reason(timesteps=1) + + dataframes = pr.filter_and_sort_edges(interpretation, ['union_probability']) + for t, df in enumerate(dataframes): + print(f'TIMESTEP - {t}') + print(df) + print() + + assert interpretation.query('union_probability(A, B) : [0.21, 1]'), 'Union probability should be 0.21' From 9128bd9537bbcfd01c989881954727090c369f91 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Fri, 23 Aug 2024 23:37:03 +0200 Subject: [PATCH 44/73] new test case for annotation functions --- pyreason/scripts/interpretation/interpretation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 4d3c2b8..d6b26cc 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -678,9 +678,9 @@ def query(self, query, return_bool=True): if ':' in query: pred_comp, bounds = query.split(':') - bounds.replace('[', '').replace(']', '') + bounds = bounds.replace('[', '').replace(']', '') l, u = bounds.split(',') - l, u = str_to_float(l), str_to_float(u) + l, u = float(l), float(u) else: if query[0] == '~': pred_comp = query[1:] From e041a3d1980d1d95d69a42d3d5c3c5cee808f577 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sat, 24 Aug 2024 12:45:35 +0200 Subject: [PATCH 45/73] new clause reordering functionality, and added test case --- pyreason/pyreason.py | 64 +++++++++++++++++-- .../numba_wrapper/numba_types/rule_type.py | 7 ++ pyreason/scripts/rules/rule_internal.py | 5 +- pyreason/scripts/utils/output.py | 22 ++++++- pyreason/scripts/utils/reorder_clauses.py | 30 +++++++++ pyreason/scripts/utils/rule_parser.py | 1 + tests/test_hello_world.py | 9 ++- tests/test_reorder_clauses.py | 55 ++++++++++++++++ 8 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 pyreason/scripts/utils/reorder_clauses.py create mode 100644 tests/test_reorder_clauses.py diff --git a/pyreason/pyreason.py b/pyreason/pyreason.py index db5303c..de852c2 100755 --- a/pyreason/pyreason.py +++ b/pyreason/pyreason.py @@ -22,11 +22,32 @@ import pyreason.scripts.numba_wrapper.numba_types.fact_node_type as fact_node import pyreason.scripts.numba_wrapper.numba_types.fact_edge_type as fact_edge import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval +from pyreason.scripts.utils.reorder_clauses import reorder_clauses # USER VARIABLES class _Settings: def __init__(self): + self.__verbose = None + self.__output_to_file = None + self.__output_file_name = None + self.__graph_attribute_parsing = None + self.__abort_on_inconsistency = None + self.__memory_profile = None + self.__reverse_digraph = None + self.__atom_trace = None + self.__save_graph_attributes_to_trace = None + self.__canonical = None + self.__inconsistency_check = None + self.__static_graph_facts = None + self.__store_interpretation_changes = None + self.__parallel_computing = None + self.__update_mode = None + self.__allow_ground_rules = None + self.__optimize_rules = None + self.reset() + + def reset(self): self.__verbose = True self.__output_to_file = False self.__output_file_name = 'pyreason_output' @@ -43,6 +64,7 @@ def __init__(self): self.__parallel_computing = False self.__update_mode = 'intersection' self.__allow_ground_rules = False + self.__optimize_rules = True @property def verbose(self) -> bool: @@ -177,6 +199,14 @@ def allow_ground_rules(self) -> bool: """ return self.__allow_ground_rules + @property + def optimize_rules(self) -> bool: + """Returns whether rules will be optimized by moving clauses around. Default is True + + :return: bool + """ + return self.__optimize_rules + @verbose.setter def verbose(self, value: bool) -> None: """Set verbose mode. Default is True @@ -376,10 +406,23 @@ def allow_ground_rules(self, value: bool) -> None: else: self.__allow_ground_rules = value + @optimize_rules.setter + def optimize_rules(self, value: bool) -> None: + """Whether to optimize rules by moving clauses around. Default is True + + :param value: Whether to optimize rules or not + :raises TypeError: If not bool raise error + """ + if not isinstance(value, bool): + raise TypeError('value has to be a bool') + else: + self.__optimize_rules = value + # VARIABLES __graph = None __rules = None +__clause_maps = None __node_facts = None __edge_facts = None __ipl = None @@ -434,7 +477,7 @@ def reset_settings(): Resets settings to default """ global settings - settings = _Settings() + settings.reset() # FUNCTIONS @@ -613,7 +656,7 @@ def reason(timesteps: int=-1, convergence_threshold: int=-1, convergence_bound_t def _reason(timesteps, convergence_threshold, convergence_bound_threshold): # Globals - global __graph, __rules, __node_facts, __edge_facts, __ipl, __node_labels, __edge_labels, __specific_node_labels, __specific_edge_labels, __graphml_parser + global __graph, __rules, __clause_maps, __node_facts, __edge_facts, __ipl, __node_labels, __edge_labels, __specific_node_labels, __specific_edge_labels, __graphml_parser global settings, __timestamp, __program # Assert variables are of correct type @@ -671,6 +714,15 @@ def _reason(timesteps, convergence_threshold, convergence_bound_threshold): # Convert list of annotation functions into tuple to be numba compatible annotation_functions = tuple(__annotation_functions) + # Optimize rules by moving clauses around + __clause_maps = {r.get_rule_name(): {i: i for i in range(len(r.get_clauses()))} for r in __rules} + if settings.optimize_rules: + __rules_copy = __rules.copy() + __rules = numba.typed.List.empty_list(rule.rule_type) + for i, r in enumerate(__rules_copy): + r, __clause_maps[r.get_rule_name()] = reorder_clauses(r) + __rules.append(r) + # Setup logical program __program = Program(__graph, all_node_facts, all_edge_facts, __rules, __ipl, annotation_functions, settings.reverse_digraph, settings.atom_trace, settings.save_graph_attributes_to_trace, settings.canonical, settings.inconsistency_check, settings.store_interpretation_changes, settings.parallel_computing, settings.update_mode, settings.allow_ground_rules) __program.available_labels_node = __node_labels @@ -712,11 +764,11 @@ def save_rule_trace(interpretation, folder: str='./'): :param interpretation: the output of `pyreason.reason()`, the final interpretation :param folder: the folder in which to save the result, defaults to './' """ - global __timestamp, settings + global __timestamp, __clause_maps, settings assert settings.store_interpretation_changes, 'store interpretation changes setting is off, turn on to save rule trace' - output = Output(__timestamp) + output = Output(__timestamp, __clause_maps) output.save_rule_trace(interpretation, folder) @@ -728,11 +780,11 @@ def get_rule_trace(interpretation) -> Tuple[pd.DataFrame, pd.DataFrame]: :param interpretation: the output of `pyreason.reason()`, the final interpretation :returns two pandas dataframes (nodes, edges) representing the changes that occurred during reasoning """ - global __timestamp, settings + global __timestamp, __clause_maps, settings assert settings.store_interpretation_changes, 'store interpretation changes setting is off, turn on to save rule trace' - output = Output(__timestamp) + output = Output(__timestamp, __clause_maps) return output.get_rule_trace(interpretation) diff --git a/pyreason/scripts/numba_wrapper/numba_types/rule_type.py b/pyreason/scripts/numba_wrapper/numba_types/rule_type.py index e4247ca..766114d 100755 --- a/pyreason/scripts/numba_wrapper/numba_types/rule_type.py +++ b/pyreason/scripts/numba_wrapper/numba_types/rule_type.py @@ -144,6 +144,13 @@ def getter(rule): return getter +@overload_method(RuleType, "set_clauses") +def set_clauses(rule): + def setter(rule, clauses): + rule.clauses = clauses + return setter + + @overload_method(RuleType, "get_bnd") def get_bnd(rule): def impl(rule): diff --git a/pyreason/scripts/rules/rule_internal.py b/pyreason/scripts/rules/rule_internal.py index 7f1976e..78cdce1 100755 --- a/pyreason/scripts/rules/rule_internal.py +++ b/pyreason/scripts/rules/rule_internal.py @@ -33,8 +33,11 @@ def get_head_variables(self): def get_delta(self): return self._delta - def get_neigh_criteria(self): + def get_clauses(self): return self._clauses + + def set_clauses(self, clauses): + self._clauses = clauses def get_bnd(self): return self._bnd diff --git a/pyreason/scripts/utils/output.py b/pyreason/scripts/utils/output.py index e0d12f9..e680083 100755 --- a/pyreason/scripts/utils/output.py +++ b/pyreason/scripts/utils/output.py @@ -4,8 +4,9 @@ class Output: - def __init__(self, timestamp): + def __init__(self, timestamp, clause_map=None): self.timestamp = timestamp + self.clause_map = clause_map self.rule_trace_node = None self.rule_trace_edge = None @@ -80,6 +81,14 @@ def _parse_internal_rule_trace(self, interpretation): # Store the trace in a DataFrame self.rule_trace_edge = pd.DataFrame(data, columns=header_edge) + # Now do the reordering + if self.clause_map is not None: + offset = 7 + columns_to_reorder_node = header_node[offset:] + columns_to_reorder_edge = header_edge[offset:] + self.rule_trace_node = self.rule_trace_node.apply(self._reorder_row, axis=1, map_dict=self.clause_map, columns_to_reorder=columns_to_reorder_node) + self.rule_trace_edge = self.rule_trace_edge.apply(self._reorder_row, axis=1, map_dict=self.clause_map, columns_to_reorder=columns_to_reorder_edge) + def save_rule_trace(self, interpretation, folder='./'): if self.rule_trace_node is None and self.rule_trace_edge is None: self._parse_internal_rule_trace(interpretation) @@ -94,3 +103,14 @@ def get_rule_trace(self, interpretation): self._parse_internal_rule_trace(interpretation) return self.rule_trace_node, self.rule_trace_edge + + @staticmethod + def _reorder_row(row, map_dict, columns_to_reorder): + if row['Occurred Due To'] in map_dict: + original_values = row[columns_to_reorder].values + new_values = [None] * len(columns_to_reorder) + for orig_pos, target_pos in map_dict[row['Occurred Due To']].items(): + new_values[target_pos] = original_values[orig_pos] + for i, col in enumerate(columns_to_reorder): + row[col] = new_values[i] + return row diff --git a/pyreason/scripts/utils/reorder_clauses.py b/pyreason/scripts/utils/reorder_clauses.py new file mode 100644 index 0000000..689cef2 --- /dev/null +++ b/pyreason/scripts/utils/reorder_clauses.py @@ -0,0 +1,30 @@ +import numba +import pyreason.scripts.numba_wrapper.numba_types.label_type as label +import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval + + +def reorder_clauses(rule): + # Go through all clauses in the rule and re-order them if necessary + # It is faster for grounding to have node clauses first and then edge clauses + # Move all the node clauses to the front of the list + reordered_clauses = numba.typed.List.empty_list(numba.types.Tuple((numba.types.string, label.label_type, numba.types.ListType(numba.types.string), interval.interval_type, numba.types.string))) + node_clauses = [] + edge_clauses = [] + reordered_clauses_map = {} + + for index, clause in enumerate(rule.get_clauses()): + print(clause) + if clause[0] == 'node': + node_clauses.append((index, clause)) + else: + edge_clauses.append((index, clause)) + print('ordered clauses') + for new_index, (original_index, clause) in enumerate(node_clauses + edge_clauses): + print(clause) + reordered_clauses.append(clause) + reordered_clauses_map[new_index] = original_index + + print() + + rule.set_clauses(reordered_clauses) + return rule, reordered_clauses_map diff --git a/pyreason/scripts/utils/rule_parser.py b/pyreason/scripts/utils/rule_parser.py index e996426..1741911 100644 --- a/pyreason/scripts/utils/rule_parser.py +++ b/pyreason/scripts/utils/rule_parser.py @@ -3,6 +3,7 @@ from typing import Union import pyreason.scripts.numba_wrapper.numba_types.rule_type as rule +# import pyreason.scripts.rules.rule_internal as rule import pyreason.scripts.numba_wrapper.numba_types.label_type as label import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval from pyreason.scripts.threshold.threshold import Threshold diff --git a/tests/test_hello_world.py b/tests/test_hello_world.py index f103d70..76bee93 100644 --- a/tests/test_hello_world.py +++ b/tests/test_hello_world.py @@ -1,18 +1,20 @@ # Test if the simple hello world program works import pyreason as pr +import faulthandler def test_hello_world(): # Reset PyReason pr.reset() pr.reset_rules() + pr.reset_settings() # Modify the paths based on where you've stored the files we made above - graph_path = './tests/friends_graph.graphml' + graph_path = './friends_graph.graphml' # Modify pyreason settings to make verbose - pr.reset_settings() pr.settings.verbose = True # Print info to screen + # pr.settings.optimize_rules = False # Disable rule optimization for debugging # Load all the files into pyreason pr.load_graphml(graph_path) @@ -20,6 +22,7 @@ def test_hello_world(): pr.add_fact(pr.Fact('popular(Mary)', 'popular_fact', 0, 2)) # Run the program for two timesteps to see the diffusion take place + faulthandler.enable() interpretation = pr.reason(timesteps=2) # Display the changes in the interpretation for each timestep @@ -44,3 +47,5 @@ def test_hello_world(): # John should be popular in timestep 3 assert 'John' in dataframes[2]['component'].values and dataframes[2].iloc[1].popular == [1, 1], 'John should have popular bounds [1,1] for t=2 timesteps' + +test_hello_world() \ No newline at end of file diff --git a/tests/test_reorder_clauses.py b/tests/test_reorder_clauses.py new file mode 100644 index 0000000..339c309 --- /dev/null +++ b/tests/test_reorder_clauses.py @@ -0,0 +1,55 @@ +# Test if the simple hello world program works +import pyreason as pr +import faulthandler + + +def test_reorder_clauses(): + # Reset PyReason + pr.reset() + pr.reset_rules() + pr.reset_settings() + + # Modify the paths based on where you've stored the files we made above + graph_path = './tests/friends_graph.graphml' + + # Modify pyreason settings to make verbose + pr.settings.verbose = True # Print info to screen + pr.settings.atom_trace = True # Print atom trace + pr.settings.optimize_rules = True # Disable rule optimization for debugging + + # Load all the files into pyreason + pr.load_graphml(graph_path) + pr.add_rule(pr.Rule('popular(x) <-1 Friends(x,y), popular(y), owns(y,z), owns(x,z)', 'popular_rule')) + pr.add_fact(pr.Fact('popular(Mary)', 'popular_fact', 0, 2)) + + # Run the program for two timesteps to see the diffusion take place + faulthandler.enable() + interpretation = pr.reason(timesteps=2) + + # Display the changes in the interpretation for each timestep + dataframes = pr.filter_and_sort_nodes(interpretation, ['popular']) + for t, df in enumerate(dataframes): + print(f'TIMESTEP - {t}') + print(df) + print() + + assert len(dataframes[0]) == 1, 'At t=0 there should be one popular person' + assert len(dataframes[1]) == 2, 'At t=1 there should be two popular people' + assert len(dataframes[2]) == 3, 'At t=2 there should be three popular people' + + # Mary should be popular in all three timesteps + assert 'Mary' in dataframes[0]['component'].values and dataframes[0].iloc[0].popular == [1, 1], 'Mary should have popular bounds [1,1] for t=0 timesteps' + assert 'Mary' in dataframes[1]['component'].values and dataframes[1].iloc[0].popular == [1, 1], 'Mary should have popular bounds [1,1] for t=1 timesteps' + assert 'Mary' in dataframes[2]['component'].values and dataframes[2].iloc[0].popular == [1, 1], 'Mary should have popular bounds [1,1] for t=2 timesteps' + + # Justin should be popular in timesteps 1, 2 + assert 'Justin' in dataframes[1]['component'].values and dataframes[1].iloc[1].popular == [1, 1], 'Justin should have popular bounds [1,1] for t=1 timesteps' + assert 'Justin' in dataframes[2]['component'].values and dataframes[2].iloc[2].popular == [1, 1], 'Justin should have popular bounds [1,1] for t=2 timesteps' + + # John should be popular in timestep 3 + assert 'John' in dataframes[2]['component'].values and dataframes[2].iloc[1].popular == [1, 1], 'John should have popular bounds [1,1] for t=2 timesteps' + + # Now look at the trace and make sure the order has gone back to the original rule + # The second row, clause 1 should be the edge grounding ('Justin', 'Mary') + rule_trace_node, _ = pr.get_rule_trace(interpretation) + assert rule_trace_node.iloc[2]['Clause-1'][0] == ('Justin', 'Mary') From 5dc4d2dd4e6e9e38099501f7ab9bba25f6b194a4 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sat, 24 Aug 2024 12:46:16 +0200 Subject: [PATCH 46/73] removed print statements --- pyreason/scripts/utils/reorder_clauses.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pyreason/scripts/utils/reorder_clauses.py b/pyreason/scripts/utils/reorder_clauses.py index 689cef2..5982cc5 100644 --- a/pyreason/scripts/utils/reorder_clauses.py +++ b/pyreason/scripts/utils/reorder_clauses.py @@ -13,18 +13,13 @@ def reorder_clauses(rule): reordered_clauses_map = {} for index, clause in enumerate(rule.get_clauses()): - print(clause) if clause[0] == 'node': node_clauses.append((index, clause)) else: edge_clauses.append((index, clause)) - print('ordered clauses') for new_index, (original_index, clause) in enumerate(node_clauses + edge_clauses): - print(clause) reordered_clauses.append(clause) reordered_clauses_map[new_index] = original_index - print() - rule.set_clauses(reordered_clauses) return rule, reordered_clauses_map From 7164af3e097a94d873fa88367eaa0a6386ad7b24 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sat, 24 Aug 2024 12:48:03 +0200 Subject: [PATCH 47/73] removed fault handler --- tests/test_reorder_clauses.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_reorder_clauses.py b/tests/test_reorder_clauses.py index 339c309..de13a07 100644 --- a/tests/test_reorder_clauses.py +++ b/tests/test_reorder_clauses.py @@ -1,6 +1,5 @@ # Test if the simple hello world program works import pyreason as pr -import faulthandler def test_reorder_clauses(): @@ -23,7 +22,6 @@ def test_reorder_clauses(): pr.add_fact(pr.Fact('popular(Mary)', 'popular_fact', 0, 2)) # Run the program for two timesteps to see the diffusion take place - faulthandler.enable() interpretation = pr.reason(timesteps=2) # Display the changes in the interpretation for each timestep From 0167e2820f290cb41e59c78002849561618bbf8b Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sat, 24 Aug 2024 12:56:12 +0200 Subject: [PATCH 48/73] bug in test --- tests/test_hello_world.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_hello_world.py b/tests/test_hello_world.py index 76bee93..84e84d2 100644 --- a/tests/test_hello_world.py +++ b/tests/test_hello_world.py @@ -10,7 +10,7 @@ def test_hello_world(): pr.reset_settings() # Modify the paths based on where you've stored the files we made above - graph_path = './friends_graph.graphml' + graph_path = './tests/friends_graph.graphml' # Modify pyreason settings to make verbose pr.settings.verbose = True # Print info to screen @@ -47,5 +47,3 @@ def test_hello_world(): # John should be popular in timestep 3 assert 'John' in dataframes[2]['component'].values and dataframes[2].iloc[1].popular == [1, 1], 'John should have popular bounds [1,1] for t=2 timesteps' - -test_hello_world() \ No newline at end of file From 9b0658486e404f77209cfc21ae87b88df603463e Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sat, 24 Aug 2024 13:14:39 +0200 Subject: [PATCH 49/73] fixed bug where we don't reorder thresholds --- pyreason/scripts/rules/rule_internal.py | 5 ++++- pyreason/scripts/utils/reorder_clauses.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pyreason/scripts/rules/rule_internal.py b/pyreason/scripts/rules/rule_internal.py index 78cdce1..c5a6054 100755 --- a/pyreason/scripts/rules/rule_internal.py +++ b/pyreason/scripts/rules/rule_internal.py @@ -43,7 +43,10 @@ def get_bnd(self): return self._bnd def get_thresholds(self): - return self._thresholds + return self._thresholds + + def set_thresholds(self, thresholds): + self._thresholds = thresholds def get_annotation_function(self): return self._ann_fn diff --git a/pyreason/scripts/utils/reorder_clauses.py b/pyreason/scripts/utils/reorder_clauses.py index 5982cc5..5ce0434 100644 --- a/pyreason/scripts/utils/reorder_clauses.py +++ b/pyreason/scripts/utils/reorder_clauses.py @@ -8,6 +8,7 @@ def reorder_clauses(rule): # It is faster for grounding to have node clauses first and then edge clauses # Move all the node clauses to the front of the list reordered_clauses = numba.typed.List.empty_list(numba.types.Tuple((numba.types.string, label.label_type, numba.types.ListType(numba.types.string), interval.interval_type, numba.types.string))) + reordered_thresholds = numba.typed.List.empty_list(numba.types.Tuple((numba.types.string, numba.types.UniTuple(numba.types.string, 2), numba.types.float64))) node_clauses = [] edge_clauses = [] reordered_clauses_map = {} @@ -17,8 +18,11 @@ def reorder_clauses(rule): node_clauses.append((index, clause)) else: edge_clauses.append((index, clause)) + + thresholds = rule.get_thresholds() for new_index, (original_index, clause) in enumerate(node_clauses + edge_clauses): reordered_clauses.append(clause) + reordered_thresholds.append(thresholds[original_index]) reordered_clauses_map[new_index] = original_index rule.set_clauses(reordered_clauses) From b81e9f8fe374ac04c418d67c73b27e1650f5048a Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sat, 24 Aug 2024 13:21:09 +0200 Subject: [PATCH 50/73] fixed bug where we don't reorder thresholds --- pyreason/scripts/utils/reorder_clauses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyreason/scripts/utils/reorder_clauses.py b/pyreason/scripts/utils/reorder_clauses.py index 5ce0434..11408ff 100644 --- a/pyreason/scripts/utils/reorder_clauses.py +++ b/pyreason/scripts/utils/reorder_clauses.py @@ -26,4 +26,5 @@ def reorder_clauses(rule): reordered_clauses_map[new_index] = original_index rule.set_clauses(reordered_clauses) + rule.set_thresholds(reordered_thresholds) return rule, reordered_clauses_map From 540575aafaa0e9d90f2a021a269b657d38e500f5 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Thu, 29 Aug 2024 18:26:13 +0200 Subject: [PATCH 51/73] Update requirements.txt numpy version --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 655c2c3..b54fb59 100755 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,11 @@ networkx pyyaml pandas numba==0.59.1 -numpy +numpy==1.26.4 memory_profiler pytest sphinx_rtd_theme sphinx sphinx-autopackagesummary -sphinx-autoapi \ No newline at end of file +sphinx-autoapi From b0194b03ed6dbe7f307bb5fd3c07d55e9d2f86c3 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Thu, 29 Aug 2024 18:41:05 +0200 Subject: [PATCH 52/73] print statement added in __init__.py --- pyreason/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyreason/__init__.py b/pyreason/__init__.py index 0e137b8..6cb1410 100755 --- a/pyreason/__init__.py +++ b/pyreason/__init__.py @@ -14,7 +14,7 @@ cache_status = yaml.safe_load(file) if not cache_status['initialized']: - print('Imported PyReason for the first time. Initializing ... this will take a minute') + print('Imported PyReason for the first time. Initializing caches for faster runtimes ... this will take a minute') graph_path = os.path.join(package_path, 'examples', 'hello-world', 'friends_graph.graphml') settings.verbose = False @@ -25,6 +25,8 @@ reset() reset_rules() + print('PyReason initialized!') + print() # Update cache status cache_status['initialized'] = True From d3293053b1037aa1e6e64517dff41b2140fbce61 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Tue, 3 Sep 2024 18:26:34 +0200 Subject: [PATCH 53/73] added `__version__` variable --- pyreason/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyreason/__init__.py b/pyreason/__init__.py index 6cb1410..3d9f46e 100755 --- a/pyreason/__init__.py +++ b/pyreason/__init__.py @@ -8,6 +8,9 @@ from pyreason.pyreason import * import yaml +from importlib.metadata import version + +__version__ = version('pyreason') with open(cache_status_path) as file: From 0f7d315007cd3c7ab4a642934f91b3571f42cddd Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Tue, 3 Sep 2024 20:51:08 +0200 Subject: [PATCH 54/73] added sets to speed up search `in` --- .../scripts/interpretation/interpretation.py | 48 +++-- .../interpretation/interpretation_parallel.py | 181 ++++++++++++++---- 2 files changed, 179 insertions(+), 50 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index d6b26cc..bab03bd 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -222,7 +222,8 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data timestep_loop = True facts_to_be_applied_node_new = numba.typed.List.empty_list(facts_to_be_applied_node_type) facts_to_be_applied_edge_new = numba.typed.List.empty_list(facts_to_be_applied_edge_type) - rules_to_remove_idx = numba.typed.List.empty_list(numba.types.int64) + rules_to_remove_idx = set() + rules_to_remove_idx.add(-1) while timestep_loop: if t==tmax: timestep_loop = False @@ -253,12 +254,14 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Start by applying facts # Nodes facts_to_be_applied_node_new.clear() + nodes_set = set(nodes) for i in range(len(facts_to_be_applied_node)): if facts_to_be_applied_node[i][0] == t: comp, l, bnd, static, graph_attribute = facts_to_be_applied_node[i][1], facts_to_be_applied_node[i][2], facts_to_be_applied_node[i][3], facts_to_be_applied_node[i][4], facts_to_be_applied_node[i][5] # If the component is not in the graph, add it - if comp not in nodes: + if comp not in nodes_set: _add_node(comp, neighbors, reverse_neighbors, nodes, interpretations_node) + nodes_set.add(comp) # Check if bnd is static. Then no need to update, just add to rule trace, check if graph attribute and add ipl complement to rule trace as well if l in interpretations_node[comp].world and interpretations_node[comp].world[l].is_static(): @@ -319,12 +322,14 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Edges facts_to_be_applied_edge_new.clear() + edges_set = set(edges) for i in range(len(facts_to_be_applied_edge)): if facts_to_be_applied_edge[i][0]==t: comp, l, bnd, static, graph_attribute = facts_to_be_applied_edge[i][1], facts_to_be_applied_edge[i][2], facts_to_be_applied_edge[i][3], facts_to_be_applied_edge[i][4], facts_to_be_applied_edge[i][5] # If the component is not in the graph, add it - if comp not in edges: + if comp not in edges_set: _add_edge(comp[0], comp[1], neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + edges_set.add(comp) # Check if bnd is static. Then no need to update, just add to rule trace, check if graph attribute, and add ipl complement to rule trace as well if l in interpretations_edge[comp].world and interpretations_edge[comp].world[l].is_static(): @@ -418,7 +423,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data changes_cnt += changes # Delete rules that have been applied from list by adding index to list - rules_to_remove_idx.append(idx) + rules_to_remove_idx.add(idx) # Remove from rules to be applied and edges to be applied lists after coming out from loop rules_to_be_applied_node[:] = numba.typed.List([rules_to_be_applied_node[i] for i in range(len(rules_to_be_applied_node)) if i not in rules_to_remove_idx]) @@ -491,7 +496,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data changes_cnt += changes # Delete rules that have been applied from list by adding the index to list - rules_to_remove_idx.append(idx) + rules_to_remove_idx.add(idx) # Remove from rules to be applied and edges to be applied lists after coming out from loop rules_to_be_applied_edge[:] = numba.typed.List([rules_to_be_applied_edge[i] for i in range(len(rules_to_be_applied_edge)) if i not in rules_to_remove_idx]) @@ -763,6 +768,9 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, dependency_graph_neighbors = numba.typed.Dict.empty(key_type=node_type, value_type=list_of_nodes) dependency_graph_reverse_neighbors = numba.typed.Dict.empty(key_type=node_type, value_type=list_of_nodes) + nodes_set = set(nodes) + edges_set = set(edges) + satisfaction = True for i, clause in enumerate(clauses): # Unpack clause variables @@ -778,7 +786,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Get subset of nodes that can be used to ground the variable # If we allow ground atoms, we can use the nodes directly - if allow_ground_rules and clause_var_1 in nodes: + if allow_ground_rules and clause_var_1 in nodes_set: grounding = numba.typed.List([clause_var_1]) else: grounding = get_rule_node_clause_grounding(clause_var_1, groundings, nodes) @@ -786,11 +794,12 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Narrow subset based on predicate qualified_groundings = get_qualified_node_groundings(interpretations_node, grounding, clause_label, clause_bnd) groundings[clause_var_1] = qualified_groundings + qualified_groundings_set = set(qualified_groundings) for c1, c2 in groundings_edges: if c1 == clause_var_1: - groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[0] in qualified_groundings]) + groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[0] in qualified_groundings_set]) if c2 == clause_var_1: - groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[1] in qualified_groundings]) + groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[1] in qualified_groundings_set]) # Check satisfaction of those nodes wrt the threshold # Only check satisfaction if the default threshold is used. This saves us from grounding the rest of the rule @@ -804,7 +813,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Get subset of edges that can be used to ground the variables # If we allow ground atoms, we can use the nodes directly - if allow_ground_rules and (clause_var_1, clause_var_2) in edges: + if allow_ground_rules and (clause_var_1, clause_var_2) in edges_set: grounding = numba.typed.List([(clause_var_1, clause_var_2)]) else: grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes) @@ -821,11 +830,15 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Update the groundings groundings[clause_var_1] = numba.typed.List.empty_list(node_type) groundings[clause_var_2] = numba.typed.List.empty_list(node_type) + groundings_clause_1_set = set(groundings[clause_var_1]) + groundings_clause_2_set = set(groundings[clause_var_2]) for e in qualified_groundings: - if e[0] not in groundings[clause_var_1]: + if e[0] not in groundings_clause_1_set: groundings[clause_var_1].append(e[0]) - if e[1] not in groundings[clause_var_2]: + groundings_clause_1_set.add(e[0]) + if e[1] not in groundings_clause_2_set: groundings[clause_var_2].append(e[1]) + groundings_clause_2_set.add(e[1]) # Update the edge groundings (to use later for grounding other clauses with the same variables) groundings_edges[(clause_var_1, clause_var_2)] = qualified_groundings @@ -995,7 +1008,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if infer_edges: valid_edge_groundings.append((g1, g2)) else: - if (g1, g2) in edges: + if (g1, g2) in edges_set: valid_edge_groundings.append((g1, g2)) # Loop through the head variable groundings @@ -1686,9 +1699,11 @@ def refine_groundings(clause_variables, groundings, groundings_edges, dependency # Update the edge groundings and node groundings qualified_groundings = numba.typed.List([edge for edge in old_edge_groundings if edge[0] in new_node_groundings]) + groundings_neighbor_set = set(groundings[neighbor]) for e in qualified_groundings: - if e[1] not in groundings[neighbor]: + if e[1] not in groundings_neighbor_set: groundings[neighbor].append(e[1]) + groundings_neighbor_set.add(e[1]) groundings_edges[(refined_variable, neighbor)] = qualified_groundings # Add the neighbor to the list of refined variables so that we can refine for all its neighbors @@ -1706,9 +1721,11 @@ def refine_groundings(clause_variables, groundings, groundings_edges, dependency # Update the edge groundings and node groundings qualified_groundings = numba.typed.List([edge for edge in old_edge_groundings if edge[1] in new_node_groundings]) + groundings_reverse_neighbor_set = set(groundings[reverse_neighbor]) for e in qualified_groundings: - if e[0] not in groundings[reverse_neighbor]: + if e[0] not in groundings_reverse_neighbor_set: groundings[reverse_neighbor].append(e[0]) + groundings_reverse_neighbor_set.add(e[0]) groundings_edges[(reverse_neighbor, refined_variable)] = qualified_groundings # Add the neighbor to the list of refined variables so that we can refine for all its neighbors @@ -1988,8 +2005,9 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun edge_groundings = groundings_edges[(clause_var_1, clause_var_2)] # We have seen both these variables but not in an edge clause together else: + groundings_clause_var_2_set = set(groundings[clause_var_2]) for n in groundings[clause_var_1]: - es = numba.typed.List([(n, nn) for nn in neighbors[n] if nn in groundings[clause_var_2]]) + es = numba.typed.List([(n, nn) for nn in neighbors[n] if nn in groundings_clause_var_2_set]) edge_groundings.extend(es) return edge_groundings diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index dedce5d..303a905 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -55,7 +55,7 @@ class Interpretation: specific_node_labels = numba.typed.Dict.empty(key_type=label.label_type, value_type=numba.types.ListType(node_type)) specific_edge_labels = numba.typed.Dict.empty(key_type=label.label_type, value_type=numba.types.ListType(edge_type)) - def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_atoms): + def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_rules): self.graph = graph self.ipl = ipl self.annotation_functions = annotation_functions @@ -66,7 +66,7 @@ def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, self.inconsistency_check = inconsistency_check self.store_interpretation_changes = store_interpretation_changes self.update_mode = update_mode - self.allow_ground_atoms = allow_ground_atoms + self.allow_ground_rules = allow_ground_rules # For reasoning and reasoning again (contains previous time and previous fp operation cnt) self.time = 0 @@ -205,7 +205,7 @@ def _init_facts(facts_node, facts_edge, facts_to_be_applied_node, facts_to_be_ap return max_time def _start_fp(self, rules, max_facts_time, verbose, again): - fp_cnt, t = self.reason(self.interpretations_node, self.interpretations_edge, self.tmax, self.prev_reasoning_data, rules, self.nodes, self.edges, self.neighbors, self.reverse_neighbors, self.rules_to_be_applied_node, self.rules_to_be_applied_edge, self.edges_to_be_added_node_rule, self.edges_to_be_added_edge_rule, self.rules_to_be_applied_node_trace, self.rules_to_be_applied_edge_trace, self.facts_to_be_applied_node, self.facts_to_be_applied_edge, self.facts_to_be_applied_node_trace, self.facts_to_be_applied_edge_trace, self.ipl, self.rule_trace_node, self.rule_trace_edge, self.rule_trace_node_atoms, self.rule_trace_edge_atoms, self.reverse_graph, self.atom_trace, self.save_graph_attributes_to_rule_trace, self.canonical, self.inconsistency_check, self.store_interpretation_changes, self.update_mode, self.allow_ground_atoms, max_facts_time, self.annotation_functions, self._convergence_mode, self._convergence_delta, verbose, again) + fp_cnt, t = self.reason(self.interpretations_node, self.interpretations_edge, self.tmax, self.prev_reasoning_data, rules, self.nodes, self.edges, self.neighbors, self.reverse_neighbors, self.rules_to_be_applied_node, self.rules_to_be_applied_edge, self.edges_to_be_added_node_rule, self.edges_to_be_added_edge_rule, self.rules_to_be_applied_node_trace, self.rules_to_be_applied_edge_trace, self.facts_to_be_applied_node, self.facts_to_be_applied_edge, self.facts_to_be_applied_node_trace, self.facts_to_be_applied_edge_trace, self.ipl, self.rule_trace_node, self.rule_trace_edge, self.rule_trace_node_atoms, self.rule_trace_edge_atoms, self.reverse_graph, self.atom_trace, self.save_graph_attributes_to_rule_trace, self.canonical, self.inconsistency_check, self.store_interpretation_changes, self.update_mode, self.allow_ground_rules, max_facts_time, self.annotation_functions, self._convergence_mode, self._convergence_delta, verbose, again) self.time = t - 1 # If we need to reason again, store the next timestep to start from self.prev_reasoning_data[0] = t @@ -215,14 +215,15 @@ def _start_fp(self, rules, max_facts_time, verbose, again): @staticmethod @numba.njit(cache=True, parallel=True) - def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_atoms, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): + def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_rules, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): t = prev_reasoning_data[0] fp_cnt = prev_reasoning_data[1] max_rules_time = 0 timestep_loop = True facts_to_be_applied_node_new = numba.typed.List.empty_list(facts_to_be_applied_node_type) facts_to_be_applied_edge_new = numba.typed.List.empty_list(facts_to_be_applied_edge_type) - rules_to_remove_idx = numba.typed.List.empty_list(numba.types.int64) + rules_to_remove_idx = set() + rules_to_remove_idx.add(-1) while timestep_loop: if t==tmax: timestep_loop = False @@ -253,12 +254,14 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Start by applying facts # Nodes facts_to_be_applied_node_new.clear() + nodes_set = set(nodes) for i in range(len(facts_to_be_applied_node)): if facts_to_be_applied_node[i][0] == t: comp, l, bnd, static, graph_attribute = facts_to_be_applied_node[i][1], facts_to_be_applied_node[i][2], facts_to_be_applied_node[i][3], facts_to_be_applied_node[i][4], facts_to_be_applied_node[i][5] # If the component is not in the graph, add it - if comp not in nodes: + if comp not in nodes_set: _add_node(comp, neighbors, reverse_neighbors, nodes, interpretations_node) + nodes_set.add(comp) # Check if bnd is static. Then no need to update, just add to rule trace, check if graph attribute and add ipl complement to rule trace as well if l in interpretations_node[comp].world and interpretations_node[comp].world[l].is_static(): @@ -319,12 +322,14 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Edges facts_to_be_applied_edge_new.clear() + edges_set = set(edges) for i in range(len(facts_to_be_applied_edge)): if facts_to_be_applied_edge[i][0]==t: comp, l, bnd, static, graph_attribute = facts_to_be_applied_edge[i][1], facts_to_be_applied_edge[i][2], facts_to_be_applied_edge[i][3], facts_to_be_applied_edge[i][4], facts_to_be_applied_edge[i][5] # If the component is not in the graph, add it - if comp not in edges: + if comp not in edges_set: _add_edge(comp[0], comp[1], neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + edges_set.add(comp) # Check if bnd is static. Then no need to update, just add to rule trace, check if graph attribute, and add ipl complement to rule trace as well if l in interpretations_edge[comp].world and interpretations_edge[comp].world[l].is_static(): @@ -418,7 +423,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data changes_cnt += changes # Delete rules that have been applied from list by adding index to list - rules_to_remove_idx.append(idx) + rules_to_remove_idx.add(idx) # Remove from rules to be applied and edges to be applied lists after coming out from loop rules_to_be_applied_node[:] = numba.typed.List([rules_to_be_applied_node[i] for i in range(len(rules_to_be_applied_node)) if i not in rules_to_remove_idx]) @@ -491,7 +496,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data changes_cnt += changes # Delete rules that have been applied from list by adding the index to list - rules_to_remove_idx.append(idx) + rules_to_remove_idx.add(idx) # Remove from rules to be applied and edges to be applied lists after coming out from loop rules_to_be_applied_edge[:] = numba.typed.List([rules_to_be_applied_edge[i] for i in range(len(rules_to_be_applied_edge)) if i not in rules_to_remove_idx]) @@ -520,7 +525,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Only go through if the rule can be applied within the given timesteps, or we're running until convergence delta_t = rule.get_delta() if t + delta_t <= tmax or tmax == -1 or again: - applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_atoms) + applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_rules) # Loop through applicable rules and add them to the rules to be applied for later or next fp operation for applicable_rule in applicable_node_rules: @@ -666,9 +671,73 @@ def get_dict(self): return interpretations + def query(self, query, return_bool=True): + """ + This function is used to query the graph after reasoning + :param query: The query string of for `pred(node)` or `pred(edge)` or `pred(node) : [l, u]` + :param return_bool: If True, returns boolean of query, else the bounds associated with it + :return: bool, or bounds + """ + # Parse the query + query = query.replace(' ', '') + + if ':' in query: + pred_comp, bounds = query.split(':') + bounds = bounds.replace('[', '').replace(']', '') + l, u = bounds.split(',') + l, u = float(l), float(u) + else: + if query[0] == '~': + pred_comp = query[1:] + l, u = 0, 0 + else: + pred_comp = query + l, u = 1, 1 + + bnd = interval.closed(l, u) + + # Split predicate and component + idx = pred_comp.find('(') + pred = label.Label(pred_comp[:idx]) + component = pred_comp[idx + 1:-1] + + if ',' in component: + component = tuple(component.split(',')) + comp_type = 'edge' + else: + comp_type = 'node' + + # Check if the component exists + if comp_type == 'node': + if component not in self.nodes: + return False if return_bool else (0, 0) + else: + if component not in self.edges: + return False if return_bool else (0, 0) + + # Check if the predicate exists + if comp_type == 'node': + if pred not in self.interpretations_node[component].world: + return False if return_bool else (0, 0) + else: + if pred not in self.interpretations_edge[component].world: + return False if return_bool else (0, 0) + + # Check if the bounds are satisfied + if comp_type == 'node': + if self.interpretations_node[component].world[pred] in bnd: + return True if return_bool else (self.interpretations_node[component].world[pred].lower, self.interpretations_node[component].world[pred].upper) + else: + return False if return_bool else (0, 0) + else: + if self.interpretations_edge[component].world[pred] in bnd: + return True if return_bool else (self.interpretations_edge[component].world[pred].lower, self.interpretations_edge[component].world[pred].upper) + else: + return False if return_bool else (0, 0) + @numba.njit(cache=True) -def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_atoms): +def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_rules): # Extract rule params rule_type = rule.get_type() head_variables = rule.get_head_variables() @@ -699,6 +768,9 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, dependency_graph_neighbors = numba.typed.Dict.empty(key_type=node_type, value_type=list_of_nodes) dependency_graph_reverse_neighbors = numba.typed.Dict.empty(key_type=node_type, value_type=list_of_nodes) + nodes_set = set(nodes) + edges_set = set(edges) + satisfaction = True for i, clause in enumerate(clauses): # Unpack clause variables @@ -714,7 +786,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Get subset of nodes that can be used to ground the variable # If we allow ground atoms, we can use the nodes directly - if allow_ground_atoms and clause_var_1 in nodes: + if allow_ground_rules and clause_var_1 in nodes_set: grounding = numba.typed.List([clause_var_1]) else: grounding = get_rule_node_clause_grounding(clause_var_1, groundings, nodes) @@ -722,13 +794,17 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Narrow subset based on predicate qualified_groundings = get_qualified_node_groundings(interpretations_node, grounding, clause_label, clause_bnd) groundings[clause_var_1] = qualified_groundings + qualified_groundings_set = set(qualified_groundings) for c1, c2 in groundings_edges: if c1 == clause_var_1: - groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[0] in qualified_groundings]) + groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[0] in qualified_groundings_set]) if c2 == clause_var_1: - groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[1] in qualified_groundings]) + groundings_edges[(c1, c2)] = numba.typed.List([e for e in groundings_edges[(c1, c2)] if e[1] in qualified_groundings_set]) # Check satisfaction of those nodes wrt the threshold + # Only check satisfaction if the default threshold is used. This saves us from grounding the rest of the rule + # It doesn't make sense to check any other thresholds because the head could be grounded with multiple nodes/edges + # if thresholds[i][1][0] == 'number' and thresholds[i][1][1] == 'total' and thresholds[i][2] == 1.0: satisfaction = check_node_grounding_threshold_satisfaction(interpretations_node, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction # This is an edge clause @@ -737,7 +813,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Get subset of edges that can be used to ground the variables # If we allow ground atoms, we can use the nodes directly - if allow_ground_atoms and (clause_var_1, clause_var_2) in edges: + if allow_ground_rules and (clause_var_1, clause_var_2) in edges_set: grounding = numba.typed.List([(clause_var_1, clause_var_2)]) else: grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes) @@ -746,16 +822,23 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, qualified_groundings = get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, clause_bnd) # Check satisfaction of those edges wrt the threshold + # Only check satisfaction if the default threshold is used. This saves us from grounding the rest of the rule + # It doesn't make sense to check any other thresholds because the head could be grounded with multiple nodes/edges + # if thresholds[i][1][0] == 'number' and thresholds[i][1][1] == 'total' and thresholds[i][2] == 1.0: satisfaction = check_edge_grounding_threshold_satisfaction(interpretations_edge, grounding, qualified_groundings, clause_label, thresholds[i]) and satisfaction # Update the groundings groundings[clause_var_1] = numba.typed.List.empty_list(node_type) groundings[clause_var_2] = numba.typed.List.empty_list(node_type) + groundings_clause_1_set = set(groundings[clause_var_1]) + groundings_clause_2_set = set(groundings[clause_var_2]) for e in qualified_groundings: - if e[0] not in groundings[clause_var_1]: + if e[0] not in groundings_clause_1_set: groundings[clause_var_1].append(e[0]) - if e[1] not in groundings[clause_var_2]: + groundings_clause_1_set.add(e[0]) + if e[1] not in groundings_clause_2_set: groundings[clause_var_2].append(e[1]) + groundings_clause_2_set.add(e[1]) # Update the edge groundings (to use later for grounding other clauses with the same variables) groundings_edges[(clause_var_1, clause_var_2)] = qualified_groundings @@ -798,11 +881,12 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # If there is no grounding for head_var_1, we treat it as a ground atom and add it to the graph head_var_1_in_nodes = head_var_1 in nodes - if allow_ground_atoms and head_var_1_in_nodes: + add_head_var_node_to_graph = False + if allow_ground_rules and head_var_1_in_nodes: groundings[head_var_1] = numba.typed.List([head_var_1]) elif head_var_1 not in groundings: if not head_var_1_in_nodes: - _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + add_head_var_node_to_graph = True groundings[head_var_1] = numba.typed.List([head_var_1]) for head_grounding in groundings[head_var_1]: @@ -839,7 +923,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, else: for qn in groundings[clause_var_1]: a.append(interpretations_node[qn].world[clause_label]) - annotations.append(a) + annotations.append(a) elif clause_type == 'edge': clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] @@ -874,6 +958,10 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # Comparison clause (we do not handle for now) pass + # Now that we're sure that the rule is satisfied, we add the head to the graph if needed (only for ground rules) + if add_head_var_node_to_graph: + _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + # For each grounding add a rule to be applied applicable_rules_node.append((head_grounding, annotations, qualified_nodes, qualified_edges, edges_to_be_added)) @@ -884,23 +972,26 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, # If there is no grounding for head_var_1 or head_var_2, we treat it as a ground atom and add it to the graph head_var_1_in_nodes = head_var_1 in nodes head_var_2_in_nodes = head_var_2 in nodes - if allow_ground_atoms and head_var_1_in_nodes: + add_head_var_1_node_to_graph = False + add_head_var_2_node_to_graph = False + add_head_edge_to_graph = False + if allow_ground_rules and head_var_1_in_nodes: groundings[head_var_1] = numba.typed.List([head_var_1]) - if allow_ground_atoms and head_var_2_in_nodes: + if allow_ground_rules and head_var_2_in_nodes: groundings[head_var_2] = numba.typed.List([head_var_2]) if head_var_1 not in groundings: if not head_var_1_in_nodes: - _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + add_head_var_1_node_to_graph = True groundings[head_var_1] = numba.typed.List([head_var_1]) if head_var_2 not in groundings: if not head_var_2_in_nodes: - _add_node(head_var_2, neighbors, reverse_neighbors, nodes, interpretations_node) + add_head_var_2_node_to_graph = True groundings[head_var_2] = numba.typed.List([head_var_2]) # Artificially connect the head variables with an edge if both of them were not in the graph if not head_var_1_in_nodes and not head_var_2_in_nodes: - _add_edge(head_var_1, head_var_2, neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + add_head_edge_to_graph = True head_var_1_groundings = groundings[head_var_1] head_var_2_groundings = groundings[head_var_2] @@ -917,12 +1008,11 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if infer_edges: valid_edge_groundings.append((g1, g2)) else: - if (g1, g2) in edges: + if (g1, g2) in edges_set: valid_edge_groundings.append((g1, g2)) # Loop through the head variable groundings for valid_e in valid_edge_groundings: - satisfaction = True head_var_1_grounding, head_var_2_grounding = valid_e[0], valid_e[1] qualified_nodes = numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)) qualified_edges = numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)) @@ -991,7 +1081,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, else: for qn in temp_groundings[clause_var_1]: a.append(interpretations_node[qn].world[clause_label]) - annotations.append(a) + annotations.append(a) elif clause_type == 'edge': clause_var_1, clause_var_2 = clause_variables[0], clause_variables[1] @@ -1055,6 +1145,14 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, a.append(interpretations_edge[qe].world[clause_label]) annotations.append(a) + # Now that we're sure that the rule is satisfied, we add the head to the graph if needed (only for ground rules) + if add_head_var_1_node_to_graph and head_var_1_grounding == head_var_1: + _add_node(head_var_1, neighbors, reverse_neighbors, nodes, interpretations_node) + if add_head_var_2_node_to_graph and head_var_2_grounding == head_var_2: + _add_node(head_var_2, neighbors, reverse_neighbors, nodes, interpretations_node) + if add_head_edge_to_graph and (head_var_1, head_var_2) == (head_var_1_grounding, head_var_2_grounding): + _add_edge(head_var_1, head_var_2, neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + # For each grounding combination add a rule to be applied # Only if all the clauses have valid groundings # if satisfaction: @@ -1601,9 +1699,11 @@ def refine_groundings(clause_variables, groundings, groundings_edges, dependency # Update the edge groundings and node groundings qualified_groundings = numba.typed.List([edge for edge in old_edge_groundings if edge[0] in new_node_groundings]) + groundings_neighbor_set = set(groundings[neighbor]) for e in qualified_groundings: - if e[1] not in groundings[neighbor]: + if e[1] not in groundings_neighbor_set: groundings[neighbor].append(e[1]) + groundings_neighbor_set.add(e[1]) groundings_edges[(refined_variable, neighbor)] = qualified_groundings # Add the neighbor to the list of refined variables so that we can refine for all its neighbors @@ -1621,9 +1721,11 @@ def refine_groundings(clause_variables, groundings, groundings_edges, dependency # Update the edge groundings and node groundings qualified_groundings = numba.typed.List([edge for edge in old_edge_groundings if edge[1] in new_node_groundings]) + groundings_reverse_neighbor_set = set(groundings[reverse_neighbor]) for e in qualified_groundings: - if e[0] not in groundings[reverse_neighbor]: + if e[0] not in groundings_reverse_neighbor_set: groundings[reverse_neighbor].append(e[0]) + groundings_reverse_neighbor_set.add(e[0]) groundings_edges[(reverse_neighbor, refined_variable)] = qualified_groundings # Add the neighbor to the list of refined variables so that we can refine for all its neighbors @@ -1903,8 +2005,9 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun edge_groundings = groundings_edges[(clause_var_1, clause_var_2)] # We have seen both these variables but not in an edge clause together else: + groundings_clause_var_2_set = set(groundings[clause_var_2]) for n in groundings[clause_var_1]: - es = numba.typed.List([(n, nn) for nn in neighbors[n] if nn in groundings[clause_var_2]]) + es = numba.typed.List([(n, nn) for nn in neighbors[n] if nn in groundings_clause_var_2_set]) edge_groundings.extend(es) return edge_groundings @@ -2325,7 +2428,9 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat if updated: ip_update_cnt = 0 for p1, p2 in ipl: - if p1==l: + if p1 == l: + if p2 not in world.world: + world.world[p2] = interval.closed(0, 1) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p2], f'IPL: {l.get_value()}') lower = max(world.world[p2].lower, 1 - world.world[p1].upper) @@ -2336,7 +2441,9 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat updated_bnds.append(world.world[p2]) if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, p2, interval.closed(lower, upper))) - if p2==l: + if p2 == l: + if p1 not in world.world: + world.world[p1] = interval.closed(0, 1) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p1], f'IPL: {l.get_value()}') lower = max(world.world[p1].lower, 1 - world.world[p2].upper) @@ -2414,7 +2521,9 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat if updated: ip_update_cnt = 0 for p1, p2 in ipl: - if p1==l: + if p1 == l: + if p2 not in world.world: + world.world[p2] = interval.closed(0, 1) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p2], f'IPL: {l.get_value()}') lower = max(world.world[p2].lower, 1 - world.world[p1].upper) @@ -2425,7 +2534,9 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat updated_bnds.append(world.world[p2]) if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, p2, interval.closed(lower, upper))) - if p2==l: + if p2 == l: + if p1 not in world.world: + world.world[p1] = interval.closed(0, 1) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p1], f'IPL: {l.get_value()}') lower = max(world.world[p1].lower, 1 - world.world[p2].upper) From 761309920bd2ba483b8d1b82e0100af07c3de126 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Thu, 5 Sep 2024 07:58:04 +0200 Subject: [PATCH 55/73] fixed encoding issue for pip install on github --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ccd7e3f..7a39f01 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from pathlib import Path this_directory = Path(__file__).parent -long_description = (this_directory / "README.md").read_text() +long_description = (this_directory / "README.md").read_text(encoding='UTF-8') setup( name='pyreason', From 7055b7ee723417cb98c6d76dbb50d316e21088f3 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Thu, 5 Sep 2024 08:05:54 +0200 Subject: [PATCH 56/73] version attribute issue --- pyreason/__init__.py | 7 ++++++- requirements.txt | 1 + setup.py | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pyreason/__init__.py b/pyreason/__init__.py index 3d9f46e..15fec58 100755 --- a/pyreason/__init__.py +++ b/pyreason/__init__.py @@ -9,8 +9,13 @@ from pyreason.pyreason import * import yaml from importlib.metadata import version +from pkg_resources import get_distribution, DistributionNotFound -__version__ = version('pyreason') +try: + __version__ = get_distribution(__name__).version +except DistributionNotFound: + # package is not installed + pass with open(cache_status_path) as file: diff --git a/requirements.txt b/requirements.txt index b54fb59..25f9bc1 100755 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ numba==0.59.1 numpy==1.26.4 memory_profiler pytest +setuptools_scm sphinx_rtd_theme sphinx diff --git a/setup.py b/setup.py index 7a39f01..8705509 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,8 @@ 'memory_profiler', 'pytest' ], + use_scm_version=True, + setup_requires=['setuptools_scm'], packages=find_packages(), include_package_data=True ) From 53085644685ac784ca847c43a70d99556a5c0742 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 8 Sep 2024 10:40:54 +0200 Subject: [PATCH 57/73] update docs --- docs/source/_static/pyreason_logo.jpg | Bin 0 -> 337802 bytes docs/source/index.rst | 10 +++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100755 docs/source/_static/pyreason_logo.jpg diff --git a/docs/source/_static/pyreason_logo.jpg b/docs/source/_static/pyreason_logo.jpg new file mode 100755 index 0000000000000000000000000000000000000000..233618a4e5eef74f9adb4ef3d155c18fe28e9bfd GIT binary patch literal 337802 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 z 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℘8zOPe5R33tlDYN8;65gtu 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 D$S-6{ literal 0 HcmV?d00001 diff --git a/docs/source/index.rst b/docs/source/index.rst index 4cd32c3..1dd8814 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,9 +3,17 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to Pyreason's documentation! +Welcome to PyReason Docs! ==================================== +.. image:: _static/pyreason_logo.jpg + :alt: PyReason Logo + :align: center + +Introduction +------------ +Welcome to the documentation for **PyReason**, a powerful tool for Reasoning over Graphs. This documentation will guide you through the installation, usage and API. + .. toctree:: :caption: Tutorials :maxdepth: 2 From e61dc13277c493cfffe178a3ccaecd884c7ebda9 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 8 Sep 2024 10:55:11 +0200 Subject: [PATCH 58/73] New folder structure for docs --- docs/source/about.rst | 0 docs/source/api_reference/index.rst | 0 docs/source/index.rst | 17 +++++++++++++---- docs/source/installation.rst | 0 docs/source/license.rst | 0 docs/source/tutorials/index.rst | 14 ++++++++++++++ docs/source/user_guide/index.rst | 0 7 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 docs/source/about.rst create mode 100644 docs/source/api_reference/index.rst create mode 100644 docs/source/installation.rst create mode 100644 docs/source/license.rst create mode 100644 docs/source/tutorials/index.rst create mode 100644 docs/source/user_guide/index.rst diff --git a/docs/source/about.rst b/docs/source/about.rst new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/api_reference/index.rst b/docs/source/api_reference/index.rst new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/index.rst b/docs/source/index.rst index 1dd8814..5c21a9d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,14 +12,23 @@ Welcome to PyReason Docs! Introduction ------------ -Welcome to the documentation for **PyReason**, a powerful tool for Reasoning over Graphs. This documentation will guide you through the installation, usage and API. +Welcome to the documentation for **PyReason**, a powerful, optimized Python tool for Reasoning over Graphs. PyReason supports a variety of Logics such as Propositional, First Order, Annotated. This documentation will guide you through the installation, usage and API. .. toctree:: - :caption: Tutorials :maxdepth: 2 - :glob: + :caption: Contents: - ./tutorials/* + about + installation + user_guide/index + api_reference/index + tutorials/index + license + + +Getting Help +------------ +If you encounter any issues or have questions, feel free to check our Github, or contact one of the authors (`dyuman.aditya@asu.edu`, `kmukher2@asu.edu`). Indices and tables ================== diff --git a/docs/source/installation.rst b/docs/source/installation.rst new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/license.rst b/docs/source/license.rst new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst new file mode 100644 index 0000000..c9fdb4c --- /dev/null +++ b/docs/source/tutorials/index.rst @@ -0,0 +1,14 @@ +Tutorials +========== + +In this section we outline a series of tutorials that will help you get started with the basics of using the `pyreason` library. + +Contents +-------- + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + :glob: + + ./tutorials/* \ No newline at end of file diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst new file mode 100644 index 0000000..e69de29 From e443c9a767f5c37206965eafe17a1f4846de6cd4 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 8 Sep 2024 11:04:55 +0200 Subject: [PATCH 59/73] docs --- docs/source/about.rst | 4 ++++ docs/source/api_reference/index.rst | 10 ++++++++++ docs/source/index.rst | 2 +- docs/source/installation.rst | 5 +++++ docs/source/license.rst | 4 ++++ docs/source/tutorials/index.rst | 2 +- docs/source/user_guide/index.rst | 10 ++++++++++ 7 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/source/about.rst b/docs/source/about.rst index e69de29..141d596 100644 --- a/docs/source/about.rst +++ b/docs/source/about.rst @@ -0,0 +1,4 @@ +About +========== + +TODO: Describe the project here. \ No newline at end of file diff --git a/docs/source/api_reference/index.rst b/docs/source/api_reference/index.rst index e69de29..49ae7af 100644 --- a/docs/source/api_reference/index.rst +++ b/docs/source/api_reference/index.rst @@ -0,0 +1,10 @@ +API Reference +========== + +In this section we outline the API Reference for the `pyreason` library. + +Contents +-------- +.. toctree:: + :maxdepth: 2 + :caption: Contents: \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 5c21a9d..4e03901 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -15,7 +15,7 @@ Introduction Welcome to the documentation for **PyReason**, a powerful, optimized Python tool for Reasoning over Graphs. PyReason supports a variety of Logics such as Propositional, First Order, Annotated. This documentation will guide you through the installation, usage and API. .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Contents: about diff --git a/docs/source/installation.rst b/docs/source/installation.rst index e69de29..aca55d1 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -0,0 +1,5 @@ +Installation +========== + +TODO: Add installation instructions here. + diff --git a/docs/source/license.rst b/docs/source/license.rst index e69de29..9e0cb0f 100644 --- a/docs/source/license.rst +++ b/docs/source/license.rst @@ -0,0 +1,4 @@ +License +========== + +TODO: Add license information here. \ No newline at end of file diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index c9fdb4c..3b59ca0 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -11,4 +11,4 @@ Contents :caption: Contents: :glob: - ./tutorials/* \ No newline at end of file + ./* \ No newline at end of file diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst index e69de29..5f3021a 100644 --- a/docs/source/user_guide/index.rst +++ b/docs/source/user_guide/index.rst @@ -0,0 +1,10 @@ +User Guide +========== + +In this section we demonstrate the functionality of the `pyreason` library and how to use it. + +Contents +-------- +.. toctree:: + :maxdepth: 2 + :caption: Contents: \ No newline at end of file From fa99bd6aa5e8f3041e36302065e0034beb689aeb Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 8 Sep 2024 11:36:44 +0200 Subject: [PATCH 60/73] docs --- docs/source/about.rst | 36 ++++++++++++++++-- ...ced tutorial.rst => advanced_tutorial.rst} | 0 ...{Basic tutorial.rst => basic_tutorial.rst} | 0 ...{Creating Rules.rst => creating_rules.rst} | 0 docs/source/tutorials/index.rst | 2 +- .../{Installation.rst => installation.rst} | 0 .../{Rule_image.png => rule_image.png} | Bin ...ding Logic.rst => understanding_logic.rst} | 2 +- .../user_guide/annotation_functions.rst | 0 .../inconsistent_predicate_list.rst | 0 docs/source/user_guide/index.rst | 5 ++- docs/source/user_guide/pyreason_facts.rst | 0 docs/source/user_guide/pyreason_graphs.rst | 0 docs/source/user_guide/pyreason_rules.rst | 0 docs/source/user_guide/pyreason_settings.rst | 0 15 files changed, 39 insertions(+), 6 deletions(-) rename docs/source/tutorials/{Advanced tutorial.rst => advanced_tutorial.rst} (100%) rename docs/source/tutorials/{Basic tutorial.rst => basic_tutorial.rst} (100%) rename docs/source/tutorials/{Creating Rules.rst => creating_rules.rst} (100%) rename docs/source/tutorials/{Installation.rst => installation.rst} (100%) rename docs/source/tutorials/{Rule_image.png => rule_image.png} (100%) rename docs/source/tutorials/{Understanding Logic.rst => understanding_logic.rst} (98%) create mode 100644 docs/source/user_guide/annotation_functions.rst create mode 100644 docs/source/user_guide/inconsistent_predicate_list.rst create mode 100644 docs/source/user_guide/pyreason_facts.rst create mode 100644 docs/source/user_guide/pyreason_graphs.rst create mode 100644 docs/source/user_guide/pyreason_rules.rst create mode 100644 docs/source/user_guide/pyreason_settings.rst diff --git a/docs/source/about.rst b/docs/source/about.rst index 141d596..2e1cca2 100644 --- a/docs/source/about.rst +++ b/docs/source/about.rst @@ -1,4 +1,34 @@ -About -========== +About PyReason +============== -TODO: Describe the project here. \ No newline at end of file +**PyReason** is a modern Python-based software framework designed for open-world temporal logic reasoning using generalized annotated logic. It addresses the growing needs of neuro-symbolic reasoning frameworks that incorporate differentiable logics and temporal extensions, allowing inference over finite periods with open-world capabilities. PyReason is particularly suited for reasoning over graphical structures such as knowledge graphs, social networks, and biological networks, offering fully explainable inference processes. + +### Key Capabilities + +1. **Graph-Based Reasoning**: PyReason supports direct reasoning over knowledge graphs, a popular representation of symbolic data. Unlike black-box frameworks, PyReason provides full explainability of the reasoning process. + +2. **Annotated Logic**: It extends classical logic with annotations, supporting various types of logic including fuzzy logic, real-valued intervals, and temporal logic. PyReason's framework goes beyond traditional logic systems like Prolog, allowing for arbitrary functions over reals, enhancing its capability to handle constructs in neuro-symbolic reasoning. + +3. **Temporal Reasoning**: PyReason includes temporal extensions to handle reasoning over sequences of time points. This feature enables the creation of rules that incorporate temporal dependencies, such as "if condition A, then condition B after a certain number of time steps." + +4. **Open World Reasoning**: Unlike closed-world assumptions where anything not explicitly stated is false, PyReason considers unknowns as a valid state, making it more flexible and suitable for real-world applications where information may be incomplete. + +5. **Handling Logical Inconsistencies**: PyReason can detect and resolve inconsistencies in the reasoning process. When inconsistencies are found, it can reset affected interpretations to a state of complete uncertainty, ensuring that the reasoning process remains robust. + +6. **Scalability and Performance**: PyReason is optimized for scalability, supporting exact deductive inference with memory-efficient implementations. It leverages sparsity in graphical structures and employs predicate-constant type checking to reduce computational complexity. + +7. **Explainability**: All inference results produced by PyReason are fully explainable, as the software maintains a trace of the inference steps that led to each conclusion. This feature is critical for applications where transparency of the reasoning process is necessary. + +8. **Integration and Extensibility**: PyReason is implemented in Python and supports integration with other tools and frameworks, making it easy to extend and adapt for specific needs. It can work with popular graph formats like GraphML and is compatible with tools like NetworkX and Neo4j. + +### Use Cases + +- **Knowledge Graph Reasoning**: PyReason can be used to perform logical inferences over knowledge graphs, aiding in tasks like knowledge completion, entity classification, and relationship extraction. + +- **Temporal Logic Applications**: Its temporal reasoning capabilities are useful in domains requiring time-based analysis, such as monitoring system states over time, or reasoning about events and their sequences. + +- **Social and Biological Network Analysis**: PyReason's support for annotated logic and reasoning over complex network structures makes it suitable for applications in social network analysis, supply chain management, and biological systems modeling. + +PyReason is open-source and available at: [GitHub - PyReason](https://github.com/lab-v2/pyreason). + +For more detailed information on PyReason’s logical framework, implementation details, and experimental results, refer to the full documentation or visit the project's GitHub repository. diff --git a/docs/source/tutorials/Advanced tutorial.rst b/docs/source/tutorials/advanced_tutorial.rst similarity index 100% rename from docs/source/tutorials/Advanced tutorial.rst rename to docs/source/tutorials/advanced_tutorial.rst diff --git a/docs/source/tutorials/Basic tutorial.rst b/docs/source/tutorials/basic_tutorial.rst similarity index 100% rename from docs/source/tutorials/Basic tutorial.rst rename to docs/source/tutorials/basic_tutorial.rst diff --git a/docs/source/tutorials/Creating Rules.rst b/docs/source/tutorials/creating_rules.rst similarity index 100% rename from docs/source/tutorials/Creating Rules.rst rename to docs/source/tutorials/creating_rules.rst diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index 3b59ca0..0d2c5bf 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -11,4 +11,4 @@ Contents :caption: Contents: :glob: - ./* \ No newline at end of file + * \ No newline at end of file diff --git a/docs/source/tutorials/Installation.rst b/docs/source/tutorials/installation.rst similarity index 100% rename from docs/source/tutorials/Installation.rst rename to docs/source/tutorials/installation.rst diff --git a/docs/source/tutorials/Rule_image.png b/docs/source/tutorials/rule_image.png similarity index 100% rename from docs/source/tutorials/Rule_image.png rename to docs/source/tutorials/rule_image.png diff --git a/docs/source/tutorials/Understanding Logic.rst b/docs/source/tutorials/understanding_logic.rst similarity index 98% rename from docs/source/tutorials/Understanding Logic.rst rename to docs/source/tutorials/understanding_logic.rst index 28cb625..cf8df84 100644 --- a/docs/source/tutorials/Understanding Logic.rst +++ b/docs/source/tutorials/understanding_logic.rst @@ -50,4 +50,4 @@ Inconsistent predicate list The first rule states that the grass is wet if it rained, while the second rule states that the grass is not wet if it rained. The fact f1 states that it rained, which is consistent with the first rule, but inconsistent with the second rule. -.. |rule_image| image:: Rule_image.png +.. |rule_image| image:: rule_image.png diff --git a/docs/source/user_guide/annotation_functions.rst b/docs/source/user_guide/annotation_functions.rst new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/user_guide/inconsistent_predicate_list.rst b/docs/source/user_guide/inconsistent_predicate_list.rst new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst index 5f3021a..ca50816 100644 --- a/docs/source/user_guide/index.rst +++ b/docs/source/user_guide/index.rst @@ -7,4 +7,7 @@ Contents -------- .. toctree:: :maxdepth: 2 - :caption: Contents: \ No newline at end of file + :caption: Contents: + :glob: + + * \ No newline at end of file diff --git a/docs/source/user_guide/pyreason_facts.rst b/docs/source/user_guide/pyreason_facts.rst new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/user_guide/pyreason_graphs.rst b/docs/source/user_guide/pyreason_graphs.rst new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/user_guide/pyreason_rules.rst b/docs/source/user_guide/pyreason_rules.rst new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/user_guide/pyreason_settings.rst b/docs/source/user_guide/pyreason_settings.rst new file mode 100644 index 0000000..e69de29 From eebf1a1c935820f339b20118b7a3519aca263f1c Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 8 Sep 2024 11:40:37 +0200 Subject: [PATCH 61/73] docs --- docs/source/about.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/source/about.rst b/docs/source/about.rst index 2e1cca2..ce1614b 100644 --- a/docs/source/about.rst +++ b/docs/source/about.rst @@ -3,7 +3,8 @@ About PyReason **PyReason** is a modern Python-based software framework designed for open-world temporal logic reasoning using generalized annotated logic. It addresses the growing needs of neuro-symbolic reasoning frameworks that incorporate differentiable logics and temporal extensions, allowing inference over finite periods with open-world capabilities. PyReason is particularly suited for reasoning over graphical structures such as knowledge graphs, social networks, and biological networks, offering fully explainable inference processes. -### Key Capabilities +Key Capabilities +-------------- 1. **Graph-Based Reasoning**: PyReason supports direct reasoning over knowledge graphs, a popular representation of symbolic data. Unlike black-box frameworks, PyReason provides full explainability of the reasoning process. @@ -21,7 +22,8 @@ About PyReason 8. **Integration and Extensibility**: PyReason is implemented in Python and supports integration with other tools and frameworks, making it easy to extend and adapt for specific needs. It can work with popular graph formats like GraphML and is compatible with tools like NetworkX and Neo4j. -### Use Cases +Use Cases +-------------- - **Knowledge Graph Reasoning**: PyReason can be used to perform logical inferences over knowledge graphs, aiding in tasks like knowledge completion, entity classification, and relationship extraction. From 92de159b0046ca143741acd52b391eea7413effd Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 15 Sep 2024 11:39:57 +0200 Subject: [PATCH 62/73] removed optimize rule setting. It is now automatic based on number of edges/nodes in graph --- pyreason/pyreason.py | 28 ++++------------------------ tests/test_reorder_clauses.py | 1 - 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/pyreason/pyreason.py b/pyreason/pyreason.py index de852c2..c1c71e4 100755 --- a/pyreason/pyreason.py +++ b/pyreason/pyreason.py @@ -44,7 +44,6 @@ def __init__(self): self.__parallel_computing = None self.__update_mode = None self.__allow_ground_rules = None - self.__optimize_rules = None self.reset() def reset(self): @@ -64,7 +63,6 @@ def reset(self): self.__parallel_computing = False self.__update_mode = 'intersection' self.__allow_ground_rules = False - self.__optimize_rules = True @property def verbose(self) -> bool: @@ -199,14 +197,6 @@ def allow_ground_rules(self) -> bool: """ return self.__allow_ground_rules - @property - def optimize_rules(self) -> bool: - """Returns whether rules will be optimized by moving clauses around. Default is True - - :return: bool - """ - return self.__optimize_rules - @verbose.setter def verbose(self, value: bool) -> None: """Set verbose mode. Default is True @@ -406,18 +396,6 @@ def allow_ground_rules(self, value: bool) -> None: else: self.__allow_ground_rules = value - @optimize_rules.setter - def optimize_rules(self, value: bool) -> None: - """Whether to optimize rules by moving clauses around. Default is True - - :param value: Whether to optimize rules or not - :raises TypeError: If not bool raise error - """ - if not isinstance(value, bool): - raise TypeError('value has to be a bool') - else: - self.__optimize_rules = value - # VARIABLES __graph = None @@ -714,9 +692,11 @@ def _reason(timesteps, convergence_threshold, convergence_bound_threshold): # Convert list of annotation functions into tuple to be numba compatible annotation_functions = tuple(__annotation_functions) - # Optimize rules by moving clauses around + # Optimize rules by moving clauses around, only if there are more edges than nodes in the graph __clause_maps = {r.get_rule_name(): {i: i for i in range(len(r.get_clauses()))} for r in __rules} - if settings.optimize_rules: + if len(__graph.edges) > len(__graph.nodes): + if settings.verbose: + print('Optimizing rules by moving node clauses ahead of edge clauses') __rules_copy = __rules.copy() __rules = numba.typed.List.empty_list(rule.rule_type) for i, r in enumerate(__rules_copy): diff --git a/tests/test_reorder_clauses.py b/tests/test_reorder_clauses.py index de13a07..6407f9b 100644 --- a/tests/test_reorder_clauses.py +++ b/tests/test_reorder_clauses.py @@ -14,7 +14,6 @@ def test_reorder_clauses(): # Modify pyreason settings to make verbose pr.settings.verbose = True # Print info to screen pr.settings.atom_trace = True # Print atom trace - pr.settings.optimize_rules = True # Disable rule optimization for debugging # Load all the files into pyreason pr.load_graphml(graph_path) From fd1d1c76254557b476e3795dc70de9524bc6b878 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 22 Sep 2024 12:06:19 +0200 Subject: [PATCH 63/73] enhanced inconsistency checking output --- .../scripts/interpretation/interpretation.py | 43 ++++++++++++------- .../interpretation/interpretation_parallel.py | 43 ++++++++++++------- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index bab03bd..81b143c 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -296,10 +296,10 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data changes_cnt += changes # Resolve inconsistency if necessary otherwise override bounds else: + mode = 'graph-attribute-fact' if graph_attribute else 'fact' if inconsistency_check: - resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_node, rule_trace_node_atoms, store_interpretation_changes) + resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, i, atom_trace, rule_trace_node, rule_trace_node_atoms, rules_to_be_applied_node_trace, facts_to_be_applied_node_trace, store_interpretation_changes, mode=mode) else: - mode = 'graph-attribute-fact' if graph_attribute else 'fact' u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, i, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode=mode, override=True) update = u or update @@ -362,10 +362,10 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data changes_cnt += changes # Resolve inconsistency else: + mode = 'graph-attribute-fact' if graph_attribute else 'fact' if inconsistency_check: - resolve_inconsistency_edge(interpretations_edge, comp, (l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_edge, rule_trace_edge_atoms, store_interpretation_changes) + resolve_inconsistency_edge(interpretations_edge, comp, (l, bnd), ipl, t, fp_cnt, i, atom_trace, rule_trace_edge, rule_trace_edge_atoms, rules_to_be_applied_edge_trace, facts_to_be_applied_edge_trace, store_interpretation_changes, mode=mode) else: - mode = 'graph-attribute-fact' if graph_attribute else 'fact' u, changes = _update_edge(interpretations_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, i, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode=mode, override=True) update = u or update @@ -411,7 +411,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Resolve inconsistency else: if inconsistency_check: - resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_node, rule_trace_node_atoms, store_interpretation_changes) + resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, idx, atom_trace, rule_trace_node, rule_trace_node_atoms, rules_to_be_applied_node_trace, facts_to_be_applied_node_trace, store_interpretation_changes, mode='rule') else: u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=True) @@ -443,6 +443,8 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Update bound for newly added edges. Use bnd to update all edges if label is specified, else use bnd to update normally if edge_l.value != '': for e in edges_added: + if interpretations_edge[e].world[edge_l].is_static(): + continue if check_consistent_edge(interpretations_edge, e, (edge_l, bnd)): override = True if update_mode == 'override' else False u, changes = _update_edge(interpretations_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=override) @@ -457,7 +459,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Resolve inconsistency else: if inconsistency_check: - resolve_inconsistency_edge(interpretations_edge, e, (edge_l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_edge, rule_trace_edge_atoms, store_interpretation_changes) + resolve_inconsistency_edge(interpretations_edge, e, (edge_l, bnd), ipl, t, fp_cnt, idx, atom_trace, rule_trace_edge, rule_trace_edge_atoms, rules_to_be_applied_edge_trace, facts_to_be_applied_edge_trace, store_interpretation_changes, mode='rule') else: u, changes = _update_edge(interpretations_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) @@ -484,7 +486,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Resolve inconsistency else: if inconsistency_check: - resolve_inconsistency_edge(interpretations_edge, comp, (l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_edge, rule_trace_edge_atoms, store_interpretation_changes) + resolve_inconsistency_edge(interpretations_edge, comp, (l, bnd), ipl, t, fp_cnt, idx, atom_trace, rule_trace_edge, rule_trace_edge_atoms, rules_to_be_applied_edge_trace, facts_to_be_applied_edge_trace, store_interpretation_changes, mode='rule') else: u, changes = _update_edge(interpretations_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) @@ -1052,6 +1054,9 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, continue if infer_edges: + # Prevent self loops while inferring edges if the clause variables are not the same + if source != target and head_var_1_grounding == head_var_2_grounding: + continue edges_to_be_added[0].append(head_var_1_grounding) edges_to_be_added[1].append(head_var_2_grounding) @@ -2714,19 +2719,23 @@ def check_consistent_edge(interpretations, comp, na): @numba.njit(cache=True) -def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, atom_trace, rule_trace, rule_trace_atoms, store_interpretation_changes): +def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, idx, atom_trace, rule_trace, rule_trace_atoms, rules_to_be_applied_trace, facts_to_be_applied_trace, store_interpretation_changes, mode): world = interpretations[comp] if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, na[0], interval.closed(0,1))) + if mode == 'fact' or mode == 'graph-attribute-fact': + name = facts_to_be_applied_trace[idx] + elif mode == 'rule': + name = rules_to_be_applied_trace[idx][2] if atom_trace: - _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[na[0]], 'Inconsistency') + _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[na[0]], f'Inconsistency due to {name}') # Resolve inconsistency and set static world.world[na[0]].set_lower_upper(0, 1) world.world[na[0]].set_static(True) for p1, p2 in ipl: if p1==na[0]: if atom_trace: - _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p2], 'Inconsistency') + _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p2], f'Inconsistency due to {name}') world.world[p2].set_lower_upper(0, 1) world.world[p2].set_static(True) if store_interpretation_changes: @@ -2734,7 +2743,7 @@ def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, at if p2==na[0]: if atom_trace: - _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p1], 'Inconsistency') + _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p1], f'Inconsistency due to {name}') world.world[p1].set_lower_upper(0, 1) world.world[p1].set_static(True) if store_interpretation_changes: @@ -2743,19 +2752,23 @@ def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, at @numba.njit(cache=True) -def resolve_inconsistency_edge(interpretations, comp, na, ipl, t_cnt, fp_cnt, atom_trace, rule_trace, rule_trace_atoms, store_interpretation_changes): +def resolve_inconsistency_edge(interpretations, comp, na, ipl, t_cnt, fp_cnt, idx, atom_trace, rule_trace, rule_trace_atoms, rules_to_be_applied_trace, facts_to_be_applied_trace, store_interpretation_changes, mode): w = interpretations[comp] if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, na[0], interval.closed(0,1))) + if mode == 'fact' or mode == 'graph-attribute-fact': + name = facts_to_be_applied_trace[idx] + elif mode == 'rule': + name = rules_to_be_applied_trace[idx][2] if atom_trace: - _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[na[0]], 'Inconsistency') + _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[na[0]], f'Inconsistency due to {name}') # Resolve inconsistency and set static w.world[na[0]].set_lower_upper(0, 1) w.world[na[0]].set_static(True) for p1, p2 in ipl: if p1==na[0]: if atom_trace: - _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[p2], 'Inconsistency') + _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[p2], f'Inconsistency due to {name}') w.world[p2].set_lower_upper(0, 1) w.world[p2].set_static(True) if store_interpretation_changes: @@ -2763,7 +2776,7 @@ def resolve_inconsistency_edge(interpretations, comp, na, ipl, t_cnt, fp_cnt, at if p2==na[0]: if atom_trace: - _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[p1], 'Inconsistency') + _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[p1], f'Inconsistency due to {name}') w.world[p1].set_lower_upper(0, 1) w.world[p1].set_static(True) if store_interpretation_changes: diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index 303a905..e5004b1 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -296,10 +296,10 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data changes_cnt += changes # Resolve inconsistency if necessary otherwise override bounds else: + mode = 'graph-attribute-fact' if graph_attribute else 'fact' if inconsistency_check: - resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_node, rule_trace_node_atoms, store_interpretation_changes) + resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, i, atom_trace, rule_trace_node, rule_trace_node_atoms, rules_to_be_applied_node_trace, facts_to_be_applied_node_trace, store_interpretation_changes, mode=mode) else: - mode = 'graph-attribute-fact' if graph_attribute else 'fact' u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, i, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode=mode, override=True) update = u or update @@ -362,10 +362,10 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data changes_cnt += changes # Resolve inconsistency else: + mode = 'graph-attribute-fact' if graph_attribute else 'fact' if inconsistency_check: - resolve_inconsistency_edge(interpretations_edge, comp, (l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_edge, rule_trace_edge_atoms, store_interpretation_changes) + resolve_inconsistency_edge(interpretations_edge, comp, (l, bnd), ipl, t, fp_cnt, i, atom_trace, rule_trace_edge, rule_trace_edge_atoms, rules_to_be_applied_edge_trace, facts_to_be_applied_edge_trace, store_interpretation_changes, mode=mode) else: - mode = 'graph-attribute-fact' if graph_attribute else 'fact' u, changes = _update_edge(interpretations_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, i, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode=mode, override=True) update = u or update @@ -411,7 +411,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Resolve inconsistency else: if inconsistency_check: - resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_node, rule_trace_node_atoms, store_interpretation_changes) + resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, idx, atom_trace, rule_trace_node, rule_trace_node_atoms, rules_to_be_applied_node_trace, facts_to_be_applied_node_trace, store_interpretation_changes, mode='rule') else: u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=True) @@ -443,6 +443,8 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Update bound for newly added edges. Use bnd to update all edges if label is specified, else use bnd to update normally if edge_l.value != '': for e in edges_added: + if interpretations_edge[e].world[edge_l].is_static(): + continue if check_consistent_edge(interpretations_edge, e, (edge_l, bnd)): override = True if update_mode == 'override' else False u, changes = _update_edge(interpretations_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=override) @@ -457,7 +459,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Resolve inconsistency else: if inconsistency_check: - resolve_inconsistency_edge(interpretations_edge, e, (edge_l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_edge, rule_trace_edge_atoms, store_interpretation_changes) + resolve_inconsistency_edge(interpretations_edge, e, (edge_l, bnd), ipl, t, fp_cnt, idx, atom_trace, rule_trace_edge, rule_trace_edge_atoms, rules_to_be_applied_edge_trace, facts_to_be_applied_edge_trace, store_interpretation_changes, mode='rule') else: u, changes = _update_edge(interpretations_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) @@ -484,7 +486,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Resolve inconsistency else: if inconsistency_check: - resolve_inconsistency_edge(interpretations_edge, comp, (l, bnd), ipl, t, fp_cnt, atom_trace, rule_trace_edge, rule_trace_edge_atoms, store_interpretation_changes) + resolve_inconsistency_edge(interpretations_edge, comp, (l, bnd), ipl, t, fp_cnt, idx, atom_trace, rule_trace_edge, rule_trace_edge_atoms, rules_to_be_applied_edge_trace, facts_to_be_applied_edge_trace, store_interpretation_changes, mode='rule') else: u, changes = _update_edge(interpretations_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) @@ -1052,6 +1054,9 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, continue if infer_edges: + # Prevent self loops while inferring edges if the clause variables are not the same + if source != target and head_var_1_grounding == head_var_2_grounding: + continue edges_to_be_added[0].append(head_var_1_grounding) edges_to_be_added[1].append(head_var_2_grounding) @@ -2714,19 +2719,23 @@ def check_consistent_edge(interpretations, comp, na): @numba.njit(cache=True) -def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, atom_trace, rule_trace, rule_trace_atoms, store_interpretation_changes): +def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, idx, atom_trace, rule_trace, rule_trace_atoms, rules_to_be_applied_trace, facts_to_be_applied_trace, store_interpretation_changes, mode): world = interpretations[comp] if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, na[0], interval.closed(0,1))) + if mode == 'fact' or mode == 'graph-attribute-fact': + name = facts_to_be_applied_trace[idx] + elif mode == 'rule': + name = rules_to_be_applied_trace[idx][2] if atom_trace: - _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[na[0]], 'Inconsistency') + _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[na[0]], f'Inconsistency due to {name}') # Resolve inconsistency and set static world.world[na[0]].set_lower_upper(0, 1) world.world[na[0]].set_static(True) for p1, p2 in ipl: if p1==na[0]: if atom_trace: - _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p2], 'Inconsistency') + _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p2], f'Inconsistency due to {name}') world.world[p2].set_lower_upper(0, 1) world.world[p2].set_static(True) if store_interpretation_changes: @@ -2734,7 +2743,7 @@ def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, at if p2==na[0]: if atom_trace: - _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p1], 'Inconsistency') + _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p1], f'Inconsistency due to {name}') world.world[p1].set_lower_upper(0, 1) world.world[p1].set_static(True) if store_interpretation_changes: @@ -2743,19 +2752,23 @@ def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, at @numba.njit(cache=True) -def resolve_inconsistency_edge(interpretations, comp, na, ipl, t_cnt, fp_cnt, atom_trace, rule_trace, rule_trace_atoms, store_interpretation_changes): +def resolve_inconsistency_edge(interpretations, comp, na, ipl, t_cnt, fp_cnt, idx, atom_trace, rule_trace, rule_trace_atoms, rules_to_be_applied_trace, facts_to_be_applied_trace, store_interpretation_changes, mode): w = interpretations[comp] if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, na[0], interval.closed(0,1))) + if mode == 'fact' or mode == 'graph-attribute-fact': + name = facts_to_be_applied_trace[idx] + elif mode == 'rule': + name = rules_to_be_applied_trace[idx][2] if atom_trace: - _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[na[0]], 'Inconsistency') + _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[na[0]], f'Inconsistency due to {name}') # Resolve inconsistency and set static w.world[na[0]].set_lower_upper(0, 1) w.world[na[0]].set_static(True) for p1, p2 in ipl: if p1==na[0]: if atom_trace: - _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[p2], 'Inconsistency') + _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[p2], f'Inconsistency due to {name}') w.world[p2].set_lower_upper(0, 1) w.world[p2].set_static(True) if store_interpretation_changes: @@ -2763,7 +2776,7 @@ def resolve_inconsistency_edge(interpretations, comp, na, ipl, t_cnt, fp_cnt, at if p2==na[0]: if atom_trace: - _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[p1], 'Inconsistency') + _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[p1], f'Inconsistency due to {name}') w.world[p1].set_lower_upper(0, 1) w.world[p1].set_static(True) if store_interpretation_changes: From 8a6548a6af823e38ebe53afa9a7c4d813a9375c1 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Mon, 7 Oct 2024 21:09:13 +0200 Subject: [PATCH 64/73] optimized edge groundings for clauses with new variables --- pyreason/scripts/interpretation/interpretation.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 81b143c..d901fe3 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -1,3 +1,5 @@ +from networkx.classes import edges + import pyreason.scripts.numba_wrapper.numba_types.world_type as world import pyreason.scripts.numba_wrapper.numba_types.label_type as label import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval @@ -818,7 +820,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if allow_ground_rules and (clause_var_1, clause_var_2) in edges_set: grounding = numba.typed.List([(clause_var_1, clause_var_2)]) else: - grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes) + grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes, edges) # Narrow subset based on predicate (save the edges that are qualified to use for finding future groundings faster) qualified_groundings = get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, clause_bnd) @@ -1973,7 +1975,7 @@ def get_rule_node_clause_grounding(clause_var_1, groundings, nodes): @numba.njit(cache=True) -def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes): +def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes, edges): # There are 4 cases for predicate(Y,Z): # 1. Both predicate variables Y and Z have not been encountered before # 2. The source variable Y has not been encountered before but the target variable Z has @@ -1984,9 +1986,7 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun # Case 1: # We replace Y by all nodes and Z by the neighbors of each of these nodes if clause_var_1 not in groundings and clause_var_2 not in groundings: - for n in nodes: - es = numba.typed.List([(n, nn) for nn in neighbors[n]]) - edge_groundings.extend(es) + edge_groundings = numba.typed.List(edges) # Case 2: # We replace Y by the sources of Z From 22f6f9273a0ef4e882fd4549c0461d6602d9119f Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 13 Oct 2024 14:20:02 +0200 Subject: [PATCH 65/73] new predicate hashsmap to speedup groundings --- .../scripts/interpretation/interpretation.py | 122 ++++++++++++----- .../interpretation/interpretation_parallel.py | 126 ++++++++++++------ 2 files changed, 172 insertions(+), 76 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index d901fe3..4103059 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -108,8 +108,8 @@ def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, else: self.available_labels_edge = numba.typed.List(self.available_labels_edge) - self.interpretations_node = self._init_interpretations_node(self.nodes, self.available_labels_node, self.specific_node_labels) - self.interpretations_edge = self._init_interpretations_edge(self.edges, self.available_labels_edge, self.specific_edge_labels) + self.interpretations_node, self.predicate_map_node = self._init_interpretations_node(self.nodes, self.available_labels_node, self.specific_node_labels) + self.interpretations_edge, self.predicate_map_edge = self._init_interpretations_edge(self.edges, self.available_labels_edge, self.specific_edge_labels) # Setup graph neighbors and reverse neighbors self.neighbors = numba.typed.Dict.empty(key_type=node_type, value_type=numba.types.ListType(node_type)) @@ -140,6 +140,7 @@ def _init_reverse_neighbors(neighbors): @numba.njit(cache=True) def _init_interpretations_node(nodes, available_labels, specific_labels): interpretations = numba.typed.Dict.empty(key_type=node_type, value_type=world.world_type) + predicate_map = numba.typed.Dict.empty(key_type=label.label_type, value_type=list_of_nodes) # General labels for n in nodes: interpretations[n] = world.World(available_labels) @@ -148,12 +149,19 @@ def _init_interpretations_node(nodes, available_labels, specific_labels): for n in ns: interpretations[n].world[l] = interval.closed(0.0, 1.0) - return interpretations + for l in available_labels: + predicate_map[l] = numba.typed.List(nodes) + + for l, ns in specific_labels.items(): + predicate_map[l] = numba.typed.List(ns) + + return interpretations, predicate_map @staticmethod @numba.njit(cache=True) def _init_interpretations_edge(edges, available_labels, specific_labels): interpretations = numba.typed.Dict.empty(key_type=edge_type, value_type=world.world_type) + predicate_map = numba.typed.Dict.empty(key_type=label.label_type, value_type=list_of_edges) # General labels for e in edges: interpretations[e] = world.World(available_labels) @@ -162,7 +170,13 @@ def _init_interpretations_edge(edges, available_labels, specific_labels): for e in es: interpretations[e].world[l] = interval.closed(0.0, 1.0) - return interpretations + for l in available_labels: + predicate_map[l] = numba.typed.List(edges) + + for l, es in specific_labels.items(): + predicate_map[l] = numba.typed.List(es) + + return interpretations, predicate_map @staticmethod @numba.njit(cache=True) @@ -207,7 +221,7 @@ def _init_facts(facts_node, facts_edge, facts_to_be_applied_node, facts_to_be_ap return max_time def _start_fp(self, rules, max_facts_time, verbose, again): - fp_cnt, t = self.reason(self.interpretations_node, self.interpretations_edge, self.tmax, self.prev_reasoning_data, rules, self.nodes, self.edges, self.neighbors, self.reverse_neighbors, self.rules_to_be_applied_node, self.rules_to_be_applied_edge, self.edges_to_be_added_node_rule, self.edges_to_be_added_edge_rule, self.rules_to_be_applied_node_trace, self.rules_to_be_applied_edge_trace, self.facts_to_be_applied_node, self.facts_to_be_applied_edge, self.facts_to_be_applied_node_trace, self.facts_to_be_applied_edge_trace, self.ipl, self.rule_trace_node, self.rule_trace_edge, self.rule_trace_node_atoms, self.rule_trace_edge_atoms, self.reverse_graph, self.atom_trace, self.save_graph_attributes_to_rule_trace, self.canonical, self.inconsistency_check, self.store_interpretation_changes, self.update_mode, self.allow_ground_rules, max_facts_time, self.annotation_functions, self._convergence_mode, self._convergence_delta, verbose, again) + fp_cnt, t = self.reason(self.interpretations_node, self.interpretations_edge, self.predicate_map_node, self.predicate_map_edge, self.tmax, self.prev_reasoning_data, rules, self.nodes, self.edges, self.neighbors, self.reverse_neighbors, self.rules_to_be_applied_node, self.rules_to_be_applied_edge, self.edges_to_be_added_node_rule, self.edges_to_be_added_edge_rule, self.rules_to_be_applied_node_trace, self.rules_to_be_applied_edge_trace, self.facts_to_be_applied_node, self.facts_to_be_applied_edge, self.facts_to_be_applied_node_trace, self.facts_to_be_applied_edge_trace, self.ipl, self.rule_trace_node, self.rule_trace_edge, self.rule_trace_node_atoms, self.rule_trace_edge_atoms, self.reverse_graph, self.atom_trace, self.save_graph_attributes_to_rule_trace, self.canonical, self.inconsistency_check, self.store_interpretation_changes, self.update_mode, self.allow_ground_rules, max_facts_time, self.annotation_functions, self._convergence_mode, self._convergence_delta, verbose, again) self.time = t - 1 # If we need to reason again, store the next timestep to start from self.prev_reasoning_data[0] = t @@ -217,7 +231,7 @@ def _start_fp(self, rules, max_facts_time, verbose, again): @staticmethod @numba.njit(cache=True, parallel=False) - def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_rules, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): + def reason(interpretations_node, interpretations_edge, predicate_map_node, predicate_map_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_rules, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): t = prev_reasoning_data[0] fp_cnt = prev_reasoning_data[1] max_rules_time = 0 @@ -288,7 +302,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if check_consistent_node(interpretations_node, comp, (l, bnd)): mode = 'graph-attribute-fact' if graph_attribute else 'fact' override = True if update_mode == 'override' else False - u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, i, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode=mode, override=override) + u, changes = _update_node(interpretations_node, predicate_map_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, i, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode=mode, override=override) update = u or update # Update convergence params @@ -302,7 +316,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if inconsistency_check: resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, i, atom_trace, rule_trace_node, rule_trace_node_atoms, rules_to_be_applied_node_trace, facts_to_be_applied_node_trace, store_interpretation_changes, mode=mode) else: - u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, i, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode=mode, override=True) + u, changes = _update_node(interpretations_node, predicate_map_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, i, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode=mode, override=True) update = u or update # Update convergence params @@ -330,7 +344,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data comp, l, bnd, static, graph_attribute = facts_to_be_applied_edge[i][1], facts_to_be_applied_edge[i][2], facts_to_be_applied_edge[i][3], facts_to_be_applied_edge[i][4], facts_to_be_applied_edge[i][5] # If the component is not in the graph, add it if comp not in edges_set: - _add_edge(comp[0], comp[1], neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + _add_edge(comp[0], comp[1], neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge, predicate_map_edge) edges_set.add(comp) # Check if bnd is static. Then no need to update, just add to rule trace, check if graph attribute, and add ipl complement to rule trace as well @@ -354,7 +368,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if check_consistent_edge(interpretations_edge, comp, (l, bnd)): mode = 'graph-attribute-fact' if graph_attribute else 'fact' override = True if update_mode == 'override' else False - u, changes = _update_edge(interpretations_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, i, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode=mode, override=override) + u, changes = _update_edge(interpretations_edge, predicate_map_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, i, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode=mode, override=override) update = u or update # Update convergence params @@ -368,7 +382,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if inconsistency_check: resolve_inconsistency_edge(interpretations_edge, comp, (l, bnd), ipl, t, fp_cnt, i, atom_trace, rule_trace_edge, rule_trace_edge_atoms, rules_to_be_applied_edge_trace, facts_to_be_applied_edge_trace, store_interpretation_changes, mode=mode) else: - u, changes = _update_edge(interpretations_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, i, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode=mode, override=True) + u, changes = _update_edge(interpretations_edge, predicate_map_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, i, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode=mode, override=True) update = u or update # Update convergence params @@ -402,7 +416,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Check for inconsistencies if check_consistent_node(interpretations_node, comp, (l, bnd)): override = True if update_mode == 'override' else False - u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=override) + u, changes = _update_node(interpretations_node, predicate_map_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=override) update = u or update # Update convergence params @@ -415,7 +429,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if inconsistency_check: resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, idx, atom_trace, rule_trace_node, rule_trace_node_atoms, rules_to_be_applied_node_trace, facts_to_be_applied_node_trace, store_interpretation_changes, mode='rule') else: - u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=True) + u, changes = _update_node(interpretations_node, predicate_map_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=True) update = u or update # Update convergence params @@ -439,7 +453,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if i[0] == t: comp, l, bnd, immediate, set_static = i[1], i[2], i[3], i[4], i[5] sources, targets, edge_l = edges_to_be_added_edge_rule[idx] - edges_added, changes = _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, edge_l, interpretations_node, interpretations_edge) + edges_added, changes = _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, edge_l, interpretations_node, interpretations_edge, predicate_map_edge) changes_cnt += changes # Update bound for newly added edges. Use bnd to update all edges if label is specified, else use bnd to update normally @@ -449,7 +463,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data continue if check_consistent_edge(interpretations_edge, e, (edge_l, bnd)): override = True if update_mode == 'override' else False - u, changes = _update_edge(interpretations_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=override) + u, changes = _update_edge(interpretations_edge, predicate_map_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=override) update = u or update @@ -463,7 +477,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if inconsistency_check: resolve_inconsistency_edge(interpretations_edge, e, (edge_l, bnd), ipl, t, fp_cnt, idx, atom_trace, rule_trace_edge, rule_trace_edge_atoms, rules_to_be_applied_edge_trace, facts_to_be_applied_edge_trace, store_interpretation_changes, mode='rule') else: - u, changes = _update_edge(interpretations_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) + u, changes = _update_edge(interpretations_edge, predicate_map_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) update = u or update @@ -477,7 +491,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Check for inconsistencies if check_consistent_edge(interpretations_edge, comp, (l, bnd)): override = True if update_mode == 'override' else False - u, changes = _update_edge(interpretations_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=override) + u, changes = _update_edge(interpretations_edge, predicate_map_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=override) update = u or update # Update convergence params @@ -490,7 +504,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if inconsistency_check: resolve_inconsistency_edge(interpretations_edge, comp, (l, bnd), ipl, t, fp_cnt, idx, atom_trace, rule_trace_edge, rule_trace_edge_atoms, rules_to_be_applied_edge_trace, facts_to_be_applied_edge_trace, store_interpretation_changes, mode='rule') else: - u, changes = _update_edge(interpretations_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) + u, changes = _update_edge(interpretations_edge, predicate_map_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) update = u or update # Update convergence params @@ -529,7 +543,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Only go through if the rule can be applied within the given timesteps, or we're running until convergence delta_t = rule.get_delta() if t + delta_t <= tmax or tmax == -1 or again: - applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_rules) + applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, predicate_map_node, predicate_map_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_rules) # Loop through applicable rules and add them to the rules to be applied for later or next fp operation for applicable_rule in applicable_node_rules: @@ -623,7 +637,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data def add_edge(self, edge, l): # This function is useful for pyreason gym, called externally - _add_edge(edge[0], edge[1], self.neighbors, self.reverse_neighbors, self.nodes, self.edges, l, self.interpretations_node, self.interpretations_edge) + _add_edge(edge[0], edge[1], self.neighbors, self.reverse_neighbors, self.nodes, self.edges, l, self.interpretations_node, self.interpretations_edge, self.predicate_map_edge) def add_node(self, node, labels): # This function is useful for pyreason gym, called externally @@ -634,11 +648,11 @@ def add_node(self, node, labels): def delete_edge(self, edge): # This function is useful for pyreason gym, called externally - _delete_edge(edge, self.neighbors, self.reverse_neighbors, self.edges, self.interpretations_edge) + _delete_edge(edge, self.neighbors, self.reverse_neighbors, self.edges, self.interpretations_edge, self.predicate_map_edge) def delete_node(self, node): # This function is useful for pyreason gym, called externally - _delete_node(node, self.neighbors, self.reverse_neighbors, self.nodes, self.interpretations_node) + _delete_node(node, self.neighbors, self.reverse_neighbors, self.nodes, self.interpretations_node, self.predicate_map_node) def get_dict(self): # This function can be called externally to retrieve a dict of the interpretation values @@ -741,7 +755,7 @@ def query(self, query, return_bool=True): @numba.njit(cache=True) -def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_rules): +def _ground_rule(rule, interpretations_node, interpretations_edge, predicate_map_node, predicate_map_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_rules): # Extract rule params rule_type = rule.get_type() head_variables = rule.get_head_variables() @@ -793,7 +807,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if allow_ground_rules and clause_var_1 in nodes_set: grounding = numba.typed.List([clause_var_1]) else: - grounding = get_rule_node_clause_grounding(clause_var_1, groundings, nodes) + grounding = get_rule_node_clause_grounding(clause_var_1, groundings, predicate_map_node, clause_label) # Narrow subset based on predicate qualified_groundings = get_qualified_node_groundings(interpretations_node, grounding, clause_label, clause_bnd) @@ -820,7 +834,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if allow_ground_rules and (clause_var_1, clause_var_2) in edges_set: grounding = numba.typed.List([(clause_var_1, clause_var_2)]) else: - grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes, edges) + grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, predicate_map_edge, clause_label) # Narrow subset based on predicate (save the edges that are qualified to use for finding future groundings faster) qualified_groundings = get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, clause_bnd) @@ -1158,7 +1172,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if add_head_var_2_node_to_graph and head_var_2_grounding == head_var_2: _add_node(head_var_2, neighbors, reverse_neighbors, nodes, interpretations_node) if add_head_edge_to_graph and (head_var_1, head_var_2) == (head_var_1_grounding, head_var_2_grounding): - _add_edge(head_var_1, head_var_2, neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + _add_edge(head_var_1, head_var_2, neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge, predicate_map_edge) # For each grounding combination add a rule to be applied # Only if all the clauses have valid groundings @@ -1968,14 +1982,14 @@ def check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, @numba.njit(cache=True) -def get_rule_node_clause_grounding(clause_var_1, groundings, nodes): +def get_rule_node_clause_grounding(clause_var_1, groundings, predicate_map, l): # The groundings for a node clause can be either a previous grounding or all possible nodes - grounding = numba.typed.List(nodes) if clause_var_1 not in groundings else groundings[clause_var_1] + grounding = predicate_map[l] if clause_var_1 not in groundings else groundings[clause_var_1] return grounding @numba.njit(cache=True) -def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes, edges): +def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, predicate_map, l): # There are 4 cases for predicate(Y,Z): # 1. Both predicate variables Y and Z have not been encountered before # 2. The source variable Y has not been encountered before but the target variable Z has @@ -1986,7 +2000,7 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun # Case 1: # We replace Y by all nodes and Z by the neighbors of each of these nodes if clause_var_1 not in groundings and clause_var_2 not in groundings: - edge_groundings = numba.typed.List(edges) + edge_groundings = predicate_map[l] # Case 2: # We replace Y by the sources of Z @@ -2390,7 +2404,7 @@ def _satisfies_threshold(num_neigh, num_qualified_component, threshold): @numba.njit(cache=True) -def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_trace, idx, facts_to_be_applied_trace, rule_trace_atoms, store_interpretation_changes, mode, override=False): +def _update_node(interpretations, predicate_map, comp, na, ipl, rule_trace, fp_cnt, t_cnt, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_trace, idx, facts_to_be_applied_trace, rule_trace_atoms, store_interpretation_changes, mode, override=False): updated = False # This is to prevent a key error in case the label is a specific label try: @@ -2401,6 +2415,10 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat # Add label to world if it is not there if l not in world.world: world.world[l] = interval.closed(0, 1) + if l in predicate_map: + predicate_map[l].append(comp) + else: + predicate_map[l] = numba.typed.List([comp]) # Check if update is necessary with previous bnd prev_bnd = world.world[l].copy() @@ -2436,6 +2454,10 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat if p1 == l: if p2 not in world.world: world.world[p2] = interval.closed(0, 1) + if p2 in predicate_map: + predicate_map[p2].append(comp) + else: + predicate_map[p2] = numba.typed.List([comp]) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p2], f'IPL: {l.get_value()}') lower = max(world.world[p2].lower, 1 - world.world[p1].upper) @@ -2449,6 +2471,10 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat if p2 == l: if p1 not in world.world: world.world[p1] = interval.closed(0, 1) + if p1 in predicate_map: + predicate_map[p1].append(comp) + else: + predicate_map[p1] = numba.typed.List([comp]) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p1], f'IPL: {l.get_value()}') lower = max(world.world[p1].lower, 1 - world.world[p2].upper) @@ -2483,7 +2509,7 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat @numba.njit(cache=True) -def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_trace, idx, facts_to_be_applied_trace, rule_trace_atoms, store_interpretation_changes, mode, override=False): +def _update_edge(interpretations, predicate_map, comp, na, ipl, rule_trace, fp_cnt, t_cnt, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_trace, idx, facts_to_be_applied_trace, rule_trace_atoms, store_interpretation_changes, mode, override=False): updated = False # This is to prevent a key error in case the label is a specific label try: @@ -2494,6 +2520,10 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat # Add label to world if it is not there if l not in world.world: world.world[l] = interval.closed(0, 1) + if l in predicate_map: + predicate_map[l].append(comp) + else: + predicate_map[l] = numba.typed.List([comp]) # Check if update is necessary with previous bnd prev_bnd = world.world[l].copy() @@ -2529,6 +2559,10 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat if p1 == l: if p2 not in world.world: world.world[p2] = interval.closed(0, 1) + if p2 in predicate_map: + predicate_map[p2].append(comp) + else: + predicate_map[p2] = numba.typed.List([comp]) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p2], f'IPL: {l.get_value()}') lower = max(world.world[p2].lower, 1 - world.world[p1].upper) @@ -2542,6 +2576,10 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat if p2 == l: if p1 not in world.world: world.world[p1] = interval.closed(0, 1) + if p1 in predicate_map: + predicate_map[p1].append(comp) + else: + predicate_map[p1] = numba.typed.List([comp]) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p1], f'IPL: {l.get_value()}') lower = max(world.world[p1].lower, 1 - world.world[p2].upper) @@ -2792,7 +2830,7 @@ def _add_node(node, neighbors, reverse_neighbors, nodes, interpretations_node): @numba.njit(cache=True) -def _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge): +def _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge, predicate_map): # If not a node, add to list of nodes and initialize neighbors if source not in nodes: _add_node(source, neighbors, reverse_neighbors, nodes, interpretations_node) @@ -2812,6 +2850,10 @@ def _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, int reverse_neighbors[target].append(source) if l.value!='': interpretations_edge[edge] = world.World(numba.typed.List([l])) + if l in predicate_map: + predicate_map[l].append(edge) + else: + predicate_map[l] = numba.typed.List([edge]) else: interpretations_edge[edge] = world.World(numba.typed.List.empty_list(label.label_type)) else: @@ -2823,32 +2865,38 @@ def _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, int @numba.njit(cache=True) -def _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge): +def _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge, predicate_map): changes = 0 edges_added = numba.typed.List.empty_list(edge_type) for source in sources: for target in targets: - edge, new_edge = _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge) + edge, new_edge = _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge, predicate_map) edges_added.append(edge) changes = changes+1 if new_edge else changes return edges_added, changes @numba.njit(cache=True) -def _delete_edge(edge, neighbors, reverse_neighbors, edges, interpretations_edge): +def _delete_edge(edge, neighbors, reverse_neighbors, edges, interpretations_edge, predicate_map): source, target = edge edges.remove(edge) del interpretations_edge[edge] + for l in predicate_map: + if edge in predicate_map[l]: + predicate_map[l].remove(edge) neighbors[source].remove(target) reverse_neighbors[target].remove(source) @numba.njit(cache=True) -def _delete_node(node, neighbors, reverse_neighbors, nodes, interpretations_node): +def _delete_node(node, neighbors, reverse_neighbors, nodes, interpretations_node, predicate_map): nodes.remove(node) del interpretations_node[node] del neighbors[node] del reverse_neighbors[node] + for l in predicate_map: + if node in predicate_map[l]: + predicate_map[l].remove(node) # Remove all occurrences of node in neighbors for n in neighbors.keys(): diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index e5004b1..23deace 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -1,3 +1,5 @@ +from networkx.classes import edges + import pyreason.scripts.numba_wrapper.numba_types.world_type as world import pyreason.scripts.numba_wrapper.numba_types.label_type as label import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval @@ -106,8 +108,8 @@ def __init__(self, graph, ipl, annotation_functions, reverse_graph, atom_trace, else: self.available_labels_edge = numba.typed.List(self.available_labels_edge) - self.interpretations_node = self._init_interpretations_node(self.nodes, self.available_labels_node, self.specific_node_labels) - self.interpretations_edge = self._init_interpretations_edge(self.edges, self.available_labels_edge, self.specific_edge_labels) + self.interpretations_node, self.predicate_map_node = self._init_interpretations_node(self.nodes, self.available_labels_node, self.specific_node_labels) + self.interpretations_edge, self.predicate_map_edge = self._init_interpretations_edge(self.edges, self.available_labels_edge, self.specific_edge_labels) # Setup graph neighbors and reverse neighbors self.neighbors = numba.typed.Dict.empty(key_type=node_type, value_type=numba.types.ListType(node_type)) @@ -138,6 +140,7 @@ def _init_reverse_neighbors(neighbors): @numba.njit(cache=True) def _init_interpretations_node(nodes, available_labels, specific_labels): interpretations = numba.typed.Dict.empty(key_type=node_type, value_type=world.world_type) + predicate_map = numba.typed.Dict.empty(key_type=label.label_type, value_type=list_of_nodes) # General labels for n in nodes: interpretations[n] = world.World(available_labels) @@ -146,12 +149,19 @@ def _init_interpretations_node(nodes, available_labels, specific_labels): for n in ns: interpretations[n].world[l] = interval.closed(0.0, 1.0) - return interpretations + for l in available_labels: + predicate_map[l] = numba.typed.List(nodes) + + for l, ns in specific_labels.items(): + predicate_map[l] = numba.typed.List(ns) + + return interpretations, predicate_map @staticmethod @numba.njit(cache=True) def _init_interpretations_edge(edges, available_labels, specific_labels): interpretations = numba.typed.Dict.empty(key_type=edge_type, value_type=world.world_type) + predicate_map = numba.typed.Dict.empty(key_type=label.label_type, value_type=list_of_edges) # General labels for e in edges: interpretations[e] = world.World(available_labels) @@ -160,7 +170,13 @@ def _init_interpretations_edge(edges, available_labels, specific_labels): for e in es: interpretations[e].world[l] = interval.closed(0.0, 1.0) - return interpretations + for l in available_labels: + predicate_map[l] = numba.typed.List(edges) + + for l, es in specific_labels.items(): + predicate_map[l] = numba.typed.List(es) + + return interpretations, predicate_map @staticmethod @numba.njit(cache=True) @@ -205,7 +221,7 @@ def _init_facts(facts_node, facts_edge, facts_to_be_applied_node, facts_to_be_ap return max_time def _start_fp(self, rules, max_facts_time, verbose, again): - fp_cnt, t = self.reason(self.interpretations_node, self.interpretations_edge, self.tmax, self.prev_reasoning_data, rules, self.nodes, self.edges, self.neighbors, self.reverse_neighbors, self.rules_to_be_applied_node, self.rules_to_be_applied_edge, self.edges_to_be_added_node_rule, self.edges_to_be_added_edge_rule, self.rules_to_be_applied_node_trace, self.rules_to_be_applied_edge_trace, self.facts_to_be_applied_node, self.facts_to_be_applied_edge, self.facts_to_be_applied_node_trace, self.facts_to_be_applied_edge_trace, self.ipl, self.rule_trace_node, self.rule_trace_edge, self.rule_trace_node_atoms, self.rule_trace_edge_atoms, self.reverse_graph, self.atom_trace, self.save_graph_attributes_to_rule_trace, self.canonical, self.inconsistency_check, self.store_interpretation_changes, self.update_mode, self.allow_ground_rules, max_facts_time, self.annotation_functions, self._convergence_mode, self._convergence_delta, verbose, again) + fp_cnt, t = self.reason(self.interpretations_node, self.interpretations_edge, self.predicate_map_node, self.predicate_map_edge, self.tmax, self.prev_reasoning_data, rules, self.nodes, self.edges, self.neighbors, self.reverse_neighbors, self.rules_to_be_applied_node, self.rules_to_be_applied_edge, self.edges_to_be_added_node_rule, self.edges_to_be_added_edge_rule, self.rules_to_be_applied_node_trace, self.rules_to_be_applied_edge_trace, self.facts_to_be_applied_node, self.facts_to_be_applied_edge, self.facts_to_be_applied_node_trace, self.facts_to_be_applied_edge_trace, self.ipl, self.rule_trace_node, self.rule_trace_edge, self.rule_trace_node_atoms, self.rule_trace_edge_atoms, self.reverse_graph, self.atom_trace, self.save_graph_attributes_to_rule_trace, self.canonical, self.inconsistency_check, self.store_interpretation_changes, self.update_mode, self.allow_ground_rules, max_facts_time, self.annotation_functions, self._convergence_mode, self._convergence_delta, verbose, again) self.time = t - 1 # If we need to reason again, store the next timestep to start from self.prev_reasoning_data[0] = t @@ -215,7 +231,7 @@ def _start_fp(self, rules, max_facts_time, verbose, again): @staticmethod @numba.njit(cache=True, parallel=True) - def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_rules, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): + def reason(interpretations_node, interpretations_edge, predicate_map_node, predicate_map_edge, tmax, prev_reasoning_data, rules, nodes, edges, neighbors, reverse_neighbors, rules_to_be_applied_node, rules_to_be_applied_edge, edges_to_be_added_node_rule, edges_to_be_added_edge_rule, rules_to_be_applied_node_trace, rules_to_be_applied_edge_trace, facts_to_be_applied_node, facts_to_be_applied_edge, facts_to_be_applied_node_trace, facts_to_be_applied_edge_trace, ipl, rule_trace_node, rule_trace_edge, rule_trace_node_atoms, rule_trace_edge_atoms, reverse_graph, atom_trace, save_graph_attributes_to_rule_trace, canonical, inconsistency_check, store_interpretation_changes, update_mode, allow_ground_rules, max_facts_time, annotation_functions, convergence_mode, convergence_delta, verbose, again): t = prev_reasoning_data[0] fp_cnt = prev_reasoning_data[1] max_rules_time = 0 @@ -286,7 +302,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if check_consistent_node(interpretations_node, comp, (l, bnd)): mode = 'graph-attribute-fact' if graph_attribute else 'fact' override = True if update_mode == 'override' else False - u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, i, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode=mode, override=override) + u, changes = _update_node(interpretations_node, predicate_map_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, i, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode=mode, override=override) update = u or update # Update convergence params @@ -300,7 +316,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if inconsistency_check: resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, i, atom_trace, rule_trace_node, rule_trace_node_atoms, rules_to_be_applied_node_trace, facts_to_be_applied_node_trace, store_interpretation_changes, mode=mode) else: - u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, i, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode=mode, override=True) + u, changes = _update_node(interpretations_node, predicate_map_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, i, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode=mode, override=True) update = u or update # Update convergence params @@ -328,7 +344,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data comp, l, bnd, static, graph_attribute = facts_to_be_applied_edge[i][1], facts_to_be_applied_edge[i][2], facts_to_be_applied_edge[i][3], facts_to_be_applied_edge[i][4], facts_to_be_applied_edge[i][5] # If the component is not in the graph, add it if comp not in edges_set: - _add_edge(comp[0], comp[1], neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + _add_edge(comp[0], comp[1], neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge, predicate_map_edge) edges_set.add(comp) # Check if bnd is static. Then no need to update, just add to rule trace, check if graph attribute, and add ipl complement to rule trace as well @@ -352,7 +368,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if check_consistent_edge(interpretations_edge, comp, (l, bnd)): mode = 'graph-attribute-fact' if graph_attribute else 'fact' override = True if update_mode == 'override' else False - u, changes = _update_edge(interpretations_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, i, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode=mode, override=override) + u, changes = _update_edge(interpretations_edge, predicate_map_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, i, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode=mode, override=override) update = u or update # Update convergence params @@ -366,7 +382,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if inconsistency_check: resolve_inconsistency_edge(interpretations_edge, comp, (l, bnd), ipl, t, fp_cnt, i, atom_trace, rule_trace_edge, rule_trace_edge_atoms, rules_to_be_applied_edge_trace, facts_to_be_applied_edge_trace, store_interpretation_changes, mode=mode) else: - u, changes = _update_edge(interpretations_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, i, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode=mode, override=True) + u, changes = _update_edge(interpretations_edge, predicate_map_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, i, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode=mode, override=True) update = u or update # Update convergence params @@ -400,7 +416,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Check for inconsistencies if check_consistent_node(interpretations_node, comp, (l, bnd)): override = True if update_mode == 'override' else False - u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=override) + u, changes = _update_node(interpretations_node, predicate_map_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=override) update = u or update # Update convergence params @@ -413,7 +429,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if inconsistency_check: resolve_inconsistency_node(interpretations_node, comp, (l, bnd), ipl, t, fp_cnt, idx, atom_trace, rule_trace_node, rule_trace_node_atoms, rules_to_be_applied_node_trace, facts_to_be_applied_node_trace, store_interpretation_changes, mode='rule') else: - u, changes = _update_node(interpretations_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=True) + u, changes = _update_node(interpretations_node, predicate_map_node, comp, (l, bnd), ipl, rule_trace_node, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_node_trace, idx, facts_to_be_applied_node_trace, rule_trace_node_atoms, store_interpretation_changes, mode='rule', override=True) update = u or update # Update convergence params @@ -437,7 +453,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if i[0] == t: comp, l, bnd, immediate, set_static = i[1], i[2], i[3], i[4], i[5] sources, targets, edge_l = edges_to_be_added_edge_rule[idx] - edges_added, changes = _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, edge_l, interpretations_node, interpretations_edge) + edges_added, changes = _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, edge_l, interpretations_node, interpretations_edge, predicate_map_edge) changes_cnt += changes # Update bound for newly added edges. Use bnd to update all edges if label is specified, else use bnd to update normally @@ -447,7 +463,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data continue if check_consistent_edge(interpretations_edge, e, (edge_l, bnd)): override = True if update_mode == 'override' else False - u, changes = _update_edge(interpretations_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=override) + u, changes = _update_edge(interpretations_edge, predicate_map_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=override) update = u or update @@ -461,7 +477,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if inconsistency_check: resolve_inconsistency_edge(interpretations_edge, e, (edge_l, bnd), ipl, t, fp_cnt, idx, atom_trace, rule_trace_edge, rule_trace_edge_atoms, rules_to_be_applied_edge_trace, facts_to_be_applied_edge_trace, store_interpretation_changes, mode='rule') else: - u, changes = _update_edge(interpretations_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) + u, changes = _update_edge(interpretations_edge, predicate_map_edge, e, (edge_l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) update = u or update @@ -475,7 +491,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Check for inconsistencies if check_consistent_edge(interpretations_edge, comp, (l, bnd)): override = True if update_mode == 'override' else False - u, changes = _update_edge(interpretations_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=override) + u, changes = _update_edge(interpretations_edge, predicate_map_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=override) update = u or update # Update convergence params @@ -488,7 +504,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data if inconsistency_check: resolve_inconsistency_edge(interpretations_edge, comp, (l, bnd), ipl, t, fp_cnt, idx, atom_trace, rule_trace_edge, rule_trace_edge_atoms, rules_to_be_applied_edge_trace, facts_to_be_applied_edge_trace, store_interpretation_changes, mode='rule') else: - u, changes = _update_edge(interpretations_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) + u, changes = _update_edge(interpretations_edge, predicate_map_edge, comp, (l, bnd), ipl, rule_trace_edge, fp_cnt, t, set_static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_edge_trace, idx, facts_to_be_applied_edge_trace, rule_trace_edge_atoms, store_interpretation_changes, mode='rule', override=True) update = u or update # Update convergence params @@ -527,7 +543,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data # Only go through if the rule can be applied within the given timesteps, or we're running until convergence delta_t = rule.get_delta() if t + delta_t <= tmax or tmax == -1 or again: - applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_rules) + applicable_node_rules, applicable_edge_rules = _ground_rule(rule, interpretations_node, interpretations_edge, predicate_map_node, predicate_map_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_rules) # Loop through applicable rules and add them to the rules to be applied for later or next fp operation for applicable_rule in applicable_node_rules: @@ -621,7 +637,7 @@ def reason(interpretations_node, interpretations_edge, tmax, prev_reasoning_data def add_edge(self, edge, l): # This function is useful for pyreason gym, called externally - _add_edge(edge[0], edge[1], self.neighbors, self.reverse_neighbors, self.nodes, self.edges, l, self.interpretations_node, self.interpretations_edge) + _add_edge(edge[0], edge[1], self.neighbors, self.reverse_neighbors, self.nodes, self.edges, l, self.interpretations_node, self.interpretations_edge, self.predicate_map_edge) def add_node(self, node, labels): # This function is useful for pyreason gym, called externally @@ -632,11 +648,11 @@ def add_node(self, node, labels): def delete_edge(self, edge): # This function is useful for pyreason gym, called externally - _delete_edge(edge, self.neighbors, self.reverse_neighbors, self.edges, self.interpretations_edge) + _delete_edge(edge, self.neighbors, self.reverse_neighbors, self.edges, self.interpretations_edge, self.predicate_map_edge) def delete_node(self, node): # This function is useful for pyreason gym, called externally - _delete_node(node, self.neighbors, self.reverse_neighbors, self.nodes, self.interpretations_node) + _delete_node(node, self.neighbors, self.reverse_neighbors, self.nodes, self.interpretations_node, self.predicate_map_node) def get_dict(self): # This function can be called externally to retrieve a dict of the interpretation values @@ -739,7 +755,7 @@ def query(self, query, return_bool=True): @numba.njit(cache=True) -def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_rules): +def _ground_rule(rule, interpretations_node, interpretations_edge, predicate_map_node, predicate_map_edge, nodes, edges, neighbors, reverse_neighbors, atom_trace, allow_ground_rules): # Extract rule params rule_type = rule.get_type() head_variables = rule.get_head_variables() @@ -791,7 +807,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if allow_ground_rules and clause_var_1 in nodes_set: grounding = numba.typed.List([clause_var_1]) else: - grounding = get_rule_node_clause_grounding(clause_var_1, groundings, nodes) + grounding = get_rule_node_clause_grounding(clause_var_1, groundings, predicate_map_node, clause_label) # Narrow subset based on predicate qualified_groundings = get_qualified_node_groundings(interpretations_node, grounding, clause_label, clause_bnd) @@ -818,7 +834,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if allow_ground_rules and (clause_var_1, clause_var_2) in edges_set: grounding = numba.typed.List([(clause_var_1, clause_var_2)]) else: - grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes) + grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, predicate_map_edge, clause_label) # Narrow subset based on predicate (save the edges that are qualified to use for finding future groundings faster) qualified_groundings = get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, clause_bnd) @@ -1156,7 +1172,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, nodes, edges, if add_head_var_2_node_to_graph and head_var_2_grounding == head_var_2: _add_node(head_var_2, neighbors, reverse_neighbors, nodes, interpretations_node) if add_head_edge_to_graph and (head_var_1, head_var_2) == (head_var_1_grounding, head_var_2_grounding): - _add_edge(head_var_1, head_var_2, neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge) + _add_edge(head_var_1, head_var_2, neighbors, reverse_neighbors, nodes, edges, label.Label(''), interpretations_node, interpretations_edge, predicate_map_edge) # For each grounding combination add a rule to be applied # Only if all the clauses have valid groundings @@ -1966,14 +1982,14 @@ def check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, @numba.njit(cache=True) -def get_rule_node_clause_grounding(clause_var_1, groundings, nodes): +def get_rule_node_clause_grounding(clause_var_1, groundings, predicate_map, l): # The groundings for a node clause can be either a previous grounding or all possible nodes - grounding = numba.typed.List(nodes) if clause_var_1 not in groundings else groundings[clause_var_1] + grounding = predicate_map[l] if clause_var_1 not in groundings else groundings[clause_var_1] return grounding @numba.njit(cache=True) -def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, nodes): +def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, predicate_map, l): # There are 4 cases for predicate(Y,Z): # 1. Both predicate variables Y and Z have not been encountered before # 2. The source variable Y has not been encountered before but the target variable Z has @@ -1984,9 +2000,7 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun # Case 1: # We replace Y by all nodes and Z by the neighbors of each of these nodes if clause_var_1 not in groundings and clause_var_2 not in groundings: - for n in nodes: - es = numba.typed.List([(n, nn) for nn in neighbors[n]]) - edge_groundings.extend(es) + edge_groundings = predicate_map[l] # Case 2: # We replace Y by the sources of Z @@ -2390,7 +2404,7 @@ def _satisfies_threshold(num_neigh, num_qualified_component, threshold): @numba.njit(cache=True) -def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_trace, idx, facts_to_be_applied_trace, rule_trace_atoms, store_interpretation_changes, mode, override=False): +def _update_node(interpretations, predicate_map, comp, na, ipl, rule_trace, fp_cnt, t_cnt, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_trace, idx, facts_to_be_applied_trace, rule_trace_atoms, store_interpretation_changes, mode, override=False): updated = False # This is to prevent a key error in case the label is a specific label try: @@ -2401,6 +2415,10 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat # Add label to world if it is not there if l not in world.world: world.world[l] = interval.closed(0, 1) + if l in predicate_map: + predicate_map[l].append(comp) + else: + predicate_map[l] = numba.typed.List([comp]) # Check if update is necessary with previous bnd prev_bnd = world.world[l].copy() @@ -2436,6 +2454,10 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat if p1 == l: if p2 not in world.world: world.world[p2] = interval.closed(0, 1) + if p2 in predicate_map: + predicate_map[p2].append(comp) + else: + predicate_map[p2] = numba.typed.List([comp]) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p2], f'IPL: {l.get_value()}') lower = max(world.world[p2].lower, 1 - world.world[p1].upper) @@ -2449,6 +2471,10 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat if p2 == l: if p1 not in world.world: world.world[p1] = interval.closed(0, 1) + if p1 in predicate_map: + predicate_map[p1].append(comp) + else: + predicate_map[p1] = numba.typed.List([comp]) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p1], f'IPL: {l.get_value()}') lower = max(world.world[p1].lower, 1 - world.world[p2].upper) @@ -2483,7 +2509,7 @@ def _update_node(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat @numba.njit(cache=True) -def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_trace, idx, facts_to_be_applied_trace, rule_trace_atoms, store_interpretation_changes, mode, override=False): +def _update_edge(interpretations, predicate_map, comp, na, ipl, rule_trace, fp_cnt, t_cnt, static, convergence_mode, atom_trace, save_graph_attributes_to_rule_trace, rules_to_be_applied_trace, idx, facts_to_be_applied_trace, rule_trace_atoms, store_interpretation_changes, mode, override=False): updated = False # This is to prevent a key error in case the label is a specific label try: @@ -2494,6 +2520,10 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat # Add label to world if it is not there if l not in world.world: world.world[l] = interval.closed(0, 1) + if l in predicate_map: + predicate_map[l].append(comp) + else: + predicate_map[l] = numba.typed.List([comp]) # Check if update is necessary with previous bnd prev_bnd = world.world[l].copy() @@ -2529,6 +2559,10 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat if p1 == l: if p2 not in world.world: world.world[p2] = interval.closed(0, 1) + if p2 in predicate_map: + predicate_map[p2].append(comp) + else: + predicate_map[p2] = numba.typed.List([comp]) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p2], f'IPL: {l.get_value()}') lower = max(world.world[p2].lower, 1 - world.world[p1].upper) @@ -2542,6 +2576,10 @@ def _update_edge(interpretations, comp, na, ipl, rule_trace, fp_cnt, t_cnt, stat if p2 == l: if p1 not in world.world: world.world[p1] = interval.closed(0, 1) + if p1 in predicate_map: + predicate_map[p1].append(comp) + else: + predicate_map[p1] = numba.typed.List([comp]) if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[p1], f'IPL: {l.get_value()}') lower = max(world.world[p1].lower, 1 - world.world[p2].upper) @@ -2792,7 +2830,7 @@ def _add_node(node, neighbors, reverse_neighbors, nodes, interpretations_node): @numba.njit(cache=True) -def _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge): +def _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge, predicate_map): # If not a node, add to list of nodes and initialize neighbors if source not in nodes: _add_node(source, neighbors, reverse_neighbors, nodes, interpretations_node) @@ -2812,6 +2850,10 @@ def _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, int reverse_neighbors[target].append(source) if l.value!='': interpretations_edge[edge] = world.World(numba.typed.List([l])) + if l in predicate_map: + predicate_map[l].append(edge) + else: + predicate_map[l] = numba.typed.List([edge]) else: interpretations_edge[edge] = world.World(numba.typed.List.empty_list(label.label_type)) else: @@ -2823,32 +2865,38 @@ def _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, int @numba.njit(cache=True) -def _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge): +def _add_edges(sources, targets, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge, predicate_map): changes = 0 edges_added = numba.typed.List.empty_list(edge_type) for source in sources: for target in targets: - edge, new_edge = _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge) + edge, new_edge = _add_edge(source, target, neighbors, reverse_neighbors, nodes, edges, l, interpretations_node, interpretations_edge, predicate_map) edges_added.append(edge) changes = changes+1 if new_edge else changes return edges_added, changes @numba.njit(cache=True) -def _delete_edge(edge, neighbors, reverse_neighbors, edges, interpretations_edge): +def _delete_edge(edge, neighbors, reverse_neighbors, edges, interpretations_edge, predicate_map): source, target = edge edges.remove(edge) del interpretations_edge[edge] + for l in predicate_map: + if edge in predicate_map[l]: + predicate_map[l].remove(edge) neighbors[source].remove(target) reverse_neighbors[target].remove(source) @numba.njit(cache=True) -def _delete_node(node, neighbors, reverse_neighbors, nodes, interpretations_node): +def _delete_node(node, neighbors, reverse_neighbors, nodes, interpretations_node, predicate_map): nodes.remove(node) del interpretations_node[node] del neighbors[node] del reverse_neighbors[node] + for l in predicate_map: + if node in predicate_map[l]: + predicate_map[l].remove(node) # Remove all occurrences of node in neighbors for n in neighbors.keys(): From 3c24ed39324cc9c4b1d08d5aa57ca9d4cf9dbd32 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Wed, 16 Oct 2024 18:05:01 +0200 Subject: [PATCH 66/73] fixed bug with hasmap where pred was not in keys --- .../scripts/interpretation/interpretation.py | 18 ++++++++++++------ .../interpretation/interpretation_parallel.py | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 4103059..6bc930c 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -807,7 +807,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, predicate_map if allow_ground_rules and clause_var_1 in nodes_set: grounding = numba.typed.List([clause_var_1]) else: - grounding = get_rule_node_clause_grounding(clause_var_1, groundings, predicate_map_node, clause_label) + grounding = get_rule_node_clause_grounding(clause_var_1, groundings, predicate_map_node, clause_label, nodes) # Narrow subset based on predicate qualified_groundings = get_qualified_node_groundings(interpretations_node, grounding, clause_label, clause_bnd) @@ -834,7 +834,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, predicate_map if allow_ground_rules and (clause_var_1, clause_var_2) in edges_set: grounding = numba.typed.List([(clause_var_1, clause_var_2)]) else: - grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, predicate_map_edge, clause_label) + grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, predicate_map_edge, clause_label, edges) # Narrow subset based on predicate (save the edges that are qualified to use for finding future groundings faster) qualified_groundings = get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, clause_bnd) @@ -1982,14 +1982,17 @@ def check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, @numba.njit(cache=True) -def get_rule_node_clause_grounding(clause_var_1, groundings, predicate_map, l): +def get_rule_node_clause_grounding(clause_var_1, groundings, predicate_map, l, nodes): # The groundings for a node clause can be either a previous grounding or all possible nodes - grounding = predicate_map[l] if clause_var_1 not in groundings else groundings[clause_var_1] + if l in predicate_map: + grounding = predicate_map[l] if clause_var_1 not in groundings else groundings[clause_var_1] + else: + grounding = nodes if clause_var_1 not in groundings else groundings[clause_var_1] return grounding @numba.njit(cache=True) -def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, predicate_map, l): +def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, predicate_map, l, edges): # There are 4 cases for predicate(Y,Z): # 1. Both predicate variables Y and Z have not been encountered before # 2. The source variable Y has not been encountered before but the target variable Z has @@ -2000,7 +2003,10 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun # Case 1: # We replace Y by all nodes and Z by the neighbors of each of these nodes if clause_var_1 not in groundings and clause_var_2 not in groundings: - edge_groundings = predicate_map[l] + if l in predicate_map: + edge_groundings = predicate_map[l] + else: + edge_groundings = edges # Case 2: # We replace Y by the sources of Z diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index 23deace..b32e9b1 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -807,7 +807,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, predicate_map if allow_ground_rules and clause_var_1 in nodes_set: grounding = numba.typed.List([clause_var_1]) else: - grounding = get_rule_node_clause_grounding(clause_var_1, groundings, predicate_map_node, clause_label) + grounding = get_rule_node_clause_grounding(clause_var_1, groundings, predicate_map_node, clause_label, nodes) # Narrow subset based on predicate qualified_groundings = get_qualified_node_groundings(interpretations_node, grounding, clause_label, clause_bnd) @@ -834,7 +834,7 @@ def _ground_rule(rule, interpretations_node, interpretations_edge, predicate_map if allow_ground_rules and (clause_var_1, clause_var_2) in edges_set: grounding = numba.typed.List([(clause_var_1, clause_var_2)]) else: - grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, predicate_map_edge, clause_label) + grounding = get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, predicate_map_edge, clause_label, edges) # Narrow subset based on predicate (save the edges that are qualified to use for finding future groundings faster) qualified_groundings = get_qualified_edge_groundings(interpretations_edge, grounding, clause_label, clause_bnd) @@ -1982,14 +1982,17 @@ def check_edge_clause_satisfaction(interpretations_edge, subsets, subset_source, @numba.njit(cache=True) -def get_rule_node_clause_grounding(clause_var_1, groundings, predicate_map, l): +def get_rule_node_clause_grounding(clause_var_1, groundings, predicate_map, l, nodes): # The groundings for a node clause can be either a previous grounding or all possible nodes - grounding = predicate_map[l] if clause_var_1 not in groundings else groundings[clause_var_1] + if l in predicate_map: + grounding = predicate_map[l] if clause_var_1 not in groundings else groundings[clause_var_1] + else: + grounding = nodes if clause_var_1 not in groundings else groundings[clause_var_1] return grounding @numba.njit(cache=True) -def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, predicate_map, l): +def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groundings_edges, neighbors, reverse_neighbors, predicate_map, l, edges): # There are 4 cases for predicate(Y,Z): # 1. Both predicate variables Y and Z have not been encountered before # 2. The source variable Y has not been encountered before but the target variable Z has @@ -2000,7 +2003,10 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun # Case 1: # We replace Y by all nodes and Z by the neighbors of each of these nodes if clause_var_1 not in groundings and clause_var_2 not in groundings: - edge_groundings = predicate_map[l] + if l in predicate_map: + edge_groundings = predicate_map[l] + else: + edge_groundings = edges # Case 2: # We replace Y by the sources of Z From 0c8e784fdd0b5aebe9de558bfb47fc8d39fa95ff Mon Sep 17 00:00:00 2001 From: astewardnolan Date: Wed, 16 Oct 2024 17:20:10 -0400 Subject: [PATCH 67/73] testing contents listings --- docs/source/user_guide/index.rst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst index ca50816..9a4db18 100644 --- a/docs/source/user_guide/index.rst +++ b/docs/source/user_guide/index.rst @@ -10,4 +10,11 @@ Contents :caption: Contents: :glob: - * \ No newline at end of file + * + annotation_functions + inconsistent_predicate_list + pyreason_facts + pyreason_graph + pyreason_rules + pyreason_settings + From fd4ce906877f5be82c7a5b26a6999c6a0d767c50 Mon Sep 17 00:00:00 2001 From: astewardnolan Date: Wed, 16 Oct 2024 22:29:22 -0400 Subject: [PATCH 68/73] rough outline of graph .rst, want to see how formating looks --- docs/source/user_guide/pyreason_graphs.rst | 89 ++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/docs/source/user_guide/pyreason_graphs.rst b/docs/source/user_guide/pyreason_graphs.rst index e69de29..ea0ea6f 100644 --- a/docs/source/user_guide/pyreason_graphs.rst +++ b/docs/source/user_guide/pyreason_graphs.rst @@ -0,0 +1,89 @@ +PyReason Graphs +============== +**PyReason Graphs ** (Brief Intro) +PyReason supports direct reasoning over knowledge graphs. PyReason graphs have full explainability of the reasoning process. (add more) + +-Notes: go more indepth about use cases of Graphs, connection to Nuero symbolic reasoning, other pyreason logic concepts etc. + +Methods for Loading Graphs +^^^^^^^^^^^^^^^^^^^^^^^^^^ +In PyReason there are two Methods for loading graphs: Networkx and GraphMl + + +Networkx Example +^^^^^^^^^^^^^^^^ +You can also build a graph using Networkx. + +.. code:: python + import networkx as nx + + # ================================ CREATE GRAPH==================================== + # Create a Directed graph + g = nx.DiGraph() + + # Add the nodes + g.add_nodes_from(['John', 'Mary', 'Justin']) + g.add_nodes_from(['Dog', 'Cat']) + + # Add the edges and their attributes. When an attribute = x which is <= 1, the annotation + # associated with it will be [x,1]. NOTE: These attributes are immutable + # Friend edges + g.add_edge('Justin', 'Mary', Friends=1) + g.add_edge('John', 'Mary', Friends=1) + g.add_edge('John', 'Justin', Friends=1) + + # Pet edges + g.add_edge('Mary', 'Cat', owns=1) + g.add_edge('Justin', 'Cat', owns=1) + g.add_edge('Justin', 'Dog', owns=1) + g.add_edge('John', 'Dog', owns=1) + + + +GraphMl Example +^^^^^^^^^^^^^^^ +Using GraphMl, you can also read in from a file. + +.. code:: xml + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + +Then load the graph using the following: + +.. code:: python + + import pyreason as pr + pr.load_graphml('path_to_file') + +?? Add image output of graph possibly? From fd607c6ca90dfd4c1a5a998cb1683a9243549336 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Thu, 17 Oct 2024 21:51:20 +0200 Subject: [PATCH 69/73] fixed resolve inconsistency index error --- pyreason/scripts/interpretation/interpretation.py | 12 ++++++++---- .../interpretation/interpretation_parallel.py | 12 ++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py index 6bc930c..858620e 100755 --- a/pyreason/scripts/interpretation/interpretation.py +++ b/pyreason/scripts/interpretation/interpretation.py @@ -2767,10 +2767,12 @@ def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, id world = interpretations[comp] if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, na[0], interval.closed(0,1))) - if mode == 'fact' or mode == 'graph-attribute-fact': + if mode == 'fact' or mode == 'graph-attribute-fact' and atom_trace: name = facts_to_be_applied_trace[idx] - elif mode == 'rule': + elif mode == 'rule' and atom_trace: name = rules_to_be_applied_trace[idx][2] + else: + name = '-' if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[na[0]], f'Inconsistency due to {name}') # Resolve inconsistency and set static @@ -2800,10 +2802,12 @@ def resolve_inconsistency_edge(interpretations, comp, na, ipl, t_cnt, fp_cnt, id w = interpretations[comp] if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, na[0], interval.closed(0,1))) - if mode == 'fact' or mode == 'graph-attribute-fact': + if mode == 'fact' or mode == 'graph-attribute-fact' and atom_trace: name = facts_to_be_applied_trace[idx] - elif mode == 'rule': + elif mode == 'rule' and atom_trace: name = rules_to_be_applied_trace[idx][2] + else: + name = '-' if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[na[0]], f'Inconsistency due to {name}') # Resolve inconsistency and set static diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py index b32e9b1..fd0f582 100644 --- a/pyreason/scripts/interpretation/interpretation_parallel.py +++ b/pyreason/scripts/interpretation/interpretation_parallel.py @@ -2767,10 +2767,12 @@ def resolve_inconsistency_node(interpretations, comp, na, ipl, t_cnt, fp_cnt, id world = interpretations[comp] if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, na[0], interval.closed(0,1))) - if mode == 'fact' or mode == 'graph-attribute-fact': + if mode == 'fact' or mode == 'graph-attribute-fact' and atom_trace: name = facts_to_be_applied_trace[idx] - elif mode == 'rule': + elif mode == 'rule' and atom_trace: name = rules_to_be_applied_trace[idx][2] + else: + name = '-' if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), world.world[na[0]], f'Inconsistency due to {name}') # Resolve inconsistency and set static @@ -2800,10 +2802,12 @@ def resolve_inconsistency_edge(interpretations, comp, na, ipl, t_cnt, fp_cnt, id w = interpretations[comp] if store_interpretation_changes: rule_trace.append((numba.types.uint16(t_cnt), numba.types.uint16(fp_cnt), comp, na[0], interval.closed(0,1))) - if mode == 'fact' or mode == 'graph-attribute-fact': + if mode == 'fact' or mode == 'graph-attribute-fact' and atom_trace: name = facts_to_be_applied_trace[idx] - elif mode == 'rule': + elif mode == 'rule' and atom_trace: name = rules_to_be_applied_trace[idx][2] + else: + name = '-' if atom_trace: _update_rule_trace(rule_trace_atoms, numba.typed.List.empty_list(numba.typed.List.empty_list(node_type)), numba.typed.List.empty_list(numba.typed.List.empty_list(edge_type)), w.world[na[0]], f'Inconsistency due to {name}') # Resolve inconsistency and set static From 3cc76988b582f6a3b8b69a9b1e1cbc51c0f0761b Mon Sep 17 00:00:00 2001 From: astewardnolan Date: Sun, 20 Oct 2024 21:22:12 -0400 Subject: [PATCH 70/73] added hyperinks, more explainations, and cleaned up format --- docs/source/user_guide/pyreason_graphs.rst | 142 ++++++++++++++------- 1 file changed, 95 insertions(+), 47 deletions(-) diff --git a/docs/source/user_guide/pyreason_graphs.rst b/docs/source/user_guide/pyreason_graphs.rst index ea0ea6f..407dec4 100644 --- a/docs/source/user_guide/pyreason_graphs.rst +++ b/docs/source/user_guide/pyreason_graphs.rst @@ -1,18 +1,32 @@ PyReason Graphs ============== -**PyReason Graphs ** (Brief Intro) -PyReason supports direct reasoning over knowledge graphs. PyReason graphs have full explainability of the reasoning process. (add more) +**PyReason Graphs ** +PyReason supports direct reasoning over knowledge graphs. PyReason graphs have full explainability of the reasoning process. Graphs serve as the knowledge base for PyReason, allowing users to create visual representations based on rules, relationships, and connections. --Notes: go more indepth about use cases of Graphs, connection to Nuero symbolic reasoning, other pyreason logic concepts etc. - -Methods for Loading Graphs -^^^^^^^^^^^^^^^^^^^^^^^^^^ -In PyReason there are two Methods for loading graphs: Networkx and GraphMl +Methods for Creating Graphs +--------------------------- +In PyReason there are two ways to create graphs: Networkx and GraphMl +Networkx allows you to manually add nodes and edges, whereas GraphMl reads in a directed graph from a file. Networkx Example -^^^^^^^^^^^^^^^^ -You can also build a graph using Networkx. +---------------- +Using Networkx, you can create a ** `directed `_ ** graph object. Users can add and remove nodes and edges from the graph. + +Read more about Networkx `here `_. + +The following graph represents a network of people and the pets that +they own. + +1. Mary is friends with Justin +2. Mary is friends with John +3. Justin is friends with John + +And + +1. Mary owns a cat +2. Justin owns a cat and a dog +3. John owns a dog .. code:: python import networkx as nx @@ -38,52 +52,86 @@ You can also build a graph using Networkx. g.add_edge('Justin', 'Dog', owns=1) g.add_edge('John', 'Dog', owns=1) +After the graph has been created, it can be loaded with: + +.. code:: python + + import pyreason as pr + pr.load_graph(graph: nx.DiGraph) + + +Additional Considerations: +-------------------------- +Attributes to Bounds: + +In Networkx, each graph, node, and edge can hold key/value attribute pairs in an associated attribute dictionary (the keys must be hashable). + +In PyReason, these attributes get transformed into "bounds". The attribute value in Networkx, is translated into the lower bound in PyReason. + +.. code:: python + import networkx as nx + g = nx.DiGraph() + g.add_node("some_node", attribute1=1, attribute2="0,0") + +When the graph is loaded, "some_node" is given the attribute1: [1,1], and attribute2 : [0,0]. + +If the attribute is a simple value, it is treated as both the lower and upper bound in PyReason. If a specific pair of bounds is required (e.g., for coordinates or ranges), the value should be provided as a string in a specific format. + GraphMl Example -^^^^^^^^^^^^^^^ -Using GraphMl, you can also read in from a file. +--------------- +Using `GraphMl `_, you can read a graph in from a file. .. code:: xml - - - - - - - - - - - - 1 - - - 1 - - - 1 - - - 1 - - - 1 - - - 1 - - - 1 - - - + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + Then load the graph using the following: .. code:: python - import pyreason as pr - pr.load_graphml('path_to_file') + import pyreason as pr + pr.load_graphml('path_to_file') + +Graph Output: + +.. code:: python + +.. figure:: basic_graph.png + :alt: image -?? Add image output of graph possibly? From 27fa3c89118c63dd5bac86a174fba8b49a36ecdd Mon Sep 17 00:00:00 2001 From: astewardnolan Date: Sun, 20 Oct 2024 21:59:08 -0400 Subject: [PATCH 71/73] fixed typo --- docs/source/user_guide/pyreason_graphs.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/user_guide/pyreason_graphs.rst b/docs/source/user_guide/pyreason_graphs.rst index 407dec4..7f2a41b 100644 --- a/docs/source/user_guide/pyreason_graphs.rst +++ b/docs/source/user_guide/pyreason_graphs.rst @@ -1,6 +1,6 @@ PyReason Graphs -============== -**PyReason Graphs ** +=============== + PyReason supports direct reasoning over knowledge graphs. PyReason graphs have full explainability of the reasoning process. Graphs serve as the knowledge base for PyReason, allowing users to create visual representations based on rules, relationships, and connections. Methods for Creating Graphs @@ -120,7 +120,7 @@ Using `GraphMl `_, you can read a - + Then load the graph using the following: .. code:: python From 012382817b25b04192c19363274ea7dc50910884 Mon Sep 17 00:00:00 2001 From: astewardnolan Date: Sun, 20 Oct 2024 22:07:28 -0400 Subject: [PATCH 72/73] fixed typo --- docs/source/user_guide/pyreason_graphs.rst | 46 +++++++++++----------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/source/user_guide/pyreason_graphs.rst b/docs/source/user_guide/pyreason_graphs.rst index 7f2a41b..1c91aa2 100644 --- a/docs/source/user_guide/pyreason_graphs.rst +++ b/docs/source/user_guide/pyreason_graphs.rst @@ -1,6 +1,6 @@ PyReason Graphs =============== - + PyReason supports direct reasoning over knowledge graphs. PyReason graphs have full explainability of the reasoning process. Graphs serve as the knowledge base for PyReason, allowing users to create visual representations based on rules, relationships, and connections. Methods for Creating Graphs @@ -29,28 +29,28 @@ And 3. John owns a dog .. code:: python - import networkx as nx - - # ================================ CREATE GRAPH==================================== - # Create a Directed graph - g = nx.DiGraph() - - # Add the nodes - g.add_nodes_from(['John', 'Mary', 'Justin']) - g.add_nodes_from(['Dog', 'Cat']) - - # Add the edges and their attributes. When an attribute = x which is <= 1, the annotation - # associated with it will be [x,1]. NOTE: These attributes are immutable - # Friend edges - g.add_edge('Justin', 'Mary', Friends=1) - g.add_edge('John', 'Mary', Friends=1) - g.add_edge('John', 'Justin', Friends=1) - - # Pet edges - g.add_edge('Mary', 'Cat', owns=1) - g.add_edge('Justin', 'Cat', owns=1) - g.add_edge('Justin', 'Dog', owns=1) - g.add_edge('John', 'Dog', owns=1) + import networkx as nx + + # ================================ CREATE GRAPH==================================== + # Create a Directed graph + g = nx.DiGraph() + + # Add the nodes + g.add_nodes_from(['John', 'Mary', 'Justin']) + g.add_nodes_from(['Dog', 'Cat']) + + # Add the edges and their attributes. When an attribute = x which is <= 1, the annotation + # associated with it will be [x,1]. NOTE: These attributes are immutable + # Friend edges + g.add_edge('Justin', 'Mary', Friends=1) + g.add_edge('John', 'Mary', Friends=1) + g.add_edge('John', 'Justin', Friends=1) + + # Pet edges + g.add_edge('Mary', 'Cat', owns=1) + g.add_edge('Justin', 'Cat', owns=1) + g.add_edge('Justin', 'Dog', owns=1) + g.add_edge('John', 'Dog', owns=1) After the graph has been created, it can be loaded with: From 89e0100320b71b608d68829f4645e577885a0390 Mon Sep 17 00:00:00 2001 From: astewardnolan Date: Sun, 20 Oct 2024 22:08:32 -0400 Subject: [PATCH 73/73] fixed typo --- docs/source/user_guide/pyreason_graphs.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/user_guide/pyreason_graphs.rst b/docs/source/user_guide/pyreason_graphs.rst index 1c91aa2..0e5c8ed 100644 --- a/docs/source/user_guide/pyreason_graphs.rst +++ b/docs/source/user_guide/pyreason_graphs.rst @@ -81,7 +81,7 @@ If the attribute is a simple value, it is treated as both the lower and upper bo GraphMl Example --------------- -Using `GraphMl `_, you can read a graph in from a file. +Using `GraphMl `_, you can read a graph in from a file. .. code:: xml