diff --git a/.github/workflows/python-package-version-test.yml b/.github/workflows/python-package-version-test.yml
index 4a0f3791..1a8f1b2d 100644
--- a/.github/workflows/python-package-version-test.yml
+++ b/.github/workflows/python-package-version-test.yml
@@ -16,12 +16,12 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.9", "3.10"]
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v3
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
@@ -32,7 +32,6 @@ jobs:
# the last release with the old E4/E7/E9/F default. Unpin only together
# with an explicit `select` in pyproject.toml and the matching cleanup.
python -m pip install ruff==0.15.22 pytest
- pip install torch==2.6.0
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with ruff
run: |
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 479faed3..ff4691f7 100755
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -21,9 +21,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set up Python
- uses: actions/setup-python@v3
+ uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 296c7943..fe758435 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -5,7 +5,7 @@ version: 2
build:
os: ubuntu-22.04
tools:
- python: "3.9"
+ python: "3.10"
sphinx:
configuration: docs/source/conf.py
diff --git a/README.md b/README.md
index 7f9059aa..077417d3 100755
--- a/README.md
+++ b/README.md
@@ -1,7 +1,11 @@
+[](https://www.python.org/downloads/release/python-380/)
[](https://www.python.org/downloads/release/python-390/)
[](https://www.python.org/downloads/release/python-3100/)
+[](https://www.python.org/downloads/release/python-3110/)
+[](https://www.python.org/downloads/release/python-3120/)
+[](https://www.python.org/downloads/release/python-3130/)
[](https://pyreason.readthedocs.io/en/latest/?badge=latest)
[](https://github.com/lab-v2/pyreason/actions/workflows/python-publish.yml)
[](https://github.com/lab-v2/pyreason/actions/workflows/python-package-version-test.yml)
@@ -43,7 +47,7 @@ PyReason can be installed as a python library using
```bash
pip install pyreason
```
-The Python versions that are currently supported are `3.7`, `3.8`, `3.9`, `3.10`. If you want multi-core parallel support only `3.9` and `3.10` versions work due to limited numba support.
+The Python versions that are currently supported are `3.8`, `3.9`, `3.10`, `3.11`, `3.12`, and `3.13`.
## 4. Bibtex
If you used this software in your work please cite our paper
diff --git a/docs/source/installation.rst b/docs/source/installation.rst
index d3586355..d6302746 100644
--- a/docs/source/installation.rst
+++ b/docs/source/installation.rst
@@ -1,7 +1,7 @@
Installation
==========
-PyReason is currently compatible with Python 3.9 and 3.10. To install PyReason, you can use pip:
+PyReason is currently compatible with Python 3.8, 3.9, 3.10, 3.11, 3.12, and 3.13. To install PyReason, you can use pip:
.. code:: bash
@@ -12,7 +12,7 @@ Make sure you're using the correct version of Python. You can create a conda env
.. code:: bash
- conda create -n pyreason-env python=3.10
+ conda create -n pyreason-env python=3.13
PyReason uses a JIT compiler called `Numba `_ to speed up the reasoning process. This means that
the first time PyReason is imported it will have to compile certain functions which will result in faster runtimes later on.
diff --git a/docs/source/tutorials/installation.rst b/docs/source/tutorials/installation.rst
index 3b41e3dd..4efa6925 100644
--- a/docs/source/tutorials/installation.rst
+++ b/docs/source/tutorials/installation.rst
@@ -13,7 +13,7 @@ Prerequisites
.. note::
- Use python version 3.9 or 3.10 .
+ Use Python version 3.8, 3.9, 3.10, 3.11, 3.12, or 3.13.
Step-by-Step Guide
@@ -50,19 +50,19 @@ Step-by-Step Guide
.. code-block:: bash
- pyenv install 3.8
+ pyenv install 3.13
4. Create a Virtual Environment
.. code-block:: bash
- pyenv virtualenv 3.8 pyreason_venv_3.8
+ pyenv virtualenv 3.13 pyreason_venv_3.13
5. Activate the Virtual Environment
.. code-block:: bash
- pyenv activate pyreason_venv_3.8
+ pyenv activate pyreason_venv_3.13
6. Install pyreason Using `pip`
diff --git a/pyproject.toml b/pyproject.toml
index bf5379f8..0f50a1ff 100755
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
[build-system]
-requires = ['setuptools>=42']
+requires = ['setuptools>=42', 'setuptools_scm[toml]>=8']
build-backend = 'setuptools.build_meta'
[tool.ruff.lint]
diff --git a/pyreason/__init__.py b/pyreason/__init__.py
index d0611ae1..45249dfb 100755
--- a/pyreason/__init__.py
+++ b/pyreason/__init__.py
@@ -1,6 +1,7 @@
# ruff: noqa: F403 F405 (Ignore Pyreason import * for public api)
# Set numba environment variable
import os
+import sys
package_path = os.path.abspath(os.path.dirname(__file__))
cache_path = os.path.join(package_path, 'cache')
cache_status_path = os.path.join(package_path, '.cache_status.yaml')
@@ -9,12 +10,11 @@
from pyreason.pyreason import *
import yaml
-from importlib.metadata import version
-from pkg_resources import get_distribution, DistributionNotFound
+from importlib.metadata import PackageNotFoundError, version
try:
- __version__ = get_distribution(__name__).version
-except DistributionNotFound:
+ __version__ = version(__name__)
+except PackageNotFoundError:
# package is not installed
pass
@@ -22,7 +22,9 @@
with open(cache_status_path) as file:
cache_status = yaml.safe_load(file)
-if not cache_status['initialized']:
+running_tests = 'pytest' in sys.modules or 'unittest' in sys.modules
+
+if not cache_status['initialized'] and not running_tests:
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')
@@ -37,9 +39,6 @@
print('PyReason initialized!')
print()
- # Update cache status (skip under test runners to keep repo file clean)
- import sys
- if 'pytest' not in sys.modules and 'unittest' not in sys.modules:
- cache_status['initialized'] = True
- with open(cache_status_path, 'w') as file:
- yaml.dump(cache_status, file)
+ cache_status['initialized'] = True
+ with open(cache_status_path, 'w') as file:
+ yaml.dump(cache_status, file)
diff --git a/pyreason/scripts/interpretation/interpretation.py b/pyreason/scripts/interpretation/interpretation.py
index d4bda1db..72d0822e 100755
--- a/pyreason/scripts/interpretation/interpretation.py
+++ b/pyreason/scripts/interpretation/interpretation.py
@@ -56,6 +56,45 @@
edges_to_be_added_type = numba.types.Tuple((numba.types.ListType(node_type), numba.types.ListType(node_type), label.label_type))
+
+@numba.njit(cache=True)
+def _filter_pending_node_rules(rules_to_be_applied_node, edges_to_be_added_node_rule, rules_to_be_applied_node_trace, rules_to_remove_idx, atom_trace):
+ rules_to_be_applied_node_new = numba.typed.List.empty_list(rules_to_be_applied_node_type)
+ edges_to_be_added_node_rule_new = numba.typed.List.empty_list(edges_to_be_added_type)
+ rules_to_be_applied_node_trace_new = numba.typed.List.empty_list(rules_to_be_applied_trace_type)
+ for keep_idx in range(len(rules_to_be_applied_node)):
+ if keep_idx not in rules_to_remove_idx:
+ rules_to_be_applied_node_new.append(rules_to_be_applied_node[keep_idx])
+ if atom_trace:
+ rules_to_be_applied_node_trace_new.append(rules_to_be_applied_node_trace[keep_idx])
+ # Node rules do not populate the edge list, so it need not match the rule queue.
+ # Preserve independent filtering instead of indexing it for each pending rule.
+ for keep_idx in range(len(edges_to_be_added_node_rule)):
+ if keep_idx not in rules_to_remove_idx:
+ edges_to_be_added_node_rule_new.append(edges_to_be_added_node_rule[keep_idx])
+ rules_to_be_applied_node[:] = rules_to_be_applied_node_new.copy()
+ edges_to_be_added_node_rule[:] = edges_to_be_added_node_rule_new.copy()
+ if atom_trace:
+ rules_to_be_applied_node_trace[:] = rules_to_be_applied_node_trace_new.copy()
+
+
+@numba.njit(cache=True)
+def _filter_pending_edge_rules(rules_to_be_applied_edge, edges_to_be_added_edge_rule, rules_to_be_applied_edge_trace, rules_to_remove_idx, atom_trace):
+ rules_to_be_applied_edge_new = numba.typed.List.empty_list(rules_to_be_applied_edge_type)
+ edges_to_be_added_edge_rule_new = numba.typed.List.empty_list(edges_to_be_added_type)
+ rules_to_be_applied_edge_trace_new = numba.typed.List.empty_list(rules_to_be_applied_trace_type)
+ for keep_idx in range(len(rules_to_be_applied_edge)):
+ if keep_idx not in rules_to_remove_idx:
+ rules_to_be_applied_edge_new.append(rules_to_be_applied_edge[keep_idx])
+ edges_to_be_added_edge_rule_new.append(edges_to_be_added_edge_rule[keep_idx])
+ if atom_trace:
+ rules_to_be_applied_edge_trace_new.append(rules_to_be_applied_edge_trace[keep_idx])
+ rules_to_be_applied_edge[:] = rules_to_be_applied_edge_new.copy()
+ edges_to_be_added_edge_rule[:] = edges_to_be_added_edge_rule_new.copy()
+ if atom_trace:
+ rules_to_be_applied_edge_trace[:] = rules_to_be_applied_edge_trace_new.copy()
+
+
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))
@@ -438,9 +477,9 @@ def reason(interpretations_node, interpretations_edge, predicate_map_node, predi
# Apply the rules that need to be applied at this timestep
# Nodes
rules_to_remove_idx.clear()
- for idx, i in enumerate(rules_to_be_applied_node):
- if i[0] == t:
- comp, l, bnd, set_static = i[1], i[2], i[3], i[4]
+ for idx, pending_rule in enumerate(rules_to_be_applied_node):
+ if pending_rule[0] == t:
+ comp, l, bnd, set_static = pending_rule[1], pending_rule[2], pending_rule[3], pending_rule[4]
# Check for inconsistencies
if check_consistent_node(interpretations_node, comp, (l, bnd)):
override = True if update_mode == 'override' else False
@@ -470,16 +509,13 @@ def reason(interpretations_node, interpretations_edge, predicate_map_node, predi
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])
- 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])
- if atom_trace:
- rules_to_be_applied_node_trace[:] = numba.typed.List([rules_to_be_applied_node_trace[i] for i in range(len(rules_to_be_applied_node_trace)) if i not in rules_to_remove_idx])
+ _filter_pending_node_rules(rules_to_be_applied_node, edges_to_be_added_node_rule, rules_to_be_applied_node_trace, rules_to_remove_idx, atom_trace)
# Edges
rules_to_remove_idx.clear()
- for idx, i in enumerate(rules_to_be_applied_edge):
- if i[0] == t:
- comp, l, bnd, set_static = i[1], i[2], i[3], i[4]
+ for idx, pending_rule in enumerate(rules_to_be_applied_edge):
+ if pending_rule[0] == t:
+ comp, l, bnd, set_static = pending_rule[1], pending_rule[2], pending_rule[3], pending_rule[4]
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, predicate_map_edge, num_ga, t)
changes_cnt += changes
@@ -544,10 +580,7 @@ def reason(interpretations_node, interpretations_edge, predicate_map_node, predi
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])
- 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])
- if atom_trace:
- rules_to_be_applied_edge_trace[:] = numba.typed.List([rules_to_be_applied_edge_trace[i] for i in range(len(rules_to_be_applied_edge_trace)) if i not in rules_to_remove_idx])
+ _filter_pending_edge_rules(rules_to_be_applied_edge, edges_to_be_added_edge_rule, rules_to_be_applied_edge_trace, rules_to_remove_idx, atom_trace)
# Fixed point
if update:
@@ -1427,15 +1460,15 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun
# 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)
+ for nn in reverse_neighbors[n]:
+ edge_groundings.append((nn, n))
# 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)
+ for nn in neighbors[n]:
+ edge_groundings.append((n, nn))
# Case 4:
# We have seen both variables before
@@ -1447,8 +1480,9 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun
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_set])
- edge_groundings.extend(es)
+ for nn in neighbors[n]:
+ if nn in groundings_clause_var_2_set:
+ edge_groundings.append((n, nn))
return edge_groundings
diff --git a/pyreason/scripts/interpretation/interpretation_fp.py b/pyreason/scripts/interpretation/interpretation_fp.py
index 207f9377..682bf734 100755
--- a/pyreason/scripts/interpretation/interpretation_fp.py
+++ b/pyreason/scripts/interpretation/interpretation_fp.py
@@ -60,6 +60,45 @@
edges_to_be_added_type = numba.types.Tuple((numba.types.ListType(node_type), numba.types.ListType(node_type), label.label_type))
+
+@numba.njit(cache=True)
+def _filter_pending_node_rules(rules_to_be_applied_node, edges_to_be_added_node_rule, rules_to_be_applied_node_trace, rules_to_remove_idx, atom_trace):
+ rules_to_be_applied_node_new = numba.typed.List.empty_list(rules_to_be_applied_node_type)
+ edges_to_be_added_node_rule_new = numba.typed.List.empty_list(edges_to_be_added_type)
+ rules_to_be_applied_node_trace_new = numba.typed.List.empty_list(rules_to_be_applied_trace_type)
+ for keep_idx in range(len(rules_to_be_applied_node)):
+ if keep_idx not in rules_to_remove_idx:
+ rules_to_be_applied_node_new.append(rules_to_be_applied_node[keep_idx])
+ if atom_trace:
+ rules_to_be_applied_node_trace_new.append(rules_to_be_applied_node_trace[keep_idx])
+ # Node rules do not populate the edge list, so it need not match the rule queue.
+ # Preserve independent filtering instead of indexing it for each pending rule.
+ for keep_idx in range(len(edges_to_be_added_node_rule)):
+ if keep_idx not in rules_to_remove_idx:
+ edges_to_be_added_node_rule_new.append(edges_to_be_added_node_rule[keep_idx])
+ rules_to_be_applied_node[:] = rules_to_be_applied_node_new.copy()
+ edges_to_be_added_node_rule[:] = edges_to_be_added_node_rule_new.copy()
+ if atom_trace:
+ rules_to_be_applied_node_trace[:] = rules_to_be_applied_node_trace_new.copy()
+
+
+@numba.njit(cache=True)
+def _filter_pending_edge_rules(rules_to_be_applied_edge, edges_to_be_added_edge_rule, rules_to_be_applied_edge_trace, rules_to_remove_idx, atom_trace):
+ rules_to_be_applied_edge_new = numba.typed.List.empty_list(rules_to_be_applied_edge_type)
+ edges_to_be_added_edge_rule_new = numba.typed.List.empty_list(edges_to_be_added_type)
+ rules_to_be_applied_edge_trace_new = numba.typed.List.empty_list(rules_to_be_applied_trace_type)
+ for keep_idx in range(len(rules_to_be_applied_edge)):
+ if keep_idx not in rules_to_remove_idx:
+ rules_to_be_applied_edge_new.append(rules_to_be_applied_edge[keep_idx])
+ edges_to_be_added_edge_rule_new.append(edges_to_be_added_edge_rule[keep_idx])
+ if atom_trace:
+ rules_to_be_applied_edge_trace_new.append(rules_to_be_applied_edge_trace[keep_idx])
+ rules_to_be_applied_edge[:] = rules_to_be_applied_edge_new.copy()
+ edges_to_be_added_edge_rule[:] = edges_to_be_added_edge_rule_new.copy()
+ if atom_trace:
+ rules_to_be_applied_edge_trace[:] = rules_to_be_applied_edge_trace_new.copy()
+
+
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))
@@ -642,8 +681,8 @@ def reason(interpretations_node, interpretations_edge, predicate_map_node, predi
# Apply the rules that need to be applied at this timestep
# Nodes
rules_to_remove_idx.clear()
- for idx, i in enumerate(rules_to_be_applied_node):
- t, comp, l, bnd, set_static = i[0], i[1], i[2], i[3], i[4]
+ for idx, pending_rule in enumerate(rules_to_be_applied_node):
+ t, comp, l, bnd, set_static = pending_rule[0], pending_rule[1], pending_rule[2], pending_rule[3], pending_rule[4]
# if node doesn't exist in interpretation, add it
if comp not in interpretations_node[t]:
@@ -682,15 +721,12 @@ def reason(interpretations_node, interpretations_edge, predicate_map_node, predi
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])
- 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])
- if atom_trace:
- rules_to_be_applied_node_trace[:] = numba.typed.List([rules_to_be_applied_node_trace[i] for i in range(len(rules_to_be_applied_node_trace)) if i not in rules_to_remove_idx])
+ _filter_pending_node_rules(rules_to_be_applied_node, edges_to_be_added_node_rule, rules_to_be_applied_node_trace, rules_to_remove_idx, atom_trace)
# Edges
rules_to_remove_idx.clear()
- for idx, i in enumerate(rules_to_be_applied_edge):
- t, comp, l, bnd, set_static = i[0], i[1], i[2], i[3], i[4]
+ for idx, pending_rule in enumerate(rules_to_be_applied_edge):
+ t, comp, l, bnd, set_static = pending_rule[0], pending_rule[1], pending_rule[2], pending_rule[3], pending_rule[4]
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[t], interpretations_edge[t], predicate_map_edge, t)
changes_cnt += changes
@@ -766,10 +802,7 @@ def reason(interpretations_node, interpretations_edge, predicate_map_node, predi
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])
- 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])
- if atom_trace:
- rules_to_be_applied_edge_trace[:] = numba.typed.List([rules_to_be_applied_edge_trace[i] for i in range(len(rules_to_be_applied_edge_trace)) if i not in rules_to_remove_idx])
+ _filter_pending_edge_rules(rules_to_be_applied_edge, edges_to_be_added_edge_rule, rules_to_be_applied_edge_trace, rules_to_remove_idx, atom_trace)
# Check for convergence after each timestep (perfect convergence or convergence specified by user)
# Check number of changed interpretations or max bound change
@@ -1547,15 +1580,15 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun
# 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)
+ for nn in reverse_neighbors[n]:
+ edge_groundings.append((nn, n))
# 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)
+ for nn in neighbors[n]:
+ edge_groundings.append((n, nn))
# Case 4:
# We have seen both variables before
@@ -1567,8 +1600,9 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun
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_set])
- edge_groundings.extend(es)
+ for nn in neighbors[n]:
+ if nn in groundings_clause_var_2_set:
+ edge_groundings.append((n, nn))
return edge_groundings
diff --git a/pyreason/scripts/interpretation/interpretation_parallel.py b/pyreason/scripts/interpretation/interpretation_parallel.py
index 32d7b3fc..cb966782 100644
--- a/pyreason/scripts/interpretation/interpretation_parallel.py
+++ b/pyreason/scripts/interpretation/interpretation_parallel.py
@@ -56,6 +56,45 @@
edges_to_be_added_type = numba.types.Tuple((numba.types.ListType(node_type), numba.types.ListType(node_type), label.label_type))
+
+@numba.njit(cache=True)
+def _filter_pending_node_rules(rules_to_be_applied_node, edges_to_be_added_node_rule, rules_to_be_applied_node_trace, rules_to_remove_idx, atom_trace):
+ rules_to_be_applied_node_new = numba.typed.List.empty_list(rules_to_be_applied_node_type)
+ edges_to_be_added_node_rule_new = numba.typed.List.empty_list(edges_to_be_added_type)
+ rules_to_be_applied_node_trace_new = numba.typed.List.empty_list(rules_to_be_applied_trace_type)
+ for keep_idx in range(len(rules_to_be_applied_node)):
+ if keep_idx not in rules_to_remove_idx:
+ rules_to_be_applied_node_new.append(rules_to_be_applied_node[keep_idx])
+ if atom_trace:
+ rules_to_be_applied_node_trace_new.append(rules_to_be_applied_node_trace[keep_idx])
+ # Node rules do not populate the edge list, so it need not match the rule queue.
+ # Preserve independent filtering instead of indexing it for each pending rule.
+ for keep_idx in range(len(edges_to_be_added_node_rule)):
+ if keep_idx not in rules_to_remove_idx:
+ edges_to_be_added_node_rule_new.append(edges_to_be_added_node_rule[keep_idx])
+ rules_to_be_applied_node[:] = rules_to_be_applied_node_new.copy()
+ edges_to_be_added_node_rule[:] = edges_to_be_added_node_rule_new.copy()
+ if atom_trace:
+ rules_to_be_applied_node_trace[:] = rules_to_be_applied_node_trace_new.copy()
+
+
+@numba.njit(cache=True)
+def _filter_pending_edge_rules(rules_to_be_applied_edge, edges_to_be_added_edge_rule, rules_to_be_applied_edge_trace, rules_to_remove_idx, atom_trace):
+ rules_to_be_applied_edge_new = numba.typed.List.empty_list(rules_to_be_applied_edge_type)
+ edges_to_be_added_edge_rule_new = numba.typed.List.empty_list(edges_to_be_added_type)
+ rules_to_be_applied_edge_trace_new = numba.typed.List.empty_list(rules_to_be_applied_trace_type)
+ for keep_idx in range(len(rules_to_be_applied_edge)):
+ if keep_idx not in rules_to_remove_idx:
+ rules_to_be_applied_edge_new.append(rules_to_be_applied_edge[keep_idx])
+ edges_to_be_added_edge_rule_new.append(edges_to_be_added_edge_rule[keep_idx])
+ if atom_trace:
+ rules_to_be_applied_edge_trace_new.append(rules_to_be_applied_edge_trace[keep_idx])
+ rules_to_be_applied_edge[:] = rules_to_be_applied_edge_new.copy()
+ edges_to_be_added_edge_rule[:] = edges_to_be_added_edge_rule_new.copy()
+ if atom_trace:
+ rules_to_be_applied_edge_trace[:] = rules_to_be_applied_edge_trace_new.copy()
+
+
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))
@@ -438,9 +477,9 @@ def reason(interpretations_node, interpretations_edge, predicate_map_node, predi
# Apply the rules that need to be applied at this timestep
# Nodes
rules_to_remove_idx.clear()
- for idx, i in enumerate(rules_to_be_applied_node):
- if i[0] == t:
- comp, l, bnd, set_static = i[1], i[2], i[3], i[4]
+ for idx, pending_rule in enumerate(rules_to_be_applied_node):
+ if pending_rule[0] == t:
+ comp, l, bnd, set_static = pending_rule[1], pending_rule[2], pending_rule[3], pending_rule[4]
# Check for inconsistencies
if check_consistent_node(interpretations_node, comp, (l, bnd)):
override = True if update_mode == 'override' else False
@@ -470,16 +509,13 @@ def reason(interpretations_node, interpretations_edge, predicate_map_node, predi
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])
- 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])
- if atom_trace:
- rules_to_be_applied_node_trace[:] = numba.typed.List([rules_to_be_applied_node_trace[i] for i in range(len(rules_to_be_applied_node_trace)) if i not in rules_to_remove_idx])
+ _filter_pending_node_rules(rules_to_be_applied_node, edges_to_be_added_node_rule, rules_to_be_applied_node_trace, rules_to_remove_idx, atom_trace)
# Edges
rules_to_remove_idx.clear()
- for idx, i in enumerate(rules_to_be_applied_edge):
- if i[0] == t:
- comp, l, bnd, set_static = i[1], i[2], i[3], i[4]
+ for idx, pending_rule in enumerate(rules_to_be_applied_edge):
+ if pending_rule[0] == t:
+ comp, l, bnd, set_static = pending_rule[1], pending_rule[2], pending_rule[3], pending_rule[4]
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, predicate_map_edge, num_ga, t)
changes_cnt += changes
@@ -544,10 +580,7 @@ def reason(interpretations_node, interpretations_edge, predicate_map_node, predi
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])
- 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])
- if atom_trace:
- rules_to_be_applied_edge_trace[:] = numba.typed.List([rules_to_be_applied_edge_trace[i] for i in range(len(rules_to_be_applied_edge_trace)) if i not in rules_to_remove_idx])
+ _filter_pending_edge_rules(rules_to_be_applied_edge, edges_to_be_added_edge_rule, rules_to_be_applied_edge_trace, rules_to_remove_idx, atom_trace)
# Fixed point
if update:
@@ -1427,15 +1460,15 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun
# 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)
+ for nn in reverse_neighbors[n]:
+ edge_groundings.append((nn, n))
# 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)
+ for nn in neighbors[n]:
+ edge_groundings.append((n, nn))
# Case 4:
# We have seen both variables before
@@ -1447,8 +1480,9 @@ def get_rule_edge_clause_grounding(clause_var_1, clause_var_2, groundings, groun
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_set])
- edge_groundings.extend(es)
+ for nn in neighbors[n]:
+ if nn in groundings_clause_var_2_set:
+ edge_groundings.append((n, nn))
return edge_groundings
diff --git a/requirements.txt b/requirements.txt
index aabec6b4..6908dec6 100755
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,8 +1,12 @@
networkx
pyyaml
pandas
-numba==0.59.1
-numpy==1.26.4
+# Use compatible Numba/NumPy versions for each supported Python version.
+numba==0.58.1; python_version < "3.9"
+numba==0.59.1; python_version >= "3.9" and python_version < "3.10"
+numba>=0.61.0; python_version >= "3.10"
+numpy==1.24.4; python_version < "3.9"
+numpy>=1.26.4; python_version >= "3.9"
memory_profiler
pytest
torch
diff --git a/run_tests.py b/run_tests.py
index 582949c2..5bd3f623 100755
--- a/run_tests.py
+++ b/run_tests.py
@@ -124,14 +124,17 @@ def _find_python_command(self) -> str:
if os.path.exists(venv_path):
return venv_path
- # Try different python commands in order of preference
- # Prioritize Python 3.9 as PyReason doesn't support 3.13+
+ # Try different python commands in order of preference.
+ # Prefer currently supported PyReason versions.
candidates = [
+ 'python3.13',
+ 'python3.12',
+ 'python3.11',
+ 'python3.10',
'python3.9',
- '/usr/bin/python3', # System Python (often 3.9 on macOS)
+ 'python3.8',
'python3',
'python',
- 'python3.11'
]
for cmd in candidates:
diff --git a/setup.py b/setup.py
index 35d71a08..38b09fe2 100644
--- a/setup.py
+++ b/setup.py
@@ -22,16 +22,26 @@
},
classifiers=[
"Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent"
],
- python_requires='>3.6',
+ python_requires='>=3.8,<3.14',
install_requires=[
'networkx',
'pyyaml',
'pandas',
- 'numba',
- 'numpy',
+ # Use compatible Numba/NumPy versions for each supported Python version.
+ 'numba==0.58.1; python_version < "3.9"',
+ 'numba==0.59.1; python_version >= "3.9" and python_version < "3.10"',
+ 'numba>=0.61.0; python_version >= "3.10"',
+ 'numpy==1.24.4; python_version < "3.9"',
+ 'numpy>=1.26.4; python_version >= "3.9"',
'memory_profiler',
'pytest'
],
diff --git a/tests/functional/test_delayed_node_rules.py b/tests/functional/test_delayed_node_rules.py
new file mode 100644
index 00000000..04b7feab
--- /dev/null
+++ b/tests/functional/test_delayed_node_rules.py
@@ -0,0 +1,50 @@
+import networkx as nx
+import pyreason as pr
+import pytest
+
+
+@pytest.mark.parametrize("mode", ["regular", "parallel"])
+@pytest.mark.parametrize("atom_trace", [False, True])
+@pytest.mark.parametrize(
+ "delay, immediate_rule",
+ [(2, False), (1, True)],
+ ids=["two-timestep-delay", "mixed-zero-and-one-timestep-delays"],
+)
+def test_pending_node_rules_keep_their_schedule(mode, atom_trace, delay, immediate_rule):
+ """Pending node rules survive both a timestep and an extra fixed-point pass."""
+ pr.reset()
+ pr.reset_rules()
+ pr.reset_settings()
+ pr.settings.verbose = False
+ pr.settings.parallel_computing = mode == "parallel"
+ pr.settings.atom_trace = atom_trace
+
+ graph = nx.DiGraph()
+ graph.add_node("a")
+ pr.load_graph(graph)
+ pr.add_fact(pr.Fact("seed(a)", "seed_fact", 0, 3))
+ pr.add_rule(pr.Rule(f"result(x) <-{delay} seed(x)", "delayed_rule"))
+ if immediate_rule:
+ pr.add_rule(pr.Rule("immediate(x) <-0 seed(x)", "immediate_rule"))
+
+ interpretation = pr.reason(timesteps=3)
+ frames = pr.filter_and_sort_nodes(interpretation, ["result"])
+ assert len(frames) == 4
+ for timestep, frame in enumerate(frames):
+ if timestep < delay:
+ assert frame.empty
+ else:
+ assert frame.to_dict("records") == [{"component": "a", "result": [1, 1]}]
+
+ if immediate_rule:
+ immediate_frames = pr.filter_and_sort_nodes(interpretation, ["immediate"])
+ assert immediate_frames[0].to_dict("records") == [
+ {"component": "a", "immediate": [1, 1]}
+ ]
+
+ if atom_trace:
+ trace, _ = pr.get_rule_trace(interpretation)
+ delayed_trace = trace[trace["Label"] == "result"]
+ assert set(delayed_trace["Time"]) == set(range(delay, 4))
+ assert set(delayed_trace["Occurred Due To"]) == {"delayed_rule"}
+ assert all(nodes == ["a"] for nodes in delayed_trace["Clause-1"])
diff --git a/tests/unit/dont_disable_jit/test_pending_node_rules.py b/tests/unit/dont_disable_jit/test_pending_node_rules.py
new file mode 100644
index 00000000..44b84535
--- /dev/null
+++ b/tests/unit/dont_disable_jit/test_pending_node_rules.py
@@ -0,0 +1,41 @@
+import importlib
+
+import numba
+import pytest
+
+
+@pytest.mark.parametrize("engine", ["interpretation", "interpretation_parallel", "interpretation_fp"])
+@pytest.mark.parametrize("atom_trace", [False, True])
+@pytest.mark.parametrize(
+ "removed_indices, expected_indices",
+ [({-1}, [0, 1, 2]), ({1}, [0, 2]), ({0, 1, 2}, [])],
+ ids=["retain-all", "remove-middle", "remove-all"],
+)
+def test_filter_pending_node_rules_with_no_edges(engine, atom_trace, removed_indices, expected_indices):
+ """Retain delayed node rules and their traces without requiring edge entries."""
+ module = importlib.import_module(f"pyreason.scripts.interpretation.{engine}")
+ rules = numba.typed.List.empty_list(module.rules_to_be_applied_node_type)
+ edges = numba.typed.List.empty_list(module.edges_to_be_added_type)
+ traces = numba.typed.List.empty_list(module.rules_to_be_applied_trace_type)
+
+ for index in range(3):
+ node = f"node_{index}"
+ rules.append((numba.uint16(index + 1), node, module.label.Label("result"),
+ module.interval.closed(1, 1), False))
+ if atom_trace:
+ qualified_nodes = numba.typed.List.empty_list(module.list_of_nodes)
+ qualified_nodes.append(numba.typed.List([node]))
+ qualified_edges = numba.typed.List.empty_list(module.list_of_edges)
+ traces.append((qualified_nodes, qualified_edges, f"rule_{index}"))
+
+ module._filter_pending_node_rules(rules, edges, traces, removed_indices, atom_trace)
+
+ assert [rule[1] for rule in rules] == [f"node_{index}" for index in expected_indices]
+ assert len(edges) == 0
+ if atom_trace:
+ assert [trace[2] for trace in traces] == [f"rule_{index}" for index in expected_indices]
+ assert [list(trace[0][0]) for trace in traces] == [
+ [f"node_{index}"] for index in expected_indices
+ ]
+ else:
+ assert len(traces) == 0