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 @@ +[![Python 3.8](https://img.shields.io/badge/python-3.8-blue.svg)](https://www.python.org/downloads/release/python-380/) [![Python 3.9](https://img.shields.io/badge/python-3.9-blue.svg)](https://www.python.org/downloads/release/python-390/) [![Python 3.10](https://img.shields.io/badge/python-3.10-blue.svg)](https://www.python.org/downloads/release/python-3100/) +[![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) +[![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/release/python-3120/) +[![Python 3.13](https://img.shields.io/badge/python-3.13-blue.svg)](https://www.python.org/downloads/release/python-3130/) [![Documentation Status](https://readthedocs.org/projects/pyreason/badge/?version=latest)](https://pyreason.readthedocs.io/en/latest/?badge=latest) [![pypi](https://github.com/lab-v2/pyreason/actions/workflows/python-publish.yml/badge.svg)](https://github.com/lab-v2/pyreason/actions/workflows/python-publish.yml) [![Tests](https://github.com/lab-v2/pyreason/actions/workflows/python-package-version-test.yml/badge.svg)](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