Skip to content

fix: reject cyclic predicate definitions, guard evaluation depth#60

Merged
rmichaelthomas merged 1 commit into
mainfrom
fix/cyclic-predicate-rejection
Jul 20, 2026
Merged

fix: reject cyclic predicate definitions, guard evaluation depth#60
rmichaelthomas merged 1 commit into
mainfrom
fix/cyclic-predicate-rejection

Conversation

@rmichaelthomas

Copy link
Copy Markdown
Owner

Summary

  • A predicate redefinition could introduce a reference cycle invisible to the existing forward-declaration check (e.g. define q: is p then define p: is q), since each define only checks that the referenced name exists right now, not what it eventually resolves to.
  • Evaluating a cyclic predicate chain recursed forever, raising a bare RecursionError instead of a structured LiminateResult — this crashed callers like seshat-app's _validate_agreement_source, which calls liminate.run() unwrapped.
  • Fix 1 (analyzer.py): _check_define now walks the reference graph of a (re)defined predicate body via a new _check_predicate_definition_cycle / _find_predicate_cycle, reusing the visited-set pattern from _composition_void_result_verb. Reaching the name being defined raises _SemanticError naming the loop (e.g. Definition 'p' refers back to itself through 'q'. A definition can't depend on itself.). Runs after the existing forward-declaration check, so a first-ever define p: is p still hits the original "I don't know a definition" error, unshadowed.
  • Fix 2 (interpreter.py, defensive backstop): a _predicate_eval_depth ContextVar (mirroring the existing _in_action_block token pattern) caps predicate-evaluation recursion at 64, raising _RuntimeError (→ ERROR_SEMANTIC) on overflow, so any cycle the analyzer misses still produces a structured result rather than a raw exception.
  • 7 new tests in tests/test_define.py; full suite 1661 passed (1654 baseline + 7 new), zero regressions, vocabulary unchanged at 61 words.

Test plan

  • pytest tests/test_define.py -v — 40 passed
  • pytest tests/ — 1661 passed, zero regressions
  • Manually verified the four-line repro, 3-way cycle, direct self-reference, legal redefinition, legal composition, hand-constructed cyclic symtab depth guard, and a legal 10-deep chain

🤖 Generated with Claude Code

A predicate redefinition could introduce a reference cycle that only
becomes visible after the fact (e.g. define q: is p, then define p: is
q), since each `define` only checks that the referenced name exists at
that moment, not what it eventually resolves to. Evaluating such a
cycle recursed forever and raised a bare RecursionError instead of a
structured LiminateResult.

Add a static cycle check in analyzer._check_define that walks the
reference graph of a predicate body being (re)defined and rejects it
if the walk reaches the name being defined. As a defensive backstop,
cap predicate evaluation depth in the interpreter at 64 so any cycle
the analyzer misses still surfaces as ERROR_SEMANTIC rather than a raw
exception.
@rmichaelthomas
rmichaelthomas force-pushed the fix/cyclic-predicate-rejection branch from 9542647 to 354f48c Compare July 20, 2026 07:10
@rmichaelthomas
rmichaelthomas merged commit dd0d711 into main Jul 20, 2026
2 checks passed
@rmichaelthomas
rmichaelthomas deleted the fix/cyclic-predicate-rejection branch July 20, 2026 07:11
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