Skip to content

feat(libprovekit): wp_rule schema + substitute/apply formula nodes + the wp evaluator (wp-as-formula PR 1/N)#620

Merged
TSavo merged 2 commits into
mainfrom
feat/wp-as-formula-1-schema-evaluator
May 12, 2026
Merged

feat(libprovekit): wp_rule schema + substitute/apply formula nodes + the wp evaluator (wp-as-formula PR 1/N)#620
TSavo merged 2 commits into
mainfrom
feat/wp-as-formula-1-schema-evaluator

Conversation

@TSavo
Copy link
Copy Markdown
Owner

@TSavo TSavo commented May 12, 2026

Implements PR 1 of the wp-as-formula sub-project, per the normative spec protocol/specs/2026-05-13-wp-as-formula.md (merged, #613).

What this PR is scoped to

Schema + grammar + evaluator + the walk/compose dedup refactor it enables. Not in scope (later PRs): migrating the ~45 concept hub ops to carry wp_rule (PR 2), the c11 + rust lifters (PR 3), the other 8 lifters (PR 4, one PR with per-lifter receipts), flipping the morphism discharge to a real check (PR 5), moving the desugaring wp-preservation obligations onto the evaluator (PR 6). The hub-op / lifter wp_rules are not touched here; the wp_rule field is supported and exercised by local test fixtures only.

The wp_rule field (spec §1)

wp_rule is a term in the existing IR formula language, a sibling of pre/post on an operation contract, parametric in the reserved postcondition meta-var Q and per-Stmt-slot transformer meta-vars wp_<slot>. It is synthesized for value-ops from pre/post (wp(op, Q) = pre ∧ Q[result := value_expr], with value_expr read off the post's result == value_expr shape), so it is REQUIRED only on the ~10–20 control-flow ops per language; the old prose wp becomes an optional non-load-bearing wp_note. The operation-contract CDDL with these fields is already in the merged spec (§1.4); the on-wire shape (the post block of an algorithm spec, copied verbatim by provekit-mint-amp) flows wp_rule through unchanged, and the JCS canonicalizer handles the new optional field byte-deterministically — nothing in the mint path or the canonicalizer needed editing for that. Test fixtures in wp::tests carry authored wp_rules for the control-flow ops (if = the Dijkstra rule, seq = wp_first(wp_second(Q)), return = Q[result := value], skip = Q); value-ops (add, div, uop_neg, bop_eq) carry only pre/post and exercise the synthesis path.

The substitute / apply grammar nodes (spec §2.3)

Two new IrFormula nodes — the smallest addition that lets a wp_rule be written in the formula language the rest of the substrate already speaks:

  • substitute { target, var, term } — explicit, capture-avoiding, single-variable substitution on a formula: Q[var := term]. Needed as a node because in a wp_rule schema the target is the postcondition meta-var, not yet known; once target is ground the node is eliminated by performing the substitution. CDDL locked key order: kind, target, term, var.
  • apply { fn, args } — application of a slot-transformer meta-var (wp_<slot>) to one formula argument: apply(wp_<slot>, X) is "the weakest precondition of the term in slot <slot> w.r.t. X." Eliminated by inlining the actual slot transformer. CDDL locked key order: args, fn, kind.

Both are conservative extensions (an existing IrFormula is still an IrFormula) and reduce to the existing grammar when fully instantiated. The postcondition meta-var Q is the nullary atomic predicate Atomic { name: "Q", args: [] } (the IR formula grammar has no var node; a propositional variable is a nullary atomic — confirmed against how provekit-walk lifts branch conditions). Added to protocol/provekit-ir.cddl, to provekit-ir-types (manual codegen extension, like the Sort grow; CDDL stays source of truth), and swept across every now-non-exhaustive match on IrFormula in the workspace (the coq + smt-lib + lean compilers, provekit-ir-symbolic's conversion, the smt-lib validator, the aarch64 lifter) with a uniform refusal arm — these nodes never reach those sites once libprovekit::wp has run, so reaching the arm is a bug. Plus provekit-ir-codegen/tests/cddl_valid.rs — the protocol grammar must parse + validate under the cddl crate (guards the grammar file when new node kinds are added).

libprovekit::wp — the evaluator (spec §2)

New module implementations/rust/libprovekit/src/wp.rs (+ wp/tests.rs), three things:

  1. The capture-avoiding substitution algebra over IrFormula / IrTerm, plus free-variable computation. Previously duplicated between provekit-walk's wp.rs and libprovekit::compose (the latter's source comments call it out: "duplicated from walk's wp.rs"). Now a single canonical home here (CCP §5: one primitive, every consumer). compose.rs deletes its copy and re-exports from wp; provekit-walk/src/wp.rs does the same. No observable behavior change to walk or compose (their result == value_expr extraction, their byte-identical output, their tests are unaffected).
  2. The two schema-node reductionssubstitute with a ground target → perform the substitution; apply(wp_<slot>, X) → recurse into the slot's sub-term with X. After wp(t, Q) with a ground Q the result formula contains no Substitute/Apply nodes and no Q-placeholder — asserted in the tests via contains_schema_node.
  3. The evaluator wp(t, Q) — structural recursion over the term. For each op node: look up its contract; take its authored wp_rule or synthesize one for a value-op from pre/post; instantiate with this Q, the recursively-computed wps of the Stmt-typed sub-terms (for the wp_<slot> meta-vars), and the values of the value-typed sub-terms (for the formals). Handles: seq(s1,s2)wp_s1(wp_s2(Q)); if-style → the Dijkstra rule (c ⇒ wp_s1(Q)) ∧ (¬c ⇒ wp_s2(Q)); while/loops → Refusal::OpaqueLoop { loop_cid } naming the missing LoopInvariantMemento (until the resolver supplies an instantiated rule); unresolved/indirect calls and unknown ops → Refusal::OpaqueCall { callee }. The evaluator is total (always a formula or a named Refusal/WpError — never a panic), deterministic (recursion order fixed by slot order; the computed wp is canonicalized + content-addressed downstream, so nondeterminism would be a correctness bug), and makes no solver call (pure formula construction; z3 comes in at the discharge step of spec §3, later).

OpContractInfo + OpContractResolver are the consumed-contract view + lookup trait; the catalog-backed resolver lands with the hub-op migration (PR 2).

Module structure

  • wp.rs: reserved-name constants + postcondition_placeholder(); Refusal (OpaqueLoop / OpaqueCall); SlotKind / SlotInfo / OpContractInfo (with value_expr(), synthesize_value_rule(), rule()) / OpContractResolver; WpError (ArityMismatch / NoRule / MalformedRule / Refused); wp + wp_with_result_var (the public entry) → wp_opvalue_expr_of_term; reduce_formula (the schema-node eliminator); find_result_equation; the substitution algebra + free-vars (the canonical home).
  • wp/tests.rs: 17 tests — synthesized wp for add (pre ∧ Q[result := lhs+rhs]) and div (not_zero(rhs) ∧ Q[result := lhs/rhs]), incl. a Q that mentions result; the authored Dijkstra-if rule instantiated on if(bop_eq(x,0), return(uop_neg(22)), return(x)); the evaluator on seq(if(c, return(x), return(y)), skip); the opaque-loop / unresolved-call / unknown-op / malformed-rule / arity-mismatch paths; substitute / apply serde round-trip + pinned BLAKE3-512 CIDs (byte-determinism — Supra omnia, rectum).

Verification

  • cargo test -p libprovekit (incl. wp + compose): all pass (17 in wp::tests).
  • cargo test -p provekit-ir-types -p provekit-canonicalizer: all pass.
  • cargo test -p provekit-ir-compiler-{smt-lib,coq,lean} -p provekit-ir-symbolic -p provekit-lift-asm-aarch64: all pass (the swept crates).
  • cargo test -p provekit-ir-codegen --test cddl_valid: passprotocol/provekit-ir.cddl parses + validates under the cddl crate.
  • cargo build --manifest-path implementations/rust/Cargo.toml: clean, no warnings in libprovekit.
  • git diff --check: clean.
  • Full-workspace cargo test --workspace --no-fail-fast has some red — provekit-bug-zoo/provekit-supply-chain-rails smoke, provekit-cli mint_kit_integration CID pins, provekit-linker --doc ("can't find crate for provekit_verifier"). These are pre-existing: verified by running rust_kit_contract_set_cid_is_pinned_to_self_contracts_canonical on a pristine origin/main worktree — it fails identically there. Not caused by this PR.

Note on provekit-walk

The Rust provekit-walk crate is not currently a workspace member — it was dropped from implementations/rust/Cargo.toml's members at commit 5ec16225 (PR #387, a baselines chore), and its version.workspace = true makes it unbuildable standalone; nothing in CI builds it. Its src/wp.rs is updated here for the refactor (it re-exports the moved substitution functions from libprovekit::wp; src/contract.rs keeps importing libprovekit::compose::substitute_in_formula, which still resolves via the new re-export) — the change lands ready for the moment walk re-joins the workspace. The task's cargo test -p provekit-walk verify line therefore can't run today regardless of this PR.

Next PRs (~6 total)

  1. migrate the ~45 concept hub ops to carry wp_rule (+ rename wpwp_note); 3. the c11 + rust lifters; 4. the other 8 lifters (one PR, per-lifter receipts); 5. flip the morphism discharge from the two structural relaxations to a real ∀Q. wp(concept:op,Q) ⇒ φ(wp(lang:op,Q)) check; 6. move the desugaring wp-preservation obligations onto the evaluator (wp(lhs,Q) ⇔ wp(rhs,Q) as a portfolio bi-implication).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added weakest-precondition (WP) evaluation engine with support for term-to-formula transformation and contract resolution.
    • Extended IR formula grammar to include schema nodes for capture-avoiding substitution and function application.
  • Bug Fixes

    • Added validation to ensure WP schema nodes are properly reduced before backend compilation.
  • Tests

    • Added comprehensive test suite for WP evaluation and CDDL protocol validation.

Review Change Stack

TSavo and others added 2 commits May 11, 2026 18:32
…sites

Per spec protocol/specs/2026-05-13-wp-as-formula.md §2.3: the wp-rule
schema needs two new IrFormula nodes the language cannot express today:

  - `substitute` — explicit, capture-avoiding, single-variable
    substitution on a formula (`Q[var := term]`), needed as a node
    because in a `wp_rule` schema the target is the postcondition
    meta-variable `Q`, not yet known.
  - `apply` — application of a slot-transformer meta-variable
    (`wp_<slot>`) to one formula argument, the "weakest precondition of
    the term in slot `<slot>`."

Both are conservative extensions (an existing IrFormula is still an
IrFormula) and reduce to the existing grammar when fully instantiated;
`libprovekit::wp` (next commit) eliminates them before any formula
reaches a solver or compiler backend.

  - protocol/provekit-ir.cddl: add SubstituteFormula / ApplyFormula to
    the IrFormula union with locked key orders.
  - provekit-ir-types: add the two enum variants (manual codegen
    extension, like the Sort grow; CDDL stays source of truth).
  - sweep every now-non-exhaustive `match` on IrFormula across the
    workspace (coq + smt-lib + lean compilers, ir-symbolic conversion,
    smt-lib validator, aarch64 lifter) with a uniform refusal arm:
    these nodes never reach those sites once `libprovekit::wp` has run,
    so reaching the arm is a bug. (The substitution / free-var sites in
    libprovekit::compose and provekit-walk are intentionally left
    non-exhaustive here; the next commit folds them into the shared
    libprovekit::wp module.)

PR 1 of the wp-as-formula sub-project, step 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ose wp duplication

Per spec protocol/specs/2026-05-13-wp-as-formula.md §2: the
weakest-precondition rule of an operation is now consumed as *data* (the
`wp_rule` field, an IrFormula over the op's formals plus the reserved
meta-vars `Q` and `wp_<slot>`) rather than as hardcoded per-language
match-arms. This commit lands the evaluator that consumes it, plus the
dedup refactor it enables.

`implementations/rust/libprovekit/src/wp.rs` — a new module with three
things:

  1. The capture-avoiding substitution algebra over IrFormula / IrTerm,
     plus free-variable computation. This was previously *duplicated*
     between `provekit-walk`'s `wp.rs` and `libprovekit::compose`; it now
     has a single canonical home here (CCP §5: one primitive, every
     consumer). `compose.rs` deletes its copy and re-exports from
     `wp`; `provekit-walk/src/wp.rs` does the same. No observable
     behavior change to either (their `result == value_expr` extraction,
     their byte-identical output, etc. are unaffected).

  2. The two wp-rule schema-node reductions (spec §2.3):
       - `substitute { target, var, term }` with a ground `target` is
         eliminated by performing the capture-avoiding substitution;
       - `apply { fn: "wp_<slot>", args: [X] }` is eliminated by inlining
         the actual slot transformer — recursing into the slot's
         sub-term with `X` as the postcondition.
     The postcondition meta-var `Q` is the nullary atomic predicate
     `Atomic { name: "Q", args: [] }` (the IR formula grammar has no
     `var` node; a propositional variable is a nullary atomic). After
     `wp(t, Q)` with a ground `Q` the result formula contains no
     `Substitute` / `Apply` nodes and no placeholder — verified by a
     `contains_schema_node` assertion in the tests.

  3. The evaluator `wp(t, Q)` — structural recursion over the term `t`.
     For each op node: look up its contract; take its authored `wp_rule`
     or *synthesize* one for a value-op from `pre`/`post`
     (`pre ∧ Q[result := value_expr]`, with `value_expr` read off the
     `post`'s `result == value_expr` shape); instantiate that rule with
     this `Q`, the recursively-computed `wp`s of `t`'s `Stmt`-typed
     sub-terms (substituted for the `wp_<slot>` meta-vars), and the
     values of `t`'s value-typed sub-terms (substituted for the
     formals). Handles: `seq(s1,s2)` → `wp_s1(wp_s2(Q))`; `if`-style →
     the Dijkstra rule `(c ⇒ wp_s1(Q)) ∧ (¬c ⇒ wp_s2(Q))`; `while`/loops
     → `Refusal::OpaqueLoop { loop_cid }` naming the missing
     `LoopInvariantMemento` (until the resolver supplies an instantiated
     rule); unresolved/indirect calls and unknown ops →
     `Refusal::OpaqueCall { callee }`. The evaluator is total (always a
     formula or a named `Refusal`/`WpError`, never a panic),
     deterministic (recursion order fixed by slot order; no
     nondeterminism — the computed `wp` is canonicalized + content-
     addressed downstream), and makes no solver call (pure formula
     construction; z3 comes later, at the discharge step of spec §3).

`OpContractInfo` + `OpContractResolver` are the consumed-contract view +
lookup trait; the catalog-backed resolver lands with the hub-op
migration (PR2). The test fixtures here carry authored `wp_rule`s for the
control-flow ops (`if` = the Dijkstra rule, `seq` = `wp_first(wp_second(Q))`,
`return` = `Q[result := value]`, `skip` = `Q`) so the evaluator has real
rules to chew on; value-ops (`add`, `div`, `uop_neg`, `bop_eq`) carry
only `pre`/`post` and exercise the synthesis path.

17 tests in `wp::tests`, including: synthesized `wp` for `add`
(`pre ∧ Q[result := lhs+rhs]`) and `div` (`not_zero(rhs) ∧ Q[result := lhs/rhs]`),
including a Q that mentions `result`; the authored Dijkstra-`if` rule
instantiated on `if(bop_eq(x,0), return(uop_neg(22)), return(x))`; the
evaluator on `seq(if(c, return(x), return(y)), skip)`; the
opaque-loop / unresolved-call / unknown-op / malformed-rule / arity-mismatch
paths; `substitute` / `apply` serde round-trip + pinned BLAKE3-512 CIDs
(byte-determinism — *Supra omnia, rectum*).

Plus: `provekit-ir-codegen/tests/cddl_valid.rs` — the protocol grammar
must parse and validate under the `cddl` crate (guards the grammar file
when new node kinds like `substitute` / `apply` are added).

Note on `provekit-walk`: the Rust `provekit-walk` crate is not currently
a workspace member (it was dropped from `members` at commit 5ec1622,
PR #387, in a baselines chore — nothing in CI builds it, and `version.workspace`
makes it unbuildable standalone). Its `src/wp.rs` is updated for the
refactor (re-exports the moved substitution functions from
`libprovekit::wp`); the change lands ready for the moment walk re-joins
the workspace. `walk/src/contract.rs` keeps importing
`libprovekit::compose::substitute_in_formula`, which still resolves via
the new re-export.

PR 1 of the wp-as-formula sub-project, step 2 (schema + grammar + evaluator
+ walk/compose dedup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 12, 2026 01:34
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

Walkthrough

This PR introduces a weakest-precondition evaluator as a new libprovekit::wp module, extends the IR with schema-reduction formula nodes (Substitute, Apply), centralizes substitution and free-variable computation, and adds validation guards in all downstream compilers to ensure schema nodes are reduced before reaching backend passes.

Changes

Weakest-Precondition Evaluator & Substitution Centralization

Layer / File(s) Summary
IR Type Extensions for WP Schema Nodes
protocol/provekit-ir.cddl, implementations/rust/provekit-ir-types/src/lib.rs, implementations/rust/provekit-ir-codegen/tests/cddl_valid.rs
Extends IrFormula grammar to include substitute and apply variants with locked-key CDDL definitions, adds corresponding enum variants with serde rename attributes to the Rust type system, and validates CDDL parsing under strict mode.
WP Module Setup, Constants, and Contract Types
implementations/rust/libprovekit/src/lib.rs, implementations/rust/libprovekit/src/wp.rs (lines 1–317)
Declares the public wp module, establishes reserved meta-variables (Q postcondition, wp_ slot-transformers), defines Refusal error type for opaque loops/calls, introduces contract view structures (SlotKind, SlotInfo, OpContractInfo), and defines the OpContractResolver trait for operation contract lookup.
WP Evaluator: Entry Points, Schema Reduction, and Value Extraction
implementations/rust/libprovekit/src/wp.rs (lines 323–695)
Implements the main entry points (wp, wp_with_result_var), defines WpError for both refusals and malformed contracts, performs structural recursion over term variants, handles op-case resolution and rule instantiation, executes schema-node reduction (Substitute/Apply), and provides value-expression extraction helpers.
WP Substitution Algebra and Free-Variable Computation
implementations/rust/libprovekit/src/wp.rs (lines 704–1088)
Implements capture-avoiding substitution over IrFormula and IrTerm with alpha-renaming for formula/term binders to prevent variable capture, free-variable computation for both terms and formulas, and an internal fresh-name generator for safe renaming.
WP Module Test Suite
implementations/rust/libprovekit/src/wp/tests.rs
Comprehensive tests for the evaluator including op contract resolution, synthesized value-op WP rules (add, div), authored control-flow rules (Dijkstra if), program composition, refusal/error handling for opaque loops and unresolved calls, and serde round-trip determinism checks for schema nodes using BLAKE3-512 CIDs.
Deduplication in Compose Module
implementations/rust/libprovekit/src/compose.rs
Removes 302 lines of local substitution/free-variable implementation and HashSet usage, replacing them with pub use re-exports from libprovekit::wp to unify the algebra and eliminate duplication.
Deduplication in Walk Module
implementations/rust/provekit-walk/src/wp.rs
Removes 584 lines of local capture-avoiding substitution and free-variable implementation, adds pub use re-exports from libprovekit::wp, and trims test coverage to retain only basic substitution validation.
Downstream Validation: Backend Guards for Unreduced Schema Nodes
implementations/rust/provekit-ir-compiler-coq/src/generated.rs, implementations/rust/provekit-ir-compiler-lean/src/lib.rs, implementations/rust/provekit-ir-compiler-smt-lib/src/..., implementations/rust/provekit-ir-symbolic/src/convert.rs, implementations/rust/provekit-lift-asm-aarch64/src/lib.rs
Adds explicit matching for Substitute and Apply formula nodes in all backend passes (Coq, Lean, SMT-LIB emitters/validators, IR-symbolic converter, AArch64 lifter) to panic or return errors if these nodes reach backends unchecked, enforcing prior reduction via libprovekit::wp.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant wp
  participant OpContractResolver
  participant reduce_formula
  participant Backend

  Caller->>wp: wp(term, postcondition, resolver)
  wp->>wp: structural recursion on term
  wp->>OpContractResolver: lookup(op_name)
  OpContractResolver-->>wp: OpContractInfo with rule
  wp->>wp: wp_op: instantiate rule with value-slot subst
  wp->>reduce_formula: eliminate Substitute/Apply nodes
  reduce_formula->>reduce_formula: substitute_in_formula, apply inline
  reduce_formula-->>wp: normal IrFormula (no schema nodes)
  wp-->>Caller: Result<IrFormula, WpError>
  Caller->>Backend: emit(formula)
  Backend->>Backend: match formula nodes
  Backend->>Backend: no Substitute/Apply arms match
  Backend-->>Caller: valid output
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • TSavo/provekit#514: Both centralizes capture-avoiding substitution and free-variable implementations into libprovekit::wp and replaces local defs with pub re-exports to unify the algebra across modules.
  • TSavo/provekit#496: Directly matches the substitution/free-var centralization strategy, moving canonical implementations into libprovekit::wp and switching other modules to re-export the unified functions.
  • TSavo/provekit#370: Addresses the same deduplication goal by removing local substitution/free-variable implementations in favor of pub use re-exports from the libprovekit::wp module API.

🐰 Weakest wishes and strongest bounds,
We evaluate before the solver sounds,
Schema nodes reduce to formulas clean,
No substitution duplicates between!
Capture-safe, deterministic, and free—
A single algebra for all to see! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main changes: adding wp_rule schema support, substitute/apply formula nodes, and the wp evaluator implementation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wp-as-formula-1-schema-evaluator

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
implementations/rust/provekit-ir-types/src/lib.rs (1)

279-354: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Manual edits in generated file create maintenance hazard.

The IrFormula enum has been manually extended with Substitute and Apply variants because the codegen (provekit-ir-codegen) does not yet emit them, despite the CDDL defining them. The comment warns that regenerating will clobber these manual additions, requiring developers to remember to re-apply them.

This is a high-effort, high-risk maintenance pattern. Consider:

  1. Short-term: Add a workspace-level integration test that fails if the manual variants are missing (e.g., attempt to deserialize a {"kind":"substitute",...} payload and assert it succeeds).
  2. Medium-term: Update provekit-ir-codegen to emit all CDDL union variants, eliminating the need for manual edits.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@implementations/rust/provekit-ir-types/src/lib.rs` around lines 279 - 354,
The generated IrFormula enum is missing the Substitute and Apply variants
(manual additions to IrFormula::Substitute and IrFormula::Apply are being
clobbered on regen), so add a failing integration test that deserializes
{"kind":"substitute",...} and {"kind":"apply",...} into IrFormula (use
serde_json::from_str and assert_ok/assert_eq) to detect regressions, and then
update the codegen (provekit-ir-codegen) to emit all CDDL-defined union arms
(ensure it generates the Substitute and Apply variants with the same field names
and serde attributes as the current manual definitions) so future regenerations
do not require manual edits.
implementations/rust/provekit-ir-compiler-smt-lib/src/generated.rs (1)

14-83: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical: Duplicate match arms in emit_term cause dead code.

The emit_term function contains duplicate match arms for Term::Ctor, Term::Lambda, and Term::Let (lines 29-37 duplicate lines 56-64, lines 38-47 duplicate lines 65-73, and lines 48-55 duplicate lines 74-81). The first set of arms will always match, making the second set unreachable dead code.

🐛 Proposed fix
 pub fn emit_term(term: &Term) -> String {
     match term {
         Term::Var { name, .. } => name.clone(),
         Term::Const { value, sort, .. } => {
             let sort_name = match sort {
                 Sort::Primitive { name } => name.as_str(),
                 Sort::Function { .. }
                 | Sort::Dependent { .. }
                 | Sort::Float { .. }
                 | Sort::Region { .. } => {
                     panic!("smt-lib: Const cannot carry a Function/Dependent/Float/Region sort in pure SMT-LIB v2.6");
                 }
             };
             emit_const_value(value, sort_name)
         }
         Term::Ctor { name, args, .. } => {
             if args.is_empty() {
                 return name.clone();
             };
             let args_str = args.iter();
             let args_str = args_str.map(emit_term);
             let args_str: Vec<String> = args_str.collect();
             format!("({} {})", name, args_str.join(" "))
         }
         Term::Lambda {
             param_name,
             param_sort,
             body,
             ..
         } => {
             let sort_str = emit_sort(param_sort);
             let body_str = emit_term(body);
             format!("(lambda (({} {})) {})", param_name, sort_str, body_str)
         }
         Term::Let { bindings, body, .. } => {
             let mut binding_strs = bindings.iter();
             let binding_strs =
                 binding_strs.map(|b| format!("({} {})", b.name, emit_term(&b.bound_term)));
             let binding_strs: Vec<String> = binding_strs.collect();
             let body_str = emit_term(body);
             format!("(let ({}) {})", binding_strs.join(" "), body_str)
         }
-        Term::Ctor { name, args } => {
-            if args.is_empty() {
-                return name.clone();
-            };
-            let args_str = args.iter();
-            let args_str = args_str.map(emit_term);
-            let args_str: Vec<String> = args_str.collect();
-            format!("({} {})", name, args_str.join(" "))
-        }
-        Term::Lambda {
-            param_name,
-            param_sort,
-            body,
-        } => {
-            let sort_str = emit_sort(param_sort);
-            let body_str = emit_term(body);
-            format!("(lambda (({} {})) {})", param_name, sort_str, body_str)
-        }
-        Term::Let { bindings, body } => {
-            let binding_strs = bindings.iter();
-            let binding_strs =
-                binding_strs.map(|b| format!("({} {})", b.name, emit_term(&b.bound_term)));
-            let binding_strs: Vec<String> = binding_strs.collect();
-            let body_str = emit_term(body);
-            format!("(let ({}) {})", binding_strs.join(" "), body_str)
-        }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@implementations/rust/provekit-ir-compiler-smt-lib/src/generated.rs` around
lines 14 - 83, The emit_term function has duplicate match arms for Term::Ctor,
Term::Lambda, and Term::Let making the later arms unreachable; remove the
redundant duplicate arms so each variant is handled only once (keep one
consistent implementation for Term::Ctor, Term::Lambda, and Term::Let), ensure
the kept arms cover the correct pattern shapes (use the existing field names
like name/args, param_name/param_sort/body, and bindings/body or use .../..
consistently) and consolidate any differing logic so emit_term produces the same
output as before without duplicate match branches.
🧹 Nitpick comments (3)
implementations/rust/provekit-ir-codegen/tests/cddl_valid.rs (1)

12-14: ⚡ Quick win

Use CARGO_MANIFEST_DIR for robust test path resolution.

The test currently depends on process CWD. Building the path from env!("CARGO_MANIFEST_DIR") avoids flaky failures across different runners.

Suggested patch
-    let path = "../../../protocol/provekit-ir.cddl";
-    let text = std::fs::read_to_string(path)
-        .unwrap_or_else(|e| panic!("read {path}: {e}"));
+    let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+        .join("../../../protocol/provekit-ir.cddl");
+    let text = std::fs::read_to_string(&path)
+        .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@implementations/rust/provekit-ir-codegen/tests/cddl_valid.rs` around lines 12
- 14, The test builds a relative path that depends on the process CWD (variable
path) and uses std::fs::read_to_string to load the file; change it to construct
the path using env!("CARGO_MANIFEST_DIR") (e.g., format! or Path/PathBuf joining
with env!("CARGO_MANIFEST_DIR")) so the test locates "protocol/provekit-ir.cddl"
reliably, then call std::fs::read_to_string on that constructed path and keep
the existing unwrap_or_else panic behavior for errors.
implementations/rust/libprovekit/src/wp.rs (2)

1071-1085: 💤 Low value

Theoretical u32 overflow in fresh_name function.

The fresh_name function uses an unbounded loop with a u32 counter (line 1077). If all ~4 billion {base}_1 through {base}_4294967295 variants are taken, the counter will overflow and wrap, causing an infinite loop.

While extremely unlikely in practice (requires 4 billion identically-named variables in scope), consider using u64 or saturating at a reasonable limit with a panic to surface the pathological case.

♻️ Optional hardening
 fn fresh_name(taken: &HashSet<String>, base: &str) -> String {
     if !taken.contains(base) {
         return base.to_string();
     }
-    let mut n: u32 = 1;
+    let mut n: u64 = 1;
     loop {
         let candidate = format!("{}_{}", base, n);
         if !taken.contains(&candidate) {
             return candidate;
         }
         n += 1;
+        if n > 1_000_000 {
+            panic!("fresh_name: failed to find fresh name after 1M attempts for base '{}'", base);
+        }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@implementations/rust/libprovekit/src/wp.rs` around lines 1071 - 1085, The
fresh_name function uses a u32 counter `n` in the loop which can overflow and
wrap if ~4 billion name variants are present; change `n` to a wider integer type
(e.g., u64) or implement a safe bound check that panics or returns an error if
`n` would overflow to prevent an infinite loop—update the `fresh_name` signature
body where `let mut n: u32 = 1;` and the loop generating `candidate` to use the
chosen strategy (e.g., `u64` counter or explicit overflow check) so lookup
against `taken: &HashSet<String>` and the `base: &str` formatting remain correct
and safe.

627-645: ⚡ Quick win

Consider validating Apply arity per CDDL specification.

The CDDL defines ApplyFormula { args: [+ IrFormula], ... } (one or more formula arguments), but line 641 handles empty args by falling back to q.clone(). While this is defensive, it silently accepts malformed rules that violate the CDDL spec.

Consider adding validation:

♻️ Proposed validation
         IrFormula::Apply { args, r#fn } => {
             if !is_slot_transformer_name(&r#fn) {
                 return Err(WpError::MalformedRule {
                     op: op_name.to_string(),
                     fn_name: r#fn,
                 });
             }
+            if args.is_empty() {
+                return Err(WpError::MalformedRule {
+                    op: op_name.to_string(),
+                    fn_name: format!("{} (empty args)", r#fn),
+                });
+            }
             let slot_name = &r#fn[SLOT_TRANSFORMER_PREFIX.len()..];
             let Some((_, slot_term)) = stmt_transformers.iter().find(|(n, _)| n == slot_name) else {
                 return Err(WpError::MalformedRule {
                     op: op_name.to_string(),
                     fn_name: r#fn,
                 });
             };
-            let arg = match args.into_iter().next() {
-                Some(a) => reduce_formula(a, q, stmt_transformers, op_name, resolver)?,
-                None => q.clone(),
-            };
+            let arg = reduce_formula(args.into_iter().next().unwrap(), q, stmt_transformers, op_name, resolver)?;
             wp(slot_term, &arg, resolver)
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@implementations/rust/libprovekit/src/wp.rs` around lines 627 - 645, The Apply
arm currently accepts empty args and substitutes q.clone(), which silently
permits malformed CDDL ApplyFormula; update IrFormula::Apply handling to
validate arity: ensure args is non-empty (per CDDL ApplyFormula { args: [+
IrFormula] }) before proceeding, and return Err(WpError::MalformedRule { op:
op_name.to_string(), fn_name: r#fn.clone() }) (or an appropriate WpError) when
args is empty; locate the match in wp.rs where IrFormula::Apply uses
is_slot_transformer_name, SLOT_TRANSFORMER_PREFIX, stmt_transformers,
reduce_formula and wp, and replace the fallback q.clone() behavior with the
explicit validation and error path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@implementations/rust/provekit-ir-compiler-smt-lib/src/generated.rs`:
- Around line 727-731: The handling of schema nodes in has_outlives_predicate is
inconsistent: change the Formula::Substitute and Formula::Apply arms in
has_outlives_predicate (currently traversing into target/args) to match other
functions by making them unreachable (e.g., replace with unreachable!()) if
schema nodes must be reduced before SMT-LIB, or alternatively keep traversal but
add a clear comment above has_outlives_predicate explaining why this pass must
accept unreduced schema nodes and document the expected temporary validation
state; reference the Formula::Substitute and Formula::Apply arms and the
has_outlives_predicate function when making the change.

---

Outside diff comments:
In `@implementations/rust/provekit-ir-compiler-smt-lib/src/generated.rs`:
- Around line 14-83: The emit_term function has duplicate match arms for
Term::Ctor, Term::Lambda, and Term::Let making the later arms unreachable;
remove the redundant duplicate arms so each variant is handled only once (keep
one consistent implementation for Term::Ctor, Term::Lambda, and Term::Let),
ensure the kept arms cover the correct pattern shapes (use the existing field
names like name/args, param_name/param_sort/body, and bindings/body or use
.../.. consistently) and consolidate any differing logic so emit_term produces
the same output as before without duplicate match branches.

In `@implementations/rust/provekit-ir-types/src/lib.rs`:
- Around line 279-354: The generated IrFormula enum is missing the Substitute
and Apply variants (manual additions to IrFormula::Substitute and
IrFormula::Apply are being clobbered on regen), so add a failing integration
test that deserializes {"kind":"substitute",...} and {"kind":"apply",...} into
IrFormula (use serde_json::from_str and assert_ok/assert_eq) to detect
regressions, and then update the codegen (provekit-ir-codegen) to emit all
CDDL-defined union arms (ensure it generates the Substitute and Apply variants
with the same field names and serde attributes as the current manual
definitions) so future regenerations do not require manual edits.

---

Nitpick comments:
In `@implementations/rust/libprovekit/src/wp.rs`:
- Around line 1071-1085: The fresh_name function uses a u32 counter `n` in the
loop which can overflow and wrap if ~4 billion name variants are present; change
`n` to a wider integer type (e.g., u64) or implement a safe bound check that
panics or returns an error if `n` would overflow to prevent an infinite
loop—update the `fresh_name` signature body where `let mut n: u32 = 1;` and the
loop generating `candidate` to use the chosen strategy (e.g., `u64` counter or
explicit overflow check) so lookup against `taken: &HashSet<String>` and the
`base: &str` formatting remain correct and safe.
- Around line 627-645: The Apply arm currently accepts empty args and
substitutes q.clone(), which silently permits malformed CDDL ApplyFormula;
update IrFormula::Apply handling to validate arity: ensure args is non-empty
(per CDDL ApplyFormula { args: [+ IrFormula] }) before proceeding, and return
Err(WpError::MalformedRule { op: op_name.to_string(), fn_name: r#fn.clone() })
(or an appropriate WpError) when args is empty; locate the match in wp.rs where
IrFormula::Apply uses is_slot_transformer_name, SLOT_TRANSFORMER_PREFIX,
stmt_transformers, reduce_formula and wp, and replace the fallback q.clone()
behavior with the explicit validation and error path.

In `@implementations/rust/provekit-ir-codegen/tests/cddl_valid.rs`:
- Around line 12-14: The test builds a relative path that depends on the process
CWD (variable path) and uses std::fs::read_to_string to load the file; change it
to construct the path using env!("CARGO_MANIFEST_DIR") (e.g., format! or
Path/PathBuf joining with env!("CARGO_MANIFEST_DIR")) so the test locates
"protocol/provekit-ir.cddl" reliably, then call std::fs::read_to_string on that
constructed path and keep the existing unwrap_or_else panic behavior for errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ce31ddfe-a0e8-4c99-b610-2dbac0966f9e

📥 Commits

Reviewing files that changed from the base of the PR and between 3e7b021 and 3d1db09.

📒 Files selected for processing (14)
  • implementations/rust/libprovekit/src/compose.rs
  • implementations/rust/libprovekit/src/lib.rs
  • implementations/rust/libprovekit/src/wp.rs
  • implementations/rust/libprovekit/src/wp/tests.rs
  • implementations/rust/provekit-ir-codegen/tests/cddl_valid.rs
  • implementations/rust/provekit-ir-compiler-coq/src/generated.rs
  • implementations/rust/provekit-ir-compiler-lean/src/lib.rs
  • implementations/rust/provekit-ir-compiler-smt-lib/src/generated.rs
  • implementations/rust/provekit-ir-compiler-smt-lib/src/lib.rs
  • implementations/rust/provekit-ir-symbolic/src/convert.rs
  • implementations/rust/provekit-ir-types/src/lib.rs
  • implementations/rust/provekit-lift-asm-aarch64/src/lib.rs
  • implementations/rust/provekit-walk/src/wp.rs
  • protocol/provekit-ir.cddl

Comment on lines +727 to +731
// wp-rule schema nodes (spec 2026-05-13-wp-as-formula.md §2.3):
// see the note in `emit_formula`.
// TODO(wp-as-formula PR1+): teach provekit-ir-codegen to emit this arm.
Formula::Substitute { target, .. } => has_outlives_predicate(target),
Formula::Apply { args, .. } => args.iter().any(has_outlives_predicate),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Inconsistent schema-node handling in has_outlives_predicate.

While all other functions treat Formula::Substitute and Formula::Apply as unreachable (they must be reduced before reaching SMT-LIB), has_outlives_predicate traverses into them to check for Outlives predicates. This is inconsistent with the stated requirement that schema nodes "must be reduced via libprovekit::wp first."

If schema nodes truly cannot reach this backend, these arms should also unreachable!. If they can temporarily exist during validation, the comment should explain why this function has different semantics.

🔍 Recommended approach

If schema nodes genuinely cannot reach has_outlives_predicate, align with other functions:

-        Formula::Substitute { target, .. } => has_outlives_predicate(target),
-        Formula::Apply { args, .. } => args.iter().any(has_outlives_predicate),
+        Formula::Substitute { .. } | Formula::Apply { .. } => {
+            unreachable!(
+                "wp-rule schema node reached has_outlives_predicate; \
+                 must be reduced via libprovekit::wp first"
+            )
+        }

Otherwise, add a comment explaining why this predicate-detection pass must handle unreduced schema nodes.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// wp-rule schema nodes (spec 2026-05-13-wp-as-formula.md §2.3):
// see the note in `emit_formula`.
// TODO(wp-as-formula PR1+): teach provekit-ir-codegen to emit this arm.
Formula::Substitute { target, .. } => has_outlives_predicate(target),
Formula::Apply { args, .. } => args.iter().any(has_outlives_predicate),
// wp-rule schema nodes (spec 2026-05-13-wp-as-formula.md §2.3):
// see the note in `emit_formula`.
// TODO(wp-as-formula PR1+): teach provekit-ir-codegen to emit this arm.
Formula::Substitute { .. } | Formula::Apply { .. } => {
unreachable!(
"wp-rule schema node reached has_outlives_predicate; \
must be reduced via libprovekit::wp first"
)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@implementations/rust/provekit-ir-compiler-smt-lib/src/generated.rs` around
lines 727 - 731, The handling of schema nodes in has_outlives_predicate is
inconsistent: change the Formula::Substitute and Formula::Apply arms in
has_outlives_predicate (currently traversing into target/args) to match other
functions by making them unreachable (e.g., replace with unreachable!()) if
schema nodes must be reduced before SMT-LIB, or alternatively keep traversal but
add a clear comment above has_outlives_predicate explaining why this pass must
accept unreduced schema nodes and document the expected temporary validation
state; reference the Formula::Substitute and Formula::Apply arms and the
has_outlives_predicate function when making the change.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-phase support for “wp-as-formula” across the Provekit Rust workspace by extending the IR formula grammar with schema nodes and introducing a new libprovekit::wp evaluator + shared substitution algebra, while sweeping downstream backends to explicitly reject (or mark unreachable) unreduced schema nodes.

Changes:

  • Extend IR CDDL + Rust IrFormula with substitute and apply schema-node variants for wp_rule.
  • Introduce libprovekit::wp (evaluator + schema-node reduction + canonical capture-avoiding substitution/free-vars) and re-export substitution helpers from compose/provekit-walk.
  • Update compiler/lifter/symbolic backends to refuse or unreachable!() on schema nodes, plus add a CDDL-parse validation test.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
protocol/provekit-ir.cddl Extends IrFormula grammar with SubstituteFormula and ApplyFormula.
implementations/rust/provekit-walk/src/wp.rs Re-exports substitution/free-var helpers from libprovekit::wp; removes local duplicated implementation.
implementations/rust/provekit-lift-asm-aarch64/src/lib.rs Adds explicit handling for schema nodes (currently unreachable!()).
implementations/rust/provekit-ir-types/src/lib.rs Manually extends IrFormula enum with Substitute/Apply serde variants.
implementations/rust/provekit-ir-symbolic/src/convert.rs Marks schema nodes as unreachable in IR→symbolic conversion.
implementations/rust/provekit-ir-compiler-smt-lib/src/lib.rs SMT-LIB validator rejects schema nodes with an explicit error.
implementations/rust/provekit-ir-compiler-smt-lib/src/generated.rs Adds schema-node unreachable!() arms (+ limited traversal in has_outlives_predicate).
implementations/rust/provekit-ir-compiler-lean/src/lib.rs Lean collector/emitter returns internal error on schema nodes.
implementations/rust/provekit-ir-compiler-coq/src/generated.rs Marks schema nodes unreachable in Coq emitter/free-var collector.
implementations/rust/provekit-ir-codegen/tests/cddl_valid.rs New integration test ensuring protocol CDDL parses/validates.
implementations/rust/libprovekit/src/wp.rs New WP evaluator + schema-node reduction + shared substitution/free-var algebra.
implementations/rust/libprovekit/src/wp/tests.rs New test suite for synthesized/authored wp rules + schema-node CID pinning.
implementations/rust/libprovekit/src/lib.rs Exposes the new wp module.
implementations/rust/libprovekit/src/compose.rs Removes duplicated substitution algebra; re-exports from libprovekit::wp.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +641 to +644
let arg = match args.into_iter().next() {
Some(a) => reduce_formula(a, q, stmt_transformers, op_name, resolver)?,
None => q.clone(),
};
callee: callee.clone(),
}
.into());
}
Comment on lines +704 to +721
/// Substitute `replacement` for every free occurrence of
/// `Var { name == var_name }` in `formula`. Capture-avoiding: a binder
/// whose bound name appears free in `replacement` is alpha-renamed to a
/// fresh name before the substitution proceeds; a binder that rebinds
/// `var_name` shadows it (substitution stops at the binder).
///
/// Note on the schema nodes: `substitute` and `apply` are *not* binders
/// over the surrounding scope (`substitute`'s `var` is a substitution
/// operator's bound name; `apply`'s `fn` is a meta-var name), so
/// term-substitution passes straight through them — they keep their
/// shape and their sub-parts are substituted into. (`reduce_formula` is
/// what eliminates these nodes; this function only moves a *term* into a
/// formula's leaves.) The postcondition placeholder
/// `Atomic { name: "Q", args: [] }` has no term args and is therefore
/// unaffected by term-substitution, which is correct: a value-slot value
/// must not be substituted *into* the postcondition until the rule is
/// applied to a concrete `Q`.
pub fn substitute_in_formula(
Comment on lines +11 to +14
// Tests run with CWD = the crate dir (`implementations/rust/provekit-ir-codegen`).
let path = "../../../protocol/provekit-ir.cddl";
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("read {path}: {e}"));
Copy link
Copy Markdown
Owner Author

@TSavo TSavo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: MERGE-WITH-NITS

Adversarial review for Supra omnia, rectum. This is the foundation the rest of the wp-as-formula chain (PRs 2-6) builds on, so I went hard on totality, determinism, schema-node elimination, and whether the dedup is truly behavior-preserving. The evaluator is correct, the grammar extension is well-locked, the tests pin the wire encoding, and the swept backends fail loudly rather than silently. Nothing here blocks; the nits should land before PR2 if cheap.

What I verified

  • cargo test -p libprovekit — 17 wp::tests + compose + everything else: green.
  • cargo test -p provekit-ir-types -p provekit-canonicalizer -p provekit-ir-codegen -p provekit-ir-compiler-{smt-lib,coq,lean} -p provekit-ir-symbolic -p provekit-lift-asm-aarch64: all green, including the new cddl_valid.rs (the protocol grammar parses + validates under the cddl crate).
  • cargo build --workspace: clean (only pre-existing warnings in provekit-lsp / provekit-lift-openapi).
  • git diff --check: clean.
  • Pre-existing red spot-check on origin/main (pk-main-620 worktree, 3e7b021f): rust_kit_contract_set_cid_is_pinned_to_self_contracts_canonical, cpp_kit_contract_set_cid_is_pinned_to_self_contracts_canonical, and kits_with_real_contracts_produce_nonempty_contract_set fail identically there. Confirmed pre-existing, not introduced by this PR.
  • provekit-walk is genuinely not a workspace member (implementations/rust/Cargo.toml line-checked; libprovekit, no provekit-walk). The refactor in provekit-walk/src/wp.rs is dead-code-until-walk-rejoins — acceptable.
  • menagerie/cross-language-port/driver.sh: exits 0 on this branch; same artifact-drift git status as on pristine origin/main, so pre-existing menagerie behavior, not caused by this PR.
  • menagerie/concept-shapes/scripts/mint.sh: does not exist on this branch (only .py files). Not exercised; the diff touches no canonicalizer/mint/transport surface, so mint flow is unaffected by construction.

Correctness of the evaluator (the load-bearing piece)

  • Value-op synthesis pre ∧ Q[result := value_expr]: find_result_equation extracts value_expr from post = (result == value_expr) in either argument order, recursing into And. The Substitute node uses the placeholder as target and the per-contract result_var. A pre that is literally true is dropped (sound: true ∧ φ = φ). Correct WP for an assignment.
  • seqwp_first(wp_second(Q)): the authored rule is apply("wp_first", apply("wp_second", Q)). reduce_formula's Apply arm reduces the arg first (inner apply("wp_second",Q)wp(second_term,Q)) then recurses wp(first_term, that). Order correct.
  • Dijkstra if: authored, instantiated as (cond ⇒ wp_then(Q)) ∧ (¬cond ⇒ wp_else(Q)). The evaluator has no built-in opinion about if — pure data-driven, exactly what the spec calls for. No-else is the authoring lifter's responsibility (else_branch := skip).
  • Loops: Refusal::OpaqueLoop is checked before recursion into the loop body. The body is never recursed into. Loops cannot cause non-termination of the evaluator. Right call for PR1.
  • Unresolved / unknown ops: Refusal::OpaqueCall for both. Naming the callee. Correct.
  • Totality: every Term variant covered; every IrFormula variant covered in reduce_formula. The one .unwrap() (synthesize_value_rule line 292) is guarded by if conj.len() == 1. Recursion bottoms out on finite term trees; loops refuse; no cyclic-vocabulary risk. No panic path under normal data.
  • Determinism: HashSet (in free_vars_*) is used only for .contains and fresh_name's u32 counter walk — no iteration order leaks into output. reduce_each is .into_iter().map().collect(). Rule lookup by name. ✓
  • Schema-node elimination: reduce_formula recurses through every operand / body / target / args. Substitute reduces the target first, then substitute_in_formula (which preserves but does not eliminate nested Substitute/Apply) — but the target is already reduced, and the term side is an IrTerm (cannot contain formula schema nodes). Apply returns wp(slot_term, arg) which is itself fully reduced. The tests' contains_schema_node assertions are tight.

Grammar extension is correctly locked

  • IrFormula::Substitute { target, term, var } with JCS key order kind, target, term, var (CDDL SubstituteFormula); IrFormula::Apply { args, fn } with key order args, fn, kind (CDDL ApplyFormula). Verified against #[serde(rename = ...)] and the CDDL file. Pinned CIDs (PINNED_SUBSTITUTE_NODE_CID / PINNED_APPLY_NODE_CID) lock the wire encoding.
  • The Q placeholder = Atomic { name: "Q", args: [] } encoding is sound. Collision risk with a legitimately-named predicate Q is theoretical only — the convention is documented and the placeholder is reserved by name; a contract author choosing Q as an unrelated atomic name would be self-inflicting. Worth keeping an eye on once the hub ops migrate (PR2).
  • Every previously-exhaustive match on IrFormula got a new arm: coq, lean, smt-lib emitter/validator/opacity/free-vars/ctor-decls/outlives, ir-symbolic, aarch64. None silently miscompile; they all refuse loudly (unreachable! or Err).

Nits (none blocking)

  1. PR-body "byte-equivalent" claim is slightly overstated. In substitute_in_term's Let arm, the new libprovekit::wp version (a) checks b.name == var_name shadowing before the alpha-rename block, and (b) extends the taken set with prior bindings' (possibly-renamed) names. Both are strict improvements over the old compose.rs copy. Concretely: the old code, when a Let binder's name equals var_name and var_name is free in the replacement, would alpha-rename the shadowing binder away instead of stopping substitution. The new code shadows correctly. Under Supra omnia, rectum the new behavior is strictly more correct, but the PR body should say "no observable behavior change on any currently-exercised input; the dedup'd version closes a latent capture-edge in Let shadowing" rather than "byte-equivalent by construction."
  2. CDDL ApplyFormula.args: [+ IrFormula] (one-or-more) vs Rust Vec<IrFormula> (allows empty). Minor laxness; reduce_formula's Apply arm explicitly handles empty args (None => q.clone()). Either tighten the Rust side to a non-empty vec, or relax the CDDL to [* IrFormula]. Pick one when PR2 lands.
  3. Backend dispatch posture asymmetry. Coq / SMT-LIB-generated / ir-symbolic / aarch64 use unreachable!() (panic). Lean and the SMT-LIB validator return Err(CompileError::Internal(...)). Both are loud-not-silent, but the inconsistency deserves a unified posture: I'd lean toward Err everywhere (a compiler backend reached by a malformed input panicking-out is a worse posture than refusing-out, especially when this same plumbing eventually carries attacker-influenced lifter output). Follow-up, not a blocker.
  4. No automated guard that the manual IrFormula extension stays in sync with provekit-ir.cddl. The new cddl_valid.rs checks the grammar file parses; it does not check the Rust enum matches. The comment in provekit-ir-types/src/lib.rs warns that regenerating from codegen will clobber the manual arms. Inherent to the manual-extension pattern (already used for Sort). Worth a follow-up issue for a JSON-roundtrip test against a wire-shape fixture so a CDDL drift is caught.

Why this merges

The evaluator is total, deterministic, schema-node-clean, and the test suite pins both the externally-observable formula shapes (synthesized add / div, Dijkstra if, seq;if;skip body) and the wire encoding of the two new node kinds (BLAKE3-512 CIDs). The grammar extension is conservative — an existing IrFormula is still an IrFormula — and every backend got a refusal arm. provekit-walk is genuinely unbuildable today, so its refactor lands dead-code-ready, which is the right call rather than reverting and re-doing later.

The wp definition itself — pre ∧ Q[result := value_expr] for value-ops, Dijkstra for if, wp(s1, wp(s2, Q)) for seq, refuse for loops / unresolved calls — is textbook Dijkstra-Hoare WP. PR5's ∀Q. wp(concept:op, Q) ⇒ φ(wp(lang:op, Q)) discharge will sit on top of this and it'll hold.

Ship it. Land the nit on the PR-body language before PR2 ideally; the rest can ride along in follow-up issues.

— Opus 4.7 reviewing for Supra omnia, rectum

TSavo added a commit that referenced this pull request May 12, 2026
…os error 26) flake (#621)

Linux refuses exec on a file that still has an open writer fd anywhere on
the system.  The parallel cargo test runner would write a plugin .sh script
then immediately spawn provekit, which internally exec'd the script before
the kernel had fully closed the writer fd -- producing intermittent ETXTBSY
("Text file busy", os error 26) on ci (PR #620 run 25707800831).

Two-layer fix applied to all five write_executable+spawn sites:
1. write_executable now uses OpenOptions + write_all + sync_all + explicit
   drop, guaranteeing the fd is fully closed and durably flushed before
   set_permissions and before the caller spawns.
2. New output_retrying_etxtbsy helper wraps every provekit Command::output()
   call at a write_executable spawn site, retrying up to 5x with 20-100ms
   backoff on ETXTBSY stderr hits.  The retry is the durable safety net;
   sync_all+drop is the actual fix.

All 13 cli_surface tests pass locally (macOS).  The race is Linux-only;
the CI green on the new branch is the real signal.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@TSavo TSavo merged commit a63f0d2 into main May 12, 2026
28 of 29 checks passed
TSavo added a commit that referenced this pull request May 12, 2026
…al-wp-rule-abstraction discharge (wp-as-formula PR 3/N)

Adds machine-checkable wp_rule to c11 and rust control-flow op specs
(if, seq, skip, source-unit), per spec section 1.2: wp_rule is REQUIRED
on Stmt-sorted ops and OPTIONAL (synthesizable) on value-ops.

Key design decisions:
- wp_rule is a per-language annotation in language-sig specs; concept-shape
  hub ops do NOT yet carry it (deferred to #633 per spec s1.4).
  concept_spec_from_base strips wp_rule after deep-copy so hub op CIDs
  do not change when a language spec adds it (PR3+). Once #633 lands the
  strip should be replaced by an OPS-table-driven authoritative wp_rule.
- A new 4th structural discharge strategy "structural-wp-rule-abstraction"
  handles morphisms where the only CID difference is the presence of
  wp_rule in the language spec that the concept spec lacks.
- The 6 control-flow PRIMITIVE_STEMS (c11+rust if/seq/skip) are removed
  from the short-circuit cache because their source CIDs changed when
  wp_rule was added. Return and eq stems are unaffected and remain cached.

Morphism count: 143 (matches HEAD baseline, no regression).
Byte-stable: two consecutive mint runs produce zero file changes.
Tests: libprovekit transport (4/4), ci (7/7), ci_check (15/15),
transport_cli (8/8), lower_formula (2/2) all pass.

Depends on #620 (wp_rule schema + evaluator, PR 1/N).
Note: #633 (feat/wp-as-formula-2-hub-op-rules) will add wp_rule to
concept hub op specs and supersede the strip in concept_spec_from_base.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TSavo added a commit that referenced this pull request May 12, 2026
…al-wp-rule-abstraction discharge (wp-as-formula PR 3/N)

Adds machine-checkable wp_rule to c11 and rust control-flow op specs
(if, seq, skip, source-unit), per spec section 1.2: wp_rule is REQUIRED
on Stmt-sorted ops and OPTIONAL (synthesizable) on value-ops.

Key design decisions:
- wp_rule is a per-language annotation in language-sig specs; concept-shape
  hub ops do NOT yet carry it (deferred to #633 per spec s1.4).
  concept_spec_from_base strips wp_rule after deep-copy so hub op CIDs
  do not change when a language spec adds it (PR3+). Once #633 lands the
  strip should be replaced by an OPS-table-driven authoritative wp_rule.
- A new 4th structural discharge strategy "structural-wp-rule-abstraction"
  handles morphisms where the only CID difference is the presence of
  wp_rule in the language spec that the concept spec lacks.
- The 6 control-flow PRIMITIVE_STEMS (c11+rust if/seq/skip) are removed
  from the short-circuit cache because their source CIDs changed when
  wp_rule was added. Return and eq stems are unaffected and remain cached.

Morphism count: 143 (matches HEAD baseline, no regression).
Byte-stable: two consecutive mint runs produce zero file changes.
Tests: libprovekit transport (4/4), ci (7/7), ci_check (15/15),
transport_cli (8/8), lower_formula (2/2) all pass.

Depends on #620 (wp_rule schema + evaluator, PR 1/N).
Note: #633 (feat/wp-as-formula-2-hub-op-rules) will add wp_rule to
concept hub op specs and supersede the strip in concept_spec_from_base.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TSavo added a commit that referenced this pull request May 12, 2026
…al-wp-rule-abstraction discharge (wp-as-formula PR 3/N)

Adds machine-checkable wp_rule to c11 and rust control-flow op specs
(if, seq, skip, source-unit), per spec section 1.2: wp_rule is REQUIRED
on Stmt-sorted ops and OPTIONAL (synthesizable) on value-ops.

Key design decisions:
- wp_rule is a per-language annotation in language-sig specs; concept-shape
  hub ops do NOT yet carry it (deferred to #633 per spec s1.4).
  concept_spec_from_base strips wp_rule after deep-copy so hub op CIDs
  do not change when a language spec adds it (PR3+). Once #633 lands the
  strip should be replaced by an OPS-table-driven authoritative wp_rule.
- A new 4th structural discharge strategy "structural-wp-rule-abstraction"
  handles morphisms where the only CID difference is the presence of
  wp_rule in the language spec that the concept spec lacks.
- The 6 control-flow PRIMITIVE_STEMS (c11+rust if/seq/skip) are removed
  from the short-circuit cache because their source CIDs changed when
  wp_rule was added. Return and eq stems are unaffected and remain cached.

Morphism count: 143 (matches HEAD baseline, no regression).
Byte-stable: two consecutive mint runs produce zero file changes.
Tests: libprovekit transport (4/4), ci (7/7), ci_check (15/15),
transport_cli (8/8), lower_formula (2/2) all pass.

Depends on #620 (wp_rule schema + evaluator, PR 1/N).
Note: #633 (feat/wp-as-formula-2-hub-op-rules) will add wp_rule to
concept hub op specs and supersede the strip in concept_spec_from_base.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TSavo added a commit that referenced this pull request May 12, 2026
…al-wp-rule-abstraction discharge (wp-as-formula PR 3/N)

Adds machine-checkable wp_rule to c11 and rust control-flow op specs
(if, seq, skip, source-unit), per spec section 1.2: wp_rule is REQUIRED
on Stmt-sorted ops and OPTIONAL (synthesizable) on value-ops.

Key design decisions:
- wp_rule is a per-language annotation in language-sig specs; concept-shape
  hub ops do NOT yet carry it (deferred to #633 per spec s1.4).
  concept_spec_from_base strips wp_rule after deep-copy so hub op CIDs
  do not change when a language spec adds it (PR3+). Once #633 lands the
  strip should be replaced by an OPS-table-driven authoritative wp_rule.
- A new 4th structural discharge strategy "structural-wp-rule-abstraction"
  handles morphisms where the only CID difference is the presence of
  wp_rule in the language spec that the concept spec lacks.
- The 6 control-flow PRIMITIVE_STEMS (c11+rust if/seq/skip) are removed
  from the short-circuit cache because their source CIDs changed when
  wp_rule was added. Return and eq stems are unaffected and remain cached.

Morphism count: 143 (matches HEAD baseline, no regression).
Byte-stable: two consecutive mint runs produce zero file changes.
Tests: libprovekit transport (4/4), ci (7/7), ci_check (15/15),
transport_cli (8/8), lower_formula (2/2) all pass.

Depends on #620 (wp_rule schema + evaluator, PR 1/N).
Note: #633 (feat/wp-as-formula-2-hub-op-rules) will add wp_rule to
concept hub op specs and supersede the strip in concept_spec_from_base.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TSavo added a commit that referenced this pull request May 12, 2026
…al-wp-rule-abstraction discharge (wp-as-formula PR 3/N)

Adds machine-checkable wp_rule to c11 and rust control-flow op specs
(if, seq, skip, source-unit), per spec section 1.2: wp_rule is REQUIRED
on Stmt-sorted ops and OPTIONAL (synthesizable) on value-ops.

Key design decisions:
- wp_rule is a per-language annotation in language-sig specs; concept-shape
  hub ops do NOT yet carry it (deferred to #633 per spec s1.4).
  concept_spec_from_base strips wp_rule after deep-copy so hub op CIDs
  do not change when a language spec adds it (PR3+). Once #633 lands the
  strip should be replaced by an OPS-table-driven authoritative wp_rule.
- A new 4th structural discharge strategy "structural-wp-rule-abstraction"
  handles morphisms where the only CID difference is the presence of
  wp_rule in the language spec that the concept spec lacks.
- The 6 control-flow PRIMITIVE_STEMS (c11+rust if/seq/skip) are removed
  from the short-circuit cache because their source CIDs changed when
  wp_rule was added. Return and eq stems are unaffected and remain cached.

Morphism count: 143 (matches HEAD baseline, no regression).
Byte-stable: two consecutive mint runs produce zero file changes.
Tests: libprovekit transport (4/4), ci (7/7), ci_check (15/15),
transport_cli (8/8), lower_formula (2/2) all pass.

Depends on #620 (wp_rule schema + evaluator, PR 1/N).
Note: #633 (feat/wp-as-formula-2-hub-op-rules) will add wp_rule to
concept hub op specs and supersede the strip in concept_spec_from_base.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TSavo added a commit that referenced this pull request May 12, 2026
…a PR 3/N) (#663)

* feat(lift-{c11,rust}): emit wp_rule on control-flow ops; add structural-wp-rule-abstraction discharge (wp-as-formula PR 3/N)

Adds machine-checkable wp_rule to c11 and rust control-flow op specs
(if, seq, skip, source-unit), per spec section 1.2: wp_rule is REQUIRED
on Stmt-sorted ops and OPTIONAL (synthesizable) on value-ops.

Key design decisions:
- wp_rule is a per-language annotation in language-sig specs; concept-shape
  hub ops do NOT yet carry it (deferred to #633 per spec s1.4).
  concept_spec_from_base strips wp_rule after deep-copy so hub op CIDs
  do not change when a language spec adds it (PR3+). Once #633 lands the
  strip should be replaced by an OPS-table-driven authoritative wp_rule.
- A new 4th structural discharge strategy "structural-wp-rule-abstraction"
  handles morphisms where the only CID difference is the presence of
  wp_rule in the language spec that the concept spec lacks.
- The 6 control-flow PRIMITIVE_STEMS (c11+rust if/seq/skip) are removed
  from the short-circuit cache because their source CIDs changed when
  wp_rule was added. Return and eq stems are unaffected and remain cached.

Morphism count: 143 (matches HEAD baseline, no regression).
Byte-stable: two consecutive mint runs produce zero file changes.
Tests: libprovekit transport (4/4), ci (7/7), ci_check (15/15),
transport_cli (8/8), lower_formula (2/2) all pass.

Depends on #620 (wp_rule schema + evaluator, PR 1/N).
Note: #633 (feat/wp-as-formula-2-hub-op-rules) will add wp_rule to
concept hub op specs and supersede the strip in concept_spec_from_base.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(concept-shapes): eliminate primitive_ops.py dual-mint; add one-name-one-CID invariant

Both primitive_ops.py and mint_language_morphisms.py were independently minting
the same c11/rust morphisms for conditional/seq/skip/return/eq, producing duplicate
catalog files with different CIDs.  Root cause: primitive_ops.py built concept specs
from c11 source specs directly (stale component-cids.json, no union-effects), while
mint_language_morphisms.py used canonical_cid_spec + union-effects across all languages.

Fix:
- Clear primitive_ops.py OPS list; mint_language_morphisms.py is now the single source
  of truth for all language morphisms including the five former primitives.
- Clear PRIMITIVE_STEMS in mint_language_morphisms.py (no pre-minted stems remain).
- Add sweep_stale_catalog_files() to remove orphan CID-named files after each mint.
- Raise in append_cids() on (kind,name) conflict with different CID (one-name-one-CID
  invariant enforced at mint time; previously silently kept the first-registered stale CID).
- Add CidInvariantTests.test_one_name_one_cid_in_cids_tsv to test_discharge.py so the
  invariant is also asserted as a unittest on every subsequent mint run.

Two consecutive mint.sh runs produce byte-identical cids.tsv (verified).
morphism_count = 172; no duplicate stems in catalog/algorithms.
c11/rust x {if,seq,skip} receipts use structural-wp-rule-abstraction discharge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(discharge): combine notes-tolerant + wp_rule-aware structural subsumption (rebase merge of #688 + #663)

Rebase of feat/wp-as-formula-3-c11-rust-lifters onto origin/main resolved
a code conflict in try_structural_subsumption between #688's notes_match
guard and #663's wp_rule-aware guard.

Both guards are additive over the same early-return condition and over
_make_relaxed's wp branch:

  - #688: adds notes_match to the byte-equality guard; strips
    documentation-only keys (notes) from the relaxed copy.
  - #663: adds 'not after_has_wp_rule' to the byte-equality guard;
    unconditionally pops wp_rule from relaxed['post'].

Combined guard (strictly more conservative -- subsumes both prior
verdicts under Supra omnia, rectum):

    if pre_matches and wp_matches and notes_match \
       and effects_match and not after_has_wp_rule:

Combined _make_relaxed: applies both strip operations.

Regenerated mint artifacts (cids.tsv + catalog/index.json) are byte-stable
across two consecutive mint.sh runs. morphism_count=161 (main mint) +
12 cell-pattern morphisms = 174 total morphism rows in cids.tsv.
Both morphism_java_throw_to_throw and morphism_typescript_throw_to_throw
present. morphism_ruby_source_unit_to_source_unit minted (no longer the
ghost orphan post-#688).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

2 participants