feat: Definitional Era — named predicates (define) (v31)#54
Merged
Conversation
Definitional Era (v31) — adds `define` as the second DECLARATION- category word (60 -> 61 public reserved words; raw len(ALL_RESERVED) is 62, including the uncounted `combine` tombstone). Unlike `about`, `define` is not first-line-only — it's a normal program statement. Fixes five pre-existing tests that asserted a raw len(ALL_RESERVED) count instead of the public len(ALL_RESERVED) - len(TOMBSTONES) formula (the v30 §78 anti-pattern), and updates test_about.py's DECLARATIONS-contents assertion to include `define`.
… 2-4) Adds two AST nodes: - DefineNode — `define <name>: <condition>`, standard inert metadata layer (rationale/inherited/starting/until) for structural consistency with other statement nodes. - PredicateApplicationNode — `<subject> is [not] <name>`, produced wherever the unified condition grammar accepts a leaf. Threads a new `preds: set[str]` parameter through the entire condition- parsing chain (_parse_or_condition/_parse_and_condition/ _parse_simple_condition/_finish_simple_condition and every verb that parses a condition: filter, keep, require, require each, forbid, permit, expect, choose, when/unless), exactly parallel to how `composition_names`/`comp` is already threaded — no module-global predicate table, no mutable default argument. Collision resolution in _finish_simple_condition: `is <name>` / `is not <name>` resolves to a predicate application only when `<name>` is in the live predicate-name set; otherwise (including every legacy program, where the set is always empty) it falls through unchanged to string equality. A quoted value always forces literal equality regardless of predicate names. Field elision (v31 §90): `define <name>: is above 100` binds the elided leaf to an implicit `each` pronoun, reusing the exact `require-each` clause mechanism. `define` dispatches from `_parse_one_operation_inner`'s DECLARATION branch (not the `about`-only first-line path) since it is a normal program statement usable anywhere. `_contains_mixed_precedence` gains a DefineNode case so a predicate body follows the same and/or amber rule as any other condition-bearing statement.
_exec_define stores the condition-body AST under the predicate name (symbol-table type "predicate"), mirroring how `remember how to` stores a composition body. Redefinition overwrites and emits an informational output line (the same non-blocking channel `permit` uses to surface without halting) rather than a new ResultStatus. _eval_condition gains a PredicateApplicationNode branch: the subject resolves via the existing _eval_field (a NameRef to a record returns that record's dict, so the body's field references bind to the record's own fields), then the stored body is evaluated fresh against that resolved subject — never cached, so a predicate stays live when a field it reads is later redefined. Predicate composition (a predicate body referencing another predicate) falls out of this recursion with no extra code. _walk_dependencies gains a PredicateApplicationNode case so a `when <subject> is <predicate>` handler tracks its subject as a reactive dependency, matching every other condition leaf — without it such a handler would silently never re-fire.
…se 6) Analyzer: DefineNode body validation is structural only (condition shape + operator validity) — the subject is unknown until application time, the same deferral `require each` already gives a right-hand BareWord, so field/value types are never resolved against the symbol table for a definition body. PredicateApplicationNode existence validation (does the name resolve to a symbol-table entry of type "predicate"?) reuses the exact symtab-lookup precedent _check_composition_call_shape already established for compositions — chosen over a runtime-only check because the analyzer already has this visibility for every statement executed so far in the program. Redefinition warnings are NOT emitted here: _check's dispatch functions only ever raise or pass silently, with no non-blocking signal channel, so that responsibility lives in the interpreter's _exec_define instead (see the previous commit). Renderer: DefineNode renders as `define <name>: <condition>`; PredicateApplicationNode renders as `<subject> is [not] <name>`. Metadata prefixes/suffixes (starting/until/inherited/because/from) apply for free via render()'s existing getattr-based wrapper. run.py: adds Session.predicate_names() (mirrors composition_names() exactly) and threads it into both parse() calls in run_line/ run_when_block. The contradiction pre-pass's parse(reordered) call is deliberately left without predicate names — the authorized v31 §87 skip-with-fallback, so a predicate-containing require/forbid parses as harmless string equality there instead of crashing. Also fixes a latent bug this feature exposed in both run.py and build.py: the first-line declaration dispatch checked only `token.type is TokenType.DECLARATION`, which now also matches `define` (the second word in that grammatical category). Since `define` is NOT first-line-only, any `define` statement anywhere in a program was being routed into `parse_about`, which returns None for non-`about` input, crashing with `AttributeError: 'NoneType' object has no attribute 'topic'` in run.py and an identical crash in build.py's independent validation pass. Both checks are narrowed to `token.value == "about"` specifically. build.py additionally gains its own predicate_names tracking (_collect_predicate_names, mirroring _collect_composition_names) so `liminate build` validates programs using `define` the same way `liminate run` does.
Covers parsing (name/colon/body, hyphenated names, reserved-word rejection, define-body field elision), collision resolution against plain string equality, every condition-consuming construct (filter, keep, require, require each, forbid, permit, expect, choose if, when, unless), end-to-end execution via Session/run (correct subset filtering, negation, record subjects, predicate composition, live redefinition, redefinition warning + overwrite, undefined-predicate error), canonical rendering + round-trip, the public vocabulary count, and contradiction pre-pass safety on a predicate-containing forbid. 33 new tests; 1648 total, zero regressions against the 1615-test baseline on main.
rmichaelthomas
added a commit
that referenced
this pull request
Jul 15, 2026
feat: Definitional Era — named predicates (define) (v31)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the Definitional Era (Liminate Checkpoint v31): named, reusable domain predicates via a new
definedeclaration.define <name>: <condition>registers a predicate;is <name>/is not <name>applies it anywhere the condition grammar accepts a leaf (where/keep/filter/require/require each/forbid/permit/expect/choose if/when/unless).Phases completed
defineadded toDECLARATIONS. Public count 60 → 61.DefineNode,PredicateApplicationNodeinparser.py.predicate_names/predsthreaded through the entire condition-parsing chain and every condition-bearing verb, exactly parallel tocomposition_names/comp.definedispatches from the DECLARATION branch in_parse_one_operation_inner(not theabout-only first-line path).is <name>resolves to a predicate only when<name>is a live predicate name; otherwise falls through to legacy string equality (non-breaking for every existing program, where the predicate set is always empty). Quoted values always force literal equality.define <name>: is above 100elides to an impliciteach, reusing therequire-eachclause mechanism._exec_definestores the body AST (type"predicate");_eval_conditionresolves the subject and re-evaluates the body fresh on every application (never cached — redefinitions of referenced values stay live); predicate composition falls out of the existing recursion._walk_dependenciesalso tracks a predicate application's subject sowhenhandlers re-fire correctly.definebodies (fields are unknown until application time); existence checks for predicate application; canonical rendering + round-trip;Session.predicate_names()threaded intorun_line/run_when_block.Files created/modified
tests/test_define.py(33 tests)src/liminate/vocabulary.py,parser.py,interpreter.py,analyzer.py,renderer.py,run.py,build.pytests/test_vocabulary.py,test_about.py,test_because.py,test_inherited.py,test_within_operator.pyInvariants satisfied (§9)
predicate_names/predsis threaded explicitly everywherecomposition_names/compalready threads — no module global, no mutable default.len(ALL_RESERVED)is 62 (includes the uncountedcombinetombstone). All count assertions uselen(ALL_RESERVED) - len(TOMBSTONES)."predicate"consistently across_exec_define,Session.predicate_names(), and_eval_condition/analyzer checks.defineis a normal program statement (never first-line-intercepted);aboutkeeps its existing first-line-only path.TokenType.UNKNOWN, neverQUOTED_STRING.listener.pyneeds no changes — predicate evaluation routes through the same_eval_conditionit already calls into.Undefined-predicate validation & redefinition-warning path (required disclosure)
_check_composition_call_shapealready validates composition calls), so_check_predicate_applicationdoes a symtab existence/type check and raises a clear_SemanticErrorif the name isn't a registered predicate. This mirrors existing precedent exactly rather than deferring to a runtime-only check._exec_define), not the analyzer. The analyzer's_checkdispatch functions only ever raise or pass silently — there's no channel for a non-blocking informational signal._exec_opfunctions already returnlist[str]output lines for exactly this purpose (the same channelpermituses to emit without halting), so_exec_defineemits"Predicate '<name>' is being redefined — the earlier definition will be replaced."there instead of inventing a newResultStatus.Bonus fixes found during implementation
Adding
defineas a secondDECLARATION-category token exposed a latent bug in bothrun.pyandbuild.py: their first-line dispatch checked onlytoken.type is TokenType.DECLARATION, which now also matchesdefine. Sincedefineis not first-line-only, anydefinestatement anywhere in a program was being routed intoparse_about(which returnsNonefor non-aboutinput), crashing withAttributeError: 'NoneType' object has no attribute 'topic'. Both checks are narrowed totoken.value == "about"specifically;build.pyadditionally gained its ownpredicate_namestracking (_collect_predicate_names) mirroring its existing_collect_composition_names, soliminate buildvalidatesdefine-using programs the same wayliminate runexecutes them.Test count
main.tests/test_define.py), zero regressions.Test plan
python3 -m pytest -q→ 1648 passeddefine+keep/countsmoke programparse(..., predicate_names=...)define+because)liminate buildvalidation path exercised with adefine-using programStops at merge — no PyPI publish, repo creation, or deploy performed. Ready for review.
🤖 Generated with Claude Code