Skip to content

Z3 encoder and seven core satisfiability checks (decidability Step 2)#63

Merged
rmichaelthomas merged 7 commits into
mainfrom
feat/z3-encoder-and-core-checks
Jul 20, 2026
Merged

Z3 encoder and seven core satisfiability checks (decidability Step 2)#63
rmichaelthomas merged 7 commits into
mainfrom
feat/z3-encoder-and-core-checks

Conversation

@rmichaelthomas

Copy link
Copy Markdown
Owner

Summary

Step 2 of the decidability path (Trust Infrastructure Addendum v1.0s §91/§92.6). Adds src/liminate/checker.py: a satisfiability checker for Liminate's enforcement fragment — the four deontic verbs (require/forbid/permit/expect), their conditions, and the define predicates they reference — encoded into SMT constraints (QF_UFLIRA via Z3) with seven authoring-time checks.

Phases completed (one commit each)

  1. Packagingcheck optional-dependency extra (z3-solver>=4.12), lazy z3 import, CheckerUnavailable/UnencodableConstruct/NonlinearArithmetic exceptions, Finding/CheckResult dataclasses.
  2. Sort model & constant allocation — number→Real, string→String, date→Int (epoch-day ordinals), per-field record constants, name sanitization with a reverse map.
  3. Condition encoder — mirrors interpreter._eval_condition/_apply_op exactly: all comparison operators, within (value=tolerance, value2=target), includes/not_includes, predicate application with EachPronoun substitution and a depth guard.
  4. Arithmetic, name inlining, TI-Q13 closure — a definitions map scanned from RememberValueNodes, inlining of arithmetic-containing defining expressions, and NonlinearArithmetic detection when neither operand of multiplied_by/divided_by is a compile-time constant.
  5. Deontic effect formulasencode_deontic reduces each verb to (effect, allowed) per the locked semantics table.
  6. Seven core checks — always-deny, dead-forbid, require/forbid conflict, unless-swallows-the-rule, redundant-forbid, dead-permit, constant-predicate, each a tracked assert_and_track solver query with a 5000ms timeout and a 200-statement pairwise cap.
  7. Public APIcheck_agreement(statements, symbol_table) -> CheckResult, with every encoding failure (including unexpected ones) caught at the boundary — never a traceback.

Files created / modified

  • src/liminate/checker.py (new)
  • tests/test_checker.py (new, 69 tests)
  • pyproject.toml (+1 line: the check extra)

interpreter.py, analyzer.py, tests/test_arithmetic.py, tests/test_contradiction_detection.py, tests/test_vocabulary.py are untouched (confirmed via git diff — empty).

Suite

1763 passed / 0 failed / 0 skipped — up from the 1694 baseline on main@95daaf5, +69 new tests, zero regressions.

Vocabulary

Confirmed still 61 wordstests/test_vocabulary.py (18/18) unchanged and passing.

TI-Q13 (value-provenance indirection) — closed

remember a number called alpha with 10
remember a number called beta with 2
remember a value called doubled from beta multiplied by beta
forbid alpha is above doubled
  • liminate.run() / the analyzer: accepts and enforces it (PROHIBITION_VIOLATED) — PR Accept bare dates in temporal prefixes; restrict arithmetic to linear forms #62's syntactic linearity check only sees a bare name in the condition, never the ArithmeticNode behind it.
  • check_agreement(): rejects itencodable=False, skipped_reason names 'doubled' and the nonlinear beta multiplied by beta term, by inlining the defining expression at encode time.

Design decisions beyond literal spec text

Five points were genuinely ambiguous in the build prompt; each is backed by a dedicated test:

  1. Constant allocation skips (doesn't raise on) non-scalar symbol-table entries eagerly — UnencodableConstruct fires lazily only when a condition actually references one.
  2. _is_compile_time_constant never resolves through name indirection, even when a name's own value is a literal — it's a free Z3 constant to the solver regardless.
  3. divided_by uses the same uniform "neither operand constant" rule as multiplied_by (no asymmetric divisor-only rule).
  4. Check 4 (unless-swallows-the-rule) is verb-adjusted — it tests each statement's own effect formula, not the literal C∧¬E notation applied invariantly (which is meaningless for require).
  5. Check 7 infers a fresh subject's Z3 sort from how the predicate body compares it, falling back to an "inconclusive" finding if inference fails.

Invariants exercised by tests

All nine consistency invariants from the build prompt are covered: vocabulary frozen at 61 (§11.1), zero new runtime deps behind a lazy import (§11.2), operator semantics matching the interpreter (§11.3, throughout test_op_*), unless uniformly condition ∧ ¬exception (test_exception_none_collapses_to_false), interpreter.py/detect_contradictions untouched (§11.5–6, confirmed via git diff), the pinned KNOWN_GAP test unmodified (§11.7), no unhandled exception escapes check_agreement (§11.8, test_check_agreement_never_raises_for_malformed_symbol_table), and the suite growing monotonically with zero regressions (§11.9).

Manual gate

Reviewed and passed by Rob — full suite diff and check-4 CheckResult output confirmed clear before this PR was opened.

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

rmichaelthomas and others added 7 commits July 20, 2026 11:32
Adds the check optional-dependency extra (z3-solver>=4.12) and the
checker.py module skeleton: CheckerUnavailable/UnencodableConstruct/
NonlinearArithmetic exceptions, the lazy _import_z3() helper, and the
Finding/CheckResult dataclasses. z3 is never imported at module scope,
so `import liminate.checker` succeeds with zero third-party runtime
dependencies whether or not z3 is installed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_Encoder allocates Z3 constants from the symbol table: number->Real,
string->String, date->Int (epoch-day ordinals), one constant per
record field named {record}__{field}. list_of_* entries get no
constant -- membership expands to a disjunction at encode time
instead (Phase 3). Names are sanitized to legal SMT identifiers with
a reverse map for diagnostics, since Liminate allows hyphens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
encode_condition mirrors interpreter._eval_condition/_apply_op exactly:
is/equal_to/not_equal_to/above/below, not_above/not_below as fused
<=/>= (never a structural Not() wrapper), within with value=tolerance
and value2=target (the counter-intuitive operand order verified
against the parser), includes/not_includes over a statically-known
list with the empty-list edge case special-cased before splatting.

PredicateApplicationNode substitutes EachPronoun with the application's
subject (never by name) and encodes the body recursively, with a depth
guard mirroring the interpreter's own defensive posture. Every other
name in a predicate body resolves normally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes TI-Q13. The symbol table only stores computed values, never the
defining expression, so check_agreement scans RememberValueNode in the
program AST into a definitions map. Value-position name resolution now
inlines a name's defining expression when it contains an ArithmeticNode
anywhere, guarded by a visited-set against self-reference. After
inlining, multiplied_by/divided_by raise NonlinearArithmetic when
neither operand is a compile-time constant -- a name reference never
counts as one here, even when its own value happens to be a literal,
because it is a free Z3 constant to the solver regardless.

This is the case PR #62's syntactic linearity check provably misses:
remember a number called beta with 4
remember a number called z from beta multiplied by beta
forbid alpha is above z
The analyzer sees only a bare name in the condition and passes it
(tests/test_arithmetic.py's test_KNOWN_GAP_value_indirection_bypasses_
the_linearity_restriction pins this, unmodified). The encoder inlines
z to beta*beta and rejects it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
encode_deontic reduces a Require/Forbid/Permit/Expect node to (effect,
allowed): require fires on ¬C∧¬E (allowed C∨E); forbid fires on C∧¬E
(allowed ¬C∨E); permit and expect fire on C∧¬E / ¬C∧¬E respectively
but are non-blocking and get no allowed-space, since they never
participate in the require/forbid contradiction check (locked DT-Q3:
permit never contradicts). exception=None collapses to BoolVal(False)
uniformly across all four verbs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each check is a separate assert_and_track solver query (tracked names
stmt_{index}_{verb}, 5000ms timeout -> an "inconclusive" info finding
rather than hanging): (1) always-deny / vacuous require, (2) dead
forbid, (3) require-forbid contradiction, (4) unless-swallows-the-rule
(the highest-value finding -- runs first and suppresses check 2 for
the same statement since the two share a query shape for forbid), (5)
redundant forbid, (6) dead permit, (7) constant predicate bodies
(contradiction or tautology), which needs a fresh Z3 constant standing
in for a predicate's implicit subject, sorted by inferring how the
body compares it.

Checks 3 and 5 are pairwise (O(n^2)); above 200 deontic statements
they're skipped with an informational finding while the per-statement
checks still run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
check_agreement(statements, symbol_table) -> CheckResult ties the
encoder and the seven checks together. Input mirrors
analyzer.detect_contradictions(statements) -- a list of top-level
statement ASTs; the checker implements its own SequenceNode-descending
walk (_iter_statements) rather than reusing detect_contradictions'
_iter_deontic_nodes, which only yields Require/ForbidNode and would
silently drop Permit/Expect/Define.

UnencodableConstruct and NonlinearArithmetic are caught at this
boundary and reported as CheckResult(encodable=False,
skipped_reason=...); so is any other exception (invariant 8 -- no
unhandled exception escapes check_agreement for any input, malformed
or out-of-fragment). Every statement rendering in a Finding goes
through renderer.render() so checker output matches canonical form
everywhere else in the system.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@rmichaelthomas
rmichaelthomas merged commit 2c628e1 into main Jul 20, 2026
2 checks passed
@rmichaelthomas
rmichaelthomas deleted the feat/z3-encoder-and-core-checks branch July 20, 2026 18:37
rmichaelthomas added a commit that referenced this pull request Jul 22, 2026
…nt public API)

Publishes the check extra (z3-solver) and the check_source/check_agreement
public API that landed on main via PRs #63-67 without a version bump.
liminate-dev's hosted /check endpoint depends on this release.
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