From f5fa4047d8592b4501f6a46ddadc971da74e0ff2 Mon Sep 17 00:00:00 2001 From: Roger Ignazio Date: Fri, 13 Feb 2026 16:43:10 -0800 Subject: [PATCH] Fix built-in annotation functions to return tuples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four built-in annotation functions (`average`, `average_lower`, `maximum`, `minimum`) return `interval.closed(lower, upper)`, producing `Interval` objects. The engine's `annotate()` function declares its return type as `Tuple((float64, float64))` via `numba.objmode`, so the `Interval` return fails the Numba type check at runtime. A second bug in `Interval.__new__` prevents `interval.closed()` from working outside JIT at all. We discovered these bugs while building custom annotation functions for a downstream project that uses PyReason for neuro-symbolic reasoning. Our `minimum`/`maximum` implementations returned `(float, float)` tuples (matching PyReason's own functional tests and examples), and we noticed that the built-in functions in `annotation_functions.py` still returned `interval.closed()` objects instead. Investigating why led us to both the return-type mismatch and the `Interval.__new__` signature bug. Neither bug surfaces under normal JIT execution because Numba bypasses `__new__` and constructs `Interval` structs directly from field definitions. The unit tests also masked both bugs by replacing the `interval` module with a `SimpleNamespace` mock and running with JIT disabled, so the real `interval.closed()` was never called. Timeline: - `b1b2051` (Nov 1, 2022): Annotation functions created, returning `interval.closed()`. Wired into the `influence()` switch-case dispatcher the same day (`69c29c9`), called directly inside JIT. Return type was inferred as `interval_type`. Everything worked. - `2b56fc9` (Jan 1, 2023): Added `prev_l`/`prev_u` fields to the `Interval` struct and passed all 5 to `StructRefProxy.__new__()`, but `Interval.__new__()` still only accepted 3 params (`lower`, `upper`, `s`). Latent outside JIT — raises `TypeError` when `closed()`, `intersection()`, or `copy()` call `Interval()` with 5 args. - `07655f1` (Jul 14, 2023): Replaced hardcoded `influence()` with dynamic `annotate()` using `objmode`. Over three commits in 21 minutes (`07655f1`, `802a2d2`, `e647991`), the objmode declaration went from `'interval.interval_type'` to `'interval_type'` to `'Tuple((float64, float64))'` — but the annotation functions were never updated to return tuples. - `ec95577` (Sep 13, 2025): Added unit tests with a `SimpleNamespace` mock on line 7, preventing either bug from surfacing. This patch: - Changes all four annotation functions to return `(lower, upper)` tuples, matching the engine's `objmode` declaration and PyReason's own examples - Fixes `Interval.__new__` to accept optional `prev_l`/`prev_u` params, making `closed()`, `intersection()`, and `copy()` work outside JIT - Removes the mock from unit tests and asserts tuple return types --- .../annotation_functions.py | 10 ++++----- pyreason/scripts/interval/interval.py | 8 +++++-- .../disable_jit/test_annotation_functions.py | 22 ++++++++++--------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/pyreason/scripts/annotation_functions/annotation_functions.py b/pyreason/scripts/annotation_functions/annotation_functions.py index 75eb9b6f..e928910d 100755 --- a/pyreason/scripts/annotation_functions/annotation_functions.py +++ b/pyreason/scripts/annotation_functions/annotation_functions.py @@ -3,8 +3,6 @@ import numba import numpy as np -import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval - @numba.njit def _get_weighted_sum(annotations, weights, mode='lower'): """ @@ -49,7 +47,7 @@ def average(annotations, weights): lower, upper = _check_bound(avg_lower, avg_upper) - return interval.closed(lower, upper) + return (lower, upper) @numba.njit def average_lower(annotations, weights): @@ -67,7 +65,7 @@ def average_lower(annotations, weights): lower, upper = _check_bound(avg_lower, max_upper) - return interval.closed(lower, upper) + return (lower, upper) @numba.njit def maximum(annotations, weights): @@ -82,7 +80,7 @@ def maximum(annotations, weights): lower, upper = _check_bound(max_lower, max_upper) - return interval.closed(lower, upper) + return (lower, upper) @numba.njit @@ -98,4 +96,4 @@ def minimum(annotations, weights): lower, upper = _check_bound(min_lower, min_upper) - return interval.closed(lower, upper) + return (lower, upper) diff --git a/pyreason/scripts/interval/interval.py b/pyreason/scripts/interval/interval.py index 18d25ede..66b7bc11 100755 --- a/pyreason/scripts/interval/interval.py +++ b/pyreason/scripts/interval/interval.py @@ -4,8 +4,12 @@ class Interval(structref.StructRefProxy): - def __new__(cls, lower, upper, s=False): - return structref.StructRefProxy.__new__(cls, lower, upper, s, lower, upper) + def __new__(cls, lower, upper, s=False, prev_l=None, prev_u=None): + if prev_l is None: + prev_l = lower + if prev_u is None: + prev_u = upper + return structref.StructRefProxy.__new__(cls, lower, upper, s, prev_l, prev_u) @property @njit diff --git a/tests/unit/disable_jit/test_annotation_functions.py b/tests/unit/disable_jit/test_annotation_functions.py index fcb94c0b..ac887827 100644 --- a/tests/unit/disable_jit/test_annotation_functions.py +++ b/tests/unit/disable_jit/test_annotation_functions.py @@ -4,8 +4,6 @@ import pyreason.scripts.annotation_functions.annotation_functions as af -af.interval = SimpleNamespace(closed=lambda l, u, static=False: SimpleNamespace(lower=l, upper=u)) - def _interval(lower, upper): return SimpleNamespace(lower=lower, upper=upper) @@ -42,23 +40,27 @@ def test_check_bound(lower, upper, expected): def test_average(): annotations, weights = _example_annotations() result = af.average(annotations, weights) - assert result.lower == pytest.approx(1.4 / 3) - assert result.upper == pytest.approx(0.6) + assert isinstance(result, tuple), f"expected tuple, got {type(result)}" + assert result[0] == pytest.approx(1.4 / 3) + assert result[1] == pytest.approx(0.6) def test_average_lower(): annotations, weights = _example_annotations() result = af.average_lower(annotations, weights) - assert result.lower == pytest.approx(1.4 / 3) - assert result.upper == pytest.approx(0.6) + assert isinstance(result, tuple), f"expected tuple, got {type(result)}" + assert result[0] == pytest.approx(1.4 / 3) + assert result[1] == pytest.approx(0.6) def test_maximum(): annotations, weights = _example_annotations() result = af.maximum(annotations, weights) - assert result.lower == pytest.approx(1.0) - assert result.upper == pytest.approx(1.0) + assert isinstance(result, tuple), f"expected tuple, got {type(result)}" + assert result[0] == pytest.approx(1.0) + assert result[1] == pytest.approx(1.0) def test_minimum(): annotations, weights = _example_annotations() result = af.minimum(annotations, weights) - assert result.lower == pytest.approx(0.4) - assert result.upper == pytest.approx(0.6) + assert isinstance(result, tuple), f"expected tuple, got {type(result)}" + assert result[0] == pytest.approx(0.4) + assert result[1] == pytest.approx(0.6)