diff --git a/src/liminate/analyzer.py b/src/liminate/analyzer.py index a0b47da..edb77a1 100644 --- a/src/liminate/analyzer.py +++ b/src/liminate/analyzer.py @@ -61,6 +61,7 @@ ConditionNode, CountNode, DateLiteral, + DefineNode, EachNode, EachPronoun, ExpectNode, @@ -73,6 +74,7 @@ NameRef, NumberLiteral, PackVerbNode, + PredicateApplicationNode, RemoveNode, QuotedString, RememberCompositionNode, @@ -144,7 +146,7 @@ class SymbolEntry: _TYPE_NAMES = frozenset({ "number", "string", "record", "date", "list_of_numbers", "list_of_strings", "list_of_records", "list_of_dates", - "composition", + "composition", "predicate", }) @@ -433,6 +435,8 @@ def _check( ) elif isinstance(node, RememberCompositionNode): pass # §23 line 466: names checked at call time, not here. + elif isinstance(node, DefineNode): + _check_define(node, symtab) elif isinstance(node, ShowNode): _check_show(node, symtab, iterator) elif isinstance(node, FilterNode): @@ -1292,6 +1296,10 @@ def _check_condition( _check_condition(cond.left, symtab, iterator) _check_condition(cond.right, symtab, iterator) return + if isinstance(cond, PredicateApplicationNode): + _check_predicate_application(cond, symtab) + _resolve_field(cond.subject, symtab, iterator) + return if not isinstance(cond, ConditionNode): raise _SemanticError("Unexpected condition shape.") @@ -1752,6 +1760,10 @@ def _check_choose_condition( _check_choose_condition(cond.left, symtab) _check_choose_condition(cond.right, symtab) return + if isinstance(cond, PredicateApplicationNode): + _check_predicate_application(cond, symtab) + _resolve_choose_operand(cond.subject, symtab) + return if not isinstance(cond, ConditionNode): raise _SemanticError("Unexpected condition shape.") field_type, field_label = _resolve_choose_operand(cond.field, symtab) @@ -1780,6 +1792,67 @@ def _check_choose_condition( raise _SemanticError(f"Unknown comparison operator '{cond.op}'.") +# --------------------------------------------------------------------------- +# define / predicate application (Definitional Era, v31) +# --------------------------------------------------------------------------- + +_VALID_CONDITION_OPS = frozenset({ + "is", "above", "below", "equal_to", "within", "includes", "not_includes", + "not_above", "not_below", "not_equal_to", +}) + + +def _check_predicate_application( + cond: PredicateApplicationNode, + symtab: dict[str, SymbolEntry], +) -> None: + """v31 §87 — a predicate application must reference a name already + registered by an earlier `define` (forward-declaration only, mirroring + `_check_composition_call_shape`'s existence check for compositions — + both rely on the symbol table already reflecting every statement + executed so far).""" + if ( + cond.predicate_name not in symtab + or symtab[cond.predicate_name].type != "predicate" + ): + raise _SemanticError( + f"I don't know a definition for '{cond.predicate_name}'. " + f"Use 'define {cond.predicate_name}: ...' to create one." + ) + + +def _check_define(node: DefineNode, symtab: dict[str, SymbolEntry]) -> None: + """v31 §87 — validate a predicate definition's body structurally. + + The subject is unknown until application time (it could later be a + record, a scalar, or a list element), so this checks only condition + shape and operator validity — the same deferral `require each` gives + a right-hand BareWord that isn't yet in the symbol table. It does + NOT resolve the body's field/value types against the symbol table + (there is no iterator context to resolve them against). + """ + _check_define_condition(node.condition, symtab) + + +def _check_define_condition( + cond: ASTNode, + symtab: dict[str, SymbolEntry], +) -> None: + if isinstance(cond, CompoundConditionNode): + _check_define_condition(cond.left, symtab) + _check_define_condition(cond.right, symtab) + return + if isinstance(cond, PredicateApplicationNode): + # v31 §84 — predicate composition: a predicate body may reference + # another predicate. Forward-declaration applies here too. + _check_predicate_application(cond, symtab) + return + if not isinstance(cond, ConditionNode): + raise _SemanticError("Unexpected condition shape.") + if cond.op not in _VALID_CONDITION_OPS: + raise _SemanticError(f"Unknown comparison operator '{cond.op}'.") + + # --------------------------------------------------------------------------- # when / finish (v3a §108, §109, §112) # --------------------------------------------------------------------------- diff --git a/src/liminate/build.py b/src/liminate/build.py index 6765b14..e086bda 100644 --- a/src/liminate/build.py +++ b/src/liminate/build.py @@ -44,6 +44,7 @@ from .lexer import LexError, leading_indent, tokenize from .parser import ( + DefineNode, RememberCompositionNode, SequenceNode, WhenNode, @@ -145,6 +146,7 @@ def _validate_and_render(source: str) -> tuple[list[str], list[Any], str | None] canonical: list[str] = [] asts: list[Any] = [] composition_names: set[str] = set() + predicate_names: set[str] = set() topic: str | None = None first_eligible_seen = False @@ -160,12 +162,21 @@ def _validate_and_render(source: str) -> tuple[list[str], list[Any], str | None] # Meta-Structural Era: an `about` declaration is consumed as # metadata before the normal pipeline. It must be the first # eligible (non-blank, non-comment) line and may appear at most - # once (MS-Q1). + # once (MS-Q1). Definitional Era (v31): `define` also lexes as + # TokenType.DECLARATION but is NOT first-line-only — it's a + # normal program statement, so this check is narrowed to `about` + # specifically and `define` falls through to the regular + # tokenize/reorder/parse pipeline below (mirrors run.py's + # identical first-line dispatch). try: decl_tokens = tokenize(line) except LexError as e: raise BuildError(f"line {i + 1}: {e.message}") from None - if decl_tokens and decl_tokens[0].type is TokenType.DECLARATION: + if ( + decl_tokens + and decl_tokens[0].type is TokenType.DECLARATION + and decl_tokens[0].value == "about" + ): if first_eligible_seen or topic is not None: raise BuildError( f"line {i + 1}: 'about' is a declaration that must be " @@ -216,7 +227,8 @@ def _validate_and_render(source: str) -> tuple[list[str], list[Any], str | None] if is_when_header: action_lines, next_i = _collect_when_block(lines, i) ast = _parse_when_block_from_source( - line, action_lines, composition_names, line_no=i + 1, + line, action_lines, composition_names, predicate_names, + line_no=i + 1, ) asts.append(ast) try: @@ -226,11 +238,12 @@ def _validate_and_render(source: str) -> tuple[list[str], list[Any], str | None] f"line {i + 1}: failed to render canonical form ({e})" ) from None _collect_composition_names(ast, composition_names) + _collect_predicate_names(ast, predicate_names) i = next_i continue # Regular statement. - ast = _parse_line(line, composition_names, line_no=i + 1) + ast = _parse_line(line, composition_names, predicate_names, line_no=i + 1) asts.append(ast) try: canonical.append(render(ast)) @@ -239,12 +252,15 @@ def _validate_and_render(source: str) -> tuple[list[str], list[Any], str | None] f"line {i + 1}: failed to render canonical form ({e})" ) from None _collect_composition_names(ast, composition_names) + _collect_predicate_names(ast, predicate_names) i += 1 return canonical, asts, topic -def _parse_line(line: str, comp_names: set[str], *, line_no: int) -> Any: +def _parse_line( + line: str, comp_names: set[str], pred_names: set[str], *, line_no: int, +) -> Any: try: tokens = tokenize(line) except LexError as e: @@ -252,7 +268,7 @@ def _parse_line(line: str, comp_names: set[str], *, line_no: int) -> Any: reordered = reorder(tokens) if isinstance(reordered, LiminateResult): raise BuildError(_fmt_result(reordered, line_no)) - ast = parse(reordered, composition_names=comp_names) + ast = parse(reordered, composition_names=comp_names, predicate_names=pred_names) if isinstance(ast, LiminateResult): raise BuildError(_fmt_result(ast, line_no)) return ast @@ -262,6 +278,7 @@ def _parse_when_block_from_source( header_line: str, action_lines: list[str], comp_names: set[str], + pred_names: set[str], *, line_no: int, ) -> Any: @@ -289,6 +306,7 @@ def _parse_when_block_from_source( ast = parse_when_block( header_reordered, action_token_lists, composition_names=comp_names, + predicate_names=pred_names, ) if isinstance(ast, LiminateResult): raise BuildError(_fmt_result(ast, line_no)) @@ -350,6 +368,19 @@ def _collect_composition_names(ast: Any, sink: set[str]) -> None: _collect_composition_names(ast.action, sink) +def _collect_predicate_names(ast: Any, sink: set[str]) -> None: + """Definitional Era (v31) — walk an AST collecting DefineNode.name + values so later statements that apply those predicates parse + successfully. Mirrors _collect_composition_names exactly.""" + if isinstance(ast, DefineNode): + sink.add(ast.name) + elif isinstance(ast, SequenceNode): + for op in ast.operations: + _collect_predicate_names(op, sink) + elif isinstance(ast, WhenNode): + _collect_predicate_names(ast.action, sink) + + def _contains_when(asts: list[Any]) -> bool: return any(isinstance(a, WhenNode) for a in asts) diff --git a/src/liminate/interpreter.py b/src/liminate/interpreter.py index 9fc6ff6..2ae9583 100644 --- a/src/liminate/interpreter.py +++ b/src/liminate/interpreter.py @@ -78,6 +78,7 @@ ConditionNode, CountNode, DateLiteral, + DefineNode, EachNode, EachPronoun, ExpectNode, @@ -90,6 +91,7 @@ NameRef, NumberLiteral, PackVerbNode, + PredicateApplicationNode, QuotedString, RemoveNode, RememberCompositionNode, @@ -250,6 +252,13 @@ def _walk_dependencies( if node.target.name not in seen: seen.add(node.target.name) ordered.append(node.target.name) + elif isinstance(node, PredicateApplicationNode): + # Definitional Era (v31) — a `when is ` + # handler depends on its subject, same as any other condition + # leaf. The predicate body itself is not walked: its fields + # resolve against the subject at application time, not against + # top-level symbols, so they aren't independent dependencies. + _walk_dependencies(node.subject, seen, ordered) # Literals (NumberLiteral, BareWord, QuotedString, EachPronoun) # contribute no dependencies — they evaluate to themselves. @@ -642,6 +651,8 @@ def _exec_op( return _exec_remember_record(node, symtab, current_item) if isinstance(node, RememberCompositionNode): return _exec_remember_composition(node, symtab) + if isinstance(node, DefineNode): + return _exec_define(node, symtab) if isinstance(node, ShowNode): return _exec_show(node, symtab, current_item) if isinstance(node, FilterNode): @@ -1326,6 +1337,30 @@ def _exec_remember_composition( return [] +def _exec_define(node: DefineNode, symtab: dict[str, SymbolEntry]) -> list[str]: + """Definitional Era (v31) — register a named predicate. Stores the + condition-body AST under the predicate name (type "predicate"), + mirroring how `remember how to` stores a composition body. + Redefinition overwrites (v1d §58 mutation semantics). The warning is + emitted here, not in the analyzer: `_check`'s per-statement functions + only ever raise or pass silently, with no channel for a non-blocking + informational signal, while `_exec_op` functions already return + `list[str]` output lines for exactly this purpose (the same channel + `permit` uses to emit without halting).""" + output: list[str] = [] + if node.name in symtab and symtab[node.name].type == "predicate": + output.append( + f"Predicate '{node.name}' is being redefined — the earlier " + f"definition will be replaced." + ) + symtab[node.name] = SymbolEntry( + name=node.name, + value=node.condition, + type="predicate", + ) + return output + + # --------------------------------------------------------------------------- # show # --------------------------------------------------------------------------- @@ -2224,6 +2259,23 @@ def _eval_condition( return _within_tolerance(field_val, tolerance, target) value_val = _eval_value(cond.value, current_item, symtab) return _apply_op(cond.op, field_val, value_val) + if isinstance(cond, PredicateApplicationNode): + # Definitional Era (v31) — apply a named predicate. The body is + # re-evaluated on every application (never cached), so a + # predicate stays live: redefining a field it reads (e.g. + # `cutoff`) changes the verdict of later applications. The + # subject resolves to the current-item context for the nested + # evaluation: EachPronoun -> current_item unchanged; a NameRef to + # a record -> that record's dict, so the body's field references + # bind to the record's own fields, not the outer current_item. + entry = symtab.get(cond.predicate_name) + if entry is None or entry.type != "predicate": + raise _RuntimeError( + f"I don't have a definition for '{cond.predicate_name}'." + ) + subject_val = _eval_field(cond.subject, current_item, symtab) + result = _eval_condition(entry.value, subject_val, symtab) + return (not result) if cond.negated else result raise _RuntimeError(f"Can't evaluate condition {type(cond).__name__}.") diff --git a/src/liminate/parser.py b/src/liminate/parser.py index e954f2f..7f98142 100644 --- a/src/liminate/parser.py +++ b/src/liminate/parser.py @@ -450,6 +450,23 @@ class CompoundConditionNode(ASTNode): connector: str # "and" or "or" +@dataclass +class PredicateApplicationNode(ASTNode): + """Definitional Era (v31) — application of a named predicate as a + condition leaf. Produced by `is ` / `is not ` + inside any condition grammar (where/keep/filter/require/forbid/ + permit/expect/choose if/when/unless). + + `subject` is the field node the predicate is applied to (NameRef, + EachPronoun, or FieldAccessNode). `predicate_name` keys into the + symbol-table predicate entry at evaluation time. `negated` inverts + the result (`is not `). + """ + subject: ASTNode + predicate_name: str + negated: bool = False + + @dataclass class ChooseBranch: """v2d §99–§101 — one (condition, action) pair inside a `choose`. @@ -969,6 +986,33 @@ class AboutNode(ASTNode): topic: str +@dataclass +class DefineNode(ASTNode): + """Definitional Era (v31) — a named, reusable predicate. + + `define : ` registers `name` as a predicate whose + body is the condition AST. Stored in the symbol table at execution + time (type "predicate"); referenced from condition grammar via + PredicateApplicationNode. Forward-declaration only: a predicate must + be defined on an earlier line before it is used (mirrors named + compositions). + + The body is the full unified condition grammar (ConditionNode / + CompoundConditionNode / PredicateApplicationNode leaves). + """ + name: str + condition: ASTNode + # Standard inert metadata layer, for structural consistency with + # other statement nodes (compare=False — never read by execution). + # Semantic interaction with a predicate definition is out of scope + # for v31; the fields exist so the render/parse machinery is uniform. + rationale: str | None = field(default=None, compare=False) + inherited: bool = field(default=False, compare=False) + inherited_from: str | None = field(default=None, compare=False) + starting_date: str | None = field(default=None, compare=False) + until_date: str | None = field(default=None, compare=False) + + # Set of operator words that may follow `is` as a comparison introducer. _COMPARISON_OPERATORS = frozenset({"above", "below", "equal_to"}) @@ -1027,6 +1071,7 @@ def in_clause(self, name: str) -> bool: def parse( tokens: list[Token], composition_names: set[str] | None = None, + predicate_names: set[str] | None = None, ) -> ASTNode | LiminateResult: """Parse a canonically-ordered token list into an AST. @@ -1046,9 +1091,10 @@ def parse( stream = TokenStream(tokens) comp = composition_names or set() + preds = predicate_names or set() try: - ast = _parse_operation_sequence(stream, comp) + ast = _parse_operation_sequence(stream, comp, preds) if not stream.at_end(): unexpected = stream.peek() raise _ParseError( @@ -1136,6 +1182,7 @@ def parse_when_block( header_tokens: list[Token], action_token_lists: list[list[Token]], composition_names: set[str] | None = None, + predicate_names: set[str] | None = None, ) -> ASTNode | LiminateResult: """Parse a `when [unless ] [:]` header plus its indented action block (v3a §108/§109/§110). @@ -1208,6 +1255,7 @@ def parse_when_block( ) comp = composition_names or set() + preds = predicate_names or set() # Parse the header — condition + optional unless guard + optional `:`. stream = TokenStream(header_tokens[1:]) # skip the `when` connective @@ -1218,7 +1266,7 @@ def parse_when_block( "I expected a condition after 'when'. " "Try: when is above ." ) - condition = _parse_or_condition(stream) + condition = _parse_or_condition(stream, preds) unless_guard: ASTNode | None = None peek = stream.peek() @@ -1232,7 +1280,7 @@ def parse_when_block( raise _ParseError( "I expected a guard condition after 'unless'." ) - unless_guard = _parse_or_condition(stream) + unless_guard = _parse_or_condition(stream, preds) # Meta-Structural Era — `inherited when ... from ` agent # attribution (Invariant Checkpoint v2 §43). Legal only with the @@ -1278,7 +1326,7 @@ def parse_when_block( # error wording; `finish` is parsed as a regular verb. action_asts: list[ASTNode] = [] for tokens in action_token_lists: - sub = parse(tokens, composition_names=comp) + sub = parse(tokens, composition_names=comp, predicate_names=preds) if isinstance(sub, LiminateResult): # Propagate parse / amber outcomes directly. Amber from an # action statement still blocks Phase 2 per v3a §107. @@ -1335,8 +1383,10 @@ def _starts_operation(tok: Token | None) -> bool: return tok.type is TokenType.OPERATOR and tok.value == "inherited" -def _parse_operation_sequence(stream: TokenStream, comp: set[str]) -> ASTNode: - first = _parse_one_operation(stream, comp) +def _parse_operation_sequence( + stream: TokenStream, comp: set[str], preds: set[str], +) -> ASTNode: + first = _parse_one_operation(stream, comp, preds) operations: list[ASTNode] = [first] connectors: list[str] = [] while not stream.at_end(): @@ -1352,7 +1402,7 @@ def _parse_operation_sequence(stream: TokenStream, comp: set[str]) -> ASTNode: break stream.consume() # eat `and` connectors.append("and") - operations.append(_parse_one_operation(stream, comp)) + operations.append(_parse_one_operation(stream, comp, preds)) elif peek.value == "then": # Normative Era batch 2: `then` always sequences operations; # the next token must be a verb (or an `inherited` verb). Unlike @@ -1366,7 +1416,7 @@ def _parse_operation_sequence(stream: TokenStream, comp: set[str]) -> ASTNode: ) stream.consume() # eat `then` connectors.append("then") - operations.append(_parse_one_operation(stream, comp)) + operations.append(_parse_one_operation(stream, comp, preds)) else: break if len(operations) == 1: @@ -1501,7 +1551,9 @@ def _try_consume_because(stream: TokenStream) -> str | None: return rationale_tok.value -def _try_consume_unless_exception(stream: TokenStream) -> ASTNode | None: +def _try_consume_unless_exception( + stream: TokenStream, preds: set[str], +) -> ASTNode | None: """v28 — consume an optional `unless ` exception clause immediately following a deontic verb's main condition. @@ -1522,7 +1574,7 @@ def _try_consume_unless_exception(stream: TokenStream) -> ASTNode | None: stream.consume() # eat `unless` if stream.at_end(): raise _ParseError("I expected a condition after 'unless'.") - return _parse_or_condition(stream) + return _parse_or_condition(stream, preds) def _try_consume_inherited_from(stream: TokenStream) -> str | None: @@ -1572,7 +1624,9 @@ def _try_consume_inherited_from(stream: TokenStream) -> str | None: return agent_tok.value -def _parse_one_operation(stream: TokenStream, comp: set[str]) -> ASTNode: +def _parse_one_operation( + stream: TokenStream, comp: set[str], preds: set[str], +) -> ASTNode: # Statement-initial `starting`/`until` temporal modifiers # (Temporal-Boundary Era, DT-Q4). Consumed before `inherited` so the # canonical order is `starting ... until ... inherited ...`. @@ -1597,7 +1651,7 @@ def _parse_one_operation(stream: TokenStream, comp: set[str]) -> ASTNode: # this single chokepoint attaches it to the last-parsed statement node # in every context — top-level, `and`/`then` sequences, `choose` # branches, and `each` bodies — without per-verb plumbing. - node = _parse_one_operation_inner(stream, comp) + node = _parse_one_operation_inner(stream, comp, preds) rationale = _try_consume_because(stream) if rationale is not None: node.rationale = rationale @@ -1621,12 +1675,14 @@ def _parse_one_operation(stream: TokenStream, comp: set[str]) -> ASTNode: return node -def _parse_one_operation_inner(stream: TokenStream, comp: set[str]) -> ASTNode: +def _parse_one_operation_inner( + stream: TokenStream, comp: set[str], preds: set[str], +) -> ASTNode: t = stream.peek() if t is None: raise _ParseError("I expected an operation here.") if t.type is TokenType.VERB: - return _parse_verb_statement(stream, comp) + return _parse_verb_statement(stream, comp, preds) if t.type is TokenType.UNKNOWN: # v1b §41 fallback: named composition call. if t.value in comp: @@ -1668,10 +1724,14 @@ def _parse_one_operation_inner(stream: TokenStream, comp: set[str]) -> ASTNode: "it can't introduce a statement on its own. " "Try: when unless ." ) - # Meta-structural: declarations are first-line-only and handled by - # the CLI before the normal pipeline. If `about` reaches here, it - # means it appeared on a non-first line. + # Meta-structural: `about` is first-line-only and handled by the CLI + # before the normal pipeline. Definitional Era (v31): `define` is NOT + # first-line-only — it's a normal program statement, dispatched here + # like any other verb-level construct. Any other DECLARATION reaching + # this point means it appeared on a non-first line. if t.type is TokenType.DECLARATION: + if t.value == "define": + return _parse_define(stream, comp, preds) raise _ParseError( f"'{t.value}' is a declaration that must be the first line " f"of the program (after any comments). It can't appear here." @@ -1679,18 +1739,20 @@ def _parse_one_operation_inner(stream: TokenStream, comp: set[str]) -> ASTNode: raise _ParseError(f"I didn't expect '{t.value}' at the start of an operation.") -def _parse_verb_statement(stream: TokenStream, comp: set[str]) -> ASTNode: +def _parse_verb_statement( + stream: TokenStream, comp: set[str], preds: set[str], +) -> ASTNode: verb = stream.consume() if verb.value == "remember": - return _parse_remember(stream, comp) + return _parse_remember(stream, comp, preds) if verb.value == "show": # v2a §69 (D1): multi-field display is only valid as the body of # an `each` loop. The parser tracks that via clause context. return _parse_show(stream, in_each=stream.in_clause("each")) if verb.value == "filter": - return _parse_filter(stream) + return _parse_filter(stream, preds) if verb.value == "keep": - return _parse_keep(stream) + return _parse_keep(stream, preds) if verb.value == "count": return _parse_count(stream) if verb.value == "gather": @@ -1698,7 +1760,7 @@ def _parse_verb_statement(stream: TokenStream, comp: set[str]) -> ASTNode: if verb.value == "sum": return _parse_sum(stream) if verb.value == "each": - return _parse_each(stream, comp) + return _parse_each(stream, comp, preds) if verb.value == "choose": # v2d §102 — `choose` inside `each` is deferred. Reject at parse # time with the spec-mandated wording (sentence 94 / Outcome 4). @@ -1707,7 +1769,7 @@ def _parse_verb_statement(stream: TokenStream, comp: set[str]) -> ASTNode: "'choose' can't appear inside 'each'. To handle items " "differently, use 'keep' to separate them by condition." ) - return _parse_choose(stream, comp) + return _parse_choose(stream, comp, preds) if verb.value == "add": return _parse_add(stream) if verb.value == "remove": @@ -1715,15 +1777,15 @@ def _parse_verb_statement(stream: TokenStream, comp: set[str]) -> ASTNode: if verb.value == "weakens": return _parse_weakens(stream) if verb.value == "require": - return _parse_require(stream) + return _parse_require(stream, preds) if verb.value == "forbid": - return _parse_forbid(stream) + return _parse_forbid(stream, preds) if verb.value == "permit": - return _parse_permit(stream) + return _parse_permit(stream, preds) if verb.value == "assign": return _parse_assign(stream) if verb.value == "expect": - return _parse_expect(stream) + return _parse_expect(stream, preds) if verb.value == "sort": return _parse_sort(stream) if verb.value == "compare": @@ -1877,7 +1939,9 @@ def _pack_verb_missing_slot_error( # --------------------------------------------------------------------------- -def _parse_remember(stream: TokenStream, comp: set[str]) -> ASTNode: +def _parse_remember( + stream: TokenStream, comp: set[str], preds: set[str], +) -> ASTNode: """remember , where is one of: how to : (composition definition)
? * called with @@ -1888,7 +1952,7 @@ def _parse_remember(stream: TokenStream, comp: set[str]) -> ASTNode: """ peek = stream.peek() if peek and peek.type is TokenType.CONNECTIVE and peek.value == "how": - return _parse_composition_definition(stream, comp) + return _parse_composition_definition(stream, comp, preds) descriptor, saw_list = _consume_remember_intro(stream) @@ -1910,10 +1974,12 @@ def _parse_remember(stream: TokenStream, comp: set[str]) -> ASTNode: if intro.value == "with": return _parse_remember_with(stream, name, descriptor, saw_list) - return _parse_remember_from(stream, name, comp, descriptor) + return _parse_remember_from(stream, name, comp, preds, descriptor) -def _parse_composition_definition(stream: TokenStream, comp: set[str]) -> RememberCompositionNode: +def _parse_composition_definition( + stream: TokenStream, comp: set[str], preds: set[str], +) -> RememberCompositionNode: stream.consume() # how to = stream.consume() if not (to and to.type is TokenType.CONNECTIVE and to.value == "to"): @@ -1935,10 +2001,43 @@ def _parse_composition_definition(stream: TokenStream, comp: set[str]) -> Rememb if not (colon and colon.type is TokenType.DELIMITER and colon.value == ":"): raise _ParseError("I expected ':' after the composition name.") - body = _parse_operation_sequence(stream, comp) + body = _parse_operation_sequence(stream, comp, preds) return RememberCompositionNode(name=name, body=body, param=param) +# --------------------------------------------------------------------------- +# define (Definitional Era, v31) +# --------------------------------------------------------------------------- + + +def _parse_define( + stream: TokenStream, comp: set[str], preds: set[str], +) -> DefineNode: + """`define : ` — registers a named, reusable predicate. + + Mirrors `_parse_composition_definition`'s name + ':' handling exactly + (same name-consumption rules: UNKNOWN token, hyphens allowed, reserved + words rejected). The body is a condition (not an operation sequence) + parsed with the `define-body` clause pushed so a field-elided leaf + (`define big: is above 100`) binds to an implicit `each` pronoun, + exactly like `require-each` (v31 §90 — see the elision guard in + `_parse_simple_condition`). + """ + stream.consume() # eat `define` + name = _consume_name(stream, after="'define'") + + colon = stream.consume() + if not (colon and colon.type is TokenType.DELIMITER and colon.value == ":"): + raise _ParseError("I expected ':' after the predicate name.") + + stream.push_clause("define-body") + try: + condition = _parse_or_condition(stream, preds) + finally: + stream.pop_clause() + return DefineNode(name=name, condition=condition) + + def _consume_remember_intro(stream: TokenStream) -> tuple[str | None, bool]: """Consume zero+ articles and zero+ descriptor UNKNOWNs before `called`. @@ -2075,7 +2174,11 @@ def _parse_record_field(stream: TokenStream) -> tuple[str, ASTNode]: def _parse_remember_from( - stream: TokenStream, name: str, comp: set[str], descriptor: str | None = None, + stream: TokenStream, + name: str, + comp: set[str], + preds: set[str], + descriptor: str | None = None, ) -> ASTNode: """`from` in `remember` (v1b §43): next token is VERB -> result capture via recursive descent @@ -2087,7 +2190,7 @@ def _parse_remember_from( if peek is None: raise _ParseError("I expected an expression after 'from'.") if peek.type is TokenType.VERB: - sub = _parse_verb_statement(stream, comp) + sub = _parse_verb_statement(stream, comp, preds) return RememberValueNode(name=name, value=sub, descriptor=descriptor) if peek.type is TokenType.UNKNOWN and peek.value in comp: # v1b §41 + v2d §98 — named composition call as value expression. @@ -2374,7 +2477,9 @@ def _parse_weakens(stream: TokenStream) -> WeakensNode: # --------------------------------------------------------------------------- -def _parse_require(stream: TokenStream) -> RequireNode | RequireEachNode: +def _parse_require( + stream: TokenStream, preds: set[str], +) -> RequireNode | RequireEachNode: """`require ` — enforcement verb. The condition uses the same parser path as `choose if` / @@ -2396,18 +2501,18 @@ def _parse_require(stream: TokenStream) -> RequireNode | RequireEachNode: # Second parse shape: `require each {name} in {list} {condition}`. peek = stream.peek() if peek and peek.type is TokenType.VERB and peek.value == "each": - return _parse_require_each(stream) + return _parse_require_each(stream, preds) stream.push_clause("require") try: - condition = _parse_or_condition(stream) - exception = _try_consume_unless_exception(stream) + condition = _parse_or_condition(stream, preds) + exception = _try_consume_unless_exception(stream, preds) finally: stream.pop_clause() return RequireNode(condition=condition, exception=exception) -def _parse_require_each(stream: TokenStream) -> RequireEachNode: +def _parse_require_each(stream: TokenStream, preds: set[str]) -> RequireEachNode: """Parse `require each {name} in {list} {condition}` (v8a §49). `each` has been peeked but not consumed. Consume it, then the @@ -2452,7 +2557,7 @@ def _parse_require_each(stream: TokenStream) -> RequireEachNode: ) stream.push_clause("require-each") try: - condition = _parse_or_condition(stream) + condition = _parse_or_condition(stream, preds) finally: stream.pop_clause() @@ -2468,7 +2573,7 @@ def _parse_require_each(stream: TokenStream) -> RequireEachNode: # --------------------------------------------------------------------------- -def _parse_forbid(stream: TokenStream) -> ForbidNode: +def _parse_forbid(stream: TokenStream, preds: set[str]) -> ForbidNode: """`forbid ` — prohibition verb. Same condition grammar as `require`. The difference is purely @@ -2482,8 +2587,8 @@ def _parse_forbid(stream: TokenStream) -> ForbidNode: ) stream.push_clause("forbid") try: - condition = _parse_or_condition(stream) - exception = _try_consume_unless_exception(stream) + condition = _parse_or_condition(stream, preds) + exception = _try_consume_unless_exception(stream, preds) finally: stream.pop_clause() return ForbidNode(condition=condition, exception=exception) @@ -2494,7 +2599,7 @@ def _parse_forbid(stream: TokenStream) -> ForbidNode: # --------------------------------------------------------------------------- -def _parse_permit(stream: TokenStream) -> PermitNode: +def _parse_permit(stream: TokenStream, preds: set[str]) -> PermitNode: """`permit ` — explicit permission verb. Same condition grammar as `require`/`forbid`. The difference @@ -2508,8 +2613,8 @@ def _parse_permit(stream: TokenStream) -> PermitNode: ) stream.push_clause("permit") try: - condition = _parse_or_condition(stream) - exception = _try_consume_unless_exception(stream) + condition = _parse_or_condition(stream, preds) + exception = _try_consume_unless_exception(stream, preds) finally: stream.pop_clause() return PermitNode(condition=condition, exception=exception) @@ -2552,7 +2657,7 @@ def _parse_assign(stream: TokenStream) -> AssignNode: # --------------------------------------------------------------------------- -def _parse_expect(stream: TokenStream) -> ExpectNode: +def _parse_expect(stream: TokenStream, preds: set[str]) -> ExpectNode: """`expect ` — tracked anticipation verb. Same condition grammar as `require`. The difference is purely @@ -2566,8 +2671,8 @@ def _parse_expect(stream: TokenStream) -> ExpectNode: ) stream.push_clause("expect") try: - condition = _parse_or_condition(stream) - exception = _try_consume_unless_exception(stream) + condition = _parse_or_condition(stream, preds) + exception = _try_consume_unless_exception(stream, preds) finally: stream.pop_clause() return ExpectNode(condition=condition, exception=exception) @@ -2842,7 +2947,9 @@ def _parse_gather(stream: TokenStream) -> GatherNode: # --------------------------------------------------------------------------- -def _parse_each(stream: TokenStream, comp: set[str]) -> EachNode: +def _parse_each( + stream: TokenStream, comp: set[str], preds: set[str], +) -> EachNode: _consume_optional_article(stream) coll_tok = stream.consume() if coll_tok is None: @@ -2865,7 +2972,7 @@ def _parse_each(stream: TokenStream, comp: set[str]) -> EachNode: try: # v2a §69: the show parser keys multi-field detection off this # clause-context flag — see _parse_verb_statement / _parse_show. - action = _parse_one_operation(stream, comp) + action = _parse_one_operation(stream, comp, preds) finally: stream.pop_clause() return EachNode(collection=collection, action=action) @@ -2876,7 +2983,9 @@ def _parse_each(stream: TokenStream, comp: set[str]) -> EachNode: # --------------------------------------------------------------------------- -def _parse_choose(stream: TokenStream, comp: set[str]) -> ChooseNode: +def _parse_choose( + stream: TokenStream, comp: set[str], preds: set[str], +) -> ChooseNode: """Parse `choose if : [otherwise [if :] ]*`. The colon is the context switch between condition mode and action @@ -2893,7 +3002,7 @@ def _parse_choose(stream: TokenStream, comp: set[str]) -> ChooseNode: "Try: choose if : ." ) branches: list[ChooseBranch] = [ - _parse_choose_branch(stream, comp, leader="'choose if'") + _parse_choose_branch(stream, comp, preds, leader="'choose if'") ] while True: peek = stream.peek() @@ -2904,11 +3013,11 @@ def _parse_choose(stream: TokenStream, comp: set[str]) -> ChooseNode: if peek2 and peek2.type is TokenType.CONNECTIVE and peek2.value == "if": stream.consume() # eat the chained `if` branches.append( - _parse_choose_branch(stream, comp, leader="'otherwise if'") + _parse_choose_branch(stream, comp, preds, leader="'otherwise if'") ) else: # Terminal `otherwise ` — no condition, no colon (§99). - action = _parse_operation_sequence(stream, comp) + action = _parse_operation_sequence(stream, comp, preds) branches.append(ChooseBranch(condition=None, action=action)) # No further branches are syntactically allowed after the # terminal otherwise — additional tokens are left for the @@ -2918,14 +3027,14 @@ def _parse_choose(stream: TokenStream, comp: set[str]) -> ChooseNode: def _parse_choose_branch( - stream: TokenStream, comp: set[str], *, leader: str, + stream: TokenStream, comp: set[str], preds: set[str], *, leader: str, ) -> ChooseBranch: """Parse `: ` for a single `choose` branch. The `leader` argument feeds the error message so the user sees whether they were inside the initial `choose if` or a chained `otherwise if`.""" stream.push_clause("choose_cond") try: - condition = _parse_or_condition(stream) + condition = _parse_or_condition(stream, preds) finally: stream.pop_clause() colon = stream.consume() @@ -2934,7 +3043,7 @@ def _parse_choose_branch( raise _ParseError( f"I expected ':' after the {leader} condition, not '{got}'." ) - action = _parse_operation_sequence(stream, comp) + action = _parse_operation_sequence(stream, comp, preds) return ChooseBranch(condition=condition, action=action) @@ -2943,19 +3052,19 @@ def _parse_choose_branch( # --------------------------------------------------------------------------- -def _parse_filter(stream: TokenStream) -> FilterNode: - target, condition = _parse_filter_shape(stream, verb="filter") +def _parse_filter(stream: TokenStream, preds: set[str]) -> FilterNode: + target, condition = _parse_filter_shape(stream, preds, verb="filter") return FilterNode(target=target, condition=condition) -def _parse_keep(stream: TokenStream) -> KeepNode: +def _parse_keep(stream: TokenStream, preds: set[str]) -> KeepNode: """v2a §67. Shares the target + where + condition shape with filter.""" - target, condition = _parse_filter_shape(stream, verb="keep") + target, condition = _parse_filter_shape(stream, preds, verb="keep") return KeepNode(target=target, condition=condition) def _parse_filter_shape( - stream: TokenStream, *, verb: str, + stream: TokenStream, preds: set[str], *, verb: str, ) -> tuple[NameRef, ASTNode]: """Shared parser for the filter/keep shape: optional article + target + 'where' + condition. v2a §67 keeps the two verbs structurally @@ -2969,14 +3078,14 @@ def _parse_filter_shape( stream.push_clause("where") try: - condition = _parse_or_condition(stream) + condition = _parse_or_condition(stream, preds) finally: stream.pop_clause() return target, condition -def _parse_or_condition(stream: TokenStream) -> ASTNode: - left = _parse_and_condition(stream) +def _parse_or_condition(stream: TokenStream, preds: set[str]) -> ASTNode: + left = _parse_and_condition(stream, preds) while True: peek = stream.peek() if not (peek and peek.type is TokenType.CONNECTIVE and peek.value == "or"): @@ -2985,13 +3094,13 @@ def _parse_or_condition(stream: TokenStream) -> ASTNode: if nxt and nxt.type is TokenType.VERB: break # operation sequencing stream.consume() - right = _parse_and_condition(stream) + right = _parse_and_condition(stream, preds) left = CompoundConditionNode(left=left, right=right, connector="or") return left -def _parse_and_condition(stream: TokenStream) -> ASTNode: - left = _parse_simple_condition(stream) +def _parse_and_condition(stream: TokenStream, preds: set[str]) -> ASTNode: + left = _parse_simple_condition(stream, preds) while True: peek = stream.peek() if not (peek and peek.type is TokenType.CONNECTIVE and peek.value == "and"): @@ -3000,12 +3109,12 @@ def _parse_and_condition(stream: TokenStream) -> ASTNode: if nxt and nxt.type is TokenType.VERB: break # operation sequencing — exit the where clause stream.consume() - right = _parse_simple_condition(stream) + right = _parse_simple_condition(stream, preds) left = CompoundConditionNode(left=left, right=right, connector="and") return left -def _parse_simple_condition(stream: TokenStream) -> ConditionNode: +def _parse_simple_condition(stream: TokenStream, preds: set[str]) -> ConditionNode: # Field reference or `each` pronoun (v1b §37). head = stream.peek() if head is None: @@ -3015,17 +3124,20 @@ def _parse_simple_condition(stream: TokenStream) -> ConditionNode: # element via an implicit `each` pronoun. Detect the elided form by a # leading comparison/membership token and inject EachPronoun without # consuming it, so the operator-parsing below proceeds normally. - if stream.in_clause("require-each") and ( + # Definitional Era (v31 §90): the same elision applies inside a + # `define` body (`define big: is above 100`), so a predicate can be + # applied per-element without repeating an explicit subject. + if (stream.in_clause("require-each") or stream.in_clause("define-body")) and ( (head.type is TokenType.OPERATOR and head.value in ("is", "not")) or (head.type is TokenType.CONNECTIVE and head.value == "includes") ): field_node: ASTNode = EachPronoun() - return _finish_simple_condition(stream, field_node) + return _finish_simple_condition(stream, field_node, preds) # v25 VW-Q2 — `highest`/`lowest` as a condition's left-hand side, e.g. # `require highest total of line-items is below single-item-cap`. if head.type is TokenType.OPERATOR and head.value in ("highest", "lowest"): field_node = _parse_extrema(stream) - return _finish_simple_condition(stream, field_node) + return _finish_simple_condition(stream, field_node, preds) head = stream.consume() if head.type is TokenType.VERB and head.value == "each": field_node: ASTNode = EachPronoun() @@ -3080,10 +3192,12 @@ def _parse_simple_condition(stream: TokenStream) -> ConditionNode: ) raise _ParseError(f"I didn't expect '{head.value}' as a field name.") - return _finish_simple_condition(stream, field_node) + return _finish_simple_condition(stream, field_node, preds) -def _finish_simple_condition(stream: TokenStream, field_node: ASTNode) -> ConditionNode: +def _finish_simple_condition( + stream: TokenStream, field_node: ASTNode, preds: set[str], +) -> ConditionNode: """Parse the operator + value tail of a simple condition, given an already-resolved field node. Shared between the explicit-field path and the `require each` field-elided path (v8a §49).""" @@ -3137,6 +3251,22 @@ def _finish_simple_condition(stream: TokenStream, field_node: ASTNode) -> Condit if nxt.type is TokenType.OPERATOR: if nxt.value == "not": + # Definitional Era (v31): `is not `. Checked before + # the comparison-negation path below so a predicate name is + # applied rather than rejected as a missing comparison + # operator. `after` is the token following `not`, peeked + # without consuming either token yet. + after = stream.peek(1) + if ( + after is not None + and after.type is TokenType.UNKNOWN + and after.value in preds + ): + stream.consume() # not + stream.consume() # predicate name + return PredicateApplicationNode( + subject=field_node, predicate_name=after.value, negated=True, + ) stream.consume() inner = stream.consume() if not ( @@ -3156,6 +3286,15 @@ def _finish_simple_condition(stream: TokenStream, field_node: ASTNode) -> Condit return ConditionNode(field=field_node, op=nxt.value, value=value) raise _ParseError(f"I didn't expect '{nxt.value}' after 'is'.") + # Definitional Era (v31): `is ` applies a named predicate + # to the subject instead of testing string equality. The predicate + # table is empty for every legacy program, so this is non-breaking; + # `is "overdue"` (quoted) still forces literal string equality since + # QUOTED_STRING never matches this UNKNOWN-only check. + if nxt.type is TokenType.UNKNOWN and nxt.value in preds: + stream.consume() + return PredicateApplicationNode(subject=field_node, predicate_name=nxt.value) + # `is` as equality operator: consume a value. value = _parse_value(stream) return ConditionNode(field=field_node, op="is", value=value) @@ -3597,6 +3736,11 @@ def _contains_mixed_precedence(node: ASTNode) -> bool: return True if _contains_mixed_precedence(node.action): return True + if isinstance(node, DefineNode): + # Definitional Era (v31): a predicate body follows the same + # mixed and/or amber rule as any other condition-bearing + # statement (v1a §30). + return _condition_is_mixed(node.condition) return False diff --git a/src/liminate/renderer.py b/src/liminate/renderer.py index f127d5e..ee58dad 100644 --- a/src/liminate/renderer.py +++ b/src/liminate/renderer.py @@ -37,6 +37,7 @@ ConditionNode, CountNode, DateLiteral, + DefineNode, EachNode, EachPronoun, ExpectNode, @@ -49,6 +50,7 @@ NameRef, NumberLiteral, PackVerbNode, + PredicateApplicationNode, QuotedString, RemoveNode, RememberCompositionNode, @@ -143,6 +145,12 @@ def _render_node(node: ASTNode) -> str: if " " in node.topic: return f'about "{node.topic}"' return f"about {node.topic}" + if isinstance(node, DefineNode): + # Definitional Era (v31) — `define : `. Metadata + # prefixes/suffixes (starting/until/inherited/because/from) are + # applied uniformly by `render`'s getattr-based wrapper, so this + # renders only the name + body. + return f"define {node.name}: {render(node.condition)}" if isinstance(node, NumberLiteral): return _fmt_number(node.value) if isinstance(node, DateLiteral): @@ -368,6 +376,12 @@ def _render_node(node: ASTNode) -> str: return _render_condition(node) if isinstance(node, CompoundConditionNode): return f"{render(node.left)} {node.connector} {render(node.right)}" + if isinstance(node, PredicateApplicationNode): + # Definitional Era (v31) — ` is [not] `. + subject = render(node.subject) + if node.negated: + return f"{subject} is not {node.predicate_name}" + return f"{subject} is {node.predicate_name}" raise TypeError(f"render() has no rule for {type(node).__name__}") diff --git a/src/liminate/run.py b/src/liminate/run.py index e48a2d8..aedee13 100644 --- a/src/liminate/run.py +++ b/src/liminate/run.py @@ -114,6 +114,9 @@ def __init__( def composition_names(self) -> set[str]: return {n for n, e in self.symtab.items() if e.type == "composition"} + def predicate_names(self) -> set[str]: + return {n for n, e in self.symtab.items() if e.type == "predicate"} + def run_line(self, line: str) -> LiminateResult | None: """Execute one source line. Returns the result, or None for blank.""" try: @@ -131,7 +134,11 @@ def run_line(self, line: str) -> LiminateResult | None: reordered = reorder(tokens) if isinstance(reordered, LiminateResult): return reordered - ast = parse(reordered, composition_names=self.composition_names()) + ast = parse( + reordered, + composition_names=self.composition_names(), + predicate_names=self.predicate_names(), + ) if isinstance(ast, LiminateResult): # Amber outcomes carry a pending_ast for confirmation flow. return ast @@ -187,6 +194,7 @@ def run_when_block( ast = parse_when_block( header_reordered, action_token_lists, composition_names=self.composition_names(), + predicate_names=self.predicate_names(), ) if isinstance(ast, LiminateResult): return ast @@ -575,14 +583,21 @@ def _emit( # Meta-Structural Era: an `about` declaration is consumed before # the normal pipeline. It must be the first eligible line and may - # appear at most once. A `DECLARATION` token anywhere else (a - # second `about`, or `about` after a normal statement) is an - # ERROR_PARSE. + # appear at most once. A second `about` (or `about` after a + # normal statement) is an ERROR_PARSE. Definitional Era (v31): + # `define` also lexes as TokenType.DECLARATION but is NOT + # first-line-only — it's a normal program statement, so this + # check is narrowed to `about` specifically and `define` falls + # through to the regular tokenize/reorder/parse pipeline below. try: decl_tokens = tokenize(line) except LexError: decl_tokens = [] - if decl_tokens and decl_tokens[0].type is TokenType.DECLARATION: + if ( + decl_tokens + and decl_tokens[0].type is TokenType.DECLARATION + and decl_tokens[0].value == "about" + ): if first_eligible_seen or session.topic is not None: err = LiminateResult( status=ResultStatus.ERROR_PARSE, diff --git a/src/liminate/vocabulary.py b/src/liminate/vocabulary.py index fb93eaf..00e1cb8 100644 --- a/src/liminate/vocabulary.py +++ b/src/liminate/vocabulary.py @@ -86,6 +86,13 @@ only, value-returning, erroring on an empty list (VW-Q2/Q3/Q4). Total 60 reserved words — 21 verbs, 22 connectives, 10 operators, 3 articles, 3 multi-word reserved, 1 declaration; tombstones uncounted. +- Definitional Era (checkpoint v31) — `define` declaration (+1). Unlike + `about`, `define` is a program-level statement dispatched through the + normal parse pipeline (not a first-line-only declaration): `define + : ` registers a named, reusable domain predicate, + referenced elsewhere as `is ` / `is not `. Total 61 + reserved words — 21 verbs, 22 connectives, 10 operators, 3 articles, + 3 multi-word reserved, 2 declarations; tombstones uncounted. """ from dataclasses import dataclass @@ -260,7 +267,12 @@ class Token: # `about` declares the program's topic as inert metadata — visible to # tooling (inspect, Receipts, Inyim) but not to the runtime symbol # table. Single, first-line-only (MS-Q1). -DECLARATIONS: frozenset[str] = frozenset({"about"}) +# +# Definitional Era (v31): `define` is the second declaration. Unlike +# `about`, it is NOT first-line-only — it's a normal program statement +# dispatched through the standard parse pipeline anywhere in the +# program, registering a named predicate in the symbol table. +DECLARATIONS: frozenset[str] = frozenset({"about", "define"}) # v25 — tombstoned renamed words. Reserved (in ALL_RESERVED) so old # programs fail with a self-explaining error instead of a generic one, @@ -269,8 +281,8 @@ class Token: # tombstone requires future evidence (checkpoint v25, VW-Q6). TOMBSTONES: dict[str, str] = {"combine": "sum"} -# All 60 reserved words. 21 verbs, 22 connectives, 10 operators, 3 -# articles, 0 V2-reserved, 3 multi-word reserved, 1 declaration. +# All 61 reserved words. 21 verbs, 22 connectives, 10 operators, 3 +# articles, 0 V2-reserved, 3 multi-word reserved, 2 declarations. # Tombstones (TOMBSTONES) are reserved but uncounted — same accounting # as pack words (see reserved_category). # v3a §124 was 34 @@ -308,6 +320,11 @@ class Token: # v25 vocabulary wave: `combine` renamed to `sum` (net 0 — verb count # stays 21; `combine` moves to TOMBSTONES, uncounted). `highest`/`lowest` # added to OPERATORS (+2 — list-extrema value selectors, VW-Q2). Total 60. +# Definitional Era (v31): +1 — `define` declaration (DECLARATIONS), the +# second member of the declaration grammatical category. Public total 61 +# (raw len(ALL_RESERVED) is 62 — includes the uncounted `combine` +# tombstone; use `len(ALL_RESERVED) - len(TOMBSTONES)` for the public +# count, never the raw length). ALL_RESERVED: frozenset[str] = ( VERBS | CONNECTIVES | OPERATORS | ARTICLES | V2_RESERVED | MULTI_WORD_RESERVED | DECLARATIONS | frozenset(TOMBSTONES) @@ -324,7 +341,7 @@ def reserved_category(word: str) -> str | None: v4a §137: active pack verbs report as "verb"; active pack nouns report as "noun". Pack words are only reserved while the pack that declared them is loaded — the base vocabulary is the canonical - surface (currently 60 reserved words — 21 verbs, 0 V2-reserved; + surface (currently 61 reserved words — 21 verbs, 0 V2-reserved; see module docstring). v25: tombstoned words (TOMBSTONES) report as "renamed word" — checked diff --git a/tests/test_about.py b/tests/test_about.py index 373f871..7cae04c 100644 --- a/tests/test_about.py +++ b/tests/test_about.py @@ -21,6 +21,7 @@ from liminate.vocabulary import ( ALL_RESERVED, DECLARATIONS, + TOMBSTONES, TokenType, reserved_category, ) @@ -133,7 +134,8 @@ def test_about_in_all_reserved(): def test_about_in_declarations(): - assert DECLARATIONS == frozenset({"about"}) + # Definitional Era (v31) added `define` as the second declaration. + assert DECLARATIONS == frozenset({"about", "define"}) def test_reserved_category_about_is_declaration(): @@ -146,8 +148,11 @@ def test_all_reserved_count_is_58(): # Deontic Era batch 2 added the `permit` verb (55 → 56). # Temporal-Boundary Era added `starting`/`until` (56 → 58). # v25 added `highest`/`lowest` operators (58 → 60 counted) plus the - # tombstoned `combine` (+1 uncounted) → 61 raw ALL_RESERVED entries. - assert len(ALL_RESERVED) == 61 + # tombstoned `combine` (+1 uncounted). + # Definitional Era (v31) added the `define` declaration (60 → 61 + # counted). Raw ALL_RESERVED (including the uncounted tombstone) is + # 62 — use len(ALL_RESERVED) - len(TOMBSTONES) for the public count. + assert len(ALL_RESERVED) - len(TOMBSTONES) == 61 def test_about_cannot_be_used_as_variable_name(): diff --git a/tests/test_because.py b/tests/test_because.py index 1adc7f5..789d52d 100644 --- a/tests/test_because.py +++ b/tests/test_because.py @@ -34,7 +34,7 @@ ) from liminate.renderer import render from liminate.result import ResultStatus -from liminate.vocabulary import ALL_RESERVED, reserved_category +from liminate.vocabulary import ALL_RESERVED, TOMBSTONES, reserved_category def _ast(source): @@ -84,8 +84,11 @@ def test_reserved_category_because_is_connective(): def test_all_reserved_count_is_58(): # Temporal-Boundary Era added `starting`/`until` connectives (56 → 58). # v25 added `highest`/`lowest` operators (58 → 60 counted) plus the - # tombstoned `combine` (+1 uncounted) → 61 raw ALL_RESERVED entries. - assert len(ALL_RESERVED) == 61 + # tombstoned `combine` (+1 uncounted). + # Definitional Era (v31) added the `define` declaration (60 → 61 + # counted). Use len(ALL_RESERVED) - len(TOMBSTONES) for the public + # count — raw len(ALL_RESERVED) is 62. + assert len(ALL_RESERVED) - len(TOMBSTONES) == 61 # --------------------------------------------------------------------------- diff --git a/tests/test_define.py b/tests/test_define.py new file mode 100644 index 0000000..20c70fe --- /dev/null +++ b/tests/test_define.py @@ -0,0 +1,361 @@ +"""Definitional Era (v31) — tests for the `define` declaration. + +`define : ` registers a named, reusable domain predicate. +Anywhere the unified condition grammar accepts a test, `is ` (or +`is not `) applies that predicate to the subject instead of doing +string equality — provided `` is a predicate already defined on an +earlier line (forward-declaration only, mirroring named compositions). + +Covers: parsing (name/colon/body, hyphenated names, reserved-word +rejection, `define-body` field elision), collision resolution against +plain string equality, every condition-consuming construct, end-to-end +execution (including negation, record subjects, composition of +predicates, and live redefinition), canonical rendering + round-trip, +the vocabulary count, and contradiction pre-pass safety. +""" + +from __future__ import annotations + +from liminate.analyzer import SymbolEntry +from liminate.cli import Session +from liminate.interpreter import execute as _execute +from liminate.lexer import tokenize +from liminate.parser import ( + BareWord, + ChooseNode, + ConditionNode, + DefineNode, + EachPronoun, + ForbidNode, + KeepNode, + NameRef, + PredicateApplicationNode, + QuotedString, + RequireEachNode, + RequireNode, + WhenNode, + parse, + parse_when_block, +) +from liminate.renderer import render +from liminate.reorderer import reorder +from liminate.result import ResultStatus +from liminate.run import run as run_program +from liminate.vocabulary import ALL_RESERVED, DECLARATIONS, TOMBSTONES, reserved_category + + +def _parse(source: str, preds=None): + """Parse directly (no reorderer) so parser-level rejection paths — a + reserved word as a predicate name, a missing colon — are reached as + units, matching tests/test_composition_param_literals.py. The + reorderer's narrow permutation table has no rule for a DECLARATION + token followed by a VERB-category word with no other verb in the + line, which is irrelevant to the grammar under test here.""" + return parse(tokenize(source), predicate_names=preds or set()) + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + + +def test_define_parses_basic(): + ast = _parse("define overdue: due-date is below cutoff") + assert isinstance(ast, DefineNode) + assert ast.name == "overdue" + assert isinstance(ast.condition, ConditionNode) + assert ast.condition.op == "below" + + +def test_define_parses_hyphenated_name(): + ast = _parse("define high-risk: total is above 10000") + assert isinstance(ast, DefineNode) + assert ast.name == "high-risk" + + +def test_define_missing_colon_is_parse_error(): + r = _parse("define overdue") + assert r.status is ResultStatus.ERROR_PARSE + assert "':'" in r.message + + +def test_define_reserved_word_name_is_parse_error(): + r = _parse("define require: total is above 1") + assert r.status is ResultStatus.ERROR_PARSE + assert "reserved" in r.message + assert "verb" in r.message + + +def test_define_body_field_elision(): + # v31 §90: inside a `define` body, a leading `is`/`includes` binds to + # an implicit `each` pronoun, exactly like `require each`. + ast = _parse("define big: is above 100") + assert isinstance(ast, DefineNode) + assert isinstance(ast.condition, ConditionNode) + assert isinstance(ast.condition.field, EachPronoun) + assert ast.condition.op == "above" + + +# --------------------------------------------------------------------------- +# Collision resolution +# --------------------------------------------------------------------------- + + +def test_is_predicate_produces_application_node(): + ast = _parse("keep the orders where each is overdue", {"overdue"}) + assert isinstance(ast, KeepNode) + assert isinstance(ast.condition, PredicateApplicationNode) + assert ast.condition.predicate_name == "overdue" + assert isinstance(ast.condition.subject, EachPronoun) + assert ast.condition.negated is False + + +def test_is_not_predicate_produces_negated_application_node(): + ast = _parse("keep the orders where each is not overdue", {"overdue"}) + assert isinstance(ast.condition, PredicateApplicationNode) + assert ast.condition.negated is True + + +def test_empty_predicate_names_falls_back_to_equality(): + # No predicates in scope — `is overdue` is legacy string equality. + ast = _parse("keep the orders where each is overdue") + assert isinstance(ast.condition, ConditionNode) + assert ast.condition.op == "is" + assert isinstance(ast.condition.value, BareWord) + assert ast.condition.value.word == "overdue" + + +def test_quoted_string_forces_equality_even_if_predicate_name_matches(): + ast = _parse('keep the orders where status is "overdue"', {"overdue"}) + assert isinstance(ast.condition, ConditionNode) + assert ast.condition.op == "is" + assert isinstance(ast.condition.value, QuotedString) + assert ast.condition.value.content == "overdue" + + +# --------------------------------------------------------------------------- +# Every condition consumer accepts `is ` +# --------------------------------------------------------------------------- + + +def test_predicate_in_filter(): + ast = _parse("filter the orders where each is overdue", {"overdue"}) + assert isinstance(ast.condition, PredicateApplicationNode) + + +def test_predicate_in_keep(): + ast = _parse("keep the orders where each is overdue", {"overdue"}) + assert isinstance(ast.condition, PredicateApplicationNode) + + +def test_predicate_in_require(): + ast = _parse("require order1 is overdue", {"overdue"}) + assert isinstance(ast, RequireNode) + assert isinstance(ast.condition, PredicateApplicationNode) + assert isinstance(ast.condition.subject, NameRef) + assert ast.condition.subject.name == "order1" + + +def test_predicate_in_require_each(): + ast = _parse("require each item in orders is overdue", {"overdue"}) + assert isinstance(ast, RequireEachNode) + assert isinstance(ast.condition, PredicateApplicationNode) + assert isinstance(ast.condition.subject, EachPronoun) + + +def test_predicate_in_forbid(): + ast = _parse("forbid total is high-risk", {"high-risk"}) + assert isinstance(ast, ForbidNode) + assert isinstance(ast.condition, PredicateApplicationNode) + + +def test_predicate_in_permit(): + ast = _parse("permit order1 is overdue", {"overdue"}) + assert isinstance(ast.condition, PredicateApplicationNode) + + +def test_predicate_in_expect(): + ast = _parse("expect order1 is overdue", {"overdue"}) + assert isinstance(ast.condition, PredicateApplicationNode) + + +def test_predicate_in_choose_if(): + ast = _parse("choose if order1 is overdue: show order1", {"overdue"}) + assert isinstance(ast, ChooseNode) + assert isinstance(ast.branches[0].condition, PredicateApplicationNode) + + +def test_predicate_in_when(): + header = reorder(tokenize("when order1 is overdue")) + action = reorder(tokenize("show order1")) + ast = parse_when_block(header, [action], predicate_names={"overdue"}) + assert isinstance(ast, WhenNode) + assert isinstance(ast.condition, PredicateApplicationNode) + + +def test_predicate_in_unless_guard(): + header = reorder(tokenize("when order1 is overdue unless order1 is overdue")) + action = reorder(tokenize("show order1")) + ast = parse_when_block(header, [action], predicate_names={"overdue"}) + assert isinstance(ast.condition, PredicateApplicationNode) + assert isinstance(ast.unless, PredicateApplicationNode) + + +# --------------------------------------------------------------------------- +# Interpreter (via Session / run) — end-to-end execution +# --------------------------------------------------------------------------- + + +def test_keep_by_predicate_returns_correct_subset(): + s = Session() + s.run_line("define overdue: days-late is above 30") + s.run_line("remember an order called o1 with days-late as 45") + s.run_line("remember an order called o2 with days-late as 10") + s.run_line("remember a list called orders with o1 and o2") + r = s.run_line("keep the orders where each is overdue") + assert r.status is ResultStatus.SUCCESS + assert r.output == ["days-late: 45"] + + +def test_is_not_predicate_returns_complement(): + s = Session() + s.run_line("define overdue: days-late is above 30") + s.run_line("remember an order called o1 with days-late as 45") + s.run_line("remember an order called o2 with days-late as 10") + s.run_line("remember a list called orders with o1 and o2") + r = s.run_line("keep the orders where each is not overdue") + assert r.status is ResultStatus.SUCCESS + assert r.output == ["days-late: 10"] + + +def test_predicate_on_named_record_subject(): + s = Session() + s.run_line("define overdue: days-late is above 30") + s.run_line("remember an order called o1 with days-late as 45") + r = s.run_line("require o1 is overdue") + assert r.status is ResultStatus.SUCCESS + + +def test_predicate_on_named_record_subject_fails_when_false(): + s = Session() + s.run_line("define overdue: days-late is above 30") + s.run_line("remember an order called o1 with days-late as 10") + r = s.run_line("require o1 is overdue") + assert r.status is ResultStatus.REQUIREMENT_NOT_MET + + +def test_composed_predicate_evaluates_correctly(): + # v31 §84 — a predicate body may reference another predicate. + s = Session() + s.run_line("define overdue: days-late is above 30") + s.run_line("define high-risk: is overdue") + s.run_line("remember an order called o1 with days-late as 45") + s.run_line("remember an order called o2 with days-late as 10") + s.run_line("remember a list called orders with o1 and o2") + r = s.run_line("keep the orders where each is high-risk") + assert r.status is ResultStatus.SUCCESS + assert r.output == ["days-late: 45"] + + +def test_live_redefinition_of_referenced_value_changes_verdict(): + # v31 §85 — the predicate body is re-evaluated on every application, + # never cached: redefining `cutoff` changes a later verdict. + s = Session() + s.run_line("remember a number called cutoff with 30") + s.run_line("define overdue: days-late is above cutoff") + s.run_line("remember an order called o1 with days-late as 45") + first = s.run_line("require o1 is overdue") + assert first.status is ResultStatus.SUCCESS + + s.run_line("remember a number called cutoff with 100") + second = s.run_line("require o1 is overdue") + assert second.status is ResultStatus.REQUIREMENT_NOT_MET + + +def test_redefining_a_predicate_emits_a_warning_and_overwrites(): + s = Session() + s.run_line("define overdue: days-late is above 30") + r = s.run_line("define overdue: days-late is above 60") + assert r.status is ResultStatus.SUCCESS + assert r.output and "redefined" in r.output[0] + s.run_line("remember an order called o1 with days-late as 45") + # 45 is above 30 but not above 60 — confirms the second definition won. + verdict = s.run_line("require o1 is overdue") + assert verdict.status is ResultStatus.REQUIREMENT_NOT_MET + + +def test_undefined_predicate_reference_raises_clear_error(): + # Analyzer-path existence check (see PR description for why this + # layer was chosen). In the normal Session/run() flow, `predicate_names` + # is always derived live from the symbol table, so the parser can only + # ever produce a PredicateApplicationNode for a name that genuinely + # exists at that moment — this check guards the decoupled case where + # an AST is parsed with a predicate name and then executed against a + # symbol table that never actually defined it (exactly how a caller + # could misuse the parse()/execute() split). + ast = parse(tokenize("require order1 is overdue"), predicate_names={"overdue"}) + symtab = {"order1": SymbolEntry(name="order1", value={"x": 1}, type="record", + schema={"x": "number"})} + result = _execute(ast, symtab) + assert result.status is ResultStatus.ERROR_SEMANTIC + assert "overdue" in result.message + assert "define overdue" in result.message + + +# --------------------------------------------------------------------------- +# Renderer / round-trip +# --------------------------------------------------------------------------- + + +def test_render_define_node(): + ast = _parse("define overdue: due-date is below cutoff") + assert render(ast) == "define overdue: due-date is below cutoff" + + +def test_render_predicate_condition_round_trip(): + ast = _parse("keep the orders where each is overdue", {"overdue"}) + rendered = render(ast) + assert rendered == "keep the orders where each is overdue" + again = _parse(rendered, {"overdue"}) + assert again == ast + + +def test_define_with_because_round_trips(): + ast = _parse('define overdue: days-late is above 30 because "policy 4.2"') + rendered = render(ast) + assert rendered == 'define overdue: days-late is above 30 because "policy 4.2"' + again = _parse(rendered) + assert again == ast + + +# --------------------------------------------------------------------------- +# Vocabulary +# --------------------------------------------------------------------------- + + +def test_define_is_declaration(): + assert "define" in DECLARATIONS + assert reserved_category("define") == "declaration" + + +def test_public_vocabulary_count_is_61(): + assert len(ALL_RESERVED) - len(TOMBSTONES) == 61 + + +# --------------------------------------------------------------------------- +# Contradiction pre-pass safety +# --------------------------------------------------------------------------- + + +def test_predicate_containing_forbid_does_not_crash_prepass(): + # The contradiction pre-pass parses `require`/`forbid` without + # predicate names (v31 §87 authorized skip-with-fallback), so + # `is high-risk` reads as harmless string equality there — it must + # never crash, even though the real pipeline treats it as a predicate. + source = "\n".join([ + "define high-risk: total is above 10000", + "remember a number called total with 50", + "forbid total is high-risk", + ]) + contract = run_program(source, enter_phase2=False) + assert contract.results[-1].status is ResultStatus.SUCCESS diff --git a/tests/test_inherited.py b/tests/test_inherited.py index cc20c86..6fab14b 100644 --- a/tests/test_inherited.py +++ b/tests/test_inherited.py @@ -40,7 +40,7 @@ from liminate.reorderer import reorder from liminate.renderer import render from liminate.result import ResultStatus -from liminate.vocabulary import ALL_RESERVED, TokenType, reserved_category +from liminate.vocabulary import ALL_RESERVED, TOMBSTONES, TokenType, reserved_category def _ast(source): @@ -93,8 +93,11 @@ def test_reserved_category_inherited_is_operator(): def test_all_reserved_count_is_58(): # Temporal-Boundary Era added `starting`/`until` connectives (56 → 58). # v25 added `highest`/`lowest` operators (58 → 60 counted) plus the - # tombstoned `combine` (+1 uncounted) → 61 raw ALL_RESERVED entries. - assert len(ALL_RESERVED) == 61 + # tombstoned `combine` (+1 uncounted). + # Definitional Era (v31) added the `define` declaration (60 → 61 + # counted). Use len(ALL_RESERVED) - len(TOMBSTONES) for the public + # count — raw len(ALL_RESERVED) is 62. + assert len(ALL_RESERVED) - len(TOMBSTONES) == 61 # --------------------------------------------------------------------------- diff --git a/tests/test_vocabulary.py b/tests/test_vocabulary.py index b4f6224..71efa36 100644 --- a/tests/test_vocabulary.py +++ b/tests/test_vocabulary.py @@ -84,13 +84,14 @@ def test_multi_word_reserved(): def test_total_reserved_count_is_54(): - # 60 reserved words total (v25: +2 for `highest`/`lowest`; `combine` - # -> `sum` is a net-0 rename, tombstoned and uncounted). 21 verbs + - # 22 connectives + 10 operators + 3 multi-word + 3 articles + - # 0 v2-deferred + 1 declaration = 60. TOMBSTONES is reserved but - # not part of the public count (see test_reserved_sets_are_disjoint, - # which accounts for it separately). - assert len(ALL_RESERVED) == 61 + # 61 public reserved words total (Definitional Era v31: +1 for the + # `define` declaration). 21 verbs + 22 connectives + 10 operators + + # 3 multi-word + 3 articles + 0 v2-deferred + 2 declarations = 61. + # TOMBSTONES is reserved but not part of the public count — raw + # len(ALL_RESERVED) is 62 (includes the uncounted `combine` + # tombstone). Count assertions must use + # len(ALL_RESERVED) - len(TOMBSTONES), never the raw len (v30 §78). + assert len(ALL_RESERVED) - len(TOMBSTONES) == 61 def test_reserved_sets_are_disjoint(): diff --git a/tests/test_within_operator.py b/tests/test_within_operator.py index 4b2712d..119f4b9 100644 --- a/tests/test_within_operator.py +++ b/tests/test_within_operator.py @@ -27,7 +27,7 @@ ) from liminate.renderer import render from liminate.result import ResultStatus -from liminate.vocabulary import ALL_RESERVED, reserved_category +from liminate.vocabulary import ALL_RESERVED, TOMBSTONES, reserved_category def _run(tmp_path, src): @@ -47,8 +47,11 @@ def test_within_still_connective_and_count_unchanged(): assert reserved_category("within") == "connective" assert "within" in ALL_RESERVED # v25 added `highest`/`lowest` operators (58 → 60 counted) plus the - # tombstoned `combine` (+1 uncounted) → 61 raw ALL_RESERVED entries. - assert len(ALL_RESERVED) == 61 + # tombstoned `combine` (+1 uncounted). + # Definitional Era (v31) added the `define` declaration (60 → 61 + # counted). Use len(ALL_RESERVED) - len(TOMBSTONES) for the public + # count — raw len(ALL_RESERVED) is 62. + assert len(ALL_RESERVED) - len(TOMBSTONES) == 61 # ---------------------------------------------------------------------------