Skip to content

Commit d1cb309

Browse files
committed
Add support for negated interval and negated explicit true/false
1 parent a402321 commit d1cb309

3 files changed

Lines changed: 53 additions & 18 deletions

File tree

pyreason/scripts/facts/fact.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,15 @@ def __init__(self, fact_text: str, name: str = None, start_time: int = 0, end_ti
3535
- `'viewed(Zach):[0.5,0.8]'` - interval bound
3636
- `'connected(Alice,Bob)'` - edge fact
3737
- `'connected(Alice,Bob):[0.7,0.9]'` - edge fact with interval
38+
- `'~pred(node):[0.2,0.8]'` - negation with explicit bound
39+
NOTE: Negating an explicit bound will round the upper and lower bounds to 10 decimal places before taking the negation
40+
This is needed to avoid floating point precision errors.
3841
3942
**Invalid examples:**
4043
- `'123pred(node)'` - predicate starts with digit
4144
- `'pred@name(node)'` - invalid characters in predicate
4245
- `'pred(node1,node2,node3)'` - more than 2 components
4346
- `'pred(node):[1.5,2.0]'` - values out of range [0,1]
44-
- `'~pred(node):[0.2,0.8]'` - negation with explicit bound
4547
4648
:type fact_text: str
4749
:param name: The name of the fact. This will appear in the trace so that you know when it was applied

pyreason/scripts/utils/fact_parser.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,24 @@ def parse_fact(fact_text):
2020
raise ValueError("Double negation is not allowed")
2121

2222
# Separate into predicate-component and bound. If there is no bound it means it's true
23+
negate_interval = False
2324
if ':' in f:
2425
parts = f.split(':')
2526
if len(parts) != 2:
2627
raise ValueError("Invalid fact format: expected at most one colon separator")
2728
pred_comp, bound = parts
2829

29-
# Check for negation with explicit bound (ambiguous)
30+
# Check for negation with explicit bound
3031
if pred_comp.startswith('~'):
31-
raise ValueError("Cannot use negation (~) with explicit bound")
32+
pred_comp = pred_comp[1:]
33+
if bound.lower() == 'true':
34+
bound = 'False'
35+
elif bound.lower() == 'false':
36+
bound = 'True'
37+
else:
38+
negate_interval = True
3239
else:
33-
pred_comp = f
40+
pred_comp = f
3441
if pred_comp.startswith('~'):
3542
bound = 'False'
3643
pred_comp = pred_comp[1:]
@@ -109,10 +116,9 @@ def parse_fact(fact_text):
109116
fact_type = 'node'
110117

111118
# Check if bound is a boolean or a list of floats
112-
bound_lower = bound.lower()
113-
if bound_lower == 'true':
119+
if bound.lower() == 'true':
114120
bound = interval.closed(1, 1)
115-
elif bound_lower == 'false':
121+
elif bound.lower() == 'false':
116122
bound = interval.closed(0, 0)
117123
else:
118124
# Validate interval format
@@ -137,7 +143,6 @@ def parse_fact(fact_text):
137143
raise ValueError(f"Invalid interval values: {e}")
138144

139145
lower, upper = bound_values
140-
141146
# Validate bounds are in valid range [0, 1]
142147
if lower < 0 or lower > 1:
143148
raise ValueError(f"Interval lower bound {lower} is out of valid range [0, 1]")
@@ -148,6 +153,11 @@ def parse_fact(fact_text):
148153
if lower > upper:
149154
raise ValueError(f"Interval lower bound {lower} cannot be greater than upper bound {upper}")
150155

151-
bound = interval.closed(*bound_values)
156+
# We calculate ~[l,u] = [1-u, 1-l]
157+
# Round to eliminate floating point precision errors (e.g., 1 - 0.8 = 0.19999999...)
158+
if negate_interval:
159+
lower, upper = round(1 - upper, 10), round(1 - lower, 10)
160+
161+
bound = interval.closed(lower, upper)
152162

153163
return pred, component, bound, fact_type

tests/unit/dont_disable_jit/test_fact_parser.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pyreason.scripts.numba_wrapper.numba_types.interval_type as interval
44

55

6-
# Tests in this class were generated with Claude Sonnet 4.5.
6+
# Tests in this class were partially generated with Claude Sonnet 4.5.
77
class TestValidFactParsing:
88
"""Test cases for valid fact inputs that should parse successfully."""
99

@@ -112,8 +112,26 @@ def test_interval_with_ones(self):
112112
assert pred == "pred"
113113
assert bound.lower == 1.0 and bound.upper == 1.0
114114

115+
def test_negation_with_interval_bound(self):
116+
"""Test that negation with interval bound."""
117+
pred, component, bound, fact_type = parse_fact("~pred(node):[0.5,0.8]")
118+
assert pred == "pred"
119+
assert bound.lower == 0.2 and bound.upper == 0.5
115120

116-
# Tests in this class were generated with Claude Sonnet 4.5.
121+
def test_negation_with_explicit_bound_true(self):
122+
"""Test that negation with an explicit bound true."""
123+
pred, component, bound, fact_type = parse_fact("~pred(node):True")
124+
assert pred == "pred"
125+
assert bound.lower == 0.0 and bound.upper == 0.0
126+
127+
def test_negation_with_explicit_bound_false(self):
128+
"""Test that negation with an explicit bound false."""
129+
pred, component, bound, fact_type = parse_fact("~pred(node):False")
130+
assert pred == "pred"
131+
assert bound.lower == 1.0 and bound.upper == 1.0
132+
133+
134+
# Tests in this class were partially generated with Claude Sonnet 4.5.
117135
class TestInvalidFactParsing:
118136
"""Test cases for invalid fact inputs that should raise validation errors."""
119137

@@ -193,7 +211,7 @@ def test_missing_opening_bracket(self):
193211
parse_fact("pred(node):0.5,0.8]")
194212

195213
def test_interval_lower_greater_than_upper(self):
196-
"""Test that interval with lower > upper raises an error or warning."""
214+
"""Test that interval with lower > upper raises an error."""
197215
with pytest.raises(ValueError):
198216
parse_fact("pred(node):[0.9,0.1]")
199217

@@ -207,6 +225,11 @@ def test_interval_out_of_range_greater_than_one(self):
207225
with pytest.raises(ValueError):
208226
parse_fact("pred(node):[0.5,1.5]")
209227

228+
def test_interval_out_of_range_greater_than_one_with_negation(self):
229+
"""Test that interval values > 1 for a negated predicate raises an error."""
230+
with pytest.raises(ValueError):
231+
parse_fact("~pred(node):[0.5,1.5]")
232+
210233
def test_empty_component_in_edge(self):
211234
"""Test that empty component in edge fact raises an error."""
212235
with pytest.raises(ValueError):
@@ -227,15 +250,15 @@ def test_too_many_components_in_edge(self):
227250
with pytest.raises(ValueError):
228251
parse_fact("pred(node1,node2,node3)")
229252

230-
def test_negation_with_explicit_bound(self):
231-
"""Test that negation with explicit bound raises an error (ambiguous)."""
253+
def test_negation_with_invalid_bound(self):
254+
"""Test that negation with invalid bound raises an error."""
232255
with pytest.raises(ValueError):
233-
parse_fact("~pred(node):True")
256+
parse_fact("~pred(node):Undefined")
234257

235-
def test_negation_with_interval_bound(self):
236-
"""Test that negation with interval bound raises an error (ambiguous)."""
258+
def test_negation_with_invalid_interval_bound(self):
259+
"""Test that negation with invalid bound raises an error."""
237260
with pytest.raises(ValueError):
238-
parse_fact("~pred(node):[0.5,0.8]")
261+
parse_fact("~pred(node):[ham, sandwitch]")
239262

240263
def test_double_negation(self):
241264
"""Test that double negation raises an error."""

0 commit comments

Comments
 (0)