Skip to content

feat: Definitional Era — named predicates (define) (v31)#54

Merged
rmichaelthomas merged 5 commits into
mainfrom
feat/definitional-era
Jul 4, 2026
Merged

feat: Definitional Era — named predicates (define) (v31)#54
rmichaelthomas merged 5 commits into
mainfrom
feat/definitional-era

Conversation

@rmichaelthomas

Copy link
Copy Markdown
Owner

Summary

Implements the Definitional Era (Liminate Checkpoint v31): named, reusable domain predicates via a new define declaration. 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

  1. Vocabularydefine added to DECLARATIONS. Public count 60 → 61.
  2. AST nodesDefineNode, PredicateApplicationNode in parser.py.
  3. Parsing + threadingpredicate_names/preds threaded through the entire condition-parsing chain and every condition-bearing verb, exactly parallel to composition_names/comp. define dispatches from the DECLARATION branch in _parse_one_operation_inner (not the about-only first-line path).
  4. Collision resolution + field elisionis <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 100 elides to an implicit each, reusing the require-each clause mechanism.
  5. Interpreter_exec_define stores the body AST (type "predicate"); _eval_condition resolves 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_dependencies also tracks a predicate application's subject so when handlers re-fire correctly.
  6. Analyzer / renderer / run.py / build.py — structural-only validation of define bodies (fields are unknown until application time); existence checks for predicate application; canonical rendering + round-trip; Session.predicate_names() threaded into run_line/run_when_block.

Files created/modified

  • Created: tests/test_define.py (33 tests)
  • Modified: src/liminate/vocabulary.py, parser.py, interpreter.py, analyzer.py, renderer.py, run.py, build.py
  • Modified (pre-existing count-assertion fixes): tests/test_vocabulary.py, test_about.py, test_because.py, test_inherited.py, test_within_operator.py

Invariants satisfied (§9)

  1. predicate_names/preds is threaded explicitly everywhere composition_names/comp already threads — no module global, no mutable default.
  2. Public vocabulary count is 61; raw len(ALL_RESERVED) is 62 (includes the uncounted combine tombstone). All count assertions use len(ALL_RESERVED) - len(TOMBSTONES).
  3. Predicate storage type is "predicate" consistently across _exec_define, Session.predicate_names(), and _eval_condition/analyzer checks.
  4. define is a normal program statement (never first-line-intercepted); about keeps its existing first-line-only path.
  5. Quoting preserves literal equality — the predicate check only ever fires on TokenType.UNKNOWN, never QUOTED_STRING.
  6. listener.py needs no changes — predicate evaluation routes through the same _eval_condition it already calls into.

Undefined-predicate validation & redefinition-warning path (required disclosure)

  • Undefined-predicate reference → analyzer. The analyzer already has full visibility into every predicate defined earlier in the program via the symbol table (identical to how _check_composition_call_shape already validates composition calls), so _check_predicate_application does a symtab existence/type check and raises a clear _SemanticError if the name isn't a registered predicate. This mirrors existing precedent exactly rather than deferring to a runtime-only check.
  • Redefinition warning → interpreter (_exec_define), not the analyzer. The analyzer's _check dispatch functions only ever raise or pass silently — there's no channel for a non-blocking informational signal. _exec_op functions already return list[str] output lines for exactly this purpose (the same channel permit uses to emit without halting), so _exec_define emits "Predicate '<name>' is being redefined — the earlier definition will be replaced." there instead of inventing a new ResultStatus.

Bonus fixes found during implementation

Adding define as a second DECLARATION-category token exposed a latent bug in both run.py and build.py: their first-line dispatch checked only token.type is TokenType.DECLARATION, which now also matches define. 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'. Both checks are narrowed to token.value == "about" specifically; build.py additionally gained its own predicate_names tracking (_collect_predicate_names) mirroring its existing _collect_composition_names, so liminate build validates define-using programs the same way liminate run executes them.

Test count

  • Before: 1615 passing on main.
  • After: 1648 passing (33 new in tests/test_define.py), zero regressions.

Test plan

  • python3 -m pytest -q → 1648 passed
  • Public vocabulary count smoke check → 61
  • End-to-end define + keep/count smoke program
  • Parse smoke check via parse(..., predicate_names=...)
  • Round-trip check (predicate condition; define + because)
  • liminate build validation path exercised with a define-using program

Stops at merge — no PyPI publish, repo creation, or deploy performed. Ready for review.

🤖 Generated with Claude Code

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
rmichaelthomas merged commit 4760e75 into main Jul 4, 2026
2 checks passed
@rmichaelthomas
rmichaelthomas deleted the feat/definitional-era branch July 4, 2026 21:54
rmichaelthomas added a commit that referenced this pull request Jul 15, 2026
feat: Definitional Era — named predicates (define) (v31)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant