From 1c8701adf13c1bee4bab7057a308464521293724 Mon Sep 17 00:00:00 2001 From: Henry Kobin Date: Thu, 9 May 2024 12:17:35 -0700 Subject: [PATCH 01/10] add thresholds --- pyreason/.cache_status.yaml | 2 +- pyreason/pyreason.py | 1 + .../numba_types/threshold_type.py | 89 +++++++++++++++++++ pyreason/scripts/threshold/__init__.py | 0 pyreason/scripts/threshold/threshold.py | 34 +++++++ pyreason/scripts/utils/rule_parser.py | 27 ++++-- tests/test_custom_thresholds.py | 46 ++++++++++ 7 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 pyreason/scripts/numba_wrapper/numba_types/threshold_type.py create mode 100644 pyreason/scripts/threshold/__init__.py create mode 100644 pyreason/scripts/threshold/threshold.py create mode 100644 tests/test_custom_thresholds.py diff --git a/pyreason/.cache_status.yaml b/pyreason/.cache_status.yaml index 32458f5d..71173842 100644 --- a/pyreason/.cache_status.yaml +++ b/pyreason/.cache_status.yaml @@ -1 +1 @@ -initialized: false +initialized: true diff --git a/pyreason/pyreason.py b/pyreason/pyreason.py index 94ac1573..48ae672f 100755 --- a/pyreason/pyreason.py +++ b/pyreason/pyreason.py @@ -17,6 +17,7 @@ import pyreason.scripts.numba_wrapper.numba_types.rule_type as rule from pyreason.scripts.facts.fact import Fact from pyreason.scripts.rules.rule import Rule +from pyreason.scripts.threshold.threshold import Threshold 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 diff --git a/pyreason/scripts/numba_wrapper/numba_types/threshold_type.py b/pyreason/scripts/numba_wrapper/numba_types/threshold_type.py new file mode 100644 index 00000000..3b5c825e --- /dev/null +++ b/pyreason/scripts/numba_wrapper/numba_types/threshold_type.py @@ -0,0 +1,89 @@ +from pyreason.scripts.threshold.threshold import Threshold +from numba import types +from numba.extending import models, register_model, make_attribute_wrapper +from numba.extending import lower_builtin +from numba.core import cgutils +from numba.extending import unbox, NativeValue, box +from numba.extending import typeof_impl + +# Define the Numba type for Threshold +class ThresholdType(types.Type): + def __init__(self): + super(ThresholdType, self).__init__(name='Threshold') + +threshold_type = ThresholdType() + +# Register the type with Numba +@typeof_impl.register(Threshold) +def typeof_threshold(val, c): + return threshold_type + +# Define the data model for the type +@register_model(ThresholdType) +class ThresholdModel(models.StructModel): + def __init__(self, dmm, fe_type): + members = [ + ('quantifier', types.unicode_type), + ('quantifier_type', types.UniTuple(types.unicode_type, 2)), + ('thresh', types.int64) + ] + models.StructModel.__init__(self, dmm, fe_type, members) + +# Make attributes accessible +make_attribute_wrapper(ThresholdType, 'quantifier', 'quantifier') +make_attribute_wrapper(ThresholdType, 'quantifier_type', 'quantifier_type') +make_attribute_wrapper(ThresholdType, 'thresh', 'thresh') + +# Implement the constructor for the type +@lower_builtin(Threshold, types.unicode_type, types.UniTuple(types.unicode_type, 2), types.int64) +def impl_threshold(context, builder, sig, args): + typ = sig.return_type + quantifier, quantifier_type, thresh = args + threshold = cgutils.create_struct_proxy(typ)(context, builder) + threshold.quantifier = quantifier + threshold.quantifier_type = quantifier_type + threshold.thresh = thresh + return threshold._getvalue() + +# Tell Numba how to unbox and box the type +@unbox(ThresholdType) +def unbox_threshold(typ, obj, c): + quantifier_obj = c.pyapi.object_getattr_string(obj, 'quantifier') + quantifier_type_obj = c.pyapi.object_getattr_string(obj, 'quantifier_type') + thresh_obj = c.pyapi.object_getattr_string(obj, 'thresh') + + threshold = cgutils.create_struct_proxy(typ)(c.context, c.builder) + threshold.quantifier = c.unbox(types.unicode_type, quantifier_obj).value + threshold.quantifier_type = c.unbox(types.UniTuple(types.unicode_type, 2), quantifier_type_obj).value + threshold.thresh = c.unbox(types.int64, thresh_obj).value + + c.pyapi.decref(quantifier_obj) + c.pyapi.decref(quantifier_type_obj) + c.pyapi.decref(thresh_obj) + + is_error = cgutils.is_not_null(c.builder, c.pyapi.err_occurred()) + return NativeValue(threshold._getvalue(), is_error=is_error) + +@box(ThresholdType) +def box_threshold(typ, val, c): + threshold = cgutils.create_struct_proxy(typ)(c.context, c.builder, value=val) + threshold_obj = c.pyapi.unserialize(c.pyapi.serialize_object(Threshold)) + + quantifier_obj = c.box(types.unicode_type, threshold.quantifier) + quantifier_type_obj = c.box(types.UniTuple(types.unicode_type, 2), threshold.quantifier_type) + thresh_obj = c.box(types.int64, threshold.thresh) + + # Create the Threshold object using its constructor + threshold_instance = c.pyapi.call_function_objargs( + threshold_obj, + (quantifier_obj, quantifier_type_obj, thresh_obj) + ) + + # Decrease the reference count of the boxed objects + c.pyapi.decref(quantifier_obj) + c.pyapi.decref(quantifier_type_obj) + c.pyapi.decref(thresh_obj) + c.pyapi.decref(threshold_obj) + + # Return the boxed Threshold object + return threshold_instance \ No newline at end of file diff --git a/pyreason/scripts/threshold/__init__.py b/pyreason/scripts/threshold/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyreason/scripts/threshold/threshold.py b/pyreason/scripts/threshold/threshold.py new file mode 100644 index 00000000..b4c89a3b --- /dev/null +++ b/pyreason/scripts/threshold/threshold.py @@ -0,0 +1,34 @@ +class Threshold: + """ + A class representing a threshold for a clause in a rule. + + Attributes: + quantifier (str): The comparison operator, e.g., 'greater_equal', 'less', etc. + quantifier_type (tuple): A tuple indicating the type of quantifier, e.g., ('number', 'total'). + thresh (int): The numerical threshold value to compare against. + + Methods: + to_tuple(): Converts the Threshold instance into a tuple compatible with numba types. + """ + + def __init__(self, quantifier, quantifier_type, thresh): + """ + Initializes a Threshold instance. + + Args: + quantifier (str): The comparison operator for the threshold. + quantifier_type (tuple): The type of quantifier ('number' or 'percent', 'total' or 'available'). + thresh (int): The numerical value for the threshold. + """ + self.quantifier = quantifier + self.quantifier_type = quantifier_type + self.thresh = thresh + + def to_tuple(self): + """ + Converts the Threshold instance into a tuple compatible with numba types. + + Returns: + tuple: A tuple representation of the Threshold instance. + """ + return (self.quantifier, self.quantifier_type, self.thresh) \ No newline at end of file diff --git a/pyreason/scripts/utils/rule_parser.py b/pyreason/scripts/utils/rule_parser.py index fc2db4bb..8e4c661e 100644 --- a/pyreason/scripts/utils/rule_parser.py +++ b/pyreason/scripts/utils/rule_parser.py @@ -5,8 +5,7 @@ import pyreason.scripts.numba_wrapper.numba_types.label_type as label import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval - -def parse_rule(rule_text: str, name: str, infer_edges: bool = False, set_static: bool = False, immediate_rule: bool = False) -> rule.Rule: +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: # First remove all spaces from line r = rule_text.replace(' ', '') @@ -152,7 +151,23 @@ def parse_rule(rule_text: str, name: str, infer_edges: bool = False, set_static: # Array to store clauses for nodes: node/edge, [subset]/[subset1, subset2], label, interval, operator 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))) - # Loop though clauses + # gather count of clauses for threshold validation + num_clauses = len(body_clauses) + + if (custom_thresholds) and (len(custom_thresholds) != num_clauses): + # raise exception here + pass + + #If no custom thresholds provided, use defaults + #otherwise loop through user-defined thresholds and convert to numba compatible format + if 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): # Neigh criteria clause_type = 'node' if len(variables) == 1 else 'edge' @@ -165,12 +180,6 @@ def parse_rule(rule_text: str, name: str, infer_edges: bool = False, set_static: bnd = interval.closed(bounds[0], bounds[1]) clauses.append((clause_type, l, subset, bnd, op)) - # Threshold. - quantifier = 'greater_equal' - quantifier_type = ('number', 'total') - thresh = 1 - thresholds.append((quantifier, quantifier_type, thresh)) - # 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: diff --git a/tests/test_custom_thresholds.py b/tests/test_custom_thresholds.py new file mode 100644 index 00000000..8084575d --- /dev/null +++ b/tests/test_custom_thresholds.py @@ -0,0 +1,46 @@ +# Test if the simple hello world program works with thresholds defined +import pyreason as pr +from pyreason import Threshold +def test_custom_thresholds(): + # 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 + pr.settings.verbose = True # Print info to screen + + # Load all the files into pyreason + pr.load_graphml(graph_path) + # add custom thresholds + user_defined_thresholds = [ + Threshold('greater_equal', ('number', 'total'), 1), + Threshold('greater_equal', ('percent', 'total'), 100) + ] + + pr.add_rule(pr.Rule('popular(x) <- friends(x,y), popular(y)', 'popular_rule', user_defined_thresholds)) + pr.add_fact(pr.Fact('popular-fact', 'Mary', 'popular', [1, 1], 0, 2)) + + # Run the program for two timesteps to see the diffusion take place + 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=0 there should be two popular people' + assert len(dataframes[2]) == 3, 'At t=0 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' From 1dfffbdcf9ec5767dc96d69c8b117f9aafc8e6fa Mon Sep 17 00:00:00 2001 From: Kamini Aggarwal Date: Thu, 9 May 2024 23:08:20 -0700 Subject: [PATCH 02/10] Added exception and testcase --- pyreason/scripts/threshold/threshold.py | 19 +++++++++++------ pyreason/scripts/utils/rule_parser.py | 9 ++++---- tests/test_custom_thresholds.py | 28 +++++++++++++++---------- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/pyreason/scripts/threshold/threshold.py b/pyreason/scripts/threshold/threshold.py index b4c89a3b..5e04d851 100644 --- a/pyreason/scripts/threshold/threshold.py +++ b/pyreason/scripts/threshold/threshold.py @@ -19,11 +19,18 @@ def __init__(self, quantifier, quantifier_type, thresh): quantifier (str): The comparison operator for the threshold. quantifier_type (tuple): The type of quantifier ('number' or 'percent', 'total' or 'available'). thresh (int): The numerical value for the threshold. - """ - self.quantifier = quantifier - self.quantifier_type = quantifier_type - self.thresh = thresh - + """ + + if quantifier not in ("greater_equal", "greater", "less_equal", "less", "equal"): + raise ValueError("Invalid quantifier") + + if quantifier_type[0] not in ("number", "percent") and quantifier_type[1] not in ("total", "available"): + raise ValueError("Invalid quantifier type") + + self.quantifier = quantifier + self.quantifier_type = quantifier_type + self.thresh = thresh + def to_tuple(self): """ Converts the Threshold instance into a tuple compatible with numba types. @@ -31,4 +38,4 @@ def to_tuple(self): Returns: tuple: A tuple representation of the Threshold instance. """ - return (self.quantifier, self.quantifier_type, self.thresh) \ No newline at end of file + return (self.quantifier, self.quantifier_type, self.thresh) \ No newline at end of file diff --git a/pyreason/scripts/utils/rule_parser.py b/pyreason/scripts/utils/rule_parser.py index 8e4c661e..4d0dc850 100644 --- a/pyreason/scripts/utils/rule_parser.py +++ b/pyreason/scripts/utils/rule_parser.py @@ -153,15 +153,16 @@ def parse_rule(rule_text: str, name: str,custom_thresholds: list, infer_edges: b # gather count of clauses for threshold validation num_clauses = len(body_clauses) + print("body_clauses:", body_clauses) - if (custom_thresholds) and (len(custom_thresholds) != num_clauses): - # raise exception here - pass + 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 no custom thresholds provided, use defaults #otherwise loop through user-defined thresholds and convert to numba compatible format if not custom_thresholds: - for _ in range(num_clauses): + for _ in range(num_clauses): thresholds.append(('greater_equal', ('number', 'total'), 1.0)) else: for threshold in custom_thresholds: diff --git a/tests/test_custom_thresholds.py b/tests/test_custom_thresholds.py index 8084575d..f6e305ed 100644 --- a/tests/test_custom_thresholds.py +++ b/tests/test_custom_thresholds.py @@ -1,6 +1,8 @@ -# Test if the simple hello world program works with thresholds defined +# Test if the simple program works with thresholds defined import pyreason as pr from pyreason import Threshold + + def test_custom_thresholds(): # Modify the paths based on where you've stored the files we made above graph_path = './tests/friends_graph.graphml' @@ -10,14 +12,16 @@ def test_custom_thresholds(): # Load all the files into pyreason pr.load_graphml(graph_path) + # add custom thresholds - user_defined_thresholds = [ - Threshold('greater_equal', ('number', 'total'), 1), - Threshold('greater_equal', ('percent', 'total'), 100) - ] + user_defined_thresholds = [ + Threshold('greater_equal', ('number', 'total'), 1), + Threshold('greater_equal', ('percent', 'total'), 100) + ] - pr.add_rule(pr.Rule('popular(x) <- friends(x,y), popular(y)', 'popular_rule', user_defined_thresholds)) - pr.add_fact(pr.Fact('popular-fact', 'Mary', 'popular', [1, 1], 0, 2)) + pr.add_rule(pr.Rule('popular(x) <-1 Friends(x,y), popular(y)', 'popular_rule', user_defined_thresholds)) + pr.add_fact(pr.Fact('popular-fact-mary', 'Mary', 'popular', [1, 1], 0, 2)) + pr.add_fact(pr.Fact('popular-fact-john', 'John', 'popular', [1, 1], 1, 2)) # Run the program for two timesteps to see the diffusion take place interpretation = pr.reason(timesteps=2) @@ -30,17 +34,19 @@ def test_custom_thresholds(): 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]) == 3, 'At t=1 there should be three popular people since Mary and John are popular by fact and Justin becomes popular by rule' + assert len(dataframes[2]) == 3, 'At t=2 there should be three popular people since Mary and John are popular by fact and Justin becomes popular by rule' # 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' + # John should be popular in timestep 1, 2 + assert 'John' in dataframes[1]['component'].values and dataframes[1].iloc[1].popular == [1, 1], 'John should have popular bounds [1,1] for t=1 timesteps' + assert 'John' in dataframes[2]['component'].values and dataframes[2].iloc[2].popular == [1, 1], 'John 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' From a252e251b85c04b9284d588f1a93fa87ea10fb8f Mon Sep 17 00:00:00 2001 From: Henry Kobin Date: Fri, 10 May 2024 07:51:28 -0700 Subject: [PATCH 03/10] add group chat example, alter test to make more sense --- docs/group-chat-example.md | 106 ++++++++++++++++++++++++++++++++ media/group_chat_graph.png | Bin 0 -> 25111 bytes tests/group_chat_graph.graphml | 26 ++++++++ tests/test_custom_thresholds.py | 35 +++++------ 4 files changed, 146 insertions(+), 21 deletions(-) create mode 100755 docs/group-chat-example.md create mode 100644 media/group_chat_graph.png create mode 100644 tests/group_chat_graph.graphml diff --git a/docs/group-chat-example.md b/docs/group-chat-example.md new file mode 100755 index 00000000..efebe292 --- /dev/null +++ b/docs/group-chat-example.md @@ -0,0 +1,106 @@ +# Custom Thresholds Example + +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. + +## Graph +First, lets create the group chat. + +```python +import networkx as nx + +# Create an empty graph +G = nx.Graph() + +# Add nodes +nodes = ["TextMessage", "Zach", "Justin", "Michelle", "Amy"] +G.add_nodes_from(nodes) + +# Add edges with attribute 'HaveAccess' +edges = [ + ("Zach", "TextMessage", {"HaveAccess": 1}), + ("Justin", "TextMessage", {"HaveAccess": 1}), + ("Michelle", "TextMessage", {"HaveAccess": 1}), + ("Amy", "TextMessage", {"HaveAccess": 1}) +] +G.add_edges_from(edges) + +``` + + +## Rules and Custom Thresholds +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) +``` + +The `head` of the rule is `popular(x)` and the body is `popular(y), Friends(x,y), owns(y,z), owns(x,z)`. The head and body are separated by an arrow and the time after which the head +will become true `<-1` in our case this happens after `1` timestep. + +We add the rule into pyreason with: + +```python +import pyreason as pr + +pr.add_rule('popular(x) <-1 popular(y), Friends(x,y), owns(y,z), owns(x,z)', 'popular_rule') +``` +Where `popular_rule` is just the name of the rule. This helps understand which rules fired during reasoning later on. + +## Facts +The facts determine the initial conditions of elements in the graph. They can be specified from the graph attributes but in that +case they will be immutable later on. Adding PyReason facts gives us more flexibility. + +In our case we want to set on of the people in our graph to be `popular` and use PyReason to see how others in the graph are affected by that. +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)) +``` + +This allows us to specify the component that has an initial condition, the initial condition itself in the form of bounds +as well as the start and end time of this condition. + +## Running PyReason +Find the full code for this example [here](hello-world.py) + +The main line that runs the reasoning in that file is: +```python +interpretation = pr.reason(timesteps=2) +``` +This specifies how many timesteps to run for. + +## Expected Output +After running the python file, the expected output is: + +``` + TIMESTEP - 0 + component popular +0 Mary [1.0,1.0] + + + TIMESTEP - 1 + component popular +0 Mary [1.0,1.0] +1 Justin [1.0,1.0] + + + TIMESTEP - 2 + component popular +0 Mary [1.0,1.0] +1 Justin [1.0,1.0] +2 John [1.0,1.0] + +``` + +1. For timestep 0 we set `Mary -> popular: [1,1]` in the facts +2. For timestep 1, Justin is the only node who has one popular friend (Mary) and who has the same pet as Mary (cat). Therefore `Justin -> popular: [1,1]` +3. For timestep 2, since Justin has just become popular, John now has one popular friend (Justin) and the same pet as Justin (dog). Therefore `Justin -> popular: [1,1]` + + +We also output two CSV files detailing all the events that took place during reasoning (one for nodes, one for edges) \ No newline at end of file diff --git a/media/group_chat_graph.png b/media/group_chat_graph.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9afac6763429e1b323bd3e326aded687c29a4d GIT binary patch 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 + + + diff --git a/tests/test_custom_thresholds.py b/tests/test_custom_thresholds.py index f6e305ed..6a97c6a2 100644 --- a/tests/test_custom_thresholds.py +++ b/tests/test_custom_thresholds.py @@ -2,10 +2,9 @@ import pyreason as pr from pyreason import Threshold - def test_custom_thresholds(): # Modify the paths based on where you've stored the files we made above - graph_path = './tests/friends_graph.graphml' + graph_path = './tests/group_chat_graph.graphml' # Modify pyreason settings to make verbose and to save the rule trace to a file pr.settings.verbose = True # Print info to screen @@ -19,34 +18,28 @@ def test_custom_thresholds(): Threshold('greater_equal', ('percent', 'total'), 100) ] - pr.add_rule(pr.Rule('popular(x) <-1 Friends(x,y), popular(y)', 'popular_rule', user_defined_thresholds)) - pr.add_fact(pr.Fact('popular-fact-mary', 'Mary', 'popular', [1, 1], 0, 2)) - pr.add_fact(pr.Fact('popular-fact-john', 'John', 'popular', [1, 1], 1, 2)) + pr.add_rule(pr.Rule('ViewedByAll(x) <- HaveAccess(x,y), Viewed(y)', 'viewed_by_all_rule', user_defined_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)) + # Run the program for two timesteps to see the diffusion take place - interpretation = pr.reason(timesteps=2) + interpretation = pr.reason(timesteps=3) # Display the changes in the interpretation for each timestep - dataframes = pr.filter_and_sort_nodes(interpretation, ['popular']) + dataframes = pr.filter_and_sort_nodes(interpretation, ['ViewedByAll']) 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]) == 3, 'At t=1 there should be three popular people since Mary and John are popular by fact and Justin becomes popular by rule' - assert len(dataframes[2]) == 3, 'At t=2 there should be three popular people since Mary and John are popular by fact and Justin becomes popular by rule' - - # 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' + assert len(dataframes[0]) == 0, 'At t=0 the TextMessage should not have been ViewedByAll' + assert len(dataframes[2]) == 1, 'At t=2 the TextMessage should have been ViewedByAll' - # John should be popular in timestep 1, 2 - assert 'John' in dataframes[1]['component'].values and dataframes[1].iloc[1].popular == [1, 1], 'John should have popular bounds [1,1] for t=1 timesteps' - assert 'John' in dataframes[2]['component'].values and dataframes[2].iloc[2].popular == [1, 1], 'John 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' + # TextMessage should be ViewedByAll in t=2 + assert 'TextMessage' in dataframes[2]['component'].values and dataframes[2].iloc[0].ViewedByAll == [1, 1], 'TextMessage should have ViewedByAll bounds [1,1] for t=2 timesteps' From 1e67ea29f15e6af478330e2f70820a671ae8f859 Mon Sep 17 00:00:00 2001 From: Kamini Aggarwal Date: Fri, 10 May 2024 12:24:56 -0700 Subject: [PATCH 04/10] Updated custom threshold documentation, removed print and formatted files --- docs/group-chat-example.md | 76 ++++++++++++++++--------- pyreason/scripts/threshold/threshold.py | 2 +- pyreason/scripts/utils/rule_parser.py | 5 +- tests/test_custom_thresholds.py | 49 ++++++++++------ 4 files changed, 84 insertions(+), 48 deletions(-) diff --git a/docs/group-chat-example.md b/docs/group-chat-example.md index efebe292..c0e559f4 100755 --- a/docs/group-chat-example.md +++ b/docs/group-chat-example.md @@ -31,7 +31,6 @@ G.add_edges_from(edges) ``` - ## Rules and Custom Thresholds 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: @@ -39,39 +38,62 @@ Considering that we only want a text message to be considered viewed by all if i ViewedByAll(x) <- HaveAccess(x,y), Viewed(y) ``` -The `head` of the rule is `popular(x)` and the body is `popular(y), Friends(x,y), owns(y,z), owns(x,z)`. The head and body are separated by an arrow and the time after which the head -will become true `<-1` in our case this happens after `1` timestep. +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 +timestep 0. We add the rule into pyreason with: ```python import pyreason as pr +from pyreason import Threshold + +user_defined_thresholds = [ + Threshold("greater_equal", ("number", "total"), 1), + Threshold("greater_equal", ("percent", "total"), 100), +] -pr.add_rule('popular(x) <-1 popular(y), Friends(x,y), owns(y,z), owns(x,z)', 'popular_rule') +pr.add_rule(pr.Rule('ViewedByAll(x) <- HaveAccess(x,y), Viewed(y)', 'viewed_by_all_rule', user_defined_thresholds)) ``` -Where `popular_rule` is just the name of the rule. This helps understand which rules fired during reasoning later on. +Where `viewed_by_all_rule` is the name of the rule. This helps to understand which rule/s are fired during reasoning later on. + +The `user_defined_thresholds` are a list of custom thresholds of the format: (quantifier, quantifier_type, thresh) where: +- quantifier can be greater_equal, greater, less_equal, less, equal +- quantifier_type is a tuple where the first element can be either number or percent and the second element can be either total or available +- thresh represents the numerical threshold value to compare against + +The custom thresholds are created corresponding to the two clauses (HaveAccess(x,y) and Viewed(y)) as below: +- ('greater_equal', ('number', 'total'), 1) (there needs to be at least one person who has access to TextMessage for the first clause to be satisfied) +- ('greater_equal', ('percent', 'total'), 100) (100% of people who have access to TextMessage need to view the message for second clause to be satisfied) ## Facts The facts determine the initial conditions of elements in the graph. They can be specified from the graph attributes but in that case they will be immutable later on. Adding PyReason facts gives us more flexibility. -In our case we want to set on of the people in our graph to be `popular` and use PyReason to see how others in the graph are affected by that. -We add a fact in PyReason like so: +In our case we want one person to view the TextMessage in a particular interval of timestep. +For example, we create facts stating: +- Zach and Justin view the TextMessage from timestep 0-3 +- Michelle views the TextMessage from timestep 1-3 +- Amy views the TextMessage from timestep 2-3 + +We add the facts in PyReason as below: ```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("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)) ``` This allows us to specify the component that has an initial condition, the initial condition itself in the form of bounds as well as the start and end time of this condition. ## Running PyReason -Find the full code for this example [here](hello-world.py) +Find the full code for this example [here](../tests/test_custom_thresholds.py) The main line that runs the reasoning in that file is: ```python -interpretation = pr.reason(timesteps=2) +interpretation = pr.reason(timesteps=3) ``` This specifies how many timesteps to run for. @@ -79,28 +101,30 @@ This specifies how many timesteps to run for. After running the python file, the expected output is: ``` - TIMESTEP - 0 - component popular -0 Mary [1.0,1.0] - +TIMESTEP - 0 +Empty DataFrame +Columns: [component, ViewedByAll] +Index: [] - TIMESTEP - 1 - component popular -0 Mary [1.0,1.0] -1 Justin [1.0,1.0] +TIMESTEP - 1 +Empty DataFrame +Columns: [component, ViewedByAll] +Index: [] +TIMESTEP - 2 + component ViewedByAll +0 TextMessage [1.0, 1.0] - TIMESTEP - 2 - component popular -0 Mary [1.0,1.0] -1 Justin [1.0,1.0] -2 John [1.0,1.0] +TIMESTEP - 3 + component ViewedByAll +0 TextMessage [1.0, 1.0] ``` -1. For timestep 0 we set `Mary -> popular: [1,1]` in the facts -2. For timestep 1, Justin is the only node who has one popular friend (Mary) and who has the same pet as Mary (cat). Therefore `Justin -> popular: [1,1]` -3. For timestep 2, since Justin has just become popular, John now has one popular friend (Justin) and the same pet as Justin (dog). Therefore `Justin -> popular: [1,1]` +1. For timestep 0, we set `Zach -> Viewed: [1,1]` and `Justin -> Viewed: [1,1]` in the facts +2. For timestep 1, Michelle views the TextMessage as stated in facts `Michelle -> Viewed: [1,1]` +3. For timestep 2, since Amy has just viewed the TextMessage, therefore `Amy -> Viewed: [1,1]`. As per the rule, +since all the people have viewed the TextMessage, the message is marked as ViewedByAll. We also output two CSV files detailing all the events that took place during reasoning (one for nodes, one for edges) \ No newline at end of file diff --git a/pyreason/scripts/threshold/threshold.py b/pyreason/scripts/threshold/threshold.py index 5e04d851..39722631 100644 --- a/pyreason/scripts/threshold/threshold.py +++ b/pyreason/scripts/threshold/threshold.py @@ -24,7 +24,7 @@ def __init__(self, quantifier, quantifier_type, thresh): if quantifier not in ("greater_equal", "greater", "less_equal", "less", "equal"): raise ValueError("Invalid quantifier") - if quantifier_type[0] not in ("number", "percent") and quantifier_type[1] not in ("total", "available"): + if quantifier_type[0] not in ("number", "percent") or quantifier_type[1] not in ("total", "available"): raise ValueError("Invalid quantifier type") self.quantifier = quantifier diff --git a/pyreason/scripts/utils/rule_parser.py b/pyreason/scripts/utils/rule_parser.py index 4d0dc850..4012b27b 100644 --- a/pyreason/scripts/utils/rule_parser.py +++ b/pyreason/scripts/utils/rule_parser.py @@ -153,14 +153,13 @@ def parse_rule(rule_text: str, name: str,custom_thresholds: list, infer_edges: b # gather count of clauses for threshold validation num_clauses = len(body_clauses) - print("body_clauses:", 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 no custom thresholds provided, use defaults - #otherwise loop through user-defined thresholds and convert to numba compatible format + # If no custom thresholds provided, use defaults + # otherwise loop through user-defined thresholds and convert to numba compatible format if not custom_thresholds: for _ in range(num_clauses): thresholds.append(('greater_equal', ('number', 'total'), 1.0)) diff --git a/tests/test_custom_thresholds.py b/tests/test_custom_thresholds.py index 6a97c6a2..73faa1f2 100644 --- a/tests/test_custom_thresholds.py +++ b/tests/test_custom_thresholds.py @@ -2,44 +2,57 @@ import pyreason as pr from pyreason import Threshold + 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' + graph_path = "./tests/group_chat_graph.graphml" # 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.verbose = True # Print info to screen # Load all the files into pyreason pr.load_graphml(graph_path) # add custom thresholds user_defined_thresholds = [ - Threshold('greater_equal', ('number', 'total'), 1), - Threshold('greater_equal', ('percent', 'total'), 100) + Threshold("greater_equal", ("number", "total"), 1), + Threshold("greater_equal", ("percent", "total"), 100), ] - pr.add_rule(pr.Rule('ViewedByAll(x) <- HaveAccess(x,y), Viewed(y)', 'viewed_by_all_rule', user_defined_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_rule( + pr.Rule( + "ViewedByAll(x) <- HaveAccess(x,y), Viewed(y)", + "viewed_by_all_rule", + user_defined_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)) - # Run the program for two timesteps to see the diffusion take place + # Run the program for three timesteps to see the diffusion take place interpretation = pr.reason(timesteps=3) # Display the changes in the interpretation for each timestep - dataframes = pr.filter_and_sort_nodes(interpretation, ['ViewedByAll']) + dataframes = pr.filter_and_sort_nodes(interpretation, ["ViewedByAll"]) for t, df in enumerate(dataframes): - print(f'TIMESTEP - {t}') + print(f"TIMESTEP - {t}") print(df) print() - assert len(dataframes[0]) == 0, 'At t=0 the TextMessage should not have been ViewedByAll' - assert len(dataframes[2]) == 1, 'At t=2 the TextMessage should have been ViewedByAll' - + assert ( + len(dataframes[0]) == 0 + ), "At t=0 the TextMessage should not have been ViewedByAll" + assert ( + len(dataframes[2]) == 1 + ), "At t=2 the TextMessage should have been ViewedByAll" # TextMessage should be ViewedByAll in t=2 - assert 'TextMessage' in dataframes[2]['component'].values and dataframes[2].iloc[0].ViewedByAll == [1, 1], 'TextMessage should have ViewedByAll bounds [1,1] for t=2 timesteps' - + assert "TextMessage" in dataframes[2]["component"].values and dataframes[2].iloc[ + 0 + ].ViewedByAll == [ + 1, + 1, + ], "TextMessage should have ViewedByAll bounds [1,1] for t=2 timesteps" From 6a83704bf04c1e7808d049e2546a016427a46ee1 Mon Sep 17 00:00:00 2001 From: Henry Kobin <163041577+f-hkobin@users.noreply.github.com> Date: Fri, 10 May 2024 12:50:29 -0700 Subject: [PATCH 05/10] Delete pyreason/scripts/numba_wrapper/numba_types/threshold_type.py remove threshold number type unless needed --- .../numba_types/threshold_type.py | 89 ------------------- 1 file changed, 89 deletions(-) delete mode 100644 pyreason/scripts/numba_wrapper/numba_types/threshold_type.py diff --git a/pyreason/scripts/numba_wrapper/numba_types/threshold_type.py b/pyreason/scripts/numba_wrapper/numba_types/threshold_type.py deleted file mode 100644 index 3b5c825e..00000000 --- a/pyreason/scripts/numba_wrapper/numba_types/threshold_type.py +++ /dev/null @@ -1,89 +0,0 @@ -from pyreason.scripts.threshold.threshold import Threshold -from numba import types -from numba.extending import models, register_model, make_attribute_wrapper -from numba.extending import lower_builtin -from numba.core import cgutils -from numba.extending import unbox, NativeValue, box -from numba.extending import typeof_impl - -# Define the Numba type for Threshold -class ThresholdType(types.Type): - def __init__(self): - super(ThresholdType, self).__init__(name='Threshold') - -threshold_type = ThresholdType() - -# Register the type with Numba -@typeof_impl.register(Threshold) -def typeof_threshold(val, c): - return threshold_type - -# Define the data model for the type -@register_model(ThresholdType) -class ThresholdModel(models.StructModel): - def __init__(self, dmm, fe_type): - members = [ - ('quantifier', types.unicode_type), - ('quantifier_type', types.UniTuple(types.unicode_type, 2)), - ('thresh', types.int64) - ] - models.StructModel.__init__(self, dmm, fe_type, members) - -# Make attributes accessible -make_attribute_wrapper(ThresholdType, 'quantifier', 'quantifier') -make_attribute_wrapper(ThresholdType, 'quantifier_type', 'quantifier_type') -make_attribute_wrapper(ThresholdType, 'thresh', 'thresh') - -# Implement the constructor for the type -@lower_builtin(Threshold, types.unicode_type, types.UniTuple(types.unicode_type, 2), types.int64) -def impl_threshold(context, builder, sig, args): - typ = sig.return_type - quantifier, quantifier_type, thresh = args - threshold = cgutils.create_struct_proxy(typ)(context, builder) - threshold.quantifier = quantifier - threshold.quantifier_type = quantifier_type - threshold.thresh = thresh - return threshold._getvalue() - -# Tell Numba how to unbox and box the type -@unbox(ThresholdType) -def unbox_threshold(typ, obj, c): - quantifier_obj = c.pyapi.object_getattr_string(obj, 'quantifier') - quantifier_type_obj = c.pyapi.object_getattr_string(obj, 'quantifier_type') - thresh_obj = c.pyapi.object_getattr_string(obj, 'thresh') - - threshold = cgutils.create_struct_proxy(typ)(c.context, c.builder) - threshold.quantifier = c.unbox(types.unicode_type, quantifier_obj).value - threshold.quantifier_type = c.unbox(types.UniTuple(types.unicode_type, 2), quantifier_type_obj).value - threshold.thresh = c.unbox(types.int64, thresh_obj).value - - c.pyapi.decref(quantifier_obj) - c.pyapi.decref(quantifier_type_obj) - c.pyapi.decref(thresh_obj) - - is_error = cgutils.is_not_null(c.builder, c.pyapi.err_occurred()) - return NativeValue(threshold._getvalue(), is_error=is_error) - -@box(ThresholdType) -def box_threshold(typ, val, c): - threshold = cgutils.create_struct_proxy(typ)(c.context, c.builder, value=val) - threshold_obj = c.pyapi.unserialize(c.pyapi.serialize_object(Threshold)) - - quantifier_obj = c.box(types.unicode_type, threshold.quantifier) - quantifier_type_obj = c.box(types.UniTuple(types.unicode_type, 2), threshold.quantifier_type) - thresh_obj = c.box(types.int64, threshold.thresh) - - # Create the Threshold object using its constructor - threshold_instance = c.pyapi.call_function_objargs( - threshold_obj, - (quantifier_obj, quantifier_type_obj, thresh_obj) - ) - - # Decrease the reference count of the boxed objects - c.pyapi.decref(quantifier_obj) - c.pyapi.decref(quantifier_type_obj) - c.pyapi.decref(thresh_obj) - c.pyapi.decref(threshold_obj) - - # Return the boxed Threshold object - return threshold_instance \ No newline at end of file From 578e29ed4815c50bf1cc3b2f190c875c284854eb Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 12 May 2024 21:17:30 +0200 Subject: [PATCH 06/10] Update group-chat-example.md made `Viewed` facts static so that they occur in one timestep and stay active throughout the program. --- docs/group-chat-example.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/group-chat-example.md b/docs/group-chat-example.md index c0e559f4..c2aaa0ce 100755 --- a/docs/group-chat-example.md +++ b/docs/group-chat-example.md @@ -71,18 +71,18 @@ case they will be immutable later on. Adding PyReason facts gives us more flexib In our case we want one person to view the TextMessage in a particular interval of timestep. For example, we create facts stating: -- Zach and Justin view the TextMessage from timestep 0-3 -- Michelle views the TextMessage from timestep 1-3 -- Amy views the TextMessage from timestep 2-3 +- Zach and Justin view the TextMessage from at timestep 0 +- Michelle views the TextMessage at timestep 1 +- Amy views the TextMessage at timestep 2 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, 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("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)) ``` This allows us to specify the component that has an initial condition, the initial condition itself in the form of bounds @@ -127,4 +127,4 @@ TIMESTEP - 3 since all the people have viewed the TextMessage, the message is marked as ViewedByAll. -We also output two CSV files detailing all the events that took place during reasoning (one for nodes, one for edges) \ No newline at end of file +We also output two CSV files detailing all the events that took place during reasoning (one for nodes, one for edges) From f5eb7f890cf5fade4b7d4d7228f07f3967c15d0e Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 12 May 2024 21:18:39 +0200 Subject: [PATCH 07/10] revert cache initialization to false --- pyreason/.cache_status.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyreason/.cache_status.yaml b/pyreason/.cache_status.yaml index 71173842..32458f5d 100644 --- a/pyreason/.cache_status.yaml +++ b/pyreason/.cache_status.yaml @@ -1 +1 @@ -initialized: true +initialized: false From e1a17bf5ee37b3b82d857930a548e2d476edcebe Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 12 May 2024 22:00:11 +0200 Subject: [PATCH 08/10] added custom threshold parameter to `Rule` class --- .gitignore | 2 ++ pyreason/scripts/rules/rule.py | 10 +++++----- pyreason/scripts/utils/rule_parser.py | 3 ++- tests/test_custom_thresholds.py | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 7b6b4961..98c7ff74 100755 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ + +*env/ diff --git a/pyreason/scripts/rules/rule.py b/pyreason/scripts/rules/rule.py index 97cca58d..73824c4d 100755 --- a/pyreason/scripts/rules/rule.py +++ b/pyreason/scripts/rules/rule.py @@ -6,12 +6,10 @@ class Rule: Example text: `'pred1(x,y) : [0.2, 1] <- pred2(a, b) : [1,1], pred3(b, c)'` - 1. It is not possible to specify thresholds. Threshold is greater than or equal to 1 by default - 2. It is not possible to have weights for different clauses. Weights are 1 by default with bias 0 - TODO: Add threshold class where we can pass this as a parameter + 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): + def __init__(self, rule_text: str, name: str, 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 @@ -19,4 +17,6 @@ def __init__(self, rule_text: str, name: str, infer_edges: bool = False, set_sta :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 """ - self.rule = rule_parser.parse_rule(rule_text, name, infer_edges, set_static, immediate_rule) + 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 4012b27b..36bdede0 100644 --- a/pyreason/scripts/utils/rule_parser.py +++ b/pyreason/scripts/utils/rule_parser.py @@ -5,7 +5,8 @@ import pyreason.scripts.numba_wrapper.numba_types.label_type as label import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval -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: list, infer_edges: bool = False, set_static: bool = False, immediate_rule: bool = False) -> rule.Rule: # First remove all spaces from line r = rule_text.replace(' ', '') diff --git a/tests/test_custom_thresholds.py b/tests/test_custom_thresholds.py index 73faa1f2..e33134f1 100644 --- a/tests/test_custom_thresholds.py +++ b/tests/test_custom_thresholds.py @@ -23,7 +23,7 @@ def test_custom_thresholds(): pr.Rule( "ViewedByAll(x) <- HaveAccess(x,y), Viewed(y)", "viewed_by_all_rule", - user_defined_thresholds, + custom_thresholds=user_defined_thresholds, ) ) From 5754112f49180cf70db0d269d626c36055800768 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Sun, 12 May 2024 22:22:40 +0200 Subject: [PATCH 09/10] fix key error in tests --- pyreason/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyreason/__init__.py b/pyreason/__init__.py index 56e7df41..85f8319a 100755 --- a/pyreason/__init__.py +++ b/pyreason/__init__.py @@ -23,6 +23,9 @@ add_fact(Fact('popular-fact', 'Mary', 'popular', [1, 1], 0, 2)) reason(timesteps=2) + reset() + reset_rules() + # Update cache status cache_status['initialized'] = True with open(cache_status_path, 'w') as file: From c796b75735b04f1f8be36667a4f99020bd009b12 Mon Sep 17 00:00:00 2001 From: Dyuman Aditya Date: Thu, 16 May 2024 21:41:11 +0200 Subject: [PATCH 10/10] modified tests to reset --- tests/test_custom_thresholds.py | 4 ++++ tests/test_hello_world.py | 4 ++++ tests/test_hello_world_parallel.py | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/tests/test_custom_thresholds.py b/tests/test_custom_thresholds.py index e33134f1..b982bf36 100644 --- a/tests/test_custom_thresholds.py +++ b/tests/test_custom_thresholds.py @@ -4,6 +4,10 @@ def test_custom_thresholds(): + # Reset PyReason + pr.reset() + pr.reset_rules() + # Modify the paths based on where you've stored the files we made above graph_path = "./tests/group_chat_graph.graphml" diff --git a/tests/test_hello_world.py b/tests/test_hello_world.py index c2213458..c932daff 100644 --- a/tests/test_hello_world.py +++ b/tests/test_hello_world.py @@ -3,6 +3,10 @@ def test_hello_world(): + # Reset PyReason + pr.reset() + pr.reset_rules() + # Modify the paths based on where you've stored the files we made above graph_path = './tests/friends_graph.graphml' diff --git a/tests/test_hello_world_parallel.py b/tests/test_hello_world_parallel.py index cd16111c..1b7ee03c 100644 --- a/tests/test_hello_world_parallel.py +++ b/tests/test_hello_world_parallel.py @@ -3,6 +3,10 @@ def test_hello_world_parallel(): + # Reset PyReason + pr.reset() + pr.reset_rules() + # Modify the paths based on where you've stored the files we made above graph_path = './tests/friends_graph.graphml'