diff --git a/docs/language/syntax.md b/docs/language/syntax.md index 231c422..f617706 100644 --- a/docs/language/syntax.md +++ b/docs/language/syntax.md @@ -431,6 +431,53 @@ sequence and evaluate independently: require total is above 100 and forbid total is above 10000 and permit category is "travel" ``` +### Exception clauses (`unless`) + +`require`, `forbid`, `permit`, and `expect` each accept an optional +`unless ` clause after the main condition: + +``` + unless +``` + +The exception uses the same condition grammar as the main condition — +comparisons, `includes`, `not`, compound `and` / `or`, field access via +`of`. Its semantics depend on the verb's polarity: + +- `require unless ` — halts only when the + condition is false AND the exception is also false. A failing + requirement is excused when the exception holds. +- `forbid unless ` — halts only when the + condition is true AND the exception is false. A triggered + prohibition is excused when the exception holds. +- `permit unless ` — emits only when the + condition is true AND the exception is false. The exception + *narrows* the permission, suppressing the emission. +- `expect unless ` — reports divergence only + when the condition is false AND the exception is also false. The + exception explains the divergence, suppressing the report. + +``` +forbid total is above 10000 unless approved is equal to yes +require margin is above 0.1 unless market-conditions is equal to recession +permit expenses is below 5000 unless override is equal to yes +``` + +`unless` sits between the condition and any `because` rationale, in +canonical order: + +``` +inherited forbid total is above 10000 unless approved is equal to yes because "policy" from agent-compliance +``` + +`unless` does not chain — a statement takes at most one `unless` +clause. Multiple exception conditions compose within that clause via +`and` / `or`, the same as the main condition. Mixed `and` / `or` in +the exception triggers the amber prompt, same as the main condition. + +`require each` does not yet support `unless` — the exception clause +is scoped to the four simple deontic verbs in this release. + ### `assign` Stores an item-to-recipient mapping. The item becomes the variable diff --git a/src/liminate/analyzer.py b/src/liminate/analyzer.py index 3bdcc75..e187fdf 100644 --- a/src/liminate/analyzer.py +++ b/src/liminate/analyzer.py @@ -89,6 +89,7 @@ WeakensNode, WhenNode, ) +from .renderer import render from .result import LiminateResult, ResultStatus from .vocabulary import ( AppendToListExecution, @@ -255,15 +256,18 @@ def analyze( # normalized comparison operator, and the (kind, value) pair. `text` is the # human-readable rendering used in the warning message. class _Deontic: - __slots__ = ("verb", "field", "op", "kind", "value", "text") + __slots__ = ("verb", "field", "op", "kind", "value", "text", "exception_text") - def __init__(self, verb, field, op, kind, value, text): + def __init__(self, verb, field, op, kind, value, text, exception_text=None): self.verb = verb # "require" | "forbid" self.field = field # field name (str) self.op = op # normalized: "above" | "below" | "is" self.kind = kind # "num" | "str" self.value = value # int/float for "num", str for "str" self.text = text # e.g. "require x is above 50" + # v28 — rendering of the statement's `unless` exception condition, + # if any, e.g. "approved is equal to yes". None when unguarded. + self.exception_text = exception_text def _iter_deontic_nodes(statements): @@ -313,7 +317,13 @@ def _extract_deontic(node) -> "_Deontic | None": verb = "require" if isinstance(node, RequireNode) else "forbid" phrase = {"above": "is above", "below": "is below", "is": "is"}[op] text = f"{verb} {field} {phrase} {value_text}" - return _Deontic(verb, field, op, kind, value, text) + # v28 — a guarded deontic still participates in contradiction checking + # (the base condition drives the conflict rules); the exception is + # carried along so the warning can name it. + exception_text = None + if node.exception is not None: + exception_text = render(node.exception) + return _Deontic(verb, field, op, kind, value, text, exception_text) def _fmt_value_num(value) -> str: @@ -371,10 +381,19 @@ def detect_contradictions(statements: list[ASTNode]) -> list[str]: for j in range(i + 1, len(deontics)): a, b = deontics[i], deontics[j] if _pair_contradicts(a, b): - warnings.append( + message = ( f"Possible contradiction: '{a.text}' conflicts with " - f"'{b.text}' — no value of {a.field} can satisfy both." + f"'{b.text}' — no value of {a.field} can satisfy both" ) + # v28 — guarded deontics get conditional wording: the + # conflict only holds when neither exception excuses it. + exceptions = [ + e for e in (a.exception_text, b.exception_text) if e + ] + if exceptions: + message += f" unless {' or '.join(exceptions)}" + message += "." + warnings.append(message) return warnings @@ -478,7 +497,10 @@ def _check( # Normative Era batch 2 — the condition follows the same # validation path as `choose if`: no iterator, names resolve # against the symbol table directly, field access uses `of`. + # v28 — the `unless` exception condition is validated the same way. _check_choose_condition(node.condition, symtab) + if node.exception is not None: + _check_choose_condition(node.exception, symtab) elif isinstance(node, RequireEachNode): # v8a §49 — iterated enforcement. Validate the collection is a # list and the binding name doesn't collide with it; the @@ -487,17 +509,24 @@ def _check( elif isinstance(node, ForbidNode): # Deontic Era — same condition validation as `require`. # Behavior differs only at runtime (halts on true instead - # of false). + # of false). v28 — validate the `unless` exception too. _check_choose_condition(node.condition, symtab) + if node.exception is not None: + _check_choose_condition(node.exception, symtab) elif isinstance(node, PermitNode): # Deontic Era — same condition validation as `require`/`forbid`. # Behavior differs only at runtime (emits on true, never halts). + # v28 — validate the `unless` exception too. _check_choose_condition(node.condition, symtab) + if node.exception is not None: + _check_choose_condition(node.exception, symtab) elif isinstance(node, ExpectNode): # Epistemic Era batch 3 — same condition validation as `require`. # Behavior differs only at runtime (divergence emits output; - # never halts). + # never halts). v28 — validate the `unless` exception too. _check_choose_condition(node.condition, symtab) + if node.exception is not None: + _check_choose_condition(node.exception, symtab) elif isinstance(node, AssignNode): _check_assign( node, symtab, iterator, diff --git a/src/liminate/interpreter.py b/src/liminate/interpreter.py index cca5b97..a987ee5 100644 --- a/src/liminate/interpreter.py +++ b/src/liminate/interpreter.py @@ -1684,9 +1684,16 @@ def _exec_require( condition in canonical form and reports the actual value(s) that violated the rule for the first failing sub-condition (helpful for diagnosis when conditions are compound). + + v28 — halts only when NOT condition AND NOT exception: a failing + condition is excused when the `unless` exception holds. """ if _eval_condition(node.condition, current_item, symtab): return [] + if node.exception is not None and _eval_condition( + node.exception, current_item, symtab + ): + return [] condition_text = render(node.condition) actual = _condition_actual_values(node.condition, current_item, symtab) msg = f"Requirement not met: {condition_text}." @@ -1765,9 +1772,16 @@ def _exec_forbid( prohibition triggered); raises _ProhibitionViolated on true. The error message echoes the condition in canonical form and reports the actual value(s) that triggered the prohibition. + + v28 — halts only when condition AND NOT exception: a triggered + prohibition is excused when the `unless` exception holds. """ if not _eval_condition(node.condition, current_item, symtab): return [] + if node.exception is not None and _eval_condition( + node.exception, current_item, symtab + ): + return [] condition_text = render(node.condition) actual = _condition_actual_values(node.condition, current_item, symtab) msg = f"Prohibition violated: {condition_text}." @@ -1789,9 +1803,16 @@ def _exec_permit( line recording the explicit permission. If false, silent pass. Never halts. The output message echoes the condition in canonical form and reports the actual value(s) that satisfied the permission. + + v28 — emits only when condition AND NOT exception: the `unless` + exception narrows the permission, suppressing the emission. """ if not _eval_condition(node.condition, current_item, symtab): return [] + if node.exception is not None and _eval_condition( + node.exception, current_item, symtab + ): + return [] condition_text = render(node.condition) actual = _condition_actual_values(node.condition, current_item, symtab) msg = f"Permitted: {condition_text}." @@ -1810,9 +1831,16 @@ def _exec_expect( actual value(s) of the first failing sub-condition. Program continues with SUCCESS — expectations are informational, not blocking. + + v28 — reports divergence only when NOT condition AND NOT exception: + the `unless` exception explains the divergence, suppressing the report. """ if _eval_condition(node.condition, current_item, symtab): return [] + if node.exception is not None and _eval_condition( + node.exception, current_item, symtab + ): + return [] condition_text = render(node.condition) actual = _condition_actual_values(node.condition, current_item, symtab) msg = f"Expectation not met: {condition_text}." diff --git a/src/liminate/parser.py b/src/liminate/parser.py index 9776207..19404e3 100644 --- a/src/liminate/parser.py +++ b/src/liminate/parser.py @@ -652,8 +652,13 @@ class RequireNode(ASTNode): false, execution halts with REQUIREMENT_NOT_MET. The condition uses the same AST shapes as `choose if` / `where` conditions: ConditionNode leaves and CompoundConditionNode `and`/`or` combinators. + + v28 — `exception` is an optional `unless ` clause. Unlike + `rationale` (inert metadata), the exception changes the verb's truth + table, so it participates in equality comparison (default compare=True). """ condition: ASTNode + exception: ASTNode | None = None rationale: str | None = field(default=None, compare=False) # Meta-Structural Era batch 3 — `inherited` operator + `from` # attribution. Both are inert provenance metadata (compare=False), @@ -705,8 +710,12 @@ class ForbidNode(ASTNode): PROHIBITION_VIOLATED. If false, silent pass. Same condition AST as `require` / `choose if` / `where`. Mirrors `require` with inverted polarity. + + v28 — `exception` is an optional `unless ` clause (see + RequireNode for the compare=True rationale). """ condition: ASTNode + exception: ASTNode | None = None rationale: str | None = field(default=None, compare=False) inherited: bool = field(default=False, compare=False) inherited_from: str | None = field(default=None, compare=False) @@ -728,8 +737,13 @@ class PermitNode(ASTNode): condition AST as `require` / `forbid` / `choose if` / `where`. Completes the deontic triangle: require (obligation), forbid (prohibition), permit (permission). + + v28 — `exception` is an optional `unless ` clause that + narrows the permission (see RequireNode for the compare=True + rationale). """ condition: ASTNode + exception: ASTNode | None = None rationale: str | None = field(default=None, compare=False) inherited: bool = field(default=False, compare=False) inherited_from: str | None = field(default=None, compare=False) @@ -905,8 +919,12 @@ class ExpectNode(ASTNode): an output line reporting the divergence. Program continues with SUCCESS — expectations are informational, not blocking. Same condition AST as `require` / `choose if` / `where`. + + v28 — `exception` is an optional `unless ` clause (see + RequireNode for the compare=True rationale). """ condition: ASTNode + exception: ASTNode | None = None rationale: str | None = field(default=None, compare=False) # Meta-Structural Era batch 3 — `inherited` operator + `from` # attribution. Both are inert provenance metadata (compare=False), @@ -1446,6 +1464,30 @@ def _try_consume_because(stream: TokenStream) -> str | None: return rationale_tok.value +def _try_consume_unless_exception(stream: TokenStream) -> ASTNode | None: + """v28 — consume an optional `unless ` exception clause + immediately following a deontic verb's main condition. + + Shared by `_parse_require`, `_parse_forbid`, `_parse_permit`, and + `_parse_expect`. Mirrors the `unless` guard consumption in + `parse_when_block` — same token shape, different grammatical + position (inside the verb's condition grammar rather than a `when` + header). Returns the exception condition AST, or None if no + `unless` is present. + """ + peek = stream.peek() + if not ( + peek + and peek.type is TokenType.CONNECTIVE + and peek.value == "unless" + ): + return None + stream.consume() # eat `unless` + if stream.at_end(): + raise _ParseError("I expected a condition after 'unless'.") + return _parse_or_condition(stream) + + def _try_consume_inherited_from(stream: TokenStream) -> str | None: """Consume an optional `from ` attribution at the end of an `inherited` statement (Meta-Structural Era batch 3, MS-Q4). @@ -2322,9 +2364,10 @@ def _parse_require(stream: TokenStream) -> RequireNode | RequireEachNode: stream.push_clause("require") try: condition = _parse_or_condition(stream) + exception = _try_consume_unless_exception(stream) finally: stream.pop_clause() - return RequireNode(condition=condition) + return RequireNode(condition=condition, exception=exception) def _parse_require_each(stream: TokenStream) -> RequireEachNode: @@ -2403,9 +2446,10 @@ def _parse_forbid(stream: TokenStream) -> ForbidNode: stream.push_clause("forbid") try: condition = _parse_or_condition(stream) + exception = _try_consume_unless_exception(stream) finally: stream.pop_clause() - return ForbidNode(condition=condition) + return ForbidNode(condition=condition, exception=exception) # --------------------------------------------------------------------------- @@ -2428,9 +2472,10 @@ def _parse_permit(stream: TokenStream) -> PermitNode: stream.push_clause("permit") try: condition = _parse_or_condition(stream) + exception = _try_consume_unless_exception(stream) finally: stream.pop_clause() - return PermitNode(condition=condition) + return PermitNode(condition=condition, exception=exception) # --------------------------------------------------------------------------- @@ -2485,9 +2530,10 @@ def _parse_expect(stream: TokenStream) -> ExpectNode: stream.push_clause("expect") try: condition = _parse_or_condition(stream) + exception = _try_consume_unless_exception(stream) finally: stream.pop_clause() - return ExpectNode(condition=condition) + return ExpectNode(condition=condition, exception=exception) # --------------------------------------------------------------------------- @@ -3445,7 +3491,10 @@ def _contains_mixed_precedence(node: ASTNode) -> bool: if isinstance(node, RequireNode): # Normative Era batch 2: `require` conditions follow the same # mixed-precedence rule as `where` / `choose if` clauses (v1a §30). - return _condition_is_mixed(node.condition) + # v28: the `unless` exception condition follows the same rule. + if _condition_is_mixed(node.condition): + return True + return node.exception is not None and _condition_is_mixed(node.exception) if isinstance(node, RequireEachNode): # v8a §49: `require each` conditions follow the same # mixed-precedence rule as the simple `require`. @@ -3453,15 +3502,24 @@ def _contains_mixed_precedence(node: ASTNode) -> bool: if isinstance(node, ForbidNode): # Deontic Era: `forbid` conditions follow the same # mixed-precedence rule as `require` / `where`. - return _condition_is_mixed(node.condition) + # v28: the `unless` exception condition follows the same rule. + if _condition_is_mixed(node.condition): + return True + return node.exception is not None and _condition_is_mixed(node.exception) if isinstance(node, PermitNode): # Deontic Era: `permit` conditions follow the same # mixed-precedence rule as `require` / `forbid` / `where`. - return _condition_is_mixed(node.condition) + # v28: the `unless` exception condition follows the same rule. + if _condition_is_mixed(node.condition): + return True + return node.exception is not None and _condition_is_mixed(node.exception) if isinstance(node, ExpectNode): # Epistemic Era batch 3: `expect` conditions follow the same # mixed-precedence rule as `require` / `where`. - return _condition_is_mixed(node.condition) + # v28: the `unless` exception condition follows the same rule. + if _condition_is_mixed(node.condition): + return True + return node.exception is not None and _condition_is_mixed(node.exception) if isinstance(node, SequenceNode): return any(_contains_mixed_precedence(op) for op in node.operations) if isinstance(node, EachNode): diff --git a/src/liminate/renderer.py b/src/liminate/renderer.py index 93cc80c..a6de01f 100644 --- a/src/liminate/renderer.py +++ b/src/liminate/renderer.py @@ -290,7 +290,11 @@ def _render_node(node: ASTNode) -> str: ) if isinstance(node, RequireNode): # Normative Era batch 2 — `require `. - return f"require {render(node.condition)}" + # v28 — optional `unless ` clause. + base = f"require {render(node.condition)}" + if node.exception is not None: + base += f" unless {render(node.exception)}" + return base if isinstance(node, RequireEachNode): # v8a §49 — `require each in `. return ( @@ -299,13 +303,25 @@ def _render_node(node: ASTNode) -> str: ) if isinstance(node, ForbidNode): # Deontic Era — `forbid `. - return f"forbid {render(node.condition)}" + # v28 — optional `unless ` clause. + base = f"forbid {render(node.condition)}" + if node.exception is not None: + base += f" unless {render(node.exception)}" + return base if isinstance(node, PermitNode): # Deontic Era — `permit `. - return f"permit {render(node.condition)}" + # v28 — optional `unless ` clause. + base = f"permit {render(node.condition)}" + if node.exception is not None: + base += f" unless {render(node.exception)}" + return base if isinstance(node, ExpectNode): # Epistemic Era batch 3 — `expect `. - return f"expect {render(node.condition)}" + # v28 — optional `unless ` clause. + base = f"expect {render(node.condition)}" + if node.exception is not None: + base += f" unless {render(node.exception)}" + return base if isinstance(node, AssignNode): # Delegated Era batch 3 — `assign to `. return f"assign {node.item.name} to {render(node.recipient)}" @@ -387,13 +403,25 @@ def render_with_explicit_precedence(node: ASTNode) -> str: if isinstance(node, SequenceNode): return _render_sequence(node, render_with_explicit_precedence) if isinstance(node, RequireNode): - return f"require {render_with_explicit_precedence(node.condition)}" + base = f"require {render_with_explicit_precedence(node.condition)}" + if node.exception is not None: + base += f" unless {render_with_explicit_precedence(node.exception)}" + return base if isinstance(node, ForbidNode): - return f"forbid {render_with_explicit_precedence(node.condition)}" + base = f"forbid {render_with_explicit_precedence(node.condition)}" + if node.exception is not None: + base += f" unless {render_with_explicit_precedence(node.exception)}" + return base if isinstance(node, PermitNode): - return f"permit {render_with_explicit_precedence(node.condition)}" + base = f"permit {render_with_explicit_precedence(node.condition)}" + if node.exception is not None: + base += f" unless {render_with_explicit_precedence(node.exception)}" + return base if isinstance(node, ExpectNode): - return f"expect {render_with_explicit_precedence(node.condition)}" + base = f"expect {render_with_explicit_precedence(node.condition)}" + if node.exception is not None: + base += f" unless {render_with_explicit_precedence(node.exception)}" + return base if isinstance(node, AssignNode): return ( f"assign {node.item.name} to " diff --git a/tests/test_contradiction_detection.py b/tests/test_contradiction_detection.py index 906b12b..15cb7b9 100644 --- a/tests/test_contradiction_detection.py +++ b/tests/test_contradiction_detection.py @@ -187,3 +187,47 @@ def test_integration_contradiction_warns_without_blocking_execution(): assert any(line == "99" for line in outputs) # The advisory pass itself does not flip had_error. assert contract.results[0].status is ResultStatus.SUCCESS + + +# --------------------------------------------------------------------------- +# v28 — guarded deontics (`unless`) get conditional contradiction wording +# --------------------------------------------------------------------------- + + +def test_guarded_forbid_vs_unguarded_require_names_exception(): + w = warnings_for([ + "require X is above 50", + "forbid X is above 50 unless approved is equal to yes", + ]) + assert len(w) == 1 + assert "unless approved is equal to yes" in w[0] + + +def test_both_guarded_joins_exceptions_with_or(): + w = warnings_for([ + "require X is above 50 unless override is equal to yes", + "forbid X is above 50 unless approved is equal to yes", + ]) + assert len(w) == 1 + assert "unless" in w[0] + assert "override is equal to yes" in w[0] + assert "approved is equal to yes" in w[0] + assert " or " in w[0] + + +def test_unguarded_pair_message_unchanged(): + w = warnings_for(["require X is above 50", "forbid X is above 50"]) + assert len(w) == 1 + assert "unless" not in w[0] + assert w[0].endswith( + "'require x is above 50' conflicts with 'forbid x is above 50' " + "— no value of x can satisfy both." + ) + + +def test_guarded_permit_still_never_contradicts(): + w = warnings_for([ + "require X is above 50", + "permit X is above 50 unless override is equal to yes", + ]) + assert w == [] diff --git a/tests/test_expect.py b/tests/test_expect.py index d54692f..cc0e22e 100644 --- a/tests/test_expect.py +++ b/tests/test_expect.py @@ -251,3 +251,84 @@ def test_assign_then_expect_met(): r = s.run_line('expect task-1 is "compliance-team"') assert r.status is ResultStatus.SUCCESS assert not (r.output or []) + + +# --------------------------------------------------------------------------- +# v28 — `unless` exception clauses +# --------------------------------------------------------------------------- + + +def test_parses_expect_with_unless(): + ast = _parse( + "expect revenue is above 1000000 unless recession is equal to yes" + ) + assert isinstance(ast, ExpectNode) + assert ast.condition.op == "above" + assert isinstance(ast.exception, ConditionNode) + + +def test_parses_expect_without_unless_has_no_exception(): + ast = _parse("expect revenue is above 1000000") + assert isinstance(ast, ExpectNode) + assert ast.exception is None + + +def test_parses_expect_unless_before_because(): + ast = _parse( + 'expect revenue is above 1000000 unless recession is equal to ' + 'yes because "macro"' + ) + assert isinstance(ast, ExpectNode) + assert ast.exception is not None + assert ast.rationale == "macro" + + +def test_expect_unless_render_round_trip(): + ast = _parse( + "expect revenue is above 1000000 unless recession is equal to yes" + ) + rendered = render(ast) + assert rendered == ( + "expect revenue is above 1000000 unless recession is equal to yes" + ) + again = _parse(rendered) + assert isinstance(again, ExpectNode) + assert again == ast + + +# Execution semantics: report divergence when NOT main AND NOT exception. + + +def test_expect_unless_main_holds_silent(): + s = _session() + s.run_line("remember a value called revenue with 1500000") + s.run_line('remember a value called recession with "no"') + r = s.run_line( + 'expect revenue is above 1000000 unless recession is equal to "yes"' + ) + assert r.status is ResultStatus.SUCCESS + assert not (r.output or []) + + +def test_expect_unless_main_fails_exception_explains_silent(): + s = _session() + s.run_line("remember a value called revenue with 500000") + s.run_line('remember a value called recession with "yes"') + r = s.run_line( + 'expect revenue is above 1000000 unless recession is equal to "yes"' + ) + assert r.status is ResultStatus.SUCCESS + assert not (r.output or []) + + +def test_expect_unless_main_fails_exception_false_reports(): + s = _session() + s.run_line("remember a value called revenue with 500000") + s.run_line('remember a value called recession with "no"') + r = s.run_line( + 'expect revenue is above 1000000 unless recession is equal to "yes"' + ) + assert r.status is ResultStatus.SUCCESS + text = _output_str(r) + assert "Expectation not met" in text + assert "revenue is above 1000000" in text diff --git a/tests/test_forbid.py b/tests/test_forbid.py index 5b9ab77..30d7d0b 100644 --- a/tests/test_forbid.py +++ b/tests/test_forbid.py @@ -311,3 +311,90 @@ def test_forbid_rejected_as_variable_name(): ResultStatus.ERROR_PARSE, ResultStatus.ERROR_SEMANTIC, ) + + +# --------------------------------------------------------------------------- +# v28 — `unless` exception clauses +# --------------------------------------------------------------------------- + + +def test_parses_forbid_with_unless(): + ast = _parse( + "forbid total is above 10000 unless approved is equal to yes" + ) + assert isinstance(ast, ForbidNode) + assert ast.condition.op == "above" + assert isinstance(ast.exception, ConditionNode) + + +def test_parses_forbid_without_unless_has_no_exception(): + ast = _parse("forbid total is above 10000") + assert isinstance(ast, ForbidNode) + assert ast.exception is None + + +def test_parses_forbid_unless_compound_exception(): + ast = _parse( + "forbid x is above 10 unless flag-a is equal to yes and " + "flag-b is equal to yes" + ) + assert isinstance(ast, ForbidNode) + assert isinstance(ast.exception, CompoundConditionNode) + assert ast.exception.connector == "and" + + +def test_parses_forbid_unless_before_because(): + ast = _parse( + 'forbid total is above 10000 unless approved is equal to yes ' + 'because "policy"' + ) + assert isinstance(ast, ForbidNode) + assert ast.exception is not None + assert ast.rationale == "policy" + + +def test_forbid_unless_render_round_trip(): + ast = _parse( + "forbid total is above 10000 unless approved is equal to yes" + ) + rendered = render(ast) + assert rendered == ( + "forbid total is above 10000 unless approved is equal to yes" + ) + again = _parse(rendered) + assert isinstance(again, ForbidNode) + assert again == ast + + +# Execution semantics: halt when main AND NOT exception. + + +def test_forbid_unless_main_false_silent(): + s = _session() + s.run_line("remember a value called total with 5000") + s.run_line('remember a value called approved with "no"') + r = s.run_line( + 'forbid total is above 10000 unless approved is equal to "yes"' + ) + assert r.status is ResultStatus.SUCCESS + + +def test_forbid_unless_main_true_exception_excuses(): + s = _session() + s.run_line("remember a value called total with 15000") + s.run_line('remember a value called approved with "yes"') + r = s.run_line( + 'forbid total is above 10000 unless approved is equal to "yes"' + ) + assert r.status is ResultStatus.SUCCESS + + +def test_forbid_unless_main_true_exception_false_violates(): + s = _session() + s.run_line("remember a value called total with 15000") + s.run_line('remember a value called approved with "no"') + r = s.run_line( + 'forbid total is above 10000 unless approved is equal to "yes"' + ) + assert r.status is ResultStatus.PROHIBITION_VIOLATED + assert "total is above 10000" in (r.message or "") diff --git a/tests/test_permit.py b/tests/test_permit.py index 3d18bbf..efeba9f 100644 --- a/tests/test_permit.py +++ b/tests/test_permit.py @@ -323,3 +323,83 @@ def test_permit_rejected_as_variable_name(): ResultStatus.ERROR_PARSE, ResultStatus.ERROR_SEMANTIC, ) + + +# --------------------------------------------------------------------------- +# v28 — `unless` exception clauses (narrowing) +# --------------------------------------------------------------------------- + + +def test_parses_permit_with_unless(): + ast = _parse( + "permit expenses is below 5000 unless frozen is equal to yes" + ) + assert isinstance(ast, PermitNode) + assert ast.condition.op == "below" + assert isinstance(ast.exception, ConditionNode) + + +def test_parses_permit_without_unless_has_no_exception(): + ast = _parse("permit expenses is below 5000") + assert isinstance(ast, PermitNode) + assert ast.exception is None + + +def test_parses_permit_unless_before_because(): + ast = _parse( + 'permit expenses is below 5000 unless frozen is equal to yes ' + 'because "narrowed"' + ) + assert isinstance(ast, PermitNode) + assert ast.exception is not None + assert ast.rationale == "narrowed" + + +def test_permit_unless_render_round_trip(): + ast = _parse( + "permit expenses is below 5000 unless frozen is equal to yes" + ) + rendered = render(ast) + assert rendered == ( + "permit expenses is below 5000 unless frozen is equal to yes" + ) + again = _parse(rendered) + assert isinstance(again, PermitNode) + assert again == ast + + +# Execution semantics: emit when main AND NOT exception (narrowing). + + +def test_permit_unless_main_true_exception_false_emits(): + s = _session() + s.run_line("remember a value called expenses with 3000") + s.run_line('remember a value called frozen with "no"') + r = s.run_line( + 'permit expenses is below 5000 unless frozen is equal to "yes"' + ) + assert r.status is ResultStatus.SUCCESS + assert r.output is not None + assert any("Permitted" in line for line in r.output) + + +def test_permit_unless_main_true_exception_true_suppresses(): + s = _session() + s.run_line("remember a value called expenses with 3000") + s.run_line('remember a value called frozen with "yes"') + r = s.run_line( + 'permit expenses is below 5000 unless frozen is equal to "yes"' + ) + assert r.status is ResultStatus.SUCCESS + assert r.output is None + + +def test_permit_unless_main_false_silent_regardless_of_exception(): + s = _session() + s.run_line("remember a value called expenses with 9000") + s.run_line('remember a value called frozen with "yes"') + r = s.run_line( + 'permit expenses is below 5000 unless frozen is equal to "yes"' + ) + assert r.status is ResultStatus.SUCCESS + assert r.output is None diff --git a/tests/test_render_roundtrip.py b/tests/test_render_roundtrip.py index f37d8d8..3937173 100644 --- a/tests/test_render_roundtrip.py +++ b/tests/test_render_roundtrip.py @@ -34,13 +34,19 @@ from liminate.parser import ( ArithmeticNode, BareWord, + ExpectNode, + ForbidNode, NameRef, NumberLiteral, + PermitNode, QuotedString, RememberValueNode, + RequireNode, parse, ) from liminate.renderer import render +from liminate.reorderer import reorder +from liminate.result import LiminateResult def _remember(src): @@ -144,3 +150,56 @@ def test_issue_18_rendered_program_runs_identically(tmp_path): assert "Error" not in rendered_out, rendered_out assert original_out == rendered_out == "off\n" + + +# --------------------------------------------------------------------------- +# v28 — round-trip fidelity for `unless` exception clauses on deontics +# --------------------------------------------------------------------------- + + +def _parse_line(src): + reordered = reorder(tokenize(src)) + assert not isinstance(reordered, LiminateResult), src + ast = parse(reordered) + assert not isinstance(ast, LiminateResult), src + return ast + + +UNLESS_DEONTIC_SOURCES = [ + ("require x is above 10 unless y is equal to yes", RequireNode), + ( + "forbid x is above 50 unless approved is equal to yes", + ForbidNode, + ), + ("permit x is below 100 unless frozen is equal to yes", PermitNode), + ( + "expect x is above 1000 unless recession is equal to yes", + ExpectNode, + ), +] + + +@pytest.mark.parametrize("src,node_type", UNLESS_DEONTIC_SOURCES) +def test_unless_deontic_round_trips(src, node_type): + ast = _parse_line(src) + assert isinstance(ast, node_type) + rendered = render(ast) + assert rendered == src + again = _parse_line(rendered) + assert isinstance(again, node_type) + assert again == ast + + +def test_unless_deontic_full_canonical_order_round_trip(): + src = ( + 'starting "2025-01-01" until "2025-12-31" inherited ' + 'require x is above 10 unless y is equal to yes ' + 'because "policy" from agent-a' + ) + ast = _parse_line(src) + assert isinstance(ast, RequireNode) + rendered = render(ast) + assert rendered == src + again = _parse_line(rendered) + assert isinstance(again, RequireNode) + assert again.exception is not None diff --git a/tests/test_require.py b/tests/test_require.py index 8a5ab72..a0baf94 100644 --- a/tests/test_require.py +++ b/tests/test_require.py @@ -300,3 +300,151 @@ def test_stepwise_commit_before_failed_require(): # Prior op committed. show = s.run_line("show audit-log") assert any("received" in line for line in (show.output or [])) + + +# --------------------------------------------------------------------------- +# v28 — `unless` exception clauses +# --------------------------------------------------------------------------- + + +def test_parses_require_with_unless(): + ast = _parse( + "require amount is above 50000 unless waiver is equal to yes" + ) + assert isinstance(ast, RequireNode) + assert ast.condition.op == "above" + assert isinstance(ast.exception, ConditionNode) + assert ast.exception.op == "equal_to" + + +def test_parses_require_without_unless_has_no_exception(): + ast = _parse("require amount is above 50000") + assert isinstance(ast, RequireNode) + assert ast.exception is None + + +def test_parses_require_unless_compound_exception(): + ast = _parse( + "require x is above 10 unless flag-a is equal to yes and " + "flag-b is equal to yes" + ) + assert isinstance(ast, RequireNode) + assert isinstance(ast.exception, CompoundConditionNode) + assert ast.exception.connector == "and" + + +def test_parses_require_unless_with_field_access(): + ast = _parse( + "require total of order is above 100 unless status of order " + "is equal to exempt" + ) + assert isinstance(ast, RequireNode) + assert ast.exception is not None + + +def test_parses_require_unless_before_because(): + ast = _parse( + 'require x is above 10 unless y is equal to yes because "policy"' + ) + assert isinstance(ast, RequireNode) + assert ast.exception is not None + assert ast.rationale == "policy" + + +def test_parses_require_unless_full_canonical_order(): + ast = _parse( + 'starting "2025-01-01" inherited require x is above 10 unless ' + 'y is equal to yes because "policy" from agent-a' + ) + assert isinstance(ast, RequireNode) + assert ast.starting_date == "2025-01-01" + assert ast.inherited is True + assert ast.exception is not None + assert ast.rationale == "policy" + assert ast.inherited_from == "agent-a" + + +def test_parse_error_unless_with_no_exception_condition(): + r = _parse("require amount is above 50000 unless") + assert isinstance(r, LiminateResult) + assert r.status is ResultStatus.ERROR_PARSE + assert "unless" in (r.message or "") + + +def test_parse_error_double_unless(): + r = _parse( + "require x is above 10 unless y is equal to yes unless " + "z is equal to yes" + ) + assert isinstance(r, LiminateResult) + assert r.status is ResultStatus.ERROR_PARSE + + +def test_require_each_does_not_consume_unless(): + # `require each` returns before the `unless` consumption code — + # a trailing `unless` is left in the stream and errors. + r = _parse( + "require each x in items x is above 5 unless y is equal to yes" + ) + assert isinstance(r, LiminateResult) + assert r.status is ResultStatus.ERROR_PARSE + + +def test_require_unless_exception_field_not_compared_via_equals_bypass(): + # Two nodes differing only in exception must NOT compare equal — + # the exception is semantic content (compare=True), not inert metadata. + a = _parse("require x is above 10 unless y is equal to yes") + b = _parse("require x is above 10 unless z is equal to yes") + assert a != b + + +def test_require_unless_mixed_and_or_in_exception_triggers_amber(): + r = _parse( + "require x is above 10 unless flag-a is equal to yes and " + "flag-b is equal to yes or flag-c is equal to yes" + ) + assert isinstance(r, LiminateResult) + assert r.status is ResultStatus.AMBER_PRECEDENCE + + +def test_require_unless_render_round_trip(): + ast = _parse("require x is above 10 unless y is equal to yes") + rendered = render(ast) + assert rendered == "require x is above 10 unless y is equal to yes" + again = _parse(rendered) + assert isinstance(again, RequireNode) + assert again == ast + + +# Execution semantics: halt when NOT main AND NOT exception. + + +def test_require_unless_main_holds_exception_irrelevant(): + s = _session() + s.run_line("remember a value called amount with 60000") + s.run_line('remember a value called waiver with "no"') + r = s.run_line( + 'require amount is above 50000 unless waiver is equal to "yes"' + ) + assert r.status is ResultStatus.SUCCESS + + +def test_require_unless_main_fails_exception_excuses(): + s = _session() + s.run_line("remember a value called amount with 30000") + s.run_line('remember a value called waiver with "yes"') + r = s.run_line( + 'require amount is above 50000 unless waiver is equal to "yes"' + ) + assert r.status is ResultStatus.SUCCESS + + +def test_require_unless_main_fails_exception_also_fails(): + s = _session() + s.run_line("remember a value called amount with 30000") + s.run_line('remember a value called waiver with "no"') + r = s.run_line( + 'require amount is above 50000 unless waiver is equal to "yes"' + ) + assert r.status is ResultStatus.REQUIREMENT_NOT_MET + assert "amount is above 50000" in (r.message or "")