Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/language/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <exception>` clause after the main condition:

```
<verb> <condition> unless <exception>
```

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 <condition> unless <exception>` — halts only when the
condition is false AND the exception is also false. A failing
requirement is excused when the exception holds.
- `forbid <condition> unless <exception>` — halts only when the
condition is true AND the exception is false. A triggered
prohibition is excused when the exception holds.
- `permit <condition> unless <exception>` — emits only when the
condition is true AND the exception is false. The exception
*narrows* the permission, suppressing the emission.
- `expect <condition> unless <exception>` — 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
Expand Down
43 changes: 36 additions & 7 deletions src/liminate/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
WeakensNode,
WhenNode,
)
from .renderer import render
from .result import LiminateResult, ResultStatus
from .vocabulary import (
AppendToListExecution,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions src/liminate/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}."
Expand Down Expand Up @@ -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}."
Expand All @@ -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}."
Expand All @@ -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}."
Expand Down
74 changes: 66 additions & 8 deletions src/liminate/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <condition>` 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),
Expand Down Expand Up @@ -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 <condition>` 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)
Expand All @@ -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 <condition>` 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)
Expand Down Expand Up @@ -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 <condition>` 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),
Expand Down Expand Up @@ -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 <condition>` 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 <agent-name>` attribution at the end of
an `inherited` statement (Meta-Structural Era batch 3, MS-Q4).
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)


# ---------------------------------------------------------------------------
Expand All @@ -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)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -3445,23 +3491,35 @@ 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`.
return _condition_is_mixed(node.condition)
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):
Expand Down
Loading