From 5680645a29c58dc48a1043c91c878321c6c5696a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 13:13:59 +0000 Subject: [PATCH 01/26] fix(formal): harden the no-False gate; sync status docs after D0/D0b MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #421 and #422. No-False gate. formal/scripts/no-false-gate.sh replaces the inline coq-build.yml step. It fails on any of four independent checks: 1. assumption vernacular or admit tactics (Admitted, admit, give_up, Axiom(s), Parameter(s), Conjecture, Hypothesis/Hypotheses, Variable(s), Context) anywhere outside comments and strings, matched at token boundaries; 2. kernel-check bypasses (Unset Guard/Positivity/Universe Checking, Bypass Check, type-in-type); 3. a Print Assumptions census of every named result, each of which must be closed under the global context, with the six headline results required to be present; 4. an independent coqchk recheck of the compiled libraries: no Ephapax axiom, nothing on type-in-type, unsafe fixpoints or assumed positivity. On main (32d8eb7) it passes: 257/257 named results closed. Planted mutants (inline Admitted, Axioms, Parameters, Conjecture, top-level Hypothesis, Unset Guard Checking) each fail it on at least two checks. Comment-only and identifier-substring controls pass. A removed headline fails check 3. This addresses the #422 review findings on inline and plural forms. Docs. Remove the claims that the legacy preservation "remains Admitted" and that L1 admits are live debt: README, STATUS, ROADMAP, RUST-SPARK-STANCE, TOPOLOGY, PROOF-NEEDS, EXPLAINME, docs/proof-debt, two formal comments, and deletion-only edits to the legacy .a2ml files (0-AI-MANIFEST, STATE, formal/PROOF-STATUS). PROOF-NEEDS and EXPLAINME now state the Proof Witness Grade position (PROOF-POLICY §8): no Ephapax result carries a PWG, and classifying obligations as theory or engineering is left to the owner. CLAUDE.md points at PWG and describes the new gate. Plan. Apply the seven open Codex findings on the v2 plan: P9 assumes typing; preservation concludes "typed successor or Trap"; TMu grades are a decidable least fixed point; tuple and list projections may not discard linear elements; -9223372036854775808 folds to i64::MIN; elaboration (S2b) infers and checks generics before monomorphising; P14 branches on D8. Survey evidence. F0 and F1 now take the retired statements as hypotheses; all eight lemmas are closed under the global context. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- .github/workflows/coq-build.yml | 37 +----- .machine_readable/6a2/STATE.a2ml | 13 +- 0-AI-MANIFEST.a2ml | 2 +- CLAUDE.md | 19 +-- EXPLAINME.adoc | 41 +++++-- PROOF-NEEDS.adoc | 70 ++++++----- README.adoc | 24 ++-- ROADMAP.adoc | 3 +- RUST-SPARK-STANCE.adoc | 3 +- STATUS.adoc | 4 +- TOPOLOGY.adoc | 3 +- docs/proof-debt.adoc | 79 ++++++------ .../EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc | 22 ++-- .../2026-11-04-v2-survey/evidence/README.adoc | 2 +- .../evidence/SurveyCounterexamples.v | 47 ++++--- formal/Counterexample_StepPop.v | 5 +- formal/PROOF-STATUS.a2ml | 12 +- formal/Semantics_L1.v | 13 +- formal/scripts/no-false-gate.sh | 116 ++++++++++++++++++ 19 files changed, 317 insertions(+), 198 deletions(-) create mode 100755 formal/scripts/no-false-gate.sh diff --git a/.github/workflows/coq-build.yml b/.github/workflows/coq-build.yml index 951fe216..9a204166 100644 --- a/.github/workflows/coq-build.yml +++ b/.github/workflows/coq-build.yml @@ -129,38 +129,11 @@ jobs: - name: No-False gate (D0/D0b, owner-ratified 2026-11-05) working-directory: formal - run: | - # The build is axiom-free: no `Admitted.` and no `Axiom` / - # `Parameter` in any `_CoqProject` file. While a refuted - # statement was `Admitted`, the build could derive `False` - # (see docs/reports/2026-11-04-v2-survey, F0/F1). Assuming - # Coq's own consistency, an axiom-free build cannot. - set -euo pipefail - FILES=$(grep -E '\.v$' _CoqProject) - if grep -nE '^[[:space:]]*(Admitted\.|Axiom[[:space:]]|Parameter[[:space:]])' $FILES; then - echo "no-False gate: Admitted/Axiom/Parameter found above -> FAIL" - exit 1 - fi - # Every headline and refutation must be closed under the global - # context; any `Axioms:` block fails the gate. - OUT=$(coqtop -R . Ephapax 2>&1 <<'EOF2' - From Ephapax Require Import Semantics Semantics_L1 TypingL2. - From Ephapax Require Import Counterexample Counterexample_StepPop Counterexample_PreservationL1. - Print Assumptions Counterexample.preservation_false. - Print Assumptions Counterexample_StepPop.step_pop_disjoint_from_type_l1_FALSE. - Print Assumptions Counterexample_PreservationL1.preservation_l1_FALSE. - Print Assumptions preservation_l2_via_l1. - Print Assumptions preservation_l2_app_eff_beta_linear. - Print Assumptions preservation_l2_app_eff_beta_ground_nonlinear. - EOF2 - ) - echo "$OUT" | tail -60 - if echo "$OUT" | grep -qE '^Axioms:|Error'; then - echo "no-False gate: a printed result depends on an axiom, or coqtop errored -> FAIL" - exit 1 - fi - test "$(echo "$OUT" | grep -c 'Closed under the global context')" -eq 6 - echo "no-False gate: PASS (0 Admitted, 0 Axiom, 6/6 closed)" + # Comment-aware token scan, kernel-bypass scan, a Print Assumptions + # census of every named result (the six headlines must be present), + # and an independent coqchk recheck of the compiled libraries. See the + # header of formal/scripts/no-false-gate.sh for the exact claim. + run: bash scripts/no-false-gate.sh # Always-on aggregator. This is the ONLY job listed in the Base ruleset's # required_status_checks rule (#244). If detect-relevant-changes determined diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index 1d22535d..8a2e02d1 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -8,10 +8,10 @@ # current_* fields below supersede the 2026-06 journal text and the dated # owner-directive snapshot; see CLAUDE.md and PROOF-NEEDS.adoc for policy. phase: "implementation" -next_action: "Owner-approved L1/L2 soundness redesign: preserve the legacy five-Qed counterexample and durable Admitted; do not prove the refuted step_pop_disjoint_from_type_l1 or preservation_l1 statements; do not claim unconditional L2 preservation. First classify each exact obligation under hyperpolymath/mathesis/estate-mathematical-architecture and standards/3-practice/PROOF-POLICY.adoc. Grade 3 is Agda-only; current Coq witnesses are not Grade 3, and Ephapax-specific engineering claims map to Idris2 unless an implementation-independent Agda theory statement is explicitly defined. Update the proof ledger after policy resolution; no proof build was run in this environment." +next_action: "Owner-approved L1/L2 soundness redesign: preserve the legacy five-Qed counterexample; do not prove the refuted step_pop_disjoint_from_type_l1 or preservation_l1 statements; do not claim unconditional L2 preservation. First classify each exact obligation under hyperpolymath/mathesis/estate-mathematical-architecture and standards/3-practice/PROOF-POLICY.adoc. Grade 3 is Agda-only; current Coq witnesses are not Grade 3, and Ephapax-specific engineering claims map to Idris2 unless an implementation-independent Agda theory statement is explicitly defined. Update the proof ledger after policy resolution; no proof build was run in this environment." last_action: "Proof + stdlib wave 2026-06-01 → 02 landed (13 PRs): canonical-forms L1 modality-polymorphic (#274, P43, 7 lemmas axiom-free, prerequisite for progress_l1/P42); Print Assumptions audit framework across L1 + L3 keystones (#270, closes P10/P32); step_pop_disjoint_from_type_l1 stated + EASY cases Qed-closed (#280, P06, HARD cases blocked on #240/#241/#242); Rust↔Coq is_linear_ty truth-table mechanical assertion (#273, P28); OwnershipKind from_byte/to_byte round-trip (#277, P59, typed-wasm ADR-0002 carrier handshake); 4 stdlib DB-theory additions — Transactions as linear scopes (#275, D04), Allen's interval algebra (#272, D11), MessageHandle as linear typestate (#279, D17), monoidal aggregates (#281, D18); doc truth-restore + banned-preservation framing buried (#263); cluster-D L3/L4 status meander (#278); Track C panic-attack triage (#271); coq-build noble apt CI fix (#282, unblocks ~5 PRs); R5b standards SHA bump (#276). Plus CHANGELOG sync (#283). Build oracle clean across all 12 .v files. Print Assumptions: canonical_*_l1_m + most new lemmas zero-axiom; expected residuals (preservation_l1, region_liveness_at_split_l1_gen, region_shrink_preserves_typing_l1_gen_m) tracked. Stages 2-4 (#240/#241/#242) still un-touched. Wiki Proof-status.md refreshed 2026-06-02. | INFRA (2026-06-24, non-proof track): governance docs landed (#314 — CODEOWNERS/GOVERNANCE.adoc/MAINTAINERS.adoc); standards reusable-workflow pins remediated (#315) and current at d135b05 (#316); Hypatia neurosymbolic gate now active and reporting 37 pre-existing findings (5 crit / 7 high / 25 med) — triage handed off (docs/governance/HYPATIA-HANDOFF.md), standards visibility patch drafted-not-applied. No .v files touched; no proof-state change." current_last_action: "2026-11-01 proof-honesty audit: updated local status surfaces, retained all proof terms, and recorded Mathesis Grade 3/standards mapping without promoting Coq evidence to Grade 3. No Coq build run; coqc unavailable." -current_proof_boundary: "Legacy preservation is refuted and its Admitted is durable; step_pop_disjoint_from_type_l1 and modality-polymorphic preservation_l1 are refuted as stated; no unconditional preservation_l2 theorem is declared; preserve all Coq regressions." +current_proof_boundary: "Legacy preservation is refuted; step_pop_disjoint_from_type_l1 and modality-polymorphic preservation_l1 are refuted as stated; no unconditional preservation_l2 theorem is declared; preserve all Coq regressions." current_grade_target: "Mathesis Grade 3 requested but not claimed. Grade 3 requires local Agda checking; current regressions are Coq. Standards proof policy maps repo-specific engineering claims to Idris2 unless an implementation-independent Agda theory statement is explicitly identified." proof_policy_sources: ["https://github.com/hyperpolymath/mathesis/blob/main/estate-mathematical-architecture", "https://github.com/hyperpolymath/standards/blob/main/3-practice/PROOF-POLICY.adoc"] proof_status_source: "PROOF-NEEDS.adoc + EXPLAINME.adoc proof ledger" @@ -61,11 +61,6 @@ updated: 2026-11-01 # and their at_pre helpers are Qed but support legacy-preservation # closure attempts that are now ruled out. Retained for diagnostic # value; no new lemmas in this class. -# - formal/Semantics_L1.v `preservation_l1`: stated `forall m`; body -# Admitted (lambda-rigidity gap, closes at Phase D / L2 effect-typed -# TFun per PRESERVATION-DESIGN.md §5.1). 5 inner `admit.` cases -# (3 distinct structural blockers + 2 mechanical mirrors) covered -# by 3 outer Admitted markers. Zero new debt introduced post-L2. # - formal/Semantics_L1.v `preservation_l3`: Qed, both modes, slice 4 # of L3 wiring landed 2026-05-27 (PR #196). # - formal/TypingL1.v: m-indexed (`has_type_l1 m R G e T R' G'`), @@ -99,11 +94,11 @@ updated: 2026-11-01 @artifacts: # Layered files on main (post-slice-4 + Phase A): -# formal/Counterexample.v — 5 Qed lemmas; preservation FALSE +# formal/Counterexample.v — preservation FALSE # formal/Echo.v — L3 calculus, K-free, 12 Qed # formal/Modality.v — L2 thin poset, all Qed # formal/TypingL1.v — m-indexed has_type_l1 + Echo/Observe rules -# formal/Semantics_L1.v — 26 Qed, 3 outer Admitted (5 inner admit.) +# formal/Semantics_L1.v # formal/TypingL2.v — L2_lift_l1 + T_App_L2_Eff + # preservation_l2_via_l1 (Phase D slice 4 partial) # formal/L4.v — ProgramMode labelling discipline (Phase A) diff --git a/0-AI-MANIFEST.a2ml b/0-AI-MANIFEST.a2ml index 1e9dac71..b77b598e 100644 --- a/0-AI-MANIFEST.a2ml +++ b/0-AI-MANIFEST.a2ml @@ -24,7 +24,7 @@ These 7 A2ML files MUST exist in `.machine_readable/6a2/` directory ONLY: ## CORE INVARIANTS -1. **Formal Proof Integrity** — Coq proofs are layered per the four-layer redesign (`formal/PRESERVATION-DESIGN.md`). The legacy `preservation` in `Semantics.v` is provably FALSE per `Counterexample.v` (5 Qed lemmas) and remains `Admitted`. The L1 preservation chain (`Semantics_L1.v`) is in flight; explicit `Admitted` / `admit.` / `Axiom` sites are enumerated by `file:line` in `docs/proof-debt.adoc` per the standards#203 trusted-base reduction policy. Idris2 source has no `believe_me` / `assert_total` / `sorry`. ANY new escape hatch must be enumerated in `docs/proof-debt.adoc` before the trusted-base CI check will pass. +1. **Formal Proof Integrity** — Coq proofs are layered per the four-layer redesign (`formal/PRESERVATION-DESIGN.md`). The legacy `preservation` in `Semantics.v` is provably FALSE per `Counterexample.v`. Explicit `Admitted` / `admit.` / `Axiom` sites are enumerated by `file:line` in `docs/proof-debt.adoc` per the standards#203 trusted-base reduction policy. Idris2 source has no `believe_me` / `assert_total` / `sorry`. ANY new escape hatch must be enumerated in `docs/proof-debt.adoc` before the trusted-base CI check will pass. 2. **Dyadic Separation** - The boundary between affine and linear types must be strictly enforced. 3. **SPDX Headers** - All source files MUST carry `SPDX-License-Identifier: MPL-2.0`. // Owner: Jonathan D.A. Jewell diff --git a/CLAUDE.md b/CLAUDE.md index b290cb23..71aebc0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,8 +109,10 @@ redesign and state sound replacement theorems, not to prove refuted ones. > `…_stmt : Prop`, refuted by `Counterexample_StepPop.v` and > `Counterexample_PreservationL1.v`. While they were `Admitted`, the build > could derive `False`. `formal/` now has **0 `Admitted` and 0 `Axiom`**, -> and CI's no-False gate (`coq-build.yml`) blocks any new -> `Admitted`/`Axiom`/`Parameter`. The fence below is unchanged in spirit: +> and CI's no-False gate (`formal/scripts/no-false-gate.sh`, run by +> `coq-build.yml`) fails on any assumption vernacular or `admit` outside +> comments, on any kernel-check bypass, on any named result not closed under +> the global context, and on any Ephapax axiom that `coqchk` finds. The fence below is unchanged in spirit: > never try to prove a refuted statement. > The guardrails below protect pinned counterexamples and the false @@ -202,13 +204,16 @@ them if asked; if asked anyway, push back and link this section. prove it locally and report its assumptions and regressions. 4. **DO follow estate proofing and grading policy.** Consult - `hyperpolymath/mathesis/estate-mathematical-architecture` for grades - and `hyperpolymath/standards/3-practice/PROOF-POLICY.adoc` for prover - mapping. Mathesis Grade 3 specifically requires a local Agda proof; - Coq `Qed` is Coq evidence, not Grade 3. Standards maps + `hyperpolymath/standards/3-practice/PROOF-POLICY.adoc` for prover + mapping and for Proof Witness Grades (§8, which supersedes the grades in + the mathesis brief). `PWG-3` requires a local Agda proof checked with + `agda --safe --without-K --cubical-compatible`; Coq `Qed` is Coq + evidence, not `PWG-3`, and counts as a recheck leg only once an Agda + source exists and the statements are `RECONCILED`. Always write the + `PWG-` prefix. Standards maps Ephapax-specific engineering claims to Idris2 unless an implementation-independent theory statement is explicitly defined. - Do not assign a Grade 3 label until that classification and exact + Do not assign a PWG until that classification and exact statement correspondence are recorded in `EXPLAINME.adoc` and `PROOF-NEEDS.adoc`. diff --git a/EXPLAINME.adoc b/EXPLAINME.adoc index 48c2222f..f47d5fd4 100644 --- a/EXPLAINME.adoc +++ b/EXPLAINME.adoc @@ -107,7 +107,8 @@ used by a surviving sibling remains live across another sibling's step. instance. It does not establish that a separate Affine-only statement is false. * The local proof artifacts are Coq evidence only. The exact open - replacement boundary and Grade 3 policy are in `PROOF-NEEDS.adoc`. + replacement boundary and the Proof Witness Grade (PWG) position are in + `PROOF-NEEDS.adoc`. == Claim: Irreversibility residue is first-class (L3, wired) @@ -251,7 +252,7 @@ Qed results are scoped to matching echo rule pairs. |=== | Component | Status | Where -| Counterexample regression (5 Qed) +| Counterexample regression (6 Qed, incl. `preservation_false`) | ✅ Pinned | `formal/Counterexample.v` — `bad_input_untypable_l1` Qed under both modes @@ -306,6 +307,18 @@ not a proof-build receipt. No fresh build is claimed here; the source proof scripts and theorem statements were not changed in this audit, only their documentation comments were clarified. +Local re-check, 2026-11-09, source `32d8eb7` (main, after the D0/D0b +retirement in ephapax#422): Coq 8.18.0 (Ubuntu noble `coq` package) built all +18 `+_CoqProject+` modules; `+formal/scripts/no-false-gate.sh+` found no +assumption vernacular and no kernel bypass, and its `+Print Assumptions+` +census found 257 of 257 named results closed under the global context; +`+coqchk -o+` re-checked the compiled libraries and reported no Ephapax +axiom, nothing relying on type-in-type, unsafe fixpoints or assumed +positivity (it lists the standard library's `+eq_rect_eq+`, which no Ephapax +result uses). This was an agent-session run, not a pinned isolated CI +receipt. No Ephapax result carries a Proof Witness Grade; see +`+PROOF-NEEDS.adoc+` (Estate proofing policy and Proof Witness Grades). + [cols="2,1,2,2,3",options="header"] |=== |Obligation / result |Status |Checker (version, flags) |Where |Trust boundary @@ -394,21 +407,22 @@ all L1 regressions remain mandatory controls. |Current β-case helpers are conditional; `+preservation_l2_via_l1+` transparently depends on the refuted L1 statement. -|Mathesis Grade 3 receipt for Ephapax language soundness |open -|Agda receipt required by the Mathesis architecture -|No Agda Grade 3 proof currently recorded -|The existing witnesses are Coq, not Grade 3. Standards classifies local +|`PWG-3` receipt for Ephapax language soundness |open +|Agda receipt required by PROOF-POLICY §8 (if classed as theory) +|No Agda proof recorded; every Ephapax result is `ungraded` or off the PWG ladder +|The existing witnesses are Coq, not `PWG-3`. Standards classifies local Ephapax-specific typing/step claims as engineering (Idris2) unless a genuine implementation-independent theory statement is defined. Resolve this -mapping before assigning a Grade 3 label; see `+PROOF-NEEDS.adoc+`. +mapping before assigning any PWG; see `+PROOF-NEEDS.adoc+`. |=== -Grade labels are not interchangeable with this ledger's statuses. Per -`+hyperpolymath/mathesis/estate-mathematical-architecture+`, Grade 3 means -locally checked in Agda without independent translation. Per +Proof Witness Grades are not interchangeable with this ledger's statuses. +Per `+hyperpolymath/standards/3-practice/PROOF-POLICY.adoc+` §8 (which +supersedes the grades of the mathesis brief), `PWG-3` means locally checked +in Agda under the canonical invocation, with no recheck leg yet counted. Per `+hyperpolymath/standards/3-practice/PROOF-POLICY.adoc+`, engineering and theory obligations follow different verifier routes. Therefore no Coq -`+Qed+` row above is labelled Grade 3, and no upstream or cross-repo +`+Qed+` row above is labelled `PWG-3`, and no upstream or cross-repo conceptual result discharges an Ephapax-specific obligation. === Idris2 (`idris2/src/Ephapax/` + `src/formal/`) @@ -532,8 +546,9 @@ so this is not a reproducibly pinned toolchain receipt. |=== Idris2 evidence follows the Standards route for repository-specific -engineering claims, but it is not thereby Mathesis Grade 3: Grade 3 -requires a locally checked Agda proof. The current Coq and Idris2 rows +engineering claims, and engineering proofs are not on the PWG ladder at all +(PROOF-POLICY §8.1): `PWG-3` requires a locally checked Agda proof of a +theory obligation. The current Coq and Idris2 rows must retain their checker labels and cannot be upgraded by cross-repo vocabulary or by the proof-policy mapping alone. diff --git a/PROOF-NEEDS.adoc b/PROOF-NEEDS.adoc index 6f4a5d75..9064437e 100644 --- a/PROOF-NEEDS.adoc +++ b/PROOF-NEEDS.adoc @@ -86,36 +86,42 @@ side conditions and state exact replacement preservation theorems. These are new obligations; the refuted legacy and L1 statements remain pinned. |=== -==== Estate proofing policy and Grade 3 target - -Follow the estate's canonical references for proof method and grade: - -* link:https://github.com/hyperpolymath/mathesis/blob/main/estate-mathematical-architecture[`hyperpolymath/mathesis/estate-mathematical-architecture`] - defines Grade 3 as an Agda proof that type-checks locally and has not been - independently translated. Grade 2/1 claims require their separate - translation/API evidence. -* link:https://github.com/hyperpolymath/standards/blob/main/3-practice/PROOF-POLICY.adoc[`hyperpolymath/standards/3-practice/PROOF-POLICY.adoc`] - assigns implementation-specific engineering obligations to Idris2 and - implementation-independent theory to Agda, rechecked in Rocq and Lean. - Its deciding test is whether a claim names a repository-specific type, - function, or wire format. The - link:https://github.com/hyperpolymath/standards/blob/main/docs/README-EXPLAINME-STANDARD.adoc[proof-ledger standard] - also requires every claimed proof (including open/refuted claims) to - appear in `+EXPLAINME.adoc+` with status, checker, location, and trust - boundary. - -*Grade 3 is the requested target, not a status to apply retroactively.* The -current regression witnesses are Coq scripts (`+Qed+`), so they are not -Mathesis Grade 3 receipts. The Ephapax-specific `+has_type_l1+`/`+step+` -claims also appear engineering-specific under the current standards mapping; -that mapping does not automatically make them Agda theory. Until an -owner-approved formulation resolves this boundary, record current Coq -results as Coq evidence, retain `+COUNTEREXAMPLE+` for refuted statements, -and do not label them Grade 3. A future Agda Grade 3 receipt must include -the exact proposition, assumptions, source commit, pinned Agda/toolchain, -canonical invocation, and evidence that it is the same Ephapax statement; -it must not be inferred from shared vocabulary or another repository's -proof. No Grade 2/1 claim is made here. +==== Estate proofing policy and Proof Witness Grades + +Follow the estate's canonical policy: +link:https://github.com/hyperpolymath/standards/blob/main/3-practice/PROOF-POLICY.adoc[`hyperpolymath/standards/3-practice/PROOF-POLICY.adoc`]. +It assigns implementation-specific engineering obligations to Idris2 and +implementation-independent theory to Agda, rechecked in Rocq and Lean. Its +deciding test is whether a claim names a repository-specific type, function +or wire format. Its §8 (owner ruling 2026-11-04) defines the *Proof Witness +Grades* `PWG-3`, `PWG-2i`, `PWG-2ii` and `PWG-1`, and supersedes the grades +in the mathesis brief (`docs/proposals/estate-mathematical-architecture.adoc`). +The +link:https://github.com/hyperpolymath/standards/blob/main/docs/README-EXPLAINME-STANDARD.adoc[proof-ledger standard] +also requires every claimed proof (including open and refuted claims) to +appear in `+EXPLAINME.adoc+` with status, checker, location and trust +boundary. + +*No result in this repository carries a PWG.* PWG applies only to theory +obligations whose source of truth is an Agda proof (§8.1). No Ephapax result +has an Agda source, so any result classed as theory is `ungraded`, and any +result classed as engineering is not on the PWG ladder at all (§8.1: it +uses the §5 tokens only). The Coq results are Coq evidence: a Coq `+Qed+` +is not `PWG-3`, and it can count as a Rocq recheck leg only after an Agda +source exists and the two statements are `RECONCILED` (§2.2, §3). + +*The classification itself is open.* The `+has_type+`/`+step+` statements +name Ephapax's own judgment, which reads as engineering under the §1 test, +but the soundness of a calculus is listed as theory. Until the owner +classifies each obligation, record Coq results as Coq evidence, keep +`+COUNTEREXAMPLE+` for refuted statements, and assign no PWG. Policy §3 adds +a consequence: an engineering proof held in a prover other than Idris2 moves +to Idris2 when next touched, unless a §4 exception is recorded beside it. +A future `PWG-3` receipt must record the exact proposition, assumptions, +source commit, pinned Agda and standard library, the canonical invocation +`agda --safe --without-K --cubical-compatible`, the import cone, and evidence +that it states the same Ephapax proposition. It must not be inferred from +shared vocabulary or from another repository's proof. The audit is split by sublanguage: @@ -182,8 +188,8 @@ Definitions only — no theorems, zero axioms. 3b (nested TFunEff) soundness-gap classes |Pinned legacy counterexample + L1 positive regression -|`+formal/Counterexample.v+` |5 Qed in total: three lemmas establish the -legacy counterexample; `+t_loc_l1_R_preserving+` supports the L1 argument; +|`+formal/Counterexample.v+` |6 Qed in total: three lemmas establish the +legacy counterexample; `+preservation_false+` refutes `+preservation_stmt+`; `+t_loc_l1_R_preserving+` supports the L1 argument; `+bad_input_untypable_l1+` rejects this one input in both modes. This is not a proof of L1 preservation. diff --git a/README.adoc b/README.adoc index dd46a713..535f7748 100644 --- a/README.adoc +++ b/README.adoc @@ -416,9 +416,10 @@ Operational semantics with small-step reduction over a substitution- based calculus. Post-2026-05-26 the formalisation is layered: * **Legacy** (`formal/Semantics.v`, `formal/Typing.v`) — the original - pre-discovery judgment. `Theorem preservation` here is **provably - false** (the counterexample at `formal/Counterexample.v` is 5 Qed - lemmas proving it). The `Admitted.` is correct. **Do not extend.** + pre-discovery judgment. Its preservation statement is **provably + false**: it is now `preservation_stmt : Prop`, refuted by + `Counterexample.preservation_false` (Qed). The former `Admitted.` was + retired on 2026-11-05 (owner-ratified D0/D0b). **Do not extend.** Treat as archaeology — see link:STATUS.adoc[`STATUS.adoc`] for the pre-discovery PR list and the explicit do-not-do reasoning. @@ -459,7 +460,7 @@ link:PROOF-NEEDS.adoc[`PROOF-NEEDS.adoc`]. The four-layer architecture rationale is in link:formal/PRESERVATION-DESIGN.adoc[`formal/PRESERVATION-DESIGN.adoc`]. The counterexample itself is -link:formal/Counterexample.v[`formal/Counterexample.v`] (5 Qed). +link:formal/Counterexample.v[`formal/Counterexample.v`] (6 Qed, including `preservation_false`). [source,bash] ---- @@ -596,8 +597,8 @@ generics, effects, traits, comptime, contracts) lives on | | Coq legacy `preservation` -| 🛑 Admitted — provably false -| Counterexample at `formal/Counterexample.v` (5 Qed). Not closable. Archaeology. +| 🛑 Retired to `preservation_stmt : Prop` — provably false +| Refuted by `Counterexample.preservation_false` (Qed, closed under the global context). Not closable. Archaeology. | Coq L1 (region capabilities) | Judgment and helpers mechanised; unrestricted preservation refuted @@ -652,7 +653,7 @@ generics, effects, traits, comptime, contracts) lives on motivated the four-layer proposal; not proof that preservation holds per layer. | link:formal/Counterexample.v[formal/Counterexample.v] -| The 5 Qed lemmas that pin the soundness gap of the legacy judgment. +| The 6 Qed lemmas, including `preservation_false`, that pin the soundness gap of the legacy judgment. Authoritative source of the gap; future proofs must coexist with it. | link:CLAUDE.md[CLAUDE.md] @@ -777,10 +778,11 @@ The highest-leverage open work, in priority order: [NOTE] ==== -*Closing the legacy `Theorem preservation` in `formal/Semantics.v` is -NOT a valid contribution.* That theorem is provably false (sealed by -`formal/Counterexample.v`); its `Admitted.` is correct and durable per -owner directive 2026-05-27. `formal/PRESERVATION-HANDOFF.adoc` is +*Proving the legacy preservation statement in `formal/Semantics.v` is +NOT a valid contribution.* It is provably false: it is retired to +`preservation_stmt : Prop` and refuted by `Counterexample.preservation_false` +(owner directive 2026-05-27; retirement owner-ratified as D0/D0b on +2026-11-05). `formal/PRESERVATION-HANDOFF.adoc` is archaeology kept for context. The redesigned judgments are a separate development, not yet a sound replacement: `preservation_l1` is refuted as stated, and no unconditional `preservation_l2` theorem exists. L2 β-case lemmas have explicit diff --git a/ROADMAP.adoc b/ROADMAP.adoc index 31aac0da..9b781962 100644 --- a/ROADMAP.adoc +++ b/ROADMAP.adoc @@ -302,7 +302,8 @@ audio buffer management: results are scoped to matching echo rule pairs. NOTE: The legacy `Theorem preservation` in `formal/Semantics.v` is -provably false (`formal/Counterexample.v`) and its `Admitted.` is durable. +provably false: it is retired to `preservation_stmt : Prop` and refuted by +`Counterexample.preservation_false` (D0/D0b, 2026-11-05). It is archaeological, not ordinary proof debt, and any replacement must be a separately stated theorem. * [ ] Idris2 `Parse/Parser.idr` upgraded from `%default covering` to diff --git a/RUST-SPARK-STANCE.adoc b/RUST-SPARK-STANCE.adoc index 947c4ccc..2fdd27d2 100644 --- a/RUST-SPARK-STANCE.adoc +++ b/RUST-SPARK-STANCE.adoc @@ -90,7 +90,8 @@ backend (lowering to a different operational model), and the Zig FFI DISCHARGED** at `Ephapax.Formal.Qualifier.splitLinearCoverage` (cited). **E3 narrow-form is DISCHARGED** at `Ephapax.Formal.RegionLinear.noEscapeTheorem` (cited). E1 - (preservation) is OWED on the Coq side and remains `Admitted`. + (preservation) is OWED on the Coq side: the legacy statement is refuted + (`Counterexample.preservation_false`) and no replacement theorem exists yet. E2 control-flow generalisation, E3 operational form, E4 (no-runtime-GC), E5 (WASM compilation), E6 (IR lowering) are stated as erased postulates so the obligations are explicit and discoverable. diff --git a/STATUS.adoc b/STATUS.adoc index c40aad3c..ec623eec 100644 --- a/STATUS.adoc +++ b/STATUS.adoc @@ -312,8 +312,8 @@ field closed. counterexamples, scoped results, ledger, and replacement obligations. * **Agent guidance**: `CLAUDE.md` (root) — the 2026-05-27 owner directive, anti-pattern detector, escalation rule. -* **Counterexample**: `formal/Counterexample.v` — the 5 Qed lemmas - that pin the soundness gap. +* **Counterexample**: `formal/Counterexample.v` — the 6 Qed lemmas, + including `preservation_false`, that pin the soundness gap. * **Upstream Echo foundation**: https://github.com/hyperpolymath/echo-types — the Agda fiber/degrade/no-section calculus this repo's L3 builds on. diff --git a/TOPOLOGY.adoc b/TOPOLOGY.adoc index 3db464d4..efeae6d6 100644 --- a/TOPOLOGY.adoc +++ b/TOPOLOGY.adoc @@ -19,7 +19,8 @@ architecture lives in: |Layer |Concern |File(s) |Status |*L1* |Region capabilities + capability environment threading |`+formal/TypingL1.v+` + `+formal/Semantics_L1.v+` |judgment 100%, -semantics 15 Qed / 9 admits (L2-integration debt) +semantics: no admits; the unrestricted `preservation_l1` is refuted +(`Counterexample_PreservationL1.v`) and retired to a `Prop` |*L2* |Structural modality (Linear vs Affine) |`+formal/Modality.v+` + `+m : Modality+` in `+has_type_l1+` |core landed, `+linear_to_affine+` diff --git a/docs/proof-debt.adoc b/docs/proof-debt.adoc index 6f5a9029..a21234cf 100644 --- a/docs/proof-debt.adoc +++ b/docs/proof-debt.adoc @@ -3,61 +3,58 @@ SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) –> == Proof Debt — ephapax -=== Current enforced inventory (2026-08-29) +=== Current enforced inventory (2026-11-09) This section is the current inventory consumed by the trusted-base gate. The older closure plan below is retained only as labelled archaeology. A path being listed here means that its trust boundary is documented; it does *not* mean the obligation has been discharged. -==== `+formal/Semantics.v:9258+` — legacy `+preservation+` - -* *Status*: one real outer `+Admitted.+`; not proved. -* *Classification*: durable legacy counterexample boundary. The theorem as - originally stated is false, as demonstrated by the five `+Qed+` witnesses in - `+formal/Counterexample.v+`. It must not be presented as closable proof debt - without first replacing the false statement. -* *Owner*: @hyperpolymath. -* *Acceptance*: retain the counterexample regression and publish any replacement - theorem with its exact restricted boundary and zero hidden escape hatches. - -==== Redesigned L1 preservation boundary — `+formal/Semantics_L1.v+` - -The source still contains two `+Admitted.+` declarations, but neither is a -routine open proof obligation. Their exact unrestricted statements are -refuted by pinned counterexamples: - -* `+step_pop_disjoint_from_type_l1+` (source near line 3318) is *false as - stated*: `+formal/Counterexample_StepPop.v+` proves that a typed step can - remove a region still free in the result type. Its `+Admitted.+` is not a - promise that this theorem should be finished. -* `+preservation_l1+` (source near line 3337) is *false as stated*: - `+formal/Counterexample_PreservationL1.v+` gives an input typed in both - modalities and a step whose post-state is untypable in the Linear - instance, refuting the theorem's modality-polymorphic quantification. - This closes the suggested value-anchored escape route as well; it does - not by itself refute a separate Affine-only theorem. +==== Coq (`+formal/+`): no escape hatches + +Since the owner-ratified D0/D0b change of 2026-11-05 (ephapax#422), no file in +`+formal/_CoqProject+` contains `+Admitted+`, `+admit+`, `+Axiom+`, +`+Parameter+`, `+Conjecture+`, a top-level `+Hypothesis+`/`+Variable+`, or a +kernel-check bypass. `+formal/scripts/no-false-gate.sh+` enforces this in CI +(`+coq-build.yml+`): a comment-aware token scan, a `+Print Assumptions+` census +of every named result (each must be closed under the global context), and an +independent `+coqchk+` recheck of the compiled libraries. Its census on +2026-11-09 found 257 of 257 named results closed. `+coqchk+` lists +`+Coq.Logic.Eqdep.Eq_rect_eq.eq_rect_eq+` among the axioms of the *loaded +standard library*; the census shows no Ephapax result depends on it. + +The three statements that used to carry `+Admitted+` are false as stated. They +are kept as `+Prop+` definitions so their refutations can be stated, and they +are not debt: + +* `+Semantics.preservation_stmt+` (legacy preservation) is refuted by + `+Counterexample.preservation_false : ~ preservation_stmt+`. +* `+Semantics_L1.step_pop_disjoint_from_type_l1_stmt+` is refuted by + `+Counterexample_StepPop.step_pop_disjoint_from_type_l1_FALSE+`: a typed + step can remove a region still free in the result type. +* `+Semantics_L1.preservation_l1_stmt+` is refuted by + `+Counterexample_PreservationL1.preservation_l1_FALSE+`: an input typed in + both modalities steps to a post-state untypable in the Linear instance. This + does not by itself refute a separate Affine-only theorem. The counterexamples are proof regressions, not proofs of an alternative soundness theorem. The open work is to redesign or strengthen the judgment / operational interface with an explicit temporal liveness/coherence invariant, -then state and prove a replacement theorem with its exact boundary. Do not -close either current statement by ad-hoc lemma work or change these `+Admitted.+` -markers into claimed proofs. Track that replacement separately from the -legacy archaeology below. +then state and prove a replacement theorem with its exact boundary. ==== L2 preservation boundary — `+formal/TypingL2.v+` There is no unconditional theorem named `+preservation_l2+` in the current -tree. `+preservation_l2_via_l1+` transparently depends on the refuted L1 -statement, while the β-case lemmas are proved only under their stated -preconditions. `+formal/Counterexample_L2.v+` (fresh-region scope crossing) -and `+formal/Counterexample_L2_nested.v+` (nested `+TFunEff+` lambda) record -distinct counterexample classes for broader L2 β-preservation. They do not -invalidate the conditional helper lemmas whose hypotheses exclude those -classes; nor do they establish full L2 preservation. Any future L2 theorem -must name and prove its restricted boundary without inheriting the L1 -counterexample by assumption. +tree. `+preservation_l2_via_l1+` takes `+Semantics_L1.preservation_l1_stmt+` +as an explicit hypothesis, which `+preservation_l1_FALSE+` refutes, so it +proves nothing unconditionally. The β-case lemmas are proved only under their +stated preconditions. `+formal/Counterexample_L2.v+` (fresh-region scope +crossing) and `+formal/Counterexample_L2_nested.v+` (nested `+TFunEff+` +lambda) record distinct counterexample classes for broader L2 +β-preservation. They do not invalidate the conditional helper lemmas whose +hypotheses exclude those classes; nor do they establish full L2 +preservation. Any future L2 theorem must name and prove its restricted +boundary without inheriting the L1 counterexample by assumption. ==== `+idris2/src/Main.idr:22+` — global `+%default partial+` diff --git a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc index e187a453..c27eb904 100644 --- a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc +++ b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc @@ -306,8 +306,10 @@ Sources: the scratch builds and probes recorded in the dossier, plus spot checks ephapax-parser (pest) --> ephapax-surface AST (type params, mode decls, echo/observe/drop!, @comptime island) | S2 desugar [O2: golden + metamorphic desugar tests; formal/v2/Desugar.v match_desugar_correct | (data/match -> right-nested sums); surface sugar (traits, contracts) = pure desugar] + | S2b elaborate [infer type arguments, check generic bodies once (type variables linear), then + | monomorphise; polymorphic recursion rejected] v - ephapax-desugar --> CORE v2 AST (src/ephapax-syntax mirrors formal/v2/SyntaxV2.v constructor-for-constructor) + ephapax-desugar + elaborate --> CORE v2 AST (src/ephapax-syntax mirrors formal/v2/SyntaxV2.v constructor-for-constructor) | \______ ephapax-ir S-expr codec --> core->SyntaxV2 translator --> tools/ephapax-oracle | (extracted check_v2 + step_v2_fn) ephapax-driver::load_program (import resolver + package tier + surface + desugar) @@ -724,9 +726,9 @@ Items marked "*(external)*" in the Home column live outside `hyperpolymath/ephap * distinct echo forms; * `S_Observe`; * a typed foreign-signature environment Φ for `extern` declarations (stdlib `Argv.eph` and others), with a host-call step rule over a host *function* (total and deterministic by construction, so `step_v2_deterministic` survives). The host function also receives an immutable host input fixed for the whole run, which carries the argument vector: the program path as element 0 followed by `Run.args`, matching `stdlib/Argv.eph`'s contract that `argv_count()` includes `argv[0]` (a finite list of byte strings, well-formed when every entry is valid UTF-8 and the list length fits in I32), so `env::argv_count`, `argv_arg_len` and `argv_arg_get` are deterministic functions of it and are covered by P9 and P10. The metatheory takes `host_wf` as a *hypothesis*, never as an `Axiom`: a host call returns a value of the declared type, touches only the buffers passed to it, and preserves `store_typed`, region tags and ownership, and the rest of `config_wf`: it never frees or retags a passed location, and its result refers to no freed region. Each signature in Φ carries its own instance of these conditions. P9 and P10 are stated for every `host_wf` host; -* an explicit trap configuration: integer division or remainder by zero, and `MIN / -1`, step to `Trap`, while `MIN % -1` evaluates to 0 as Wasm `rem_s` does (matching the interpreter's `DivisionByZero` and the Wasm trap), and `progress_v2` reads "a value, a step, or `Trap`"; +* an explicit trap configuration: integer division or remainder by zero, and `MIN / -1`, step to `Trap`, while `MIN % -1` evaluates to 0 as Wasm `rem_s` does (matching the interpreter's `DivisionByZero` and the Wasm trap), and `progress_v2` reads "a value, a step, or `Trap`". `Trap` is a terminal configuration with no `has_type_v2` judgment, so `preservation_v2` concludes "a typed successor or `Trap`" (P10), and `type_safety_v2` reads "never stuck: a value, `Trap`, or a further step"; * a global Σ for recursion, with a `sigma_wf` judgment (every definition checked against its declared type under the whole recursive Σ) that `config_wf` requires and that P9 and P10 take as a hypothesis; -* the recursive-type form D7 selected (ratified in W1, before P4), so P7–P13 are proved against the full core: iso-recursive `TMu` with fold/unfold under the recommended option; nominal named types with unfold at `match` under the nominal option; no recursive type former under the no-core-recursion option, in which case P8 drops `mu`, P17 becomes the encoding-alignment rung, and E9/E10 use the bounded encodings; +* the recursive-type form D7 selected (ratified in W1, before P4), so P7–P13 are proved against the full core: iso-recursive `TMu` with fold/unfold under the recommended option; nominal named types with unfold at `match` under the nominal option; no recursive type former under the no-core-recursion option, in which case P8 drops `mu`, P17 becomes the encoding-alignment rung, and E9/E10 use the bounded encodings. Grades of recursive types are defined, not left to unfolding: the grade of `TMu X. T` is the least fixed point of `g ↦ grade(T[X := g])` over the finite lattice {0,1,ω}, computed by Kleene iteration from 0, which stabilises after at most three steps per bound variable, so it is decidable and needs no unfolding (for example `μX. 1 + (String × X)` gets the grade of `String`). Under the nominal option each named type carries the same fixed point, checked against any declared grade annotation. P4 defines this `grade_of`, P8 and P10 use it, and the E9/E13 checker computes the same function, with a fixture that agrees with the extracted `grade_of` on every recursive type in the corpus; * store coherence in `config_wf`. Includes `step_v2_deterministic`. @@ -759,13 +761,13 @@ Includes `step_v2_deterministic`. |S–M |P9 `progress_v2` -|For closed well-formed configurations: every one is a value, steps, or is `Trap` (P4's trap configuration for integer division and remainder). EscapeCex and the stuck `EObserve` are already ruled out at P5. +|For closed configurations that are well formed (`config_wf`) *and* well typed (`has_type_v2` of the expression under the configuration's store typing and Σ): every one is a value, steps, or is `Trap`. Well-formedness alone does not suffice: a closed well-formed configuration such as an integer applied as a function is neither a value, nor `Trap`, nor able to step. (`Trap` is P4's trap configuration for integer division and remainder.) EscapeCex and the stuck `EObserve` are already ruled out at P5. |CLOSED |P7, P8 |M |P10 `preservation_v2` (L1 + L2 folded) -|All step cases, including an unconditional effect-typed beta. This discharges what #240–#242 were working toward. Corollary `type_safety_v2`. Sub-agents work by case family: region, drop, app/beta, let/case/pair, ref/borrow, echo/observe. +|All step cases, including an unconditional effect-typed beta. The conclusion is "the successor is typed at the same type, or it is `Trap`" (P4); the division and remainder steps to `Trap` take the second disjunct. This discharges what #240–#242 were working toward. Corollary `type_safety_v2`. Sub-agents work by case family: region, drop, app/beta, let/case/pair, ref/borrow, echo/observe. |CLOSED; allowlist unchanged |P7, P8, P9 |L–XL @@ -789,7 +791,7 @@ Includes `step_v2_deterministic`. |L |P14 Meta plane -|`MetaPlane.v`: `comptime_normalises` (structural Fixpoint) and `comptime_ignores_runtime`. +|Branches on D8, like E15. Recommended option (halting island): `MetaPlane.v` with `comptime_normalises` (structural Fixpoint) and `comptime_ignores_runtime`. Fuel-bounded option: `comptime_normalises_or_fuel_exhausted` (every evaluation returns a normal form or the named fuel-exhausted outcome, never anything else) and `comptime_ignores_runtime`. 2LTT option: a typed-staging normalisation argument (`stage_normalises`: every well-typed meta-level term normalises, by a logical relation over the meta-level types) and `comptime_ignores_runtime`. Drop option: P14 is not scheduled, and E15's UNIMPLEMENTED diagnostics stand. |CLOSED |E15 grammar (parallel) |M @@ -854,8 +856,8 @@ These are kept off the critical path. |Rung |Deliverable |Gate |Depends |Size |E0 Holes first -|`mark_used` routes through `demands_consumption` (lib.rs:191). `check_lambda` rejects consuming a captured linear value. `Ty::is_linear` takes the join over Prod/Sum/Tuple/List (conservative *reject*, D5-independent; D5 in W1 may relax it). Recursive `data` gives a `DesugarError`, not an overflow. Dereferencing a borrow of a linear value may not consume ownership (today `check_deref` returns the owned inner type, `ephapax-typing/src/lib.rs:1739`, and borrows are not loan-tracked). A pattern may not discard a linear payload: a wildcard over a linear component, or a duplicate binder, is rejected (today a wildcard binds nothing, `lib.rs:1592`). -|`tests/linearity_holes.rs`: dup2, dup4, pairdup, lamcap, sum-, tuple- and list-of-linear duplication probes, a double-borrow-then-drop probe, and a `(_, y)` match over a linear pair are each rejected with a named `TypeError` kind; rec1 fails with the named `DesugarError` and the compiler's normal error exit, never a crash or abort; `cargo test --workspace` green (≥ 507) +|`mark_used` routes through `demands_consumption` (lib.rs:191). `check_lambda` rejects consuming a captured linear value. `Ty::is_linear` takes the join over Prod/Sum/Tuple/List (conservative *reject*, D5-independent; D5 in W1 may relax it). Recursive `data` gives a `DesugarError`, not an overflow. Dereferencing a borrow of a linear value may not consume ownership (today `check_deref` returns the owned inner type, `ephapax-typing/src/lib.rs:1739`, and borrows are not loan-tracked). A pattern may not discard a linear payload: a wildcard over a linear component, or a duplicate binder, is rejected (today a wildcard binds nothing, `lib.rs:1592`). Projection may not discard one either: `check_tuple_index` and `check_list_index` reject a projection from a container whose unselected fields or elements are linear (today they consume the container and return one element, so the rest are lost), as `check_fst`/`check_snd` already do; such containers must be destructured. +|`tests/linearity_holes.rs`: dup2, dup4, pairdup, lamcap, sum-, tuple- and list-of-linear duplication probes, a double-borrow-then-drop probe, a `(_, y)` match over a linear pair, a tuple projection `t.0` from a tuple of two linear strings and a list index from a list of linear strings are each rejected with a named `TypeError` kind; rec1 fails with the named `DesugarError` and the compiler's normal error exit, never a crash or abort; `cargo test --workspace` green (≥ 507) |none (escalation note for pairdup) |S–M @@ -873,7 +875,7 @@ These are kept off the critical path. |E3 Driver |`src/ephapax-driver` used by every tool; `src/ephapax-tools` either joins the workspace or is excluded. -|`driver_agreement.rs`: every entry point (check, compile, run, repl, lsp, query, fmt) loads each corpus file through `ephapax-driver` and agrees on parse/desugar success; check == compile on all corpus files, which holds because `check` runs the same unsupported-feature validation as codegen (so the E1 fail-loud forms, such as the `perform` probe, are rejected by both with the same named diagnostic until E17); an integer literal above `i64::MAX` is a named `ParseError` on every entry point (today the core-parser fallback turns it into 0 via `unwrap_or(0)`, `ephapax-parser/src/lib.rs:965`), with a corpus-gate fixture; `qualified-import/app.eph` and `bridge.eph` check +|`driver_agreement.rs`: every entry point (check, compile, run, repl, lsp, query, fmt) loads each corpus file through `ephapax-driver` and agrees on parse/desugar success; check == compile on all corpus files, which holds because `check` runs the same unsupported-feature validation as codegen (so the E1 fail-loud forms, such as the `perform` probe, are rejected by both with the same named diagnostic until E17); an integer literal above `i64::MAX` is a named `ParseError` on every entry point, except that `-9223372036854775808` (unary negation applied directly to the literal `9223372036854775808`, which today parses as `UnaryOp::Neg` over an unsigned token, `ephapax.pest:331-336`) is folded into the single literal `i64::MIN` before the range check; a fixture checks that it evaluates to `i64::MIN`, and that `9223372036854775808` alone, `-9223372036854775809` and `-(9223372036854775808)` (where the literal is not the direct operand of the minus sign) are each the named `ParseError` (today the core-parser fallback turns it into 0 via `unwrap_or(0)`, `ephapax-parser/src/lib.rs:965`), with a corpus-gate fixture; `qualified-import/app.eph` and `bridge.eph` check |none |M @@ -896,7 +898,7 @@ These are kept off the critical path. |S |E7 Grammar + generics -|One normative grammar plus a conformance fixture test. `SurfaceDecl::Fn` type params (desugar lib.rs:323). Until E13 brings grades, unconstrained type parameters are treated as linear (today `Ty::is_linear` makes `Ty::Var`/`Ty::Unif` non-linear, `ephapax-syntax/src/lib.rs:154`), so a generic body cannot duplicate a value that is later instantiated at a linear type. Desugar monomorphises type parameters before the core, so the core, the translator and `CheckV2` only see monomorphic terms; polymorphic recursion is rejected with a named error. `M.Ctor(x)` lowers to `Construct`. Match guards are honoured by desugar (today `build_arm_body_inner` never consults `arm.guard`, `ephapax-desugar/src/lib.rs:1169`, so `Some(x) if false` is taken unconditionally), or guarded constructor arms are rejected with a named error until they are; P16 proves the lowering later. Import and extern syntax reconciled. +|One normative grammar plus a conformance fixture test. `SurfaceDecl::Fn` type params (desugar lib.rs:323). Until E13 brings grades, unconstrained type parameters are treated as linear (today `Ty::is_linear` makes `Ty::Var`/`Ty::Unif` non-linear, `ephapax-syntax/src/lib.rs:154`), so a generic body cannot duplicate a value that is later instantiated at a linear type. Generic functions are checked once, polymorphically, by a new elaboration step S2b between desugar and S3: it infers the type arguments of every call (or reads explicit type applications), and checks each generic body with its type variables treated as linear. Only after that check succeeds does it monomorphise: each instantiation is specialised and the generic definition erased, so the core, the translator and `CheckV2` see only monomorphic terms. Desugar itself never specialises, because it runs before inference and has no substitution for `a` in `id(3)`. Polymorphic recursion is rejected with a named error. `M.Ctor(x)` lowers to `Construct`. Match guards are honoured by desugar (today `build_arm_body_inner` never consults `arm.guard`, `ephapax-desugar/src/lib.rs:1169`, so `Some(x) if false` is taken unconditionally), or guarded constructor arms are rejected with a named error until they are; P16 proves the lowering later. Import and extern syntax reconciled. |`gen_angle.eph` and `qa/app2.eph` compile and run; a guarded constructor match whose guard is false falls through to the next arm in both interpreter and wasm (or is rejected with the named error); `fn dup(x: a) = (x, x)` is rejected with a named `TypeError` (and so cannot be instantiated at a region-owned `String`); `id` instantiated at two types desugars to two monomorphic core functions that translate to `SyntaxV2`; 0 EBNF productions unaccounted for |E3, the syntax ADR (normative grammar source) ratified in the W1 T1a packet |M diff --git a/docs/reports/2026-11-04-v2-survey/evidence/README.adoc b/docs/reports/2026-11-04-v2-survey/evidence/README.adoc index 61f3a813..0ab7f973 100644 --- a/docs/reports/2026-11-04-v2-survey/evidence/README.adoc +++ b/docs/reports/2026-11-04-v2-survey/evidence/README.adoc @@ -30,7 +30,7 @@ Expected `Print Assumptions` output with coqc 8.18.0. This was first checked at |`echo_copyable_linear_mode` |Closed under the global context |=== -NOTE: These results describe `main` before the D0/D0b retirement in hyperpolymath/ephapax#422 (owner-ratified 2026-11-05). After #422 merges, the three refuted statements are `Prop` definitions rather than `Admitted` lemmas. `legacy_inconsistent`, `env_inconsistent_preservation_l1` and `env_inconsistent_step_pop` then fail to compile, which is the intended result: the environment no longer derives `False`. The other five lemmas compile unchanged on top of #422 and stay "Closed under the global context" (checked with coqc 8.18.0 after removing F0/F1); P2 moves them into `formal/regress/`. +NOTE: The F0/F1 results above describe `main` before the D0/D0b retirement in hyperpolymath/ephapax#422 (owner-ratified 2026-11-05), when the three refuted statements were `Admitted` and the environment derived `False`. Since #422 they are `Prop` definitions, and `SurveyCounterexamples.v` restates F0 and F1 to take them as hypotheses (`preservation_stmt -> False` and the two L1 forms). Re-checked 2026-11-09 with coqc 8.18.0 against `main` at `32d8eb7`: all eight lemmas, F0 and F1 included, print "Closed under the global context". P2 moves the other five into `formal/regress/`. The first three rows mean the global environment can derive `False`. The L1 pair needs the P0 retirement in the plan. The legacy row needs decision D0b in the plan, because the legacy theorem is fenced. Both are owner decisions. The `Admitted.` must not be closed, and no side-condition may be added. diff --git a/docs/reports/2026-11-04-v2-survey/evidence/SurveyCounterexamples.v b/docs/reports/2026-11-04-v2-survey/evidence/SurveyCounterexamples.v index 9aa0e2dc..e2dcbf1b 100644 --- a/docs/reports/2026-11-04-v2-survey/evidence/SurveyCounterexamples.v +++ b/docs/reports/2026-11-04-v2-survey/evidence/SurveyCounterexamples.v @@ -5,9 +5,11 @@ Re-checkable witnesses for the findings in docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc. Apart from F0, every lemma here is against the CURRENT v2 judgment - ([has_type_l1], [step]). F0 is the single exception: it *uses* the - legacy [Semantics.preservation] (Admitted by owner directive) together - with [Counterexample.v] to witness the legacy inconsistency. Nothing + ([has_type_l1], [step]). F0 is the single exception: it is about the + legacy preservation statement. Since the D0/D0b retirement + (ephapax#422, 2026-11-05) the refuted statements are [Prop] + definitions, so F0 and F1 take them as hypotheses: the derivations + that used to prove [False] outright now only refute the hypothesis. Nothing here closes, edits or extends the legacy theorem or Semantics.v. Check (from repo root, after building formal/): @@ -21,27 +23,32 @@ From Ephapax Require Import Syntax Typing TypingL1 Semantics Semantics_L1 Require Import Coq.Lists.List Coq.Strings.String. Import ListNotations. Open Scope string_scope. -(** ** F0. The legacy [Semantics.preservation] is [Admitted] (kept by - owner directive) and refuted by [Counterexample.v], so loading both - also derives [False]. This does not ask for the legacy theorem to - be closed or edited. It records that it must never appear in a - headline theorem's assumptions. *) -Lemma legacy_inconsistent : False. +(** ** F0. While the legacy preservation was [Admitted] and refuted by + [Counterexample.v], loading both derived [False] (survey run, + 2026-11-04). It is now [Semantics.preservation_stmt : Prop]; the same + derivation only refutes that hypothesis, which is what + [Counterexample.preservation_false] states. *) +Lemma legacy_inconsistent : Semantics.preservation_stmt -> False. Proof. - destruct (Semantics.preservation _ _ _ _ _ _ (Counterexample.bad_step nil) + intro Hpres. + destruct (Hpres _ _ _ _ _ _ (Counterexample.bad_step nil) _ _ _ Counterexample.bad_typable) as [G_out H]. exact (Counterexample.bad_post_untypable _ _ H). Qed. -(** ** F1. The global environment is inconsistent. - [preservation_l1] and [step_pop_disjoint_from_type_l1] are - [Admitted] (hence axioms) and are refuted by Qed, axiom-free - files that are built in the same project. *) -Lemma env_inconsistent_preservation_l1 : False. -Proof. exact (preservation_l1_FALSE preservation_l1). Qed. +(** ** F1. While [preservation_l1] and [step_pop_disjoint_from_type_l1] + were [Admitted] (hence axioms), the global environment was + inconsistent: Qed, axiom-free files built in the same project refute + them. They are now the [Prop]s [Semantics_L1.preservation_l1_stmt] and + [Semantics_L1.step_pop_disjoint_from_type_l1_stmt], and the same + terms only refute those hypotheses. *) +Lemma env_inconsistent_preservation_l1 : + Semantics_L1.preservation_l1_stmt -> False. +Proof. exact preservation_l1_FALSE. Qed. -Lemma env_inconsistent_step_pop : False. -Proof. exact (step_pop_disjoint_from_type_l1_FALSE step_pop_disjoint_from_type_l1). Qed. +Lemma env_inconsistent_step_pop : + Semantics_L1.step_pop_disjoint_from_type_l1_stmt -> False. +Proof. exact step_pop_disjoint_from_type_l1_FALSE. Qed. (** ** F2. preservation_l1 is false with a closed context ([G = []]). An un-entered same-name [ERegion] exits immediately and pops the @@ -173,8 +180,8 @@ Proof. apply T_Echo_L1; [constructor | now apply T_Loc_L1]. Qed. -(** Assumption audit. Expected: F0/F1 lemmas list the refuted axiom; - every other lemma is "Closed under the global context". *) +(** Assumption audit. Expected since the D0/D0b retirement: every lemma, + F0 and F1 included, is "Closed under the global context". *) Print Assumptions legacy_inconsistent. Print Assumptions env_inconsistent_preservation_l1. Print Assumptions env_inconsistent_step_pop. diff --git a/formal/Counterexample_StepPop.v b/formal/Counterexample_StepPop.v index e6012a7e..2811348e 100644 --- a/formal/Counterexample_StepPop.v +++ b/formal/Counterexample_StepPop.v @@ -75,8 +75,9 @@ now with a machine-checked forcing argument. Owner escalation, not patching: this file only ADDS a regression - witness. [Semantics_L1.v] is untouched; its Admitted markers stand - until the owner ratifies a reformulation. *) + witness. (2026-11-05: the refuted statement is now the [Prop] + [step_pop_disjoint_from_type_l1_stmt]; see the D0 retirement note in + [Semantics_L1.v].) *) Require Import Coq.Lists.List. Import ListNotations. diff --git a/formal/PROOF-STATUS.a2ml b/formal/PROOF-STATUS.a2ml index d9fbbb53..18596fe8 100644 --- a/formal/PROOF-STATUS.a2ml +++ b/formal/PROOF-STATUS.a2ml @@ -21,7 +21,7 @@ counterexample = "formal/Counterexample.v" [current-proof-boundary] status-date = "2026-11-01" source-of-truth = "PROOF-NEEDS.adoc" -legacy-preservation = "refuted-as-stated; durable Admitted retained; five-Qed regression pinned" +legacy-preservation = "refuted-as-stated" step-pop-disjoint-from-type-l1 = "refuted-as-stated; Counterexample_StepPop.v" preservation-l1 = "refuted-as-stated in Linear instance; Counterexample_PreservationL1.v" preservation-l2 = "no unconditional theorem declared; see Counterexample_L2.v and Counterexample_L2_nested.v" @@ -68,17 +68,14 @@ key-lemmas = [ path = "formal/Semantics.v" purpose = "operational step + legacy preservation" status = "frozen-refuted" -admit-count = 1 -note = "The legacy preservation statement is false as stated; its durable Admitted is pinned by the five-Qed formal/Counterexample.v regression. Do not treat as ordinary debt." +note = "The legacy preservation statement is false as stated. Do not treat as ordinary debt." [[files]] path = "formal/Semantics_L1.v" purpose = "L1 helper lemmas, false unrestricted theorem statements, and scoped regressions" status = "active-with-refuted-preservation-statements" -admit-count = 2 axiom-count = 0 -admitted-lemma-count = 2 -note = "Current two outer Admitted lemmas are refuted as stated by Counterexample_StepPop.v and Counterexample_PreservationL1.v; replacements require redesign, not proof completion." +note = "Replacements require redesign, not proof completion." landed-in = ["PR-157", "PR-158", "PR-159", "PR-160", "PR-161", "PR-162", "PR-163"] # ============================================================================ @@ -143,10 +140,9 @@ purpose = "corollary: In r R' implies In r R" file = "Semantics_L1.v" landed-in = "PR-161" swarm-stream = "L1.D" -status = "Admitted; refuted-as-stated" +status = "refuted-as-stated" cases-total = 33 cases-qed = 29 -cases-admit = 4 note = "The theorem statement is refuted by Counterexample_PreservationL1.v (Linear instance). Case counts are historical; do not treat residual admits as unfinished routine proof debt." # ============================================================================ diff --git a/formal/Semantics_L1.v b/formal/Semantics_L1.v index 90468aab..ef07d9e6 100644 --- a/formal/Semantics_L1.v +++ b/formal/Semantics_L1.v @@ -3187,8 +3187,9 @@ Qed. HISTORICAL proof decomposition (pre-counterexample; case counts and proposed closure route below are not current proof debt). The unrestricted theorem is now refuted as stated by - [Counterexample_PreservationL1.v]. Its [Admitted] is a transparent - marker for a false statement, not a proof expected to be completed. + [Counterexample_PreservationL1.v]. It is now the [Prop] + [preservation_l1_stmt] (D0, 2026-11-05), not a proof expected to be + completed. The old case-by-case diagnosis follows only as design archaeology. At the time, the proof sketch described 30 of 33 case subgoals as @@ -3484,12 +3485,12 @@ Print Assumptions preservation_l3. structured. It is superseded as a closure plan: the unrestricted theorem is refuted by [Counterexample_StepPop.v], and a later value-anchored witness in [Counterexample_PreservationL1.v] also - defeats the proposed restriction. Its [Admitted] is a transparency - marker for a false statement, not an unfinished routine proof. + defeats the proposed restriction. It is now the [Prop] + [step_pop_disjoint_from_type_l1_stmt] (D0, 2026-11-05), not an + unfinished routine proof. The Qed cases in the body remain local proof facts; they do not - salvage the universal theorem. [Print Assumptions] reports the - admitted theorem as an axiom. Current replacement status is in + salvage the universal theorem. Current replacement status is in [PROOF-NEEDS.adoc]. *) diff --git a/formal/scripts/no-false-gate.sh b/formal/scripts/no-false-gate.sh new file mode 100755 index 00000000..69ed1664 --- /dev/null +++ b/formal/scripts/no-false-gate.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Owner: Jonathan D.A. Jewell +# +# No-False gate for the Coq development in formal/. +# +# Claim this gate supports: every file in _CoqProject is free of declared +# assumptions and of kernel-check bypasses, and every named result is +# "Closed under the global context". Assuming Coq's own consistency, such +# a development cannot derive False. It says nothing about whether the +# stated theorems are the right theorems. +# +# Four independent checks; any one failing fails the gate: +# 1. token scan on comment- and string-stripped source for assumption +# vernacular and admit tactics (comment-aware, word-boundary); +# 2. token scan for kernel bypass flags (guard, positivity, universe +# checking, Bypass Check, type-in-type); +# 3. census: `Print Assumptions` on every Theorem/Lemma/Corollary/ +# Proposition/Fact/Remark/Example/Instance; each must print +# "Closed under the global context", and the count must match; +# 4. independent recheck of the compiled .vo files with coqchk: no +# axiom in the Ephapax namespace, nothing relying on type-in-type, +# unsafe (co)fixpoints or assumed positivity. +# +# Usage: run from formal/ after the build: bash scripts/no-false-gate.sh +set -euo pipefail + +FILES=$(grep -E '^[^#].*\.v[[:space:]]*$' _CoqProject | awk '{print $1}' | tr '\n' ' ') +MODS=$(for f in $FILES; do printf '%s ' "${f%.v}"; done) +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# Strip nested Coq comments and string literals, keeping line structure. +strip() { + awk ' + BEGIN { depth = 0; instr = 0 } + { + out = ""; n = length($0); i = 1 + while (i <= n) { + c = substr($0, i, 1); d = substr($0, i, 2) + if (instr) { + if (c == "\"") { if (substr($0, i + 1, 1) == "\"") { i += 2; continue } instr = 0 } + i++; continue + } + if (d == "(*") { depth++; i += 2; continue } + if (depth > 0 && d == "*)") { depth--; i += 2; continue } + if (depth > 0) { i++; continue } + if (c == "\"") { instr = 1; i++; continue } + out = out c; i++ + } + print out + }' "$1" +} + +fail=0 +for f in $FILES; do strip "$f" > "$WORK/$f"; done + +# 1. Assumption vernacular and admit tactics. +ASSUME='(^|[^A-Za-z0-9_'"'"'.])(Admitted|admit|give_up|Axiom|Axioms|Parameter|Parameters|Conjecture|Hypothesis|Hypotheses|Variable|Variables|Context)([^A-Za-z0-9_'"'"']|$)' +if (cd "$WORK" && grep -nE "$ASSUME" $FILES); then + echo "no-False gate [1]: assumption vernacular or admit tactic found above -> FAIL"; fail=1 +fi + +# 2. Kernel bypass flags. +BYPASS='(Unset[[:space:]]+(Guard|Positivity|Universe)[[:space:]]+Checking|Bypass[[:space:]]+Check|Set[[:space:]]+Type[[:space:]]+In[[:space:]]+Type|-type-in-type|bypass_check)' +if (cd "$WORK" && grep -nE "$BYPASS" $FILES); then + echo "no-False gate [2]: kernel-check bypass found above -> FAIL"; fail=1 +fi + +# 3. Census of every named result. +NAMES="$WORK/names" +: > "$NAMES" +for f in $FILES; do + m=${f%.v} + { grep -oE '(^|[[:space:]])(Theorem|Lemma|Corollary|Proposition|Fact|Remark|Example|Instance)[[:space:]]+[A-Za-z_][A-Za-z0-9_'"'"']*' "$WORK/$f" || true; } \ + | awk -v m="$m" '{print "Ephapax." m "." $NF}' >> "$NAMES" +done +total=$(wc -l < "$NAMES") +# The headline results must stay in the census under these names. +for h in Counterexample.preservation_false \ + Counterexample_StepPop.step_pop_disjoint_from_type_l1_FALSE \ + Counterexample_PreservationL1.preservation_l1_FALSE \ + TypingL2.preservation_l2_via_l1 \ + TypingL2.preservation_l2_app_eff_beta_linear \ + TypingL2.preservation_l2_app_eff_beta_ground_nonlinear; do + grep -qx "Ephapax.$h" "$NAMES" || { echo "no-False gate [3]: headline Ephapax.$h is missing from the census -> FAIL"; fail=1; } +done +{ echo "From Ephapax Require $MODS."; sed 's/.*/Print Assumptions &./' "$NAMES"; } > "$WORK/census.v" +coqtop -quiet -R . Ephapax < "$WORK/census.v" > "$WORK/census.out" 2>&1 || true +closed=$(grep -c 'Closed under the global context' "$WORK/census.out" || true) +if grep -qE '^Axioms:|Error' "$WORK/census.out"; then + grep -nE -B3 -A8 '^Axioms:|Error' "$WORK/census.out" | head -80 + echo "no-False gate [3]: a named result depends on an assumption, or coqtop errored -> FAIL"; fail=1 +fi +if [ "$closed" -ne "$total" ]; then + echo "no-False gate [3]: $closed of $total named results closed -> FAIL"; fail=1 +fi + +# 4. Independent kernel recheck of the compiled libraries. +QUAL=$(for m in $MODS; do printf 'Ephapax.%s ' "$m"; done) +coqchk -o -silent -R . Ephapax $QUAL > "$WORK/coqchk.out" 2>&1 || { tail -30 "$WORK/coqchk.out"; echo "no-False gate [4]: coqchk rejected a library -> FAIL"; fail=1; } +awk '/^\* Axioms:/{a=1;next} /^\* /{a=0} a && NF' "$WORK/coqchk.out" > "$WORK/axioms" +if grep -q 'Ephapax\.' "$WORK/axioms"; then + cat "$WORK/axioms"; echo "no-False gate [4]: coqchk reports an Ephapax axiom -> FAIL"; fail=1 +fi +for k in 'type-in-type' 'unsafe (co)fixpoints' 'positivity is assumed'; do + if ! grep -A1 -F "$k" "$WORK/coqchk.out" | grep -q ''; then + grep -A3 -F "$k" "$WORK/coqchk.out"; echo "no-False gate [4]: coqchk: '$k' is not -> FAIL"; fail=1 + fi +done + +echo "--- census: $closed/$total named results closed under the global context" +echo "--- coqchk environment axioms (loaded libraries, not necessarily used):" +sed 's/^/ /' "$WORK/axioms" +if [ "$fail" -ne 0 ]; then echo "no-False gate: FAIL"; exit 1; fi +echo "no-False gate: PASS" From 11772f8db37888826d997d48c8387c92b9ef6040 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 13:33:23 +0000 Subject: [PATCH 02/26] =?UTF-8?q?docs(proof):=20record=20owner=20ruling=20?= =?UTF-8?q?=E2=80=94=20soundness=20statements=20are=20theory=20(Agda=20sou?= =?UTF-8?q?rce)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner ruling 2026-11-09: Ephapax's soundness statements (calculus, progress, preservation, L1-L4 obligations, refutations) are theory under PROOF-POLICY §1, each with an Agda source of truth. Coq proofs become the Rocq recheck leg once RECONCILED; everything stays ungraded until an Agda proof checks. The v2 plan records the ruling, names the three refutations as the first Agda targets, and opens D13 (Coq/Agda ordering) for the W0 packet. Also notes how the D0b branch rows for T0g and the survey evidence were applied. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- CLAUDE.md | 7 ++++--- EXPLAINME.adoc | 10 +++++----- PROOF-NEEDS.adoc | 20 ++++++++++++------- .../EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc | 15 ++++++++++++-- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 71aebc0f..534fe9c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,9 +210,10 @@ them if asked; if asked anyway, push back and link this section. `agda --safe --without-K --cubical-compatible`; Coq `Qed` is Coq evidence, not `PWG-3`, and counts as a recheck leg only once an Agda source exists and the statements are `RECONCILED`. Always write the - `PWG-` prefix. Standards maps - Ephapax-specific engineering claims to Idris2 unless an - implementation-independent theory statement is explicitly defined. + `PWG-` prefix. Owner ruling 2026-11-09: the soundness statements + (calculus, progress, preservation, L1–L4 obligations, refutations) are + *theory*, with an Agda source of truth; the Idris2 ABI and the Rust + implementation stay engineering. Do not assign a PWG until that classification and exact statement correspondence are recorded in `EXPLAINME.adoc` and `PROOF-NEEDS.adoc`. diff --git a/EXPLAINME.adoc b/EXPLAINME.adoc index f47d5fd4..05603211 100644 --- a/EXPLAINME.adoc +++ b/EXPLAINME.adoc @@ -408,12 +408,12 @@ all L1 regressions remain mandatory controls. transparently depends on the refuted L1 statement. |`PWG-3` receipt for Ephapax language soundness |open -|Agda receipt required by PROOF-POLICY §8 (if classed as theory) +|Agda receipt required by PROOF-POLICY §8 (classed as theory, owner ruling 2026-11-09) |No Agda proof recorded; every Ephapax result is `ungraded` or off the PWG ladder -|The existing witnesses are Coq, not `PWG-3`. Standards classifies local -Ephapax-specific typing/step claims as engineering (Idris2) unless a genuine -implementation-independent theory statement is defined. Resolve this -mapping before assigning any PWG; see `+PROOF-NEEDS.adoc+`. +|The existing witnesses are Coq, not `PWG-3`. The owner classed the soundness +statements as theory on 2026-11-09, so each needs an Agda source; the Coq +proofs become the Rocq recheck leg once statements are `RECONCILED`. See +`+PROOF-NEEDS.adoc+`. |=== Proof Witness Grades are not interchangeable with this ledger's statuses. diff --git a/PROOF-NEEDS.adoc b/PROOF-NEEDS.adoc index 9064437e..18a6e3b2 100644 --- a/PROOF-NEEDS.adoc +++ b/PROOF-NEEDS.adoc @@ -110,13 +110,19 @@ uses the §5 tokens only). The Coq results are Coq evidence: a Coq `+Qed+` is not `PWG-3`, and it can count as a Rocq recheck leg only after an Agda source exists and the two statements are `RECONCILED` (§2.2, §3). -*The classification itself is open.* The `+has_type+`/`+step+` statements -name Ephapax's own judgment, which reads as engineering under the §1 test, -but the soundness of a calculus is listed as theory. Until the owner -classifies each obligation, record Coq results as Coq evidence, keep -`+COUNTEREXAMPLE+` for refuted statements, and assign no PWG. Policy §3 adds -a consequence: an engineering proof held in a prover other than Idris2 moves -to Idris2 when next touched, unless a §4 exception is recorded beside it. +*Classification: theory (owner ruling, 2026-11-09).* The soundness +statements (the typing judgment and step relation as a calculus, progress, +preservation, the L1–L4 obligations, and the refutations of the retired +statements) are theory under §1, and each gets an Agda source of truth. +Until an Agda proof checks, each is `ungraded`. The Coq proofs are the Rocq +recheck leg (§2): target-only evidence (§3) until the matching Agda +statement exists and the correspondence is `RECONCILED`. Keep +`+COUNTEREXAMPLE+` for refuted statements. The first Agda targets are the +three refutations; the order of Coq and Agda work for the v2 core is +decision D13 in the v2 plan +(`+docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc+`, +"Ratified: proof classification"). The Idris2 ABI, the Rust implementation +and typed-wasm emission stay engineering and are not graded. A future `PWG-3` receipt must record the exact proposition, assumptions, source commit, pinned Agda and standard library, the canonical invocation `agda --safe --without-K --cubical-compatible`, the import cone, and evidence diff --git a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc index c27eb904..a85f932d 100644 --- a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc +++ b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc @@ -1235,15 +1235,26 @@ The legacy `Semantics.preservation` is `Admitted` by directive and is refuted by |P1 |The tripwire's quarantine list starts empty after P0. Any refuted `Admitted` fails CI. |T0g -|Replaced: instead of the import-closure job, a CI gate checks that no `_CoqProject` file contains `Admitted`, `Axiom` or `Parameter`, and that the named refutations and L2 results print "Closed under the global context". With no axioms in the build, a closed proof of `False` would contradict the consistency of Coq's own theory; that meta-level argument, not the enumerated checks, is what rules out every other derivation. +|Replaced: instead of the import-closure job, a CI gate checks that no `_CoqProject` file contains `Admitted`, `Axiom` or `Parameter`, and that the named refutations and L2 results print "Closed under the global context". With no axioms in the build, a closed proof of `False` would contradict the consistency of Coq's own theory; that meta-level argument, not the enumerated checks, is what rules out every other derivation. (Hardened on 2026-11-09 in `formal/scripts/no-false-gate.sh`: a comment-aware scan of every assumption form, a census of every named result, and a `coqchk` recheck.) |T0f |The doc-correction PR also revises the 2026-05-27 fence in CLAUDE.md (DO NOT items 1, 2 and 5), PROOF-NEEDS, EXPLAINME and V1-TO-V2, and records that the legacy theorem is retired, not closed. Applied by the owner, GPG-signed. |Evidence -|`evidence/SurveyCounterexamples.v` F0 becomes an expected-failure target, like F1. +|`evidence/SurveyCounterexamples.v` F0 becomes an expected-failure target, like F1. (Applied differently on 2026-11-09: F0 and F1 were restated to take the retired statements as hypotheses, so all eight survey lemmas still compile and are closed.) |=== Beyond this repo: the memory entry `feedback_ephapax_no_patching_legacy_preservation.md` needs the same revision. +=== Ratified: proof classification (owner, 2026-11-09) + +Ephapax's soundness statements are *theory* under PROOF-POLICY §1: the typing judgment and step relation as a calculus, progress, preservation, the layer obligations (L1–L4) and the refutations of the retired statements. Each gets an Agda source of truth. + +* *Grades.* Every such result is `ungraded` until its Agda proof checks under `agda --safe --without-K --cubical-compatible` (`PWG-3`). A grade is derived from receipts, never asserted (§8.4). +* *Coq becomes a recheck leg.* The existing Coq proofs, and the Coq P-rungs of this plan, are the Rocq leg of §2. A Coq proof counts towards `PWG-2i`/`PWG-2ii` only once the matching Agda statement exists and the correspondence is `RECONCILED` (§2.2). Until then it is target-only evidence (§3). The Lean leg is transported through Dedukti where the pinned exporter allows it, and otherwise re-proved by hand without Mathlib. +* *First targets.* The refutations are small and closed (`preservation_false`, `step_pop_disjoint_from_type_l1_FALSE`, `preservation_l1_FALSE`). Porting them needs only the fragment of the legacy and L1 judgments their witnesses touch. They are the first Agda rows, and they test the correspondence method before the v2 core is ported. +* *What stays engineering.* The Idris2 ABI, the Rust checker and code generator (Creusot/Pearlite and Verus, #378) and typed-wasm emission are about this repository's implementation. They keep the §1 engineering mapping and are not graded. +* *Plan impact.* The P-ladder is written for Coq, because the executable oracle (D9a, D9) extracts from Coq. This ruling adds an Agda port obligation to every headline rung. The order of the two is a new decision, *D13*, in the W0 packet: Coq first then Agda port, Agda first with Coq as the recheck leg, or both developed together. Under the decision-branch rule, no P-rung past P4 starts until D13 is ratified with its branch table. +* *Beyond this repo.* The PWG ledger lives in `PROOF-NEEDS.adoc`. Mathesis may catalogue the grades it derives from those receipts (PROOF-POLICY §8.5). The memory entry `feedback_ephapax_no_patching_legacy_preservation.md` should record the classification. + === D1 — Where the v2 core lives [cols="3,1,1,4",options="header"] From 090fb6cd7a2af47763103e91f4649fe368c91126 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 13:52:34 +0000 Subject: [PATCH 03/26] docs: finish the W0 doc-truth items from the v2 survey list - formal/Echo.v banner, STATUS, PROOF-NEEDS, EXPLAINME: Echo.v has 19 Qed and 0 admits (counted on comment-stripped source), not 12. - docs/wikis/Home.md: L2 has no unconditional preservation (no Admitted regressions remain); L3 Qed is scoped to matching rule pairs; legacy preservation is retired, not 'Theorem preservation'. - docs/testing-and-bench-strategy.adoc: honesty preamble matches D0/D0b. - spec/FRG-PROFILE.adoc: .md links to .adoc; legacy preservation retired. - Cargo.toml: the typed-wasm pin is the #203 merge (2026-07-07), not #65. - ephapax-syntax: the 'Coq bridge' TFunEff test equated two different constructors (named-effect row vs region lists); renamed and reworded as Rust-only behaviour. cargo test -p ephapax-syntax --lib: 10 passed. README erasure text (README.adoc:265-273) was already hedged; unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- Cargo.toml | 10 +++++----- EXPLAINME.adoc | 2 +- PROOF-NEEDS.adoc | 2 +- STATUS.adoc | 2 +- docs/testing-and-bench-strategy.adoc | 2 +- docs/wikis/Home.md | 6 +++--- formal/Echo.v | 2 +- spec/FRG-PROFILE.adoc | 8 ++++---- src/ephapax-syntax/src/lib.rs | 9 ++++++--- 9 files changed, 23 insertions(+), 20 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fbfdfcbf..5f499534 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,11 +57,11 @@ ephapax-meta = { path = "src/ephapax-meta" } ephapax-vram-cache = { path = "src/ephapax-vram-cache" } # Sibling-repo crates. -# NOTE: rev pin is the post-merge SHA of typed-wasm#65 on -# `hyperpolymath/typed-wasm:origin/main` (renames the custom section -# `affinescript.ownership` → `typedwasm.ownership`). This is the -# canonical C7 pin (cf. CHANGELOG.md "Added" / "C7 (#72)"). Re-bump -# only if a newer `typed-wasm-verify` API change is needed. +# NOTE: rev pin is the merge commit of typed-wasm#203 (2026-07-07, +# per-module split + `tw link`, #140), checked 2026-11-09. It postdates +# typed-wasm#65, which renamed the custom section `affinescript.ownership` +# → `typedwasm.ownership`. Re-bump only when a newer `typed-wasm-verify` +# API change is needed, and update this comment with the new PR. typed-wasm-verify = { git = "https://github.com/hyperpolymath/typed-wasm", rev = "5d67603852098561d5f4e967f6d0bdf3d3e10a16", features = ["unstable-l2"] } # External dependencies diff --git a/EXPLAINME.adoc b/EXPLAINME.adoc index 05603211..eadd481c 100644 --- a/EXPLAINME.adoc +++ b/EXPLAINME.adoc @@ -269,7 +269,7 @@ Qed results are scoped to matching echo rule pairs. | `formal/Modality.v` + `m : Modality` in `has_type_l1`. PRs #176 + #177. `linear_to_affine` Qed with zero axioms. | L3 echo / residue calculus -| ✅ Calculus 12 Qed + wiring landed (slice 4, 2026-05-27) +| ✅ Calculus + wiring landed (slice 4, 2026-05-27); `Echo.v` has 19 Qed, 0 admits (counted 2026-11-09) | `formal/Echo.v` — one type former (fiber `Echo f y := Σ x. f x ≡ y`) + degrade map + no-section-collapse-to-residue proof. The Qed wiring lemmas cover matching echo-emitting rule pairs only; they do not prove general L1/L2 preservation. | L4 dyadic interaction diff --git a/PROOF-NEEDS.adoc b/PROOF-NEEDS.adoc index 18a6e3b2..83d67803 100644 --- a/PROOF-NEEDS.adoc +++ b/PROOF-NEEDS.adoc @@ -159,7 +159,7 @@ link:formal/PRESERVATION-DESIGN.adoc[`+formal/PRESERVATION-DESIGN.adoc+`]. |`+formal/Modality.v+` |1 Qed, zero axioms |L3 calculus (echo / residue fiber + degrade + no-section proof) -|`+formal/Echo.v+` |12 Qed, 0 admits +|`+formal/Echo.v+` |19 Qed, 0 admits (counted 2026-11-09) |Linear-mode forward progress lemmas |`+formal/Semantics_L1.v+` |37 Qed; 0 `+Admitted.+` since D0 (2026-11-05). Historical note: *2* outer `+Admitted.+` markers (`+step_pop_disjoint_from_type_l1+` / diff --git a/STATUS.adoc b/STATUS.adoc index ec623eec..3ec4e732 100644 --- a/STATUS.adoc +++ b/STATUS.adoc @@ -155,7 +155,7 @@ and its assumptions explicitly. `preservation_l3_region_active_echo`, `preservation_l3_drop_echo`, and `preservation_l3` are Qed for the matching echo rule pairs. This scoped result is not general L1/L2 preservation. -| `Echo.v`: **12 / 0** (plus 3 new Qed in `Semantics_L1.v` for the wiring). +| `Echo.v`: **19 / 0** (counted 2026-11-09) (plus 3 new Qed in `Semantics_L1.v` for the wiring). | L4 | Dyadic interaction semantics (mother–child distinction) diff --git a/docs/testing-and-bench-strategy.adoc b/docs/testing-and-bench-strategy.adoc index dc5c1b7d..2e89867c 100644 --- a/docs/testing-and-bench-strategy.adoc +++ b/docs/testing-and-bench-strategy.adoc @@ -31,7 +31,7 @@ benches:: not a level — a cross-cutting measurement family hung off each aspec *How to read the matrix.* The matrix (§2) is the index: each cell names the 2–3 highest-value obligations. The per-aspect sections (§3) expand a cell across all levels and list its benches. Three transverse sections then consolidate the cross-aspect story: *Interoperability* (§4), *Risk transfer* (§5 — the load-bearing section), and *Today vs blocked* (§6). §7 is the prioritised rollout. -*Honesty preamble (non-negotiable).* The formal evidence is partially open and one piece is *deliberately false*: `formal/Semantics.v`'s legacy `Theorem preservation` is `Admitted` and `formal/Counterexample.v` *Qed-proves it false*. The four-layer redesign (L1–L4) is mid-build with 4 outer L1 admits. The suite must (a) carry the weight the open/false proofs cannot yet bear, and (b) *pin the proof-debt state itself* as a tested invariant. No test or doc may claim soundness coverage the proofs do not provide. Per CLAUDE.md fences: do not close legacy preservation or patch admits with proof tricks. +*Honesty preamble (non-negotiable).* The formal evidence is partially open and one piece is *deliberately false*: `formal/Semantics.v`'s legacy preservation is false: it is retired to `preservation_stmt : Prop` and `formal/Counterexample.v` *Qed-proves it false* (`preservation_false`). The unrestricted L1 statements are refuted the same way, and `formal/` has no `Admitted` (D0/D0b, 2026-11-05). The four-layer redesign (L1–L4) is mid-build. The suite must (a) carry the weight the open/false proofs cannot yet bear, and (b) *pin the proof-debt state itself* as a tested invariant. No test or doc may claim soundness coverage the proofs do not provide. Per CLAUDE.md fences: do not prove a refuted statement or reintroduce `Admitted`. == 2. The matrix diff --git a/docs/wikis/Home.md b/docs/wikis/Home.md index 79cbce68..c3816e35 100644 --- a/docs/wikis/Home.md +++ b/docs/wikis/Home.md @@ -52,11 +52,11 @@ The four layers and their current state: | Layer | Description | Status | |---|---|---| | **L1** — Region capabilities | `R_in`, `R_out`, `In r R` threading | Judgment/helpers mechanised; unrestricted `preservation_l1` refuted by pinned counterexample; replacement redesign open | -| **L2** — Structural modality | `m : Modality` in `has_type_l1`; `linear_to_affine` | Core landed (PRs #176+#177); `linear_to_affine` Qed, zero axioms; 6 supporting lemmas regressed to Admitted pending Phase D Stages 2–4 | -| **L3** — Echo residue | What was lost at an irreversible step | `preservation_l3` + two companions Qed (conditional); wired into L1 | +| **L2** — Structural modality | `m : Modality` in `has_type_l1`; `linear_to_affine` | Core landed (PRs #176+#177); `linear_to_affine` Qed, zero axioms; no unconditional `preservation_l2`: the β-case lemmas are conditional, and `preservation_l2_via_l1` takes the refuted L1 statement as a hypothesis | +| **L3** — Echo residue | What was lost at an irreversible step | `preservation_l3` + two companions Qed, scoped to matching echo-emitting rule pairs; not general L1/L2 preservation | | **L4** — Dyadic mode | Project-level mode declaration | Phase A scaffold landed (`formal/L4.v`): `PModeLinear`/`PModeAffine`/`PModeBoundaryMix` + round-trip; definitions only by design | -**Do not** attempt to close `Theorem preservation` in `formal/Semantics.v`. It is provably false. The authoritative do/do-not list is in [`CLAUDE.md`](https://github.com/hyperpolymath/ephapax/blob/main/CLAUDE.md) under "Owner directive 2026-05-27". +**Do not** attempt to prove the legacy preservation in `formal/Semantics.v`. It is provably false: it is retired to `preservation_stmt : Prop` and refuted by `Counterexample.preservation_false`. The authoritative do/do-not list is in [`CLAUDE.md`](https://github.com/hyperpolymath/ephapax/blob/main/CLAUDE.md) under "Owner directive snapshot (2026-05-27, revised 2026-11-05)". ## Component map diff --git a/formal/Echo.v b/formal/Echo.v index 40124af4..efa2c2ac 100644 --- a/formal/Echo.v +++ b/formal/Echo.v @@ -7,7 +7,7 @@ ********************************************************************* *** ✅ ACTIVE -- L3 echo / residue. ONE type former, fiber-shaped. *** *** *** - *** 12 Qed, 0 admits at last count. K-free. *** + *** 19 Qed, 0 admits (counted 2026-11-09). K-free. *** *** *** *** Mirrors upstream Agda development at *** *** https://github.com/hyperpolymath/echo-types *** diff --git a/spec/FRG-PROFILE.adoc b/spec/FRG-PROFILE.adoc index 8c7e3424..d0996d8b 100644 --- a/spec/FRG-PROFILE.adoc +++ b/spec/FRG-PROFILE.adoc @@ -26,14 +26,14 @@ Ephapax has reached FRG-D: preservation/progress theorems stated, operational semantics formalised, layered redesign committed -(`formal/PRESERVATION-DESIGN.md`), open obligations enumerated in -`PROOF-NEEDS.md` and tracked in `formal/PROOF-STATUS.a2ml`. The +(`formal/PRESERVATION-DESIGN.adoc`), open obligations enumerated in +`PROOF-NEEDS.adoc` and tracked in `formal/PROOF-STATUS.a2ml`. The language *was* FRG-F for the period during which `formal/Counterexample.v` had `Qed`'d a refutation of the original preservation statement without a redesign commitment; the F-grade was *evacuated* on 2026-05-27 by the owner directive locking in the -four-layer redesign as canonical (legacy `Theorem preservation` in -`Semantics.v` is now archaeology per the lockdown banner). +four-layer redesign as canonical (the legacy preservation in `Semantics.v` is archaeology: since 2026-11-05 it +is the retired `preservation_stmt : Prop`, refuted by `preservation_false`). FRG = D. The full chain of axes: diff --git a/src/ephapax-syntax/src/lib.rs b/src/ephapax-syntax/src/lib.rs index b62ebea8..a1617e67 100644 --- a/src/ephapax-syntax/src/lib.rs +++ b/src/ephapax-syntax/src/lib.rs @@ -877,14 +877,17 @@ mod tests { } #[test] - fn coq_bridge_effectful_fallthrough_unrestricted() { - // Coq: TFunEff _ _ _ _ falls through → false. + fn effectful_fallthrough_unrestricted() { + // Rust-only behaviour, NOT a Coq bridge: `Ty::Effectful` is a + // function type with a named-effect row, while Coq's `TFunEff` + // carries region lists (R_in, R_out). The two do not correspond; + // they only agree that neither is linear. let effectful = Ty::Effectful { param: Box::new(Ty::String("r".into())), ret: boxed_base(), effects: vec!["IO".into()], }; - assert!(!effectful.is_linear(), "Coq bridge: TFunEff is NOT linear"); + assert!(!effectful.is_linear(), "Ty::Effectful is NOT linear"); } #[test] From 25cef138811deaa26e68c1b291c8e18a6468eb4c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:14:50 +0000 Subject: [PATCH 04/26] feat(formal): land the P2 regression families; record Idris2 postulates P2 (W0 T0c): formal/regress/ joins _CoqProject with - SurveyFamilies.v: F2-F6 from the survey evidence (ClosedCex, EchoCex, PairDup, EchoCopy), ported unchanged; - EscapeCex.v, L2Comp.v, LetCheck.v: the three families lost with the survey scratch files, re-created against the current judgment. EscapeCex is a progress failure (the exit guard leaves a typed configuration stuck); L2Comp shows has_type_l2 is not closed under pairing, in Affine and Linear; LetCheck shows every ELet with a non-linear bound type is untypable (a completeness gap). All Qed. no-false-gate.sh maps subdirectory paths to module names and now counts 299/299 named results closed under the global context; coqchk finds no Ephapax axiom. The coq_makefile build path builds them. Idris2 (now installed, 0.8.0): ephapax-abi and ephapax-formal build. The ABI's 12 clause-less `0 name : ...` declarations are postulates (Idris2 accepts them under %default total; checked with a `0 bad : Void` module that proves 1 = 2). They are jointly consistent and assert nothing about Ephapax; docs/proof-debt.adoc records them as ASSUMED, and E1's text no longer calls the refuted legacy preservation "currently Admitted". The survey plan gains a dated re-check note (Idris2, Zig, wasmtime CLI, z3 results; Lean, Isabelle, julia and Creusot still unverified), and PROOF-NEEDS a row for the regression families. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- PROOF-NEEDS.adoc | 11 + docs/proof-debt.adoc | 29 ++- .../EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc | 11 + formal/_CoqProject | 4 + formal/regress/EscapeCex.v | 186 ++++++++++++++++ formal/regress/L2Comp.v | 198 ++++++++++++++++++ formal/regress/LetCheck.v | 115 ++++++++++ formal/regress/SurveyFamilies.v | 150 +++++++++++++ formal/scripts/no-false-gate.sh | 6 +- src/abi/Ephapax/ABI/Invariants.idr | 29 ++- 10 files changed, 725 insertions(+), 14 deletions(-) create mode 100644 formal/regress/EscapeCex.v create mode 100644 formal/regress/L2Comp.v create mode 100644 formal/regress/LetCheck.v create mode 100644 formal/regress/SurveyFamilies.v diff --git a/PROOF-NEEDS.adoc b/PROOF-NEEDS.adoc index 83d67803..948ea48a 100644 --- a/PROOF-NEEDS.adoc +++ b/PROOF-NEEDS.adoc @@ -193,6 +193,17 @@ Definitions only — no theorems, zero axioms. |5 + 5 Qed pinning the Phase 4c (fresh-region scope crossing) and Phase 3b (nested TFunEff) soundness-gap classes +|P2 survey regression families (2026-11-09) +|`+formal/regress/+` (`+SurveyFamilies.v+`, `+EscapeCex.v+`, `+L2Comp.v+`, +`+LetCheck.v+`) +|Against the current judgment, all Qed and closed: ClosedCex (closed-context +preservation failure), EchoCex (echo cross-path failure and stuck +`+EObserve+`), PairDup and EchoCopy (compound-linearity holes), EscapeCex +(a typed configuration whose closure carries an `+ELoc+` out of its region +is stuck: a progress failure), L2Comp (`+has_type_l2+` is not closed under +pairing) and LetCheck (a completeness gap: every `+ELet+` with a non-linear +bound type is untypable). Regressions only; v2 rung P5 restates them. + |Pinned legacy counterexample + L1 positive regression |`+formal/Counterexample.v+` |6 Qed in total: three lemmas establish the legacy counterexample; `+preservation_false+` refutes `+preservation_stmt+`; `+t_loc_l1_R_preserving+` supports the L1 argument; diff --git a/docs/proof-debt.adoc b/docs/proof-debt.adoc index a21234cf..9dba3b86 100644 --- a/docs/proof-debt.adoc +++ b/docs/proof-debt.adoc @@ -19,7 +19,8 @@ kernel-check bypass. `+formal/scripts/no-false-gate.sh+` enforces this in CI (`+coq-build.yml+`): a comment-aware token scan, a `+Print Assumptions+` census of every named result (each must be closed under the global context), and an independent `+coqchk+` recheck of the compiled libraries. Its census on -2026-11-09 found 257 of 257 named results closed. `+coqchk+` lists +2026-11-09 found 299 of 299 named results closed, including the P2 +regression families in `+formal/regress/+`. `+coqchk+` lists `+Coq.Logic.Eqdep.Eq_rect_eq.eq_rect_eq+` among the axioms of the *loaded standard library*; the census shows no Ephapax result depends on it. @@ -56,6 +57,32 @@ hypotheses exclude those classes; nor do they establish full L2 preservation. Any future L2 theorem must name and prove its restricted boundary without inheriting the L1 counterexample by assumption. +==== Idris2 ABI seam (`+src/abi/+`): 12 erased postulates + +* *Status*: `ASSUMED`. Twelve top-level `+0 name : …+` declarations have no + clauses: `+typePreservation+`, `+linearConsumptionAcrossControlFlow+`, + `+ReferencesRegion+`, `+regionNoRuntimeEscape+`, `+noRuntimeGC+`, + `+WasmTyped+`, `+compile+`, `+wasmCompilationPreservesTyping+`, `+SStep+`, + `+lowerIR+`, `+irLoweringPreservesSemantics+` (all in + `+src/abi/Ephapax/ABI/Invariants.idr+`) and `+compileOkImpliesWasmTyped+` + (`+src/abi/Ephapax/ABI/Foreign.idr+`). Idris2 0.8.0 accepts such a + declaration as a postulate even under `+%default total+`; a test module + with `+0 bad : Void+` proves `+1 = 2+` with no warning (checked + 2026-11-09). +* *Consistency*: reading `+WellTyped+` and `+Step+` as empty (they have no + constructors) makes every postulated proposition vacuously true, and the + postulated types and functions are abstract constants over inhabited + types. So the set is consistent, and for the same reason it asserts + nothing about Ephapax. E1 (`+typePreservation+`) is refuted for the + legacy judgment by `+Counterexample.preservation_false+`; the obligation + moves to the v2 judgment. +* *Build*: `+idris2 --build ephapax-abi.ipkg+` and + `+idris2 --build ephapax-formal.ipkg+` exit 0 with Idris2 0.8.0 + (2026-11-09). `+src/formal/+` has no undefined declarations. +* *Detection*: textual escape-hatch scans that look only for `+believe_me+` + or `+assert_total+` miss these. The mathesis census scanner reports them + as `+undefined declaration+` axiom candidates (hyperpolymath/mathesis#27). + ==== `+idris2/src/Main.idr:22+` — global `+%default partial+` * *Status*: real module-wide Idris2 totality waiver; configured and compiled, diff --git a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc index a85f932d..40fdf029 100644 --- a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc +++ b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc @@ -17,6 +17,17 @@ The survey ran the following toolchains: Idris2, Lean, Isabelle, zig, julia, z3 and the wasmtime CLI are not installed. Every claim that depends on them is marked *UNVERIFIED*. +[NOTE] +.Re-check, 2026-11-09 (toolchains installed in a later session) +==== +* *P2 landed (T0c).* EscapeCex, L2Comp and LetCheck were re-created against the current judgment and land with F2–F6 in `formal/regress/` (in `_CoqProject`): 299 of 299 named results in `formal/` are closed under the global context. The re-created statements differ from the reported ones in two places. EscapeCex is a *progress* failure: `S_Region_Exit`'s guard catches the captured `ELoc`, so the typed configuration is stuck rather than stepping to an untypable one (`escaped_value_untypable_at_type` shows the unguarded exit would be untypable at the original type). LetCheck is broader than reported: every `ELet` whose bound type is non-linear is untypable, in both `has_type_l1` and the legacy `has_type`. +* *Idris2 0.8.0.* `ephapax-abi.ipkg` and `ephapax-formal.ipkg` build (exit 0). The ABI's 12 clause-less `0 name : …` declarations are postulates (Idris2 accepts them under `%default total`); they are jointly consistent and assert nothing about Ephapax (`docs/proof-debt.adoc`). `idris2/ephapax-affine.ipkg` needs the `proven` package, which is not installed: still UNVERIFIED. +* *Zig.* `zig test tools/coproc/ephapax_coproc.zig`: 2/2 pass (Zig 0.14.0). typed-wasm `ffi/zig`: 7/7 pass with Zig 0.15.1, the version its CI uses (it does not build with 0.14). +* *wasmtime CLI 36.0.2.* `p1.wasm` (959 B) validates; with stub `env.print_i32`/`env.print_string` imports, `main` returns 162. +* *z3 4.8.12.* absolute-zero `OND_checks.smt2` reproduces sat/unsat/sat; its OND-3 dir 2 block was vacuous and is fixed in hyperpolymath/absolute-zero#198. +* *Still UNVERIFIED here:* Lean (elan installs, but the toolchain download is refused by the network proxy), Isabelle, julia and Creusot. +==== + Nothing in this document proposes closing or extending `formal/Semantics.v` or `formal/Typing.v`. The legacy `Theorem preservation` is false (`formal/Counterexample.v`). It stayed `Admitted.` by owner directive until the owner ratified D0b (2026-11-05): P0 now retires it to a `Prop` statement, which the owner applies, rather than closing it. Because an `Admitted` statement is an axiom, the legacy theorem together with `Counterexample.v` also derives `False` (`evidence/SurveyCounterexamples.v`, F0). It therefore must never appear in the `Print Assumptions` of any headline theorem; see P0/P1 and D0b. Throughout, *ephapax-affine* means the internal sublanguage. It is not AffineScript. The two projects share only the compile target, typed-wasm. diff --git a/formal/_CoqProject b/formal/_CoqProject index 6b5b28f4..a096b253 100644 --- a/formal/_CoqProject +++ b/formal/_CoqProject @@ -23,3 +23,7 @@ TypingL2.v Counterexample_L2.v Counterexample_L2_nested.v L4.v +regress/SurveyFamilies.v +regress/EscapeCex.v +regress/L2Comp.v +regress/LetCheck.v diff --git a/formal/regress/EscapeCex.v b/formal/regress/EscapeCex.v new file mode 100644 index 00000000..e7568722 --- /dev/null +++ b/formal/regress/EscapeCex.v @@ -0,0 +1,186 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(** * EscapeCex — region escape through a [TFun] closure. + + A closed program, well typed in the current L1 judgment + ([has_type_l1 Linear [] [] ...]), allocates a string in region [r], + builds a closure that captures a borrow of it, drops the string, and + returns the closure out of [r]. [T_Region_L1]'s escape check + [~ In r (free_regions T)] is vacuous here because [TFun] carries no + region information. After four steps the configuration is a + well-typed non-value that cannot step: [S_Region_Exit] (and + [S_Region_Exit_Echo]) are blocked by the syntactic guard + [expr_free_of_region r v], since the closure body mentions + [ELoc 0 r]. So progress fails. Were the guard dropped, the exit + would produce a value untypable at its original type in the + post-exit region env (see [escaped_value_untypable_at_type]). *) + +From Ephapax Require Import Syntax Typing TypingL1 Semantics Semantics_L1 Modality. +Require Import Coq.Lists.List Coq.Strings.String. Import ListNotations. +Open Scope string_scope. + +Definition r : region_name := "r". +Definition Targ : ty := TRef Lin (TBase TUnit). +Definition Tclo : ty := TFun Targ (TProd (TBase TUnit) (TBase TI32)). +Definition Tprog : ty := TProd Tclo (TBase TUnit). + +(** Surface closure: [fun (k : Targ) => (drop k, strlen &s)], [s] = index 1. *) +Definition clo : expr := + ELam Targ (EPair (EDrop (EVar 0)) (EStringLen (EVar 1))). +(** [region r { let s = new@r "s" in (clo, drop s) }] *) +Definition prog : expr := + ERegion r (ELet (EStringNew r "s") (EPair clo (EDrop (EVar 0)))). + +(** Runtime forms after substitution of the location. *) +Definition clo' : expr := + ELam Targ (EPair (EDrop (EVar 0)) (EStringLen (ELoc 0 r))). +Definition stuck_expr : expr := ERegion r (EPair clo' EUnit). + +(** Root cause: the result type mentions no region at all. *) +Lemma Tprog_region_free : Typing.free_regions Tprog = []. +Proof. reflexivity. Qed. + +(** Typed pre-state: the closed program, empty store, empty region env. *) +Lemma prog_typed : has_type_l1 Linear [] [] prog Tprog [] []. +Proof. + unfold prog, Tprog, Tclo, clo, Targ. + change (@nil region_name) with (remove_first_L1 r [r]) at 2. + apply T_Region_L1; [simpl; auto | simpl; intros [] | simpl; auto |]. + eapply T_Let_L1. + - apply T_StringNew_L1. simpl; auto. + - eapply T_Pair_L1. + + apply T_Lam_L1_Linear. eapply T_Pair_L1. + * apply (T_Drop_L1 Linear _ _ _ _ _ (TRef Lin (TBase TUnit))); [reflexivity|]. + eapply T_Var_Lin_L1; reflexivity. + * eapply T_StringLen_L1. eapply T_Borrow_L1. reflexivity. + + apply (T_Drop_L1 Linear _ _ _ _ _ (TString r)); [reflexivity|]. + eapply T_Var_Lin_L1; reflexivity. +Qed. + +(** The four steps. *) +Lemma step1 : step ([], [], prog) ([], [r], prog). +Proof. apply S_Region_Enter. simpl; auto. Qed. + +Lemma step2 : + step ([], [r], prog) + ([CString r "s"], [r], + ERegion r (ELet (ELoc 0 r) (EPair clo (EDrop (EVar 0))))). +Proof. + apply S_Region_Step; [simpl; auto|]. + apply S_Let_Step. apply S_StringNew; [simpl; auto | reflexivity]. +Qed. + +Lemma step3 : + step ([CString r "s"], [r], + ERegion r (ELet (ELoc 0 r) (EPair clo (EDrop (EVar 0))))) + ([CString r "s"], [r], ERegion r (EPair clo' (EDrop (ELoc 0 r)))). +Proof. + apply S_Region_Step; [simpl; auto|]. + apply (S_Let_Val [CString r "s"] [r] (ELoc 0 r) (EPair clo (EDrop (EVar 0)))). + constructor. +Qed. + +Lemma step4 : + step ([CString r "s"], [r], ERegion r (EPair clo' (EDrop (ELoc 0 r)))) + ([CFree], [r], stuck_expr). +Proof. + apply S_Region_Step; [simpl; auto|]. + apply S_Pair_Step2; [constructor|]. + change [CFree] with (mem_write [CString r "s"] 0 CFree). + apply S_Drop. +Qed. + +Lemma prog_reaches_stuck : multi_step ([], [], prog) ([CFree], [r], stuck_expr). +Proof. + eapply MS_Step; [exact step1|]. + eapply MS_Step; [exact step2|]. + eapply MS_Step; [exact step3|]. + eapply MS_Step; [exact step4|]. + apply MS_Refl. +Qed. + +(** Bad post-state 1: well typed (same type, same final env) ... *) +Lemma stuck_typed : has_type_l1 Linear [r] [] stuck_expr Tprog [] []. +Proof. + unfold stuck_expr, Tprog, Tclo, clo', Targ. + change (@nil region_name) with (remove_first_L1 r [r]) at 2. + apply T_Region_Active_L1; [simpl; auto | simpl; intros [] | simpl; auto |]. + eapply T_Pair_L1. + - apply T_Lam_L1_Linear. eapply T_Pair_L1. + + apply (T_Drop_L1 Linear _ _ _ _ _ (TRef Lin (TBase TUnit))); [reflexivity|]. + eapply T_Var_Lin_L1; reflexivity. + + eapply T_StringLen_L1. apply T_Borrow_Val_L1; [constructor|]. + apply T_Loc_L1. simpl; auto. + - apply T_Unit_L1. +Qed. + +(** ... not a value ... *) +Lemma stuck_not_value : ~ is_value stuck_expr. +Proof. intro H; inversion H. Qed. + +(** ... and cannot step: progress fails. *) +Theorem stuck_no_step : forall c', ~ step ([CFree], [r], stuck_expr) c'. +Proof. + intros c' H. unfold stuck_expr in H. inversion H; subst. + - (* S_Region_Enter *) + match goal with Hn : ~ In r [r] |- _ => apply Hn; simpl; auto end. + - (* S_Region_Exit: guard fails, the closure mentions ELoc 0 r *) + match goal with Hf : expr_free_of_region r _ |- _ => + cbn in Hf; destruct Hf as [[_ Hne] _]; apply Hne; reflexivity end. + - (* S_Region_Exit_Echo: same guard *) + match goal with Hf : expr_free_of_region r _ |- _ => + cbn in Hf; destruct Hf as [[_ Hne] _]; apply Hne; reflexivity end. + - (* S_Region_Step: the body is a value *) + eapply values_dont_step; [| eassumption]. repeat constructor. +Qed. + +(** Headline: a closed well-typed program reaches, in 4 steps, a + well-typed configuration that is neither a value nor able to step. *) +Theorem escape_cex : + has_type_l1 Linear [] [] prog Tprog [] [] /\ + multi_step ([], [], prog) ([CFree], [r], stuck_expr) /\ + has_type_l1 Linear [r] [] stuck_expr Tprog [] [] /\ + ~ is_value stuck_expr /\ + (forall c', ~ step ([CFree], [r], stuck_expr) c'). +Proof. + repeat split. + - exact prog_typed. + - exact prog_reaches_stuck. + - exact stuck_typed. + - exact stuck_not_value. + - exact stuck_no_step. +Qed. + +(** Counterfactual: the escaped value, at the post-exit env [[]], is + untypable at its original type in any modality and context. So + removing the [expr_free_of_region] guard would turn the progress + failure into a preservation failure. *) +Theorem escaped_value_untypable_at_type : + forall m G R' G', ~ has_type_l1 m [] G (EPair clo' EUnit) Tprog R' G'. +Proof. + intros m G R' G' H. unfold clo', Tprog, Tclo, Targ in H. + inversion H; subst. + match goal with Hl : has_type_l1 _ [] _ (ELam _ _) _ _ _ |- _ => + inversion Hl; subst end; + match goal with Hb : has_type_l1 _ [] _ (EPair _ _) _ [] _ |- _ => + inversion Hb; subst end; + match goal with Hd : has_type_l1 _ [] _ (EDrop (EVar 0)) _ _ _ |- _ => + inversion Hd; subst end; + repeat match goal with Hv : has_type_l1 _ _ _ (EVar 0) _ _ _ |- _ => + inversion Hv; subst; clear Hv end; + match goal with Hs : has_type_l1 _ _ _ (EStringLen _) _ _ _ |- _ => + inversion Hs; subst end; + match goal with Hb : has_type_l1 _ _ _ (EBorrow _) _ _ _ |- _ => + inversion Hb; subst end; + match goal with Hl : has_type_l1 _ _ _ (ELoc _ _) _ _ _ |- _ => + inversion Hl; subst end; + match goal with Hin : In _ [] |- _ => inversion Hin end. +Qed. + +Print Assumptions Tprog_region_free. +Print Assumptions prog_typed. +Print Assumptions prog_reaches_stuck. +Print Assumptions stuck_typed. +Print Assumptions stuck_not_value. +Print Assumptions stuck_no_step. +Print Assumptions escape_cex. +Print Assumptions escaped_value_untypable_at_type. diff --git a/formal/regress/L2Comp.v b/formal/regress/L2Comp.v new file mode 100644 index 00000000..46e06af8 --- /dev/null +++ b/formal/regress/L2Comp.v @@ -0,0 +1,198 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(** * L2Comp — [has_type_l2] does not compose. + + [has_type_l2] (TypingL2.v) has exactly two constructors: + [L2_lift_l1] (any [has_type_l1] derivation) and [T_App_L2_Eff] + (application of a [TFunEff]). There is no L2-level pair / let / + lambda / region rule, and [has_type_l1] has no rule eliminating + [TFunEff]. So an effect-typed application, once formed by + [T_App_L2_Eff], cannot be placed inside any compound form. + + "Compose" here means the L1 pairing rule [T_Pair_L1], lifted to L2: + L2 ⊢ e1 : T1 [R→R1, G→G1] and L2 ⊢ e2 : T2 [R1→R2, G1→G2] + ⟹ L2 ⊢ EPair e1 e2 : TProd T1 T2 [R→R2, G→G2]. + We exhibit two L2-typable parts whose pair is untypable at every + type and output, so this closure property fails. *) + +From Ephapax Require Import Syntax Typing TypingL1 Semantics Semantics_L1 Modality TypingL2. +Require Import Coq.Lists.List Coq.Strings.String. Import ListNotations. +Open Scope string_scope. + +Definition r : region_name := "r". + +(** A lambda whose body exits the already-active region [r]: not + R-preserving, so it is typable only at [TFunEff], never at [TFun]. *) +Definition lam : expr := ELam (TBase TUnit) (ERegion r EUnit). +Definition app : expr := EApp lam EUnit. +Definition composed : expr := EPair app EUnit. + +Lemma lam_typed_eff : + has_type_l1 Affine [r] [] lam (TFunEff (TBase TUnit) (TBase TUnit) [r] []) [r] []. +Proof. + unfold lam. apply (T_Lam_L1_Affine_Eff [r] [] _ _ _ false [r] []). + - auto. + - change (@nil region_name) with (remove_first_L1 r [r]). + apply T_Region_Active_L1; [simpl; auto | simpl; intros [] | simpl; auto | apply T_Unit_L1]. +Qed. + +(** Part 1: the application is L2-typable (via [T_App_L2_Eff]). *) +Definition app_typed_l2 : + has_type_l2 Affine [r] [] app (TBase TUnit) [] []. +Proof. + eapply T_App_L2_Eff. + - apply L2_lift_l1. exact lam_typed_eff. + - apply L2_lift_l1. apply T_Unit_L1. +Defined. + +(** Part 2: [EUnit] is L2-typable at the output of part 1. *) +Definition unit_typed_l2 : + has_type_l2 Affine [] [] EUnit (TBase TUnit) [] []. +Proof. apply L2_lift_l1. apply T_Unit_L1. Defined. + +(** Exiting an already-active region [r] around [EUnit] removes [r]. *) +Lemma region_unit_active_out : + forall m R G T R' G', + In r R -> + has_type_l1 m R G (ERegion r EUnit) T R' G' -> + R' = remove_first_L1 r R. +Proof. + intros m R G T R' G' Hin H. + inversion H; subst; + try (exfalso; match goal with Hn : ~ In r _ |- _ => apply Hn; exact Hin end); + match goal with Hu : has_type_l1 _ _ _ EUnit _ _ _ |- _ => + inversion Hu; subst; reflexivity end. +Qed. + +(** Key L1 fact: at [R = [r]], [lam] has no [TFun] typing, in any mode. *) +Lemma lam_not_tfun_l1 : + forall m G T1 T2 R' G', ~ has_type_l1 m [r] G lam (TFun T1 T2) R' G'. +Proof. + intros m G T1 T2 R' G' H. unfold lam in H. + inversion H; subst; + match goal with Hb : has_type_l1 _ [r] _ (ERegion r EUnit) _ [r] _ |- _ => + apply region_unit_active_out in Hb; [cbn in Hb; discriminate | simpl; auto] end. +Qed. + +(** Hence [app] has no L1 typing at [R = [r]] (only [T_App_L1] could apply). *) +Lemma app_not_l1 : + forall m G T R' G', ~ has_type_l1 m [r] G app T R' G'. +Proof. + intros m G T R' G' H. unfold app in H. inversion H; subst. + eapply lam_not_tfun_l1; eassumption. +Qed. + +(** Headline 1: the composed term is untypable in L2, at every + modality, type and output. *) +Theorem composed_untypable_l2 : + forall m T R' G', has_type_l2 m [r] [] composed T R' G' -> False. +Proof. + intros m T R' G' H. inversion H; subst. + (* only L2_lift_l1 matches an EPair *) + match goal with Hl : has_type_l1 _ _ _ composed _ _ _ |- _ => + unfold composed in Hl; inversion Hl; subst end. + eapply app_not_l1; eassumption. +Qed. + +(** Headline 2: the lifted pairing rule is not admissible for [has_type_l2] + (stated as [... -> False] because [has_type_l2] lives in [Type]). *) +Theorem l2_pair_composition_fails : + (forall m R R1 R2 G G1 G2 e1 e2 T1 T2, + has_type_l2 m R G e1 T1 R1 G1 -> + has_type_l2 m R1 G1 e2 T2 R2 G2 -> + has_type_l2 m R G (EPair e1 e2) (TProd T1 T2) R2 G2) -> False. +Proof. + intro Hcomp. + exact (composed_untypable_l2 _ _ _ _ + (Hcomp _ _ _ _ _ _ _ _ _ _ _ app_typed_l2 unit_typed_l2)). +Qed. + +(** ** Linear-mode variant (the strict ephapax-linear core). + The parameter is a linear [TString r], consumed by [EDrop]; the + argument is the runtime location [ELoc 0 r]. *) +Definition lam_lin : expr := + ELam (TString r) (EPair (EDrop (EVar 0)) (ERegion r EUnit)). +Definition app_lin : expr := EApp lam_lin (ELoc 0 r). +Definition composed_lin : expr := EPair app_lin EUnit. + +Lemma lam_lin_typed_eff : + has_type_l1 Linear [r] [] lam_lin + (TFunEff (TString r) (TProd (TBase TUnit) (TBase TUnit)) [r] []) [r] []. +Proof. + unfold lam_lin. apply T_Lam_L1_Linear_Eff; [auto|]. + eapply T_Pair_L1. + - apply (T_Drop_L1 Linear _ _ _ _ _ (TString r)); [reflexivity|]. + eapply T_Var_Lin_L1; reflexivity. + - change (@nil region_name) with (remove_first_L1 r [r]). + apply T_Region_Active_L1; [simpl; auto | simpl; intros [] | simpl; auto | apply T_Unit_L1]. +Qed. + +Definition app_lin_typed_l2 : + has_type_l2 Linear [r] [] app_lin (TProd (TBase TUnit) (TBase TUnit)) [] []. +Proof. + eapply T_App_L2_Eff. + - apply L2_lift_l1. exact lam_lin_typed_eff. + - apply L2_lift_l1. apply T_Loc_L1. simpl; auto. +Defined. + +Definition unit_typed_l2_lin : + has_type_l2 Linear [] [] EUnit (TBase TUnit) [] []. +Proof. apply L2_lift_l1. apply T_Unit_L1. Defined. + +Lemma drop_var_out : + forall m R G i T R' G', has_type_l1 m R G (EDrop (EVar i)) T R' G' -> R' = R. +Proof. + intros m R G i T R' G' H. inversion H; subst; + match goal with Hv : has_type_l1 _ _ _ (EVar _) _ _ _ |- _ => + inversion Hv; subst; reflexivity end. +Qed. + +Lemma lam_lin_not_tfun_l1 : + forall m G T1 T2 R' G', ~ has_type_l1 m [r] G lam_lin (TFun T1 T2) R' G'. +Proof. + intros m G T1 T2 R' G' H. unfold lam_lin in H. + inversion H; subst; + match goal with Hb : has_type_l1 _ [r] _ (EPair _ _) _ [r] _ |- _ => + inversion Hb; subst end; + match goal with Hd : has_type_l1 _ [r] _ (EDrop (EVar 0)) _ ?R1 _ |- _ => + apply drop_var_out in Hd; subst end; + match goal with Hg : has_type_l1 _ [r] _ (ERegion r EUnit) _ [r] _ |- _ => + apply region_unit_active_out in Hg; [cbn in Hg; discriminate | simpl; auto] end. +Qed. + +Theorem composed_lin_untypable_l2 : + forall m T R' G', has_type_l2 m [r] [] composed_lin T R' G' -> False. +Proof. + intros m T R' G' H. inversion H; subst. + match goal with Hl : has_type_l1 _ _ _ composed_lin _ _ _ |- _ => + unfold composed_lin in Hl; inversion Hl; subst end. + match goal with Ha : has_type_l1 _ _ _ app_lin _ _ _ |- _ => + unfold app_lin in Ha; inversion Ha; subst end. + eapply lam_lin_not_tfun_l1; eassumption. +Qed. + +Theorem l2_pair_composition_fails_linear : + (forall R R1 R2 G G1 G2 e1 e2 T1 T2, + has_type_l2 Linear R G e1 T1 R1 G1 -> + has_type_l2 Linear R1 G1 e2 T2 R2 G2 -> + has_type_l2 Linear R G (EPair e1 e2) (TProd T1 T2) R2 G2) -> False. +Proof. + intro Hcomp. + exact (composed_lin_untypable_l2 _ _ _ _ + (Hcomp _ _ _ _ _ _ _ _ _ _ app_lin_typed_l2 unit_typed_l2_lin)). +Qed. + + +Print Assumptions region_unit_active_out. +Print Assumptions lam_typed_eff. +Print Assumptions app_typed_l2. +Print Assumptions unit_typed_l2. +Print Assumptions lam_not_tfun_l1. +Print Assumptions app_not_l1. +Print Assumptions composed_untypable_l2. +Print Assumptions l2_pair_composition_fails. +Print Assumptions lam_lin_typed_eff. +Print Assumptions app_lin_typed_l2. +Print Assumptions drop_var_out. +Print Assumptions lam_lin_not_tfun_l1. +Print Assumptions composed_lin_untypable_l2. +Print Assumptions l2_pair_composition_fails_linear. diff --git a/formal/regress/LetCheck.v b/formal/regress/LetCheck.v new file mode 100644 index 00000000..cb9eeb7a --- /dev/null +++ b/formal/regress/LetCheck.v @@ -0,0 +1,115 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(** * LetCheck — a COMPLETENESS gap in the current judgments. + + [ELet e1 e2] is only typable when the bound type is linear, under + both the current L1 judgment ([has_type_l1], either modality) and + the legacy [has_type]. Reason: [T_Let_L1] / [T_Let] demand the + body's output context be [(T1, true) :: G''], but a non-linear + binding's flag is never flipped to [true] ([T_Var_Unr_L1] leaves + the context unchanged; only [T_Var_Lin_L1] marks, and it needs a + linear type). So [let x = () in ()] — the intended [let_unit] — is + rejected, although it is a closed, effect-free, resource-free + program that any reasonable judgment should accept. This is a + completeness gap, not a soundness hole. *) + +From Ephapax Require Import Syntax Typing TypingL1 Semantics Semantics_L1 Modality. +Require Import Coq.Lists.List Coq.Arith.Arith. Import ListNotations. + +(** Modality-polymorphic version of [Semantics_L1.unrestricted_flag_unchanged_l1] + (which is stated for [Linear] only). *) +Lemma unr_flag_unchanged_l1_m : + forall m R G e T R' G', + has_type_l1 m R G e T R' G' -> + forall j T0 u, + is_linear_ty T0 = false -> + ctx_lookup G j = Some (T0, u) -> + ctx_lookup G' j = Some (T0, u). +Proof. + intros m R G e T R' G' Htype. + induction Htype; intros idx T0 u0 Hnlin Hlk; simpl in *; + try exact Hlk; + try (eapply IHHtype; eassumption); + try (eapply IHHtype2; [exact Hnlin|]; eapply IHHtype1; eassumption). + - (* T_Var_Lin_L1 *) + destruct (Nat.eq_dec i idx) as [->|Hne]. + + unfold ctx_lookup in *. rewrite H in Hlk. injection Hlk as <- <-. + rewrite Hnlin in H0. discriminate. + + rewrite ctx_mark_used_lookup_other by exact Hne. exact Hlk. + - (* T_Let_L1 *) + apply (IHHtype1 idx T0 u0 Hnlin) in Hlk. + apply (IHHtype2 (S idx) T0 u0 Hnlin) in Hlk. exact Hlk. + - (* T_LetLin_L1 *) + apply (IHHtype1 idx T0 u0 Hnlin) in Hlk. + apply (IHHtype2 (S idx) T0 u0 Hnlin) in Hlk. exact Hlk. + - (* T_Case_L1_Linear *) + apply (IHHtype1 idx T0 u0 Hnlin) in Hlk. + apply (IHHtype2 (S idx) T0 u0 Hnlin) in Hlk. exact Hlk. + - (* T_Case_L1_Affine *) + apply (IHHtype1 idx T0 u0 Hnlin) in Hlk. + apply (IHHtype2 (S idx) T0 u0 Hnlin) in Hlk. exact Hlk. +Qed. + +(** Headline (L1, any modality): a typable [ELet] has a linear bound type. *) +Theorem let_l1_forces_linear_binder : + forall m R G e1 e2 T R' G', + has_type_l1 m R G (ELet e1 e2) T R' G' -> + exists T1 R1 G1, has_type_l1 m R G e1 T1 R1 G1 /\ is_linear_ty T1 = true. +Proof. + intros m R G e1 e2 T R' G' H. inversion H; subst. + exists T1, R1, G'0. split; [assumption|]. + destruct (is_linear_ty T1) eqn:Hl; [reflexivity|]. + match goal with Hb : has_type_l1 _ _ (ctx_extend _ T1) e2 _ _ _ |- _ => + pose proof (unr_flag_unchanged_l1_m _ _ _ _ _ _ _ Hb 0 T1 false Hl eq_refl) as Hc + end. + cbn in Hc. discriminate. +Qed. + +(** [let_unit] in the current L1 judgment: untypable at every modality, + every region env, every context, every body. *) +Definition let_unit : expr := ELet EUnit EUnit. + +Theorem let_unit_untypable_l1 : + forall m R G T R' G', ~ has_type_l1 m R G let_unit T R' G'. +Proof. + intros m R G T R' G' H. + destruct (let_l1_forces_linear_binder _ _ _ _ _ _ _ _ H) as (T1 & R1 & G1 & He1 & Hl). + inversion He1; subst. discriminate. +Qed. + +(** Stronger: no body makes [let _ = () in body] typable. *) +Theorem let_of_unit_untypable_l1 : + forall m R G e2 T R' G', ~ has_type_l1 m R G (ELet EUnit e2) T R' G'. +Proof. + intros m R G e2 T R' G' H. + destruct (let_l1_forces_linear_binder _ _ _ _ _ _ _ _ H) as (T1 & R1 & G1 & He1 & Hl). + inversion He1; subst. discriminate. +Qed. + +(** The same gap in the legacy judgment [has_type] (Typing.v), via the + existing Qed lemma [Semantics.flag_false_to_true_implies_linear]. *) +Theorem let_of_unit_untypable_legacy : + forall R G e2 T G', ~ has_type R G (ELet EUnit e2) T G'. +Proof. + intros R G e2 T G' H. inversion H; subst. + match goal with He1 : has_type _ _ EUnit _ _ |- _ => inversion He1; subst end. + match goal with Hb : has_type _ (ctx_extend _ _) e2 _ _ |- _ => + pose proof (flag_false_to_true_implies_linear _ _ _ _ _ 0 _ Hb eq_refl eq_refl) as Hc + end. + discriminate. +Qed. + +(** Sanity (the gap is about the binder, not the body): the body alone + and the bound expression alone are both typable everywhere. *) +Lemma let_unit_parts_typable : + forall m R G, + has_type_l1 m R G EUnit (TBase TUnit) R G /\ + has_type_l1 m R (ctx_extend G (TBase TUnit)) EUnit (TBase TUnit) + R (ctx_extend G (TBase TUnit)). +Proof. intros; split; apply T_Unit_L1. Qed. + +Print Assumptions unr_flag_unchanged_l1_m. +Print Assumptions let_l1_forces_linear_binder. +Print Assumptions let_unit_untypable_l1. +Print Assumptions let_of_unit_untypable_l1. +Print Assumptions let_of_unit_untypable_legacy. +Print Assumptions let_unit_parts_typable. diff --git a/formal/regress/SurveyFamilies.v b/formal/regress/SurveyFamilies.v new file mode 100644 index 00000000..551ecf45 --- /dev/null +++ b/formal/regress/SurveyFamilies.v @@ -0,0 +1,150 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(* SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell *) +(** * P2 regression families from the 2026-11-04 v2 survey (F2–F6). + + Ported unchanged from + docs/reports/2026-11-04-v2-survey/evidence/SurveyCounterexamples.v + (which keeps F0 and F1, the statements about the retired Props). + Every lemma is against the CURRENT judgment ([has_type_l1], [step]), + is Qed, and must print "Closed under the global context" + (formal/scripts/no-false-gate.sh). These are regressions, not a + replacement soundness theorem: v2 rung P5 restates each family against + the v2 judgment. + + ClosedCex = F2, EchoCex = F3 + F4, PairDup = F5, EchoCopy = F6. *) + +From Ephapax Require Import Syntax Typing TypingL1 Semantics Semantics_L1 + Modality Counterexample Counterexample_PreservationL1 Counterexample_StepPop. +Require Import Coq.Lists.List Coq.Strings.String. Import ListNotations. +Open Scope string_scope. + +(** ** F2. preservation_l1 is false with a closed context ([G = []]). + An un-entered same-name [ERegion] exits immediately and pops the + ambient occurrence of [rv] that the sibling [ELoc 0 rv] needs. *) +Definition rv : region_name := "rv". + +Definition closed_pre : expr := + EStringConcat (ELoc 0 rv) (EFst (EPair (ELoc 1 rv) (ERegion rv (EI32 0)))). +Definition closed_post : expr := + EStringConcat (ELoc 0 rv) (EFst (EPair (ELoc 1 rv) (EI32 0))). + +Lemma closed_pre_typed : + has_type_l1 Linear [rv] [] closed_pre (TString rv) [] []. +Proof. + unfold closed_pre. + eapply T_StringConcat_L1. + - apply T_Loc_L1. simpl; auto. + - apply (T_Fst_L1 Linear [rv] [] [] [] _ (TString rv) (TBase TI32)); [| reflexivity]. + eapply T_Pair_L1. + + apply T_Loc_L1. simpl; auto. + + change (@nil region_name) with (remove_first_L1 rv [rv]). + eapply T_Region_Active_L1. + * simpl; auto. + * simpl; intros []. + * simpl; auto. + * apply T_I32_L1. +Qed. + +Lemma closed_step : + step ([], [rv], closed_pre) ([], [], closed_post). +Proof. + unfold closed_pre, closed_post. + apply S_StringConcat_Step2; [constructor |]. + apply S_Fst_Step. + apply S_Pair_Step2; [constructor |]. + change (@nil region_name) with (remove_first rv [rv]). + change (@nil mem_cell) with (mem_free_region [] rv). + apply S_Region_Exit; [constructor | simpl; auto | simpl; auto]. +Qed. + +Lemma closed_post_untypable : + forall T Rf G', ~ has_type_l1 Linear [] [] closed_post T Rf G'. +Proof. + intros T Rf G' H. unfold closed_post in H. + inversion H; subst. + match goal with + | Hl : has_type_l1 _ [] [] (ELoc 0 rv) _ _ _ |- _ => inversion Hl; subst + end. + match goal with Hin : In _ [] |- _ => inversion Hin end. +Qed. + +Theorem preservation_l1_FALSE_closed : + ~ (forall m mu R e mu' R' e', + step (mu, R, e) (mu', R', e') -> + forall G T R_final G', + has_type_l1 m R G e T R_final G' -> + has_type_l1 m R' G e' T R_final G'). +Proof. + intro Hp. + eapply closed_post_untypable. + eapply (Hp Linear [] [rv] closed_pre [] [] closed_post closed_step); + exact closed_pre_typed. +Qed. + +(** ** F3. preservation_l1 is false for a reason unrelated to regions: + [S_Drop] and [S_Drop_Echo] both fire on the same term, so [step] + is nondeterministic, and the echo path leaves a [T_Drop_L1] + derivation untypable. *) +Lemma drop_typed : + has_type_l1 Linear [rv] [] (EDrop (ELoc 0 rv)) (TBase TUnit) [rv] []. +Proof. apply (T_Drop_L1 Linear [rv] [rv] [] [] (ELoc 0 rv) (TString rv)); [reflexivity | apply T_Loc_L1; simpl; auto]. Qed. + +Lemma drop_echo_step : + step ([], [rv], EDrop (ELoc 0 rv)) ([], [rv], EEcho (TString rv) (ELoc 0 rv)). +Proof. + change (@nil mem_cell) with (mem_write [] 0 CFree) at 2. + apply S_Drop_Echo. +Qed. + +Lemma echo_not_unit : + forall R G Rf G' T, ~ has_type_l1 Linear R G (EEcho T (ELoc 0 rv)) (TBase TUnit) Rf G'. +Proof. intros R G Rf G' T H. inversion H. Qed. + +Theorem preservation_l1_FALSE_echo_crosspath : + ~ (forall m mu R e mu' R' e', + step (mu, R, e) (mu', R', e') -> + forall G T R_final G', + has_type_l1 m R G e T R_final G' -> + has_type_l1 m R' G e' T R_final G'). +Proof. + intro Hp. + eapply echo_not_unit. + eapply (Hp Linear [] [rv] _ [] [rv] _ drop_echo_step); exact drop_typed. +Qed. + +(** ** F4. progress fails: [EObserve] has a typing rule but no step rule. *) +Lemma observe_typed : + has_type_l1 Linear [] [] (EObserve (EEcho (TBase TUnit) EUnit)) (TBase TUnit) [] []. +Proof. apply (T_Observe_L1 Linear [] [] [] [] _ (TBase TUnit)). apply T_Echo_L1; [constructor | apply T_Unit_L1]. Qed. + +Lemma observe_not_value : ~ is_value (EObserve (EEcho (TBase TUnit) EUnit)). +Proof. intro H; inversion H. Qed. + +Lemma observe_stuck : + forall mu c', ~ step (mu, [], EObserve (EEcho (TBase TUnit) EUnit)) c'. +Proof. intros mu c' H. inversion H. Qed. + +(** ** F5. Compound linearity hole: a pair variable whose first + component is a linear [TString] is treated as unrestricted + ([is_linear_ty (TProd _ _) = false]), so Linear mode duplicates + the string. *) +Lemma pair_dup_linear_string : forall r, + has_type_l1 Linear [r] [(TProd (TString r) (TBase TI32), false)] + (EPair (EFst (EVar 0)) (EFst (EVar 0))) + (TProd (TString r) (TString r)) [r] [(TProd (TString r) (TBase TI32), false)]. +Proof. + intros r. eapply T_Pair_L1; + (eapply T_Fst_L1; [eapply T_Var_Unr_L1; reflexivity | reflexivity]). +Qed. + +(** ** F6. A drop-echo residue of a linear String can be copied in + Linear mode ([is_linear_ty (TEcho _) = false]). *) +Lemma echo_copyable_linear_mode : + forall l r R G, In r R -> + has_type_l1 Linear R G (ECopy (EEcho (TString r) (ELoc l r))) + (TProd (TEcho (TString r)) (TEcho (TString r))) R G. +Proof. + intros l r R G Hin. apply T_Copy_L1; [reflexivity|]. + apply T_Echo_L1; [constructor | now apply T_Loc_L1]. +Qed. + diff --git a/formal/scripts/no-false-gate.sh b/formal/scripts/no-false-gate.sh index 69ed1664..780913b4 100755 --- a/formal/scripts/no-false-gate.sh +++ b/formal/scripts/no-false-gate.sh @@ -26,7 +26,7 @@ set -euo pipefail FILES=$(grep -E '^[^#].*\.v[[:space:]]*$' _CoqProject | awk '{print $1}' | tr '\n' ' ') -MODS=$(for f in $FILES; do printf '%s ' "${f%.v}"; done) +MODS=$(for f in $FILES; do m=${f%.v}; printf '%s ' "${m//\//.}"; done) WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT @@ -53,7 +53,7 @@ strip() { } fail=0 -for f in $FILES; do strip "$f" > "$WORK/$f"; done +for f in $FILES; do mkdir -p "$(dirname "$WORK/$f")"; strip "$f" > "$WORK/$f"; done # 1. Assumption vernacular and admit tactics. ASSUME='(^|[^A-Za-z0-9_'"'"'.])(Admitted|admit|give_up|Axiom|Axioms|Parameter|Parameters|Conjecture|Hypothesis|Hypotheses|Variable|Variables|Context)([^A-Za-z0-9_'"'"']|$)' @@ -71,7 +71,7 @@ fi NAMES="$WORK/names" : > "$NAMES" for f in $FILES; do - m=${f%.v} + m=${f%.v}; m=${m//\//.} { grep -oE '(^|[[:space:]])(Theorem|Lemma|Corollary|Proposition|Fact|Remark|Example|Instance)[[:space:]]+[A-Za-z_][A-Za-z0-9_'"'"']*' "$WORK/$f" || true; } \ | awk -v m="$m" '{print "Ephapax." m "." $NF}' >> "$NAMES" done diff --git a/src/abi/Ephapax/ABI/Invariants.idr b/src/abi/Ephapax/ABI/Invariants.idr index a01922ae..bfcc5c36 100644 --- a/src/abi/Ephapax/ABI/Invariants.idr +++ b/src/abi/Ephapax/ABI/Invariants.idr @@ -29,11 +29,21 @@ import Ephapax.ABI.Types -- formal/Typing.v step-respects-typing supporting lemmas. -- -- Statement (informal): if `well_typed t T` and `t ⇒ t'` then --- `well_typed t' T`. Currently `Admitted` in Coq — the proof script at --- formal/Semantics.v L3215–L3326 leaves residual open goals --- (`coqc` 8.18.0 rejects the `Qed.` with "Attempt to save an incomplete --- proof"). This Idris2 seam states the obligation; discharge happens on --- the Coq side. +-- `well_typed t' T`. For the legacy judgment this is FALSE: formal/Semantics.v +-- retires it to `preservation_stmt : Prop` and formal/Counterexample.v proves +-- `preservation_false : ~ preservation_stmt` (2026-11-05, D0/D0b). The +-- obligation is restated for the v2 judgment (plan rung P10, +-- `preservation_v2`); per the owner ruling of 2026-11-09 it is theory, with +-- an Agda source of truth. Until then this seam states only the shape. +-- +-- NOTE: `typePreservation` below has no clauses, so Idris2 treats it as a +-- postulate (Idris2 0.8.0 accepts it even under %default total). So are +-- the other `0 name : …` declarations without clauses here and in +-- Foreign.idr: 12 in all. They are jointly consistent: reading `WellTyped` +-- and `Step` as empty (they have no constructors) makes every postulated +-- proposition vacuously true, and `ReferencesRegion`, `WasmTyped`, `SStep`, +-- `compile` and `lowerIR` are abstract constants over inhabited types. For +-- the same reason they assert nothing about Ephapax. -------------------------------------------------------------------------------- ||| Abstract typing relation. The Coq formalisation `formal/Typing.v` @@ -51,11 +61,10 @@ data WellTyped : TypeCtx -> Term -> Ty -> Type where public export data Step : Term -> Term -> Type where -||| E1 (OWED — Coq side). If `t` is well-typed at `T` in `g` and steps -||| to `t'`, then `t'` is also well-typed at `T` in `g`. **The Coq -||| `preservation` theorem in formal/Semantics.v is currently Admitted** -||| (see PROOF-NEEDS.md L1–L18). The Idris2 seam states the obligation -||| so a SPARK/Ada implementation sees what is owed. +||| E1 (OWED, for the v2 judgment). If `t` is well-typed at `T` in `g` and +||| steps to `t'`, then `t'` is also well-typed at `T` in `g`. For the legacy +||| judgment this is refuted (`Counterexample.preservation_false`); see +||| PROOF-NEEDS.adoc. An erased postulate over abstract, empty relations. public export 0 typePreservation : (g : TypeCtx) -> (t, t' : Term) -> (T : Ty) -> From 1d8993a7228daea5c42ac5f822f31cfa88afccf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:20:01 +0000 Subject: [PATCH 05/26] docs(plan): add decision D13, the order of Coq and Agda work The 2026-11-09 classification ruling makes the soundness statements theory, with an Agda source of truth, while the P-ladder is written for Coq. D13 states the options (Coq first with an Agda port per headline, recommended; Agda first; both together; Dedukti transport) with elegance/dependability ratings, and joins the decision timing table for ratification in the W1 packet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- .../EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc index 40fdf029..4a7c37c8 100644 --- a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc +++ b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc @@ -1174,6 +1174,7 @@ Engineering tracks inside each wave run in parallel with the proof tracks. The o |D9 |W4 (T4h) |X10 / P15, E13 (W5) |D10 |W1 (T1a) |layout-profile note / E5 (W2), then E9, E8, E10, E18 |D12 |W5 (T5g) |erasure notes / E16, P21 (W6) +|D13 (added 2026-11-09) |W1 (T1a) |the 2026-11-09 classification ruling / P4 and every later P-rung |=== A rung whose dependency has not completed (for example E8 while the typed-wasm fix is unmerged) carries over to the next wave together with every rung that depends on it (E9 completion, E10, E14, P18, the W5 gate-acceptance exit). It is never declared done against a missing dependency. @@ -1535,6 +1536,31 @@ The naive `check_v2` and `step_v2_fn` from P6 need an executable form for the W2 |Mandatory *now* regardless of which option is chosen later. The full-fiber Linear drop echo is RMR (retention), never erasure. |=== +=== D13 — Order of Coq and Agda work for the theory obligations (added 2026-11-09) + +Follows the owner ruling of 2026-11-09 that the soundness statements are theory, with an Agda source of truth (see "Ratified: proof classification"). It decides how the P-ladder, written for Coq, meets that ruling. It does not change what is proved. + +[cols="3,1,1,4",options="header"] +|=== +|Option |(a) |(b) |Comment + +|*Recommended:* Coq first per rung, Agda port of each headline statement before the rung closes, correspondence recorded as the rung's last step +|4 |5 +|Keeps the executable oracle (D9a/D9 extract from Coq) and the Coq tooling the plan is sized for; every headline still gets its Agda source before it is cited, so nothing stays `ungraded` for long. The Coq proof becomes the Rocq leg once the statements are `RECONCILED`. Departure from the most elegant option: Agda-first would make the source of truth come first, but would put the oracle and every W2 gate behind a second toolchain that the plan has not sized. + +|Agda first, Coq as the recheck leg +|5 |3 +|The policy order exactly. Costs a re-plan: P4–P15 re-sized for Agda, and the oracle needs Agda extraction or a separate Coq development anyway. `--safe --without-K --cubical-compatible` rules out the axioms the Coq development does not use either, so the core theory is portable. + +|Both together, rung by rung, with a statement-correspondence check in CI +|4 |4 +|Highest assurance per rung and the earliest `PWG-2i`; roughly doubles proof effort per rung and needs two proof engineers or agents per rung. + +|Unconventional: one shared statement in Dedukti/Lambdapi, proved in Coq and transported to Agda +|4 |3 +|PROOF-POLICY §2 prefers transport. Coq → Dedukti → Agda is the reverse of the policy's direction and depends on exporters the estate has not pinned; worth an X-experiment before relying on it. +|=== + == Risks and kill criteria [cols="3,3,3",options="header"] From 81f54e288e39ccc492a4eb00e122d6ed22808812 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:30:41 +0000 Subject: [PATCH 06/26] fix(formal): no-False gate catches split flags, split names, nested modules Review on #425 found three ways the gate's source scans could miss things: - A bypass flag split across lines ("Unset\nGuard\nChecking") was not matched. Scan 2 now runs on whitespace-collapsed source. - A declaration whose name is on the line after its keyword was left out of the Print Assumptions census. The census now splits the source into Coq sentences (coq-decls.awk). - Names inside a nested Module were qualified with the file name only. coq-decls.awk tracks Module/Section scopes, skips Module Type and functor bodies, and fails loudly on an unmatched End. The scanners move to no-false-lib.sh, shared with a new self-test, scripts/test-no-false-gate.sh (15 fixtures, no Coq needed). It runs in coq-build.yml before the build. The old scanners miss the split-flag and split-name fixtures. The gate still passes on the tree: 299/299 named results closed, coqchk clean. Docs: remove the remaining statements that the legacy and L1 preservation statements are Admitted (README, ROADMAP, STATUS, RUST-SPARK-STANCE, Counterexample_StepPop.v and Semantics_L1.v comments). Counterexample.v has six Qed lemmas, not five. The evidence README marks the pre-retirement findings as historical. The plan adds config_wf_preserved_v2 to P10 and checks the two id specialisations on real inputs in E7. 0-AI-MANIFEST: delete the false sentence saying inventoried escape hatches pass the trusted-base check (deletion only; A2ML is retired). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- .github/workflows/coq-build.yml | 6 ++ 0-AI-MANIFEST.a2ml | 2 +- EXPLAINME.adoc | 8 +- PROOF-NEEDS.adoc | 4 +- README.adoc | 11 ++- ROADMAP.adoc | 17 +++-- RUST-SPARK-STANCE.adoc | 27 ++++--- STATUS.adoc | 15 ++-- .../EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc | 4 +- .../2026-11-04-v2-survey/evidence/README.adoc | 2 +- formal/Counterexample_StepPop.v | 9 ++- formal/Semantics_L1.v | 20 +++-- formal/scripts/coq-decls.awk | 75 +++++++++++++++++++ formal/scripts/no-false-gate.sh | 55 ++++++-------- formal/scripts/no-false-lib.sh | 50 +++++++++++++ formal/scripts/test-no-false-gate.sh | 72 ++++++++++++++++++ 16 files changed, 292 insertions(+), 85 deletions(-) create mode 100644 formal/scripts/coq-decls.awk create mode 100644 formal/scripts/no-false-lib.sh create mode 100755 formal/scripts/test-no-false-gate.sh diff --git a/.github/workflows/coq-build.yml b/.github/workflows/coq-build.yml index 9a204166..bd58581a 100644 --- a/.github/workflows/coq-build.yml +++ b/.github/workflows/coq-build.yml @@ -116,6 +116,12 @@ jobs: coqc --version coq_makefile --help 2>&1 | head -1 || true + - name: No-False gate scanner self-test + working-directory: formal + # Fixtures for the gate's source scanners: split bypass flags, + # split declarations, nested Module qualification. No Coq needed. + run: bash scripts/test-no-false-gate.sh + - name: Build formal/ via coq_makefile (the merge-oracle command) working-directory: formal run: | diff --git a/0-AI-MANIFEST.a2ml b/0-AI-MANIFEST.a2ml index b77b598e..430c42ad 100644 --- a/0-AI-MANIFEST.a2ml +++ b/0-AI-MANIFEST.a2ml @@ -24,7 +24,7 @@ These 7 A2ML files MUST exist in `.machine_readable/6a2/` directory ONLY: ## CORE INVARIANTS -1. **Formal Proof Integrity** — Coq proofs are layered per the four-layer redesign (`formal/PRESERVATION-DESIGN.md`). The legacy `preservation` in `Semantics.v` is provably FALSE per `Counterexample.v`. Explicit `Admitted` / `admit.` / `Axiom` sites are enumerated by `file:line` in `docs/proof-debt.adoc` per the standards#203 trusted-base reduction policy. Idris2 source has no `believe_me` / `assert_total` / `sorry`. ANY new escape hatch must be enumerated in `docs/proof-debt.adoc` before the trusted-base CI check will pass. +1. **Formal Proof Integrity** — Coq proofs are layered per the four-layer redesign (`formal/PRESERVATION-DESIGN.md`). The legacy `preservation` in `Semantics.v` is provably FALSE per `Counterexample.v`. Explicit `Admitted` / `admit.` / `Axiom` sites are enumerated by `file:line` in `docs/proof-debt.adoc` per the standards#203 trusted-base reduction policy. Idris2 source has no `believe_me` / `assert_total` / `sorry`. 2. **Dyadic Separation** - The boundary between affine and linear types must be strictly enforced. 3. **SPDX Headers** - All source files MUST carry `SPDX-License-Identifier: MPL-2.0`. // Owner: Jonathan D.A. Jewell diff --git a/EXPLAINME.adoc b/EXPLAINME.adoc index eadd481c..184e5872 100644 --- a/EXPLAINME.adoc +++ b/EXPLAINME.adoc @@ -97,9 +97,11 @@ rules in `formal/TypingL1.v`. This is the redesign's intended capability accounting, but the current judgment does **not** establish that a region used by a surviving sibling remains live across another sibling's step. -* `formal/Counterexample.v` contains five Qed lemmas. Three prove the - legacy witness types, steps, and becomes untypable; the L1 regression - proves only that this particular input is rejected in both modes. +* `formal/Counterexample.v` contains six Qed lemmas. Three prove the + legacy witness types, steps, and becomes untypable; `preservation_false` + packages them as the refutation of `preservation_stmt`; the two L1 + regression lemmas prove only that this particular input is rejected in + both modes. * `formal/Counterexample_StepPop.v` refutes the unrestricted `step_pop_disjoint_from_type_l1` statement. * `formal/Counterexample_PreservationL1.v` refutes the diff --git a/PROOF-NEEDS.adoc b/PROOF-NEEDS.adoc index 948ea48a..07ecce2b 100644 --- a/PROOF-NEEDS.adoc +++ b/PROOF-NEEDS.adoc @@ -988,8 +988,8 @@ map. — canonical four-layer design doctrine. * link:CLAUDE.md[`+CLAUDE.md+`] — agent guidance; owner directive 2026-05-27. -* link:formal/Counterexample.v[`+formal/Counterexample.v+`] — the 5 Qed -lemmas that pin the soundness gap. +* link:formal/Counterexample.v[`+formal/Counterexample.v+`] — the 6 Qed +lemmas, including `preservation_false`, that pin the soundness gap. * https://github.com/hyperpolymath/echo-types[`+https://github.com/hyperpolymath/echo-types+`] — upstream Agda foundation for L3. * https://github.com/hyperpolymath/nextgen-languages/blob/main/docs/disambiguation/ephapax-vs-affinescript.md[Disambiguation: diff --git a/README.adoc b/README.adoc index 535f7748..706fc597 100644 --- a/README.adoc +++ b/README.adoc @@ -58,11 +58,13 @@ suite). **The soundness proof (v1 → v2).** On 2026-05-26 a machine-checked Coq counterexample (link:formal/Counterexample.v[`formal/Counterexample.v`], -5 Qed lemmas) *proved* that preservation as stated in the legacy +then 5 Qed lemmas, now 6 with `preservation_false`) *proved* that preservation as stated in the legacy single-judgment design is unsound — a region exit could invalidate a sibling's assumptions untracked. Discovering that **with a proof, before anyone depended on it**, is the rare correct thing, not a failure. The four-layer architecture is a design program, not an established -preservation result. The legacy theorem stays `Admitted` on purpose; later +preservation result. The legacy statement is retired to +`preservation_stmt : Prop` and refuted by `Counterexample.preservation_false`; +the no-False gate rejects any `Admitted`, so it must not be restored. Later pinned counterexamples also refute the unrestricted L1 preservation claim. There is no unconditional L2 preservation theorem, while the L2 β-case helpers are conditional. L3's `preservation_l3` and case lemmas are Qed @@ -429,8 +431,9 @@ based calculus. Post-2026-05-26 the formalisation is layered: mechanised, but the unrestricted `preservation_l1` statement is refuted by `Counterexample_PreservationL1.v`; its supporting `step_pop_disjoint_from_type_l1` statement is separately refuted by - `Counterexample_StepPop.v`. Their `Admitted.` markers must not be - treated as routine proof debt. A sound replacement requires a + `Counterexample_StepPop.v`. Both are retired to `Prop` statements + (`preservation_l1_stmt`, `step_pop_disjoint_from_type_l1_stmt`; D0, + 2026-11-05) and are not proof debt. A sound replacement requires a judgment/step redesign and an explicit theorem boundary. See `PROOF-NEEDS.adoc`. diff --git a/ROADMAP.adoc b/ROADMAP.adoc index 9b781962..37670f77 100644 --- a/ROADMAP.adoc +++ b/ROADMAP.adoc @@ -48,9 +48,10 @@ statements and proofs remain the live soundness milestone. region-capability environment) through every compound rule. - Land in `formal/TypingL1.v` + `formal/Semantics_L1.v` against `Counterexample.v` as a regression oracle. -- Status: judgment landed and `m`-indexed. Two outer L1 `Admitted.` - markers remain, but both unrestricted statements are refuted by pinned - counterexamples; they are not routine integration debt. Several helpers +- Status: judgment landed and `m`-indexed. The two former outer L1 + `Admitted.` markers were retired to `Prop` statements (D0, 2026-11-05): + both unrestricted statements are refuted by pinned counterexamples and + are not integration debt. Several helpers are conditional and do not establish general preservation. . *L2 — Modality parameter* @@ -124,14 +125,15 @@ substitution semantics: | Closing a region scope preserves typing of the body's value form. | **`preservation`** (legacy) -| 🔴 Admitted — **provably false**, do not attempt to close -| `formal/Counterexample.v` pins five `Qed` lemmas, including the three core legacy witness lemmas (`bad_typable`, `bad_step`, `bad_post_untypable`) and the separate L1 regression. The 11-goals story (PRs #92/#102/#104/#106/#114/#116/#117/#121/#146) is pre-discovery archaeology. The four-layer architecture in `formal/PRESERVATION-DESIGN.adoc` is a design program, not a proof result: later pinned regressions refute unrestricted L1 preservation, and no unconditional L2 theorem is established. Do not describe preservation as re-derived per layer. See `CLAUDE.md` and `PROOF-NEEDS.adoc` for current boundaries. +| 🔴 Refuted — **provably false**; retired to `preservation_stmt : Prop` (D0/D0b, 2026-11-05). Do not restore an `Admitted`: the no-False gate rejects it. +| `formal/Counterexample.v` pins six `Qed` lemmas: the three core legacy witness lemmas (`bad_typable`, `bad_step`, `bad_post_untypable`), `preservation_false` (the refutation of `preservation_stmt`), and the two-lemma L1 regression. The 11-goals story (PRs #92/#102/#104/#106/#114/#116/#117/#121/#146) is pre-discovery archaeology. The four-layer architecture in `formal/PRESERVATION-DESIGN.adoc` is a design program, not a proof result: later pinned regressions refute unrestricted L1 preservation, and no unconditional L2 theorem is established. Do not describe preservation as re-derived per layer. See `CLAUDE.md` and `PROOF-NEEDS.adoc` for current boundaries. |=== **Historical note**: an earlier in-file comment claimed `preservation` was Qed-closed on 2026-04-27. That claim was unsubstantiated; `coqc` 8.18.0 rejected the proof script. PR #92 (2026-05-20) corrected the -status to `Admitted.` with honest framing. The "92%" `progress` +status to `Admitted.` with honest framing; on 2026-11-05 (D0/D0b) that +`Admitted` was in turn retired to `preservation_stmt : Prop`. The "92%" `progress` proof referenced in older docs was deleted in the substitution- semantics rewrite and is not currently formalised in this tree. @@ -222,7 +224,8 @@ All four headline theorems complete with **zero `believe_me` / * [x] Coq: `subst_preserves_typing` (Qed) * [ ] **Coq: specify and prove sound replacement preservation theorems.** The legacy `Theorem preservation` is refuted by `Counterexample.v` - and must remain `Admitted.` The current unrestricted L1 + and is retired to `preservation_stmt : Prop` (D0/D0b); no `Admitted` + may be restored. The current unrestricted L1 `preservation_l1` is refuted by `Counterexample_PreservationL1.v`, with its supporting `step_pop_disjoint_from_type_l1` separately refuted by `Counterexample_StepPop.v`; do not treat either as diff --git a/RUST-SPARK-STANCE.adoc b/RUST-SPARK-STANCE.adoc index 2fdd27d2..325899d8 100644 --- a/RUST-SPARK-STANCE.adoc +++ b/RUST-SPARK-STANCE.adoc @@ -41,10 +41,10 @@ backend (lowering to a different operational model), and the Zig FFI | Type preservation | `well_typed t T && t ⇒ t' → well_typed t' T` -| `formal/Semantics.v::preservation` (currently **`Admitted`** — - earlier in-file comment claiming "Qed, closed 2026-04-27" was - unsubstantiated; `coqc` rejects the proof script with remaining - open goals — see ephapax#92). +| `formal/Semantics.v::preservation_stmt` — a `Prop`, not a theorem: the + legacy statement is false, refuted by `Counterexample.preservation_false` + (retired from `Admitted` by D0/D0b, 2026-11-05). A sound replacement + preservation theorem is OWED; none exists yet. | Linear consumption | Every linear binding used exactly once across a context split @@ -148,14 +148,13 @@ Rust project. == Honest gaps -* `formal/Semantics.v::preservation` is **`Admitted`** — the proof - script at L3215–L3326 leaves residual open goals (`coqc` 8.18.0 - rejects `Qed.` with "Attempt to save an incomplete proof"). PR#87 - (earlier today) propagated an in-file comment's "Qed, closed - 2026-04-27" claim into ROADMAP + PROOF-NEEDS without running the - build; **the comment was the lie**. ephapax#92 (open) reverts that - doc drift and marks the source `Admitted.` honestly so the Coq CI - goes green. +* No type-preservation theorem exists. The legacy + `formal/Semantics.v` statement is false (`Counterexample.preservation_false`) + and was retired to `preservation_stmt : Prop` (D0/D0b, 2026-11-05); the + unrestricted L1 statement is refuted too. A sound replacement theorem, + with an explicit boundary, is owed. (History: an in-file "Qed, closed + 2026-04-27" claim was false; ephapax#92 marked it `Admitted`, which D0 + then retired.) * `src/formal/Ephapax/Formal/RegionLinear.idr::regionSafetyExtract` and `noGCExtract` are **vacuous wrappers** — body is the input unchanged (`= ne`, `= (ne, lc)`). The proofs are sound (no escape @@ -180,5 +179,5 @@ Rust project. * standards#134 — estate proof-debt sub-issue tracking ephapax * ephapax#85 — `splitLinearCoverage` discharge (MERGED 2026-05-19) * ephapax#88 — `idris2/src/*.idr` SPDX headers (OPEN) -* ephapax#92 — `Qed.` → `Admitted.` on `preservation` (OPEN — companion - to this PR; the doc-honesty fix this stance doc cites) +* ephapax#92 — `Qed.` → `Admitted.` on `preservation` (historical; the + `Admitted` was later retired to `preservation_stmt : Prop` by D0/D0b) diff --git a/STATUS.adoc b/STATUS.adoc index 3ec4e732..37442e60 100644 --- a/STATUS.adoc +++ b/STATUS.adoc @@ -39,12 +39,14 @@ For the project framing, read link:V1-TO-V2.adoc[`V1-TO-V2.adoc`]; this document is the *operational* map underneath it. On 2026-05-26 a verified Coq counterexample (`formal/Counterexample.v`, -5 Qed lemmas) *proved* that preservation as stated in the legacy +now 6 Qed lemmas including `preservation_false`) *proved* that preservation as stated in the legacy single-judgment design (`formal/Semantics.v`) is unsound. Proving your own first draft's limit, before anyone depends on it, is the rare correct move — and it named exactly the invariant the redesign needed. The work -since is **not** to close that theorem (no closure exists; the -`Admitted` is deliberate). The redesign is a separate line of work, not +since is **not** to close that theorem (no closure exists). Since +2026-11-05 (D0/D0b) the false statement is `preservation_stmt : Prop`, +refuted by `Counterexample.preservation_false`; there is no `Admitted` +and the no-False gate rejects one. The redesign is a separate line of work, not an already sound replacement: the unrestricted L1 preservation statement is also refuted by `formal/Counterexample_StepPop.v` and `formal/Counterexample_PreservationL1.v`, while no unconditional L2 @@ -76,8 +78,9 @@ v2 layers below are where forward work goes). | `formal/Semantics.v` — `Theorem preservation` | The legacy preservation theorem. The judgment lacks the structural invariant the counterexample demands. -| **Provably false.** Marked `Admitted`. Any "closure" is impossible - by construction. +| **Provably false.** Retired to `preservation_stmt : Prop`, refuted by + `Counterexample.preservation_false` (D0/D0b, 2026-11-05). Any "closure" + is impossible by construction. | `formal/Semantics.v` — Lemma B variants (`step_preserves_type`, `step_output_context_eq`, @@ -139,7 +142,7 @@ and its assumptions explicitly. | `formal/TypingL1.v` + `formal/Semantics_L1.v` | Judgment and some helper lemmas are mechanised; unrestricted preservation is refuted, not merely unfinished -| `TypingL1.v`: **2 / 0**. `Semantics_L1.v`: current proof markers are inventoried in `PROOF-NEEDS.adoc`; the two remaining outer admits (`step_pop_disjoint_from_type_l1`, `preservation_l1`) have refuted unrestricted statements, pinned by `Counterexample_StepPop.v` and `Counterexample_PreservationL1.v`. The L1 judgment and helper lemmas are not a preservation proof. +| `TypingL1.v`: **2 / 0**. `Semantics_L1.v`: current proof markers are inventoried in `PROOF-NEEDS.adoc`; the two former outer admits were retired to `Prop` statements (`step_pop_disjoint_from_type_l1_stmt`, `preservation_l1_stmt`; D0, 2026-11-05): both unrestricted statements are refuted, pinned by `Counterexample_StepPop.v` and `Counterexample_PreservationL1.v`. The L1 judgment and helper lemmas are not a preservation proof. | L2 | Structural modality (Linear vs Affine) diff --git a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc index 4a7c37c8..9e726f46 100644 --- a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc +++ b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc @@ -778,7 +778,7 @@ Includes `step_v2_deterministic`. |M |P10 `preservation_v2` (L1 + L2 folded) -|All step cases, including an unconditional effect-typed beta. The conclusion is "the successor is typed at the same type, or it is `Trap`" (P4); the division and remainder steps to `Trap` take the second disjunct. This discharges what #240–#242 were working toward. Corollary `type_safety_v2`. Sub-agents work by case family: region, drop, app/beta, let/case/pair, ref/borrow, echo/observe. +|All step cases, including an unconditional effect-typed beta. The conclusion is "the successor is typed at the same type, or it is `Trap`" (P4); the division and remainder steps to `Trap` take the second disjunct. This discharges what #240–#242 were working toward. It also proves `config_wf_preserved_v2`: every successor of a closed, well-typed, well-formed configuration that is not `Trap` satisfies the *whole* of `config_wf`, not only typing. That covers the `next_r` freshness bound, `sigma_wf` (no step changes Σ), store coherence, agreement between runtime `EFrame`s and the region environment, and closedness of the expression. P7's per-component lemmas (`frames_unique`, `frames_fresh_at_enter`, store coherence) are its inputs. Without it P9 applies only to the first step, so the corollary `type_safety_v2`, stated over multi-step runs, uses it. Sub-agents work by case family: region, drop, app/beta, let/case/pair, ref/borrow, echo/observe. |CLOSED; allowlist unchanged |P7, P8, P9 |L–XL @@ -910,7 +910,7 @@ These are kept off the critical path. |E7 Grammar + generics |One normative grammar plus a conformance fixture test. `SurfaceDecl::Fn` type params (desugar lib.rs:323). Until E13 brings grades, unconstrained type parameters are treated as linear (today `Ty::is_linear` makes `Ty::Var`/`Ty::Unif` non-linear, `ephapax-syntax/src/lib.rs:154`), so a generic body cannot duplicate a value that is later instantiated at a linear type. Generic functions are checked once, polymorphically, by a new elaboration step S2b between desugar and S3: it infers the type arguments of every call (or reads explicit type applications), and checks each generic body with its type variables treated as linear. Only after that check succeeds does it monomorphise: each instantiation is specialised and the generic definition erased, so the core, the translator and `CheckV2` see only monomorphic terms. Desugar itself never specialises, because it runs before inference and has no substitution for `a` in `id(3)`. Polymorphic recursion is rejected with a named error. `M.Ctor(x)` lowers to `Construct`. Match guards are honoured by desugar (today `build_arm_body_inner` never consults `arm.guard`, `ephapax-desugar/src/lib.rs:1169`, so `Some(x) if false` is taken unconditionally), or guarded constructor arms are rejected with a named error until they are; P16 proves the lowering later. Import and extern syntax reconciled. -|`gen_angle.eph` and `qa/app2.eph` compile and run; a guarded constructor match whose guard is false falls through to the next arm in both interpreter and wasm (or is rejected with the named error); `fn dup(x: a) = (x, x)` is rejected with a named `TypeError` (and so cannot be instantiated at a region-owned `String`); `id` instantiated at two types desugars to two monomorphic core functions that translate to `SyntaxV2`; 0 EBNF productions unaccounted for +|`gen_angle.eph` and `qa/app2.eph` compile and run; a guarded constructor match whose guard is false falls through to the next arm in both interpreter and wasm (or is rejected with the named error); `fn dup(x: a) = (x, x)` is rejected with a named `TypeError` (and so cannot be instantiated at a region-owned `String`); `id` instantiated at two types desugars to two monomorphic core functions that translate to `SyntaxV2`, and each specialisation, run in both interpreter and wasm on two distinct inputs, returns a value equal to its input; 0 EBNF productions unaccounted for |E3, the syntax ADR (normative grammar source) ratified in the W1 T1a packet |M diff --git a/docs/reports/2026-11-04-v2-survey/evidence/README.adoc b/docs/reports/2026-11-04-v2-survey/evidence/README.adoc index 0ab7f973..1cb2e90d 100644 --- a/docs/reports/2026-11-04-v2-survey/evidence/README.adoc +++ b/docs/reports/2026-11-04-v2-survey/evidence/README.adoc @@ -32,7 +32,7 @@ Expected `Print Assumptions` output with coqc 8.18.0. This was first checked at NOTE: The F0/F1 results above describe `main` before the D0/D0b retirement in hyperpolymath/ephapax#422 (owner-ratified 2026-11-05), when the three refuted statements were `Admitted` and the environment derived `False`. Since #422 they are `Prop` definitions, and `SurveyCounterexamples.v` restates F0 and F1 to take them as hypotheses (`preservation_stmt -> False` and the two L1 forms). Re-checked 2026-11-09 with coqc 8.18.0 against `main` at `32d8eb7`: all eight lemmas, F0 and F1 included, print "Closed under the global context". P2 moves the other five into `formal/regress/`. -The first three rows mean the global environment can derive `False`. The L1 pair needs the P0 retirement in the plan. The legacy row needs decision D0b in the plan, because the legacy theorem is fenced. Both are owner decisions. The `Admitted.` must not be closed, and no side-condition may be added. +Historical, before #422: the first three rows meant the global environment could derive `False`; the L1 pair needed the P0 retirement and the legacy row needed decision D0b. That retirement is complete: on `main` today none of the three statements is assumed, the environment does not derive `False`, and the no-False gate (`formal/scripts/no-false-gate.sh`) rejects any `Admitted`. The refuted statements must not be closed, and no side-condition may be added. == Rust checker probes diff --git a/formal/Counterexample_StepPop.v b/formal/Counterexample_StepPop.v index 2811348e..a510cc39 100644 --- a/formal/Counterexample_StepPop.v +++ b/formal/Counterexample_StepPop.v @@ -6,16 +6,17 @@ (Experiment 3 of the choreographic series; regression witness in the tradition of [Counterexample.v] / [Counterexample_RegionShrink.v].) - The Admitted lemma [step_pop_disjoint_from_type_l1] - ([Semantics_L1.v]) claims: + The statement [step_pop_disjoint_from_type_l1] ([Semantics_L1.v]), + formerly an Admitted lemma and since D0 (2026-11-05) the refuted + [Prop] [step_pop_disjoint_from_type_l1_stmt], claims: step (mu,R,e) (mu',R',e') -> has_type_l1 m R G e T R_final G' -> In r (free_regions T) -> In r R -> In r R'. This file exhibits a configuration that satisfies every premise and - falsifies the conclusion, [Qed], zero axioms. The Admitted marker was - therefore hiding a FALSEHOOD, not an unfinished proof — the same + falsifies the conclusion, [Qed], zero axioms. The former Admitted marker + was therefore hiding a FALSEHOOD, not an unfinished proof — the same class as the removed [region_liveness_at_split_l1] (2026-06-26). No proof effort can close the lemma as stated; the honest move is reformulation (see "What this forces", below). diff --git a/formal/Semantics_L1.v b/formal/Semantics_L1.v index ef07d9e6..ff764df6 100644 --- a/formal/Semantics_L1.v +++ b/formal/Semantics_L1.v @@ -2099,7 +2099,8 @@ Definition val_region_no_exit (v e : expr) : Prop := [region_liveness_no_exit_l1_gen] above (each [e1] is a sub-term of [e], so its no-exit follows from [e]'s by [destruct]), DELETE this false lemma + its wrapper, and thread the no-exit hypothesis up to - [preservation_l1] (the capstone, already Admitted, where + [preservation_l1] (the capstone, then Admitted, now the refuted [Prop] + [preservation_l1_stmt], where region-liveness-through-[S_Region_Exit] is the actual theorem). This is a signature change threaded through a ~30-case induction + preservation; staged as a follow-on, not a drive-by. @@ -3204,8 +3205,9 @@ Qed. STATES and partially proves that lemma; the EASY cases of the lemma are Qed (atomic + S_Region_Exit / Exit_Echo + same-type congruences). The HARD cases of the lemma (which - match the HARD cases of preservation_l1) remain admitted - and ride the same §4.8 lambda-rigidity / variable-typing + match the HARD cases of preservation_l1) were left admitted + at the time; the whole statement is now the refuted [Prop] + [step_pop_disjoint_from_type_l1_stmt] (D0, 2026-11-05). They rode the same §4.8 lambda-rigidity / variable-typing gap — see [step_pop_disjoint_from_type_l1]'s header. - S_App_Step2 / S_Pair_Step2: arbitrary value types — exposes @@ -3275,8 +3277,8 @@ Qed. **Verdict (historical §6, superseded).** This analysis isolated a count-coherence obligation but did not establish it. The unrestricted - lemma above is now refuted by [Counterexample_StepPop.v]; its outer - [Admitted] is not routine proof debt. The choreography documents a + lemma above is now refuted by [Counterexample_StepPop.v]; its former + outer [Admitted] was retired to a [Prop] by D0 (2026-11-05). The choreography documents a redesign direction, not a proof route for the current statement. *) Definition step_pop_disjoint_from_type_l1_stmt : Prop := forall mu R e mu' R' e' m G T R_final G', @@ -3455,9 +3457,11 @@ Qed. ([preservation_l2_app_eff_beta_linear] / [_l1]) are therefore now axiom-free rather than depending on a false axiom. - - [preservation_l1] Admitted → surfaces itself plus - any structural debt its conditional Qed-portion already - pulls in. + - [preservation_l1_stmt] a [Prop], not a proof obligation: the + unrestricted statement is refuted by + [Counterexample_PreservationL1.preservation_l1_FALSE] and was + retired from [Admitted] by D0 (2026-11-05). It contributes no + assumption to [Print Assumptions]. - [preservation_l3] / [preservation_l3_region_active_echo] / [preservation_l3_drop_echo] are now UNCONDITIONALLY diff --git a/formal/scripts/coq-decls.awk b/formal/scripts/coq-decls.awk new file mode 100644 index 00000000..7fef52fb --- /dev/null +++ b/formal/scripts/coq-decls.awk @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: MPL-2.0 +# Owner: Jonathan D.A. Jewell +# +# List the named results of one comment- and string-stripped Coq file, +# fully qualified, one per line: ..... +# +# Input must already have comments and string literals removed (see +# strip() in no-false-gate.sh). The file is read as one stream and split +# into Coq sentences at a "." followed by whitespace or end of input, so a +# keyword and its name may sit on different lines. +# +# Module and Section scopes are tracked. A Module adds its name to the +# qualification; a Section does not. Results inside a Module Type or a +# functor (a Module with parameters) are skipped, because they cannot be +# named from outside. "Module X := ..." opens no scope. +# +# Usage: awk -v prefix=Ephapax.File -f coq-decls.awk stripped.v +BEGIN { buf = ""; depth = 0 } +{ buf = buf " " $0 } +END { + gsub(/[[:space:]]+/, " ", buf) + n = 0 + while (match(buf, /\.( |$)/)) { + s = substr(buf, 1, RSTART - 1) + buf = substr(buf, RSTART + RLENGTH) + sentence(s) + } + if (buf ~ /[^ ]/) sentence(buf) +} + +function trim(x) { sub(/^ +/, "", x); sub(/ +$/, "", x); return x } + +function sentence(s, w, k, i, name, kind, skip, path) { + s = trim(s) + # Drop attributes and locality/flag prefixes. + while (match(s, /^#\[[^]]*\] */)) s = substr(s, RLENGTH + 1) + while (match(s, /^(Local|Global|Polymorphic|Monomorphic|Program|Cumulative|NonCumulative|Private) /)) + s = substr(s, RLENGTH + 1) + k = split(s, w, " ") + if (k == 0) return + + if (w[1] == "Module") { + i = 2 + if (w[i] == "Type") { if (index(s, ":=") == 0) push(w[3], "mtype"); return } + if (w[i] == "Import" || w[i] == "Export") i++ + name = w[i]; sub(/[(:].*$/, "", name) + if (name == "") return + if (index(s, ":=") > 0) return # alias / definition: no scope + kind = (w[i] ~ /\(/ || w[i + 1] ~ /^\(/) ? "functor" : "module" + push(name, kind); return + } + if (w[1] == "Section") { push(w[2], "section"); return } + if (w[1] == "End") { pop(w[2]); return } + + if (w[1] ~ /^(Theorem|Lemma|Corollary|Proposition|Fact|Remark|Example|Instance)$/) { + name = w[2]; sub(/[^A-Za-z0-9_'].*$/, "", name) + if (name !~ /^[A-Za-z_][A-Za-z0-9_']*$/) return # anonymous Instance + skip = 0; path = prefix + for (i = 1; i <= depth; i++) { + if (kinds[i] == "mtype" || kinds[i] == "functor") skip = 1 + if (kinds[i] == "module") path = path "." names[i] + } + if (!skip) print path "." name + } +} + +function push(nm, kd) { depth++; names[depth] = nm; kinds[depth] = kd } +function pop(nm) { + if (depth > 0 && names[depth] == nm) { depth--; return } + # Mismatched End: report loudly rather than mis-qualify silently. + print "coq-decls.awk: unmatched End " nm " in " prefix > "/dev/stderr" + bad = 1 +} +END { if (depth != 0) { print "coq-decls.awk: " depth " unclosed scope(s) in " prefix > "/dev/stderr"; exit 2 } + if (bad) exit 2 } diff --git a/formal/scripts/no-false-gate.sh b/formal/scripts/no-false-gate.sh index 780913b4..4d386c49 100755 --- a/formal/scripts/no-false-gate.sh +++ b/formal/scripts/no-false-gate.sh @@ -13,10 +13,13 @@ # Four independent checks; any one failing fails the gate: # 1. token scan on comment- and string-stripped source for assumption # vernacular and admit tactics (comment-aware, word-boundary); -# 2. token scan for kernel bypass flags (guard, positivity, universe -# checking, Bypass Check, type-in-type); +# 2. scan of the whitespace-collapsed source for kernel bypass flags +# (guard, positivity, universe checking, Bypass Check, type-in-type), +# so a flag split across lines is caught; # 3. census: `Print Assumptions` on every Theorem/Lemma/Corollary/ -# Proposition/Fact/Remark/Example/Instance; each must print +# Proposition/Fact/Remark/Example/Instance, found by sentence (so a +# keyword and its name may be on different lines) and qualified +# through nested Modules (coq-decls.awk); each must print # "Closed under the global context", and the count must match; # 4. independent recheck of the compiled .vo files with coqchk: no # axiom in the Ephapax namespace, nothing relying on type-in-type, @@ -30,50 +33,35 @@ MODS=$(for f in $FILES; do m=${f%.v}; printf '%s ' "${m//\//.}"; done) WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT -# Strip nested Coq comments and string literals, keeping line structure. -strip() { - awk ' - BEGIN { depth = 0; instr = 0 } - { - out = ""; n = length($0); i = 1 - while (i <= n) { - c = substr($0, i, 1); d = substr($0, i, 2) - if (instr) { - if (c == "\"") { if (substr($0, i + 1, 1) == "\"") { i += 2; continue } instr = 0 } - i++; continue - } - if (d == "(*") { depth++; i += 2; continue } - if (depth > 0 && d == "*)") { depth--; i += 2; continue } - if (depth > 0) { i++; continue } - if (c == "\"") { instr = 1; i++; continue } - out = out c; i++ - } - print out - }' "$1" -} +# shellcheck source=no-false-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/no-false-lib.sh" fail=0 -for f in $FILES; do mkdir -p "$(dirname "$WORK/$f")"; strip "$f" > "$WORK/$f"; done +for f in $FILES; do mkdir -p "$(dirname "$WORK/$f")"; nofalse_strip "$f" > "$WORK/$f"; done # 1. Assumption vernacular and admit tactics. -ASSUME='(^|[^A-Za-z0-9_'"'"'.])(Admitted|admit|give_up|Axiom|Axioms|Parameter|Parameters|Conjecture|Hypothesis|Hypotheses|Variable|Variables|Context)([^A-Za-z0-9_'"'"']|$)' -if (cd "$WORK" && grep -nE "$ASSUME" $FILES); then +# shellcheck disable=SC2086 # FILES is a deliberate word list +hits=$(cd "$WORK" && nofalse_assume_scan $FILES) +if [ -n "$hits" ]; then + printf '%s\n' "$hits" echo "no-False gate [1]: assumption vernacular or admit tactic found above -> FAIL"; fail=1 fi -# 2. Kernel bypass flags. -BYPASS='(Unset[[:space:]]+(Guard|Positivity|Universe)[[:space:]]+Checking|Bypass[[:space:]]+Check|Set[[:space:]]+Type[[:space:]]+In[[:space:]]+Type|-type-in-type|bypass_check)' -if (cd "$WORK" && grep -nE "$BYPASS" $FILES); then +# 2. Kernel bypass flags (whitespace-collapsed, so split lines are caught). +# shellcheck disable=SC2086 # FILES is a deliberate word list +hits=$(cd "$WORK" && nofalse_bypass_scan $FILES) +if [ -n "$hits" ]; then + printf '%s\n' "$hits" echo "no-False gate [2]: kernel-check bypass found above -> FAIL"; fail=1 fi -# 3. Census of every named result. +# 3. Census of every named result, qualified through nested Modules. NAMES="$WORK/names" : > "$NAMES" for f in $FILES; do m=${f%.v}; m=${m//\//.} - { grep -oE '(^|[[:space:]])(Theorem|Lemma|Corollary|Proposition|Fact|Remark|Example|Instance)[[:space:]]+[A-Za-z_][A-Za-z0-9_'"'"']*' "$WORK/$f" || true; } \ - | awk -v m="$m" '{print "Ephapax." m "." $NF}' >> "$NAMES" + nofalse_decls "Ephapax.$m" "$WORK/$f" >> "$NAMES" \ + || { echo "no-False gate [3]: could not read the declarations of $f -> FAIL"; fail=1; } done total=$(wc -l < "$NAMES") # The headline results must stay in the census under these names. @@ -98,6 +86,7 @@ fi # 4. Independent kernel recheck of the compiled libraries. QUAL=$(for m in $MODS; do printf 'Ephapax.%s ' "$m"; done) +# shellcheck disable=SC2086 # QUAL is a deliberate word list coqchk -o -silent -R . Ephapax $QUAL > "$WORK/coqchk.out" 2>&1 || { tail -30 "$WORK/coqchk.out"; echo "no-False gate [4]: coqchk rejected a library -> FAIL"; fail=1; } awk '/^\* Axioms:/{a=1;next} /^\* /{a=0} a && NF' "$WORK/coqchk.out" > "$WORK/axioms" if grep -q 'Ephapax\.' "$WORK/axioms"; then diff --git a/formal/scripts/no-false-lib.sh b/formal/scripts/no-false-lib.sh new file mode 100644 index 00000000..f5132b42 --- /dev/null +++ b/formal/scripts/no-false-lib.sh @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: MPL-2.0 +# shellcheck shell=bash +# Owner: Jonathan D.A. Jewell +# +# Source-level scanners shared by no-false-gate.sh and its self-test +# (test-no-false-gate.sh). Sourced, not executed. + +NOFALSE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +# Strip nested Coq comments and string literals, keeping line structure. +nofalse_strip() { + awk ' + BEGIN { depth = 0; instr = 0 } + { + out = ""; n = length($0); i = 1 + while (i <= n) { + c = substr($0, i, 1); d = substr($0, i, 2) + if (instr) { + if (c == "\"") { if (substr($0, i + 1, 1) == "\"") { i += 2; continue } instr = 0 } + i++; continue + } + if (d == "(*") { depth++; i += 2; continue } + if (depth > 0 && d == "*)") { depth--; i += 2; continue } + if (depth > 0) { i++; continue } + if (c == "\"") { instr = 1; i++; continue } + out = out c; i++ + } + print out + }' "$1" +} + +# Assumption vernacular and admit tactics: single tokens, so a line scan +# with word boundaries is enough. Prints file:line:text for each hit. +NOFALSE_ASSUME='(^|[^A-Za-z0-9_'"'"'.])(Admitted|admit|give_up|Axiom|Axioms|Parameter|Parameters|Conjecture|Hypothesis|Hypotheses|Variable|Variables|Context)([^A-Za-z0-9_'"'"']|$)' +nofalse_assume_scan() { grep -nHE "$NOFALSE_ASSUME" "$@" || true; } + +# Kernel bypass flags. These are multi-word, so each stripped file is +# scanned with all whitespace (newlines included) collapsed to one space; +# "Unset\nGuard\nChecking" is caught. Any Unset counts, even if a later +# Set restores the flag. Prints file: match for each hit. +NOFALSE_BYPASS='(Unset (Guard|Positivity|Universe) Checking|Bypass Check|Set Type In Type|-type-in-type|bypass_check)' +nofalse_bypass_scan() { + local f + for f in "$@"; do + tr -s '[:space:]' ' ' < "$f" | grep -oE "$NOFALSE_BYPASS" | sed "s|^|$f: |" || true + done +} + +# Fully qualified named results of one stripped file (see coq-decls.awk). +nofalse_decls() { awk -v prefix="$1" -f "$NOFALSE_DIR/coq-decls.awk" "$2"; } diff --git a/formal/scripts/test-no-false-gate.sh b/formal/scripts/test-no-false-gate.sh new file mode 100755 index 00000000..c4e1eaae --- /dev/null +++ b/formal/scripts/test-no-false-gate.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Owner: Jonathan D.A. Jewell +# +# Self-test for the source scanners of no-false-gate.sh. Needs only bash, +# awk, grep, sed and tr (no Coq), so it runs before the build. Each case is +# a small Coq fragment and the output the scanner must give for it. +# +# Usage: bash formal/scripts/test-no-false-gate.sh +set -euo pipefail +# shellcheck source=no-false-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/no-false-lib.sh" + +T=$(mktemp -d) +trap 'rm -rf "$T"' EXIT +pass=0; failn=0 +ok() { pass=$((pass + 1)); } +bad() { failn=$((failn + 1)); echo "FAIL: $1"; } + +# Write fragment $2 to $1, stripped, and print the stripped path. +frag() { printf '%s\n' "$2" > "$T/$1.raw"; nofalse_strip "$T/$1.raw" > "$T/$1.v"; echo "$T/$1.v"; } + +# --- bypass scan ------------------------------------------------------- +expect_bypass() { # name fragment + local f; f=$(frag "$1" "$2") + if [ -n "$(nofalse_bypass_scan "$f")" ]; then ok; else bad "bypass not caught: $1"; fi +} +expect_no_bypass() { + local f; f=$(frag "$1" "$2") + if [ -z "$(nofalse_bypass_scan "$f")" ]; then ok; else bad "false bypass hit: $1"; fi +} +expect_bypass b_one_line 'Unset Guard Checking.' +expect_bypass b_split $'Unset\nGuard\nChecking.\nFixpoint f (n : nat) : nat := f n.' +expect_bypass b_restored $'Local Unset\n Positivity Checking.\nInductive T := C : (T -> False) -> T.\nSet Positivity Checking.' +expect_bypass b_universe $'Unset Universe\n\tChecking.' +expect_bypass b_attr '#[bypass_check(guard)] Fixpoint f (n : nat) : nat := f n.' +expect_no_bypass b_comment $'(* Unset\n Guard Checking *)\nLemma x : True. Proof. exact I. Qed.' +expect_no_bypass b_string 'Definition s := "Unset Guard Checking".' + +# --- assumption scan --------------------------------------------------- +expect_assume() { + local f; f=$(frag "$1" "$2") + if [ -n "$(nofalse_assume_scan "$f")" ]; then ok; else bad "assumption not caught: $1"; fi +} +expect_no_assume() { + local f; f=$(frag "$1" "$2") + if [ -z "$(nofalse_assume_scan "$f")" ]; then ok; else bad "false assumption hit: $1"; fi +} +expect_assume a_axiom $'Axiom\n bad : False.' +expect_assume a_admitted $'Lemma x : False.\nProof. Admitted.' +expect_no_assume a_qualified 'Lemma x : True. Proof. exact Foo.Axiom_free. Qed.' + +# --- declaration census ------------------------------------------------ +expect_decls() { # name fragment expected(newline-separated) + local f got; f=$(frag "$1" "$2") + got=$(nofalse_decls P "$f" 2>&1) || true + if [ "$got" = "$3" ]; then ok; else bad "decls $1: expected [$3] got [$got]"; fi +} +expect_decls d_split $'Lemma\n split_name : True.\nProof. exact I. Qed.\nTheorem\nother\n: True. Proof. exact I. Qed.' \ + $'P.split_name\nP.other' +expect_decls d_nested $'Module A.\n Lemma a1 : True. Proof. exact I. Qed.\n Module Import B.\n Lemma b1 : True. Proof. exact I. Qed.\n End B.\n Section S.\n Lemma s1 : True. Proof. exact I. Qed.\n End S.\nEnd A.\nLemma top : True. Proof. exact I. Qed.' \ + $'P.A.a1\nP.A.B.b1\nP.A.s1\nP.top' +expect_decls d_skip $'Module Type MT.\n Lemma hidden : True. Proof. exact I. Qed.\nEnd MT.\nModule F (X : MT).\n Lemma infunctor : True. Proof. exact I. Qed.\nEnd F.\nModule M := F.\nInstance : True := I.\n#[export] Instance named : True := I.\nLocal Lemma loc : True. Proof. exact I. Qed.' \ + $'P.named\nP.loc' +expect_decls d_qualid $'Lemma q : True.\nProof. apply Coq.Init.Logic.I. Qed.\nLemma r : True. Proof. exact I. Qed.' \ + $'P.q\nP.r' +# An unmatched End must fail loudly, not mis-qualify. +f=$(frag d_bad $'Module A.\nLemma x : True. Proof. exact I. Qed.\nEnd B.') +if nofalse_decls P "$f" >/dev/null 2>&1; then bad "unmatched End accepted"; else ok; fi + +echo "no-false-gate self-test: $pass passed, $failn failed" +[ "$failn" -eq 0 ] From 8a75f40b3ab118101e048b0b47dcfb7502455035 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:28:28 +0000 Subject: [PATCH 07/26] test(probes): X1 probe battery with a recorded baseline scripts/probe-battery.sh runs check / compile / run / wasm-run over the survey probes and the five conformance/invalid files and prints a TSV (file, command, exit, verdict, status, baseline, target, diagnostic). It diffs each verdict against tests/probes/expectations.tsv, which records today's verdict (known-bad behaviour included) and the plan's target verdict per row: exit 0 all SAME, 1 CHANGED, 2 IMPROVED only (record it with --update in the same PR), 3 could not run. Probe inputs are stored as .eph.txt so the repo-wide corpus gate ignores them; the battery stages them as .eph. dup2, dup4, pairdup, lamcap, rec1, i64, perform, g5 and gen_angle are the survey evidence copies. s1, s6, t3, p1 and qa/app2 (+ qa/lib2) are recreated from the survey scratch copies (p1 still compiles to the 959-byte module that returns 162). total_loop is new, written from the plan's description of the @total loop. tests/probes/env.wat stubs env.print_i32/print_string for the wasmtime CLI. Baseline (58 rows, all SAME): dup2/dup4/pairdup/lamcap check, compile and run; rec1 aborts with a stack overflow (134); i64, s1, s6, t3 and g5 emit invalid wasm; perform compiles to a module that traps on `unreachable`; total_loop is accepted; gen_angle is rejected by check but `run` prints 3; qa/app2 fails to desugar `MkBox`; `ephapax run p1` is a ParseError although the wasm returns 162; the five conformance/invalid files are rejected only by ParseError, never by the E001-E005 discipline errors they document. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- scripts/probe-battery.sh | 244 ++++++++++++++++++++++++++++++++ tests/probes/dup2.eph.txt | 7 + tests/probes/dup4.eph.txt | 5 + tests/probes/env.wat | 7 + tests/probes/expectations.tsv | 77 ++++++++++ tests/probes/g5.eph.txt | 4 + tests/probes/gen_angle.eph.txt | 4 + tests/probes/i64.eph.txt | 4 + tests/probes/lamcap.eph.txt | 12 ++ tests/probes/p1.eph.txt | 24 ++++ tests/probes/pairdup.eph.txt | 12 ++ tests/probes/perform.eph.txt | 7 + tests/probes/qa/app2.eph.txt | 7 + tests/probes/qa/lib2.eph.txt | 5 + tests/probes/rec1.eph.txt | 5 + tests/probes/s1.eph.txt | 10 ++ tests/probes/s6.eph.txt | 5 + tests/probes/t3.eph.txt | 7 + tests/probes/total_loop.eph.txt | 9 ++ 19 files changed, 455 insertions(+) create mode 100755 scripts/probe-battery.sh create mode 100644 tests/probes/dup2.eph.txt create mode 100644 tests/probes/dup4.eph.txt create mode 100644 tests/probes/env.wat create mode 100644 tests/probes/expectations.tsv create mode 100644 tests/probes/g5.eph.txt create mode 100644 tests/probes/gen_angle.eph.txt create mode 100644 tests/probes/i64.eph.txt create mode 100644 tests/probes/lamcap.eph.txt create mode 100644 tests/probes/p1.eph.txt create mode 100644 tests/probes/pairdup.eph.txt create mode 100644 tests/probes/perform.eph.txt create mode 100644 tests/probes/qa/app2.eph.txt create mode 100644 tests/probes/qa/lib2.eph.txt create mode 100644 tests/probes/rec1.eph.txt create mode 100644 tests/probes/s1.eph.txt create mode 100644 tests/probes/s6.eph.txt create mode 100644 tests/probes/t3.eph.txt create mode 100644 tests/probes/total_loop.eph.txt diff --git a/scripts/probe-battery.sh b/scripts/probe-battery.sh new file mode 100755 index 00000000..1c58c12d --- /dev/null +++ b/scripts/probe-battery.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Owner: Jonathan D.A. Jewell +# +# probe-battery.sh — experiment X1 of the v2 plan: run the checker/compiler +# probe battery and diff today's verdicts against a checked-in baseline. +# +# Inputs: +# tests/probes/*.eph.txt, tests/probes/qa/*.eph.txt +# Probe sources. Stored with a .txt suffix so the repo-wide .eph +# corpus gate (src/ephapax-cli/tests/eph_corpus_gate.rs) ignores them; +# this script stages each one as .eph in a temporary directory. +# conformance/invalid/*.eph +# Run in place. +# tests/probes/expectations.tsv +# One row per (probe, command): the BASELINE verdict (what the tree +# did when the row was last recorded, known-bad behaviour included) +# and the plan's TARGET verdict (a bash glob), with the plan rung. +# tests/probes/env.wat +# Stub host module (env.print_i32, env.print_string) for wasm-run. +# +# Commands per row: +# check ephapax check .eph +# compile ephapax compile .eph -o .wasm +# (compile always runs wasmparser validation before writing) +# run ephapax run .eph (today: the interpreter) +# wasm-run compile, then `wasmtime run --preload env=env.wat --invoke main` +# with the wasmtime CLI; verdict `no-module` if compile fails, +# `unavailable:no-wasmtime` (status SKIP) if the CLI is absent. +# +# Verdicts: ok | ok: | error: | crash:[-stack-overflow] +# | timeout | no-module | unavailable:no-wasmtime +# Kinds: InvalidWasm AnnotationNotEnforced CodegenError DesugarError +# TypeError ParseError RuntimeError Other, and Trap: (wasm-run) +# +# Output: a TSV on stdout (or --out FILE), one line per row: +# file command exit verdict status baseline target diagnostic +# status is SAME (== baseline), IMPROVED (!= baseline, matches target), +# CHANGED (matches neither) or SKIP. A summary goes to stderr. +# +# Exit codes: +# 0 every row SAME or SKIP +# 1 some row CHANGED (a regression, or a move the plan did not predict) +# 2 no CHANGED, but some row IMPROVED: record the new baseline in the +# same PR (run with --update) so later waves diff against it +# 3 could not run (missing binary, input or expectations) +# +# Usage: +# scripts/probe-battery.sh [--bin PATH] [--out FILE] [--update] +# --bin PATH ephapax binary (default: $EPHAPAX_BIN, else +# `cargo build -p ephapax-cli --bin ephapax` and use +# target/debug/ephapax) +# --update rewrite the baseline column of expectations.tsv with the +# observed verdicts (targets are kept); SKIP rows unchanged +set -uo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROBES="$ROOT_DIR/tests/probes" +EXPECT="$PROBES/expectations.tsv" +ENV_WAT="$PROBES/env.wat" + +bin="${EPHAPAX_BIN:-}" +out="" +update=0 +while [ $# -gt 0 ]; do + case "$1" in + --bin) bin="$2"; shift 2 ;; + --out) out="$2"; shift 2 ;; + --update) update=1; shift ;; + -h|--help) sed -n '2,52p' "$0"; exit 0 ;; + *) echo "probe-battery: unknown argument $1" >&2; exit 3 ;; + esac +done + +die() { echo "probe-battery: $*" >&2; exit 3; } + +[ -f "$EXPECT" ] || die "missing $EXPECT" +[ -f "$ENV_WAT" ] || die "missing $ENV_WAT" + +if [ -z "$bin" ]; then + (cd "$ROOT_DIR" && cargo build -q -p ephapax-cli --bin ephapax) \ + || die "cargo build of the ephapax binary failed" + bin="$ROOT_DIR/target/debug/ephapax" +fi +[ -x "$bin" ] || die "ephapax binary not executable: $bin" +bin="$(cd "$(dirname "$bin")" && pwd)/$(basename "$bin")" + +wasmtime_bin="$(command -v wasmtime || true)" + +stage="$(mktemp -d)" +trap 'rm -rf "$stage"' EXIT + +# Stage every probe source as .eph, keeping subdirectories (qa/). +while IFS= read -r -d '' f; do + rel="${f#"$PROBES"/}" + mkdir -p "$stage/$(dirname "$rel")" + cp "$f" "$stage/${rel%.txt}" +done < <(find "$PROBES" -name '*.eph.txt' -print0) + +strip_ansi() { sed 's/\x1b\[[0-9;]*m//g'; } + +# classify COMMAND EXIT OUTPUT -> verdict +classify() { + local cmd="$1" code="$2" text="$3" + if [ "$code" -eq 124 ]; then echo "timeout"; return; fi + # The wasmtime CLI exits 134 on a wasm trap; that is a trap, not a crash. + if [ "$cmd" = wasm-run ] && [ "$code" -ne 0 ] && grep -q 'wasm trap' <<<"$text"; then + echo "error:Trap:$(grep -o 'wasm trap: .*' <<<"$text" | head -n 1 | sed 's/^wasm trap: //; s/[[:space:]]/_/g')" + return + fi + if [ "$code" -ge 128 ]; then + if grep -q 'overflowed its stack' <<<"$text"; then + echo "crash:$code-stack-overflow" + else + echo "crash:$code" + fi + return + fi + if [ "$code" -eq 0 ]; then + case "$cmd" in + run|wasm-run) + local last + last="$(grep -v '^warning: using .--invoke. with a function' <<<"$text" | sed '/^[[:space:]]*$/d' | tail -n 1)" + echo "ok:$last" ;; + *) echo "ok" ;; + esac + return + fi + local kind=Other + if grep -q 'failed structural validation' <<<"$text"; then kind=InvalidWasm + elif grep -qi 'annotation not enforced' <<<"$text"; then kind=AnnotationNotEnforced + elif grep -q 'Codegen error' <<<"$text"; then kind=CodegenError + elif grep -qi 'desugar error' <<<"$text"; then kind=DesugarError + elif grep -q 'Type error' <<<"$text"; then kind=TypeError + elif grep -qi 'parse error' <<<"$text"; then kind=ParseError + elif grep -q 'Runtime error' <<<"$text"; then kind=RuntimeError + fi + echo "error:$kind" +} + +# First line that says what went wrong (or, on success, the size line), +# with the staging directory masked. Informational only; never compared. +diagnostic() { + local text="$1" + grep -m1 -E 'Error|error|overflowed|Compiled to|checked successfully' <<<"$text" \ + | sed -E -e "s#$stage##g" -e 's/thread .main. \([0-9]+\)/thread main/' -e "s#$ROOT_DIR/##g" -e 's/\t/ /g' \ + | cut -c1-160 +} + +# source_of PROBE -> path of the .eph to run, and the directory to run in +source_of() { + case "$1" in + conformance/*) echo "$ROOT_DIR/$1.eph" ;; + *) echo "$stage/$1.eph" ;; + esac +} + +run_row() { # PROBE COMMAND -> sets CODE TEXT VERDICT + local probe="$1" cmd="$2" src dir base wasm + src="$(source_of "$probe")" + [ -f "$src" ] || die "missing probe source for $probe ($src)" + dir="$(dirname "$src")" + base="$(basename "$src")" + wasm="$stage/out/${probe//\//_}.wasm" + mkdir -p "$stage/out" + case "$cmd" in + check|run) + TEXT="$(cd "$dir" && timeout 60 "$bin" "$cmd" "$base" 2>&1)" + CODE=$? + ;; + compile) + TEXT="$(cd "$dir" && timeout 60 "$bin" compile "$base" -o "$wasm" 2>&1)" + CODE=$? + ;; + wasm-run) + if ! { (cd "$dir" && timeout 60 "$bin" compile "$base" -o "$wasm" >/dev/null 2>&1); } 2>/dev/null; then + CODE=1; TEXT="compile failed"; VERDICT="no-module"; return + fi + if [ -z "$wasmtime_bin" ]; then + CODE=0; TEXT="wasmtime CLI not found"; VERDICT="unavailable:no-wasmtime"; return + fi + TEXT="$(timeout 60 "$wasmtime_bin" run --preload "env=$ENV_WAT" --invoke main "$wasm" 2>&1)" + CODE=$? + ;; + *) die "unknown command '$cmd' in $EXPECT" ;; + esac + # 126/127: the command itself could not be executed; never a verdict. + if [ "$CODE" -eq 126 ] || [ "$CODE" -eq 127 ]; then + die "could not execute $cmd for $probe (exit $CODE): $TEXT" + fi + TEXT="$(strip_ansi <<<"$TEXT")" + VERDICT="$(classify "$cmd" "$CODE" "$TEXT")" +} + +tsv="$(mktemp -p "$stage")" +printf 'file\tcommand\texit\tverdict\tstatus\tbaseline\ttarget\tdiagnostic\n' >"$tsv" +n_same=0 n_improved=0 n_changed=0 n_skip=0 rows=0 +declare -A observed=() + +while IFS=$'\t' read -r probe cmd baseline target ref; do + case "$probe" in ''|'#'*|probe) continue ;; esac + rows=$((rows + 1)) + run_row "$probe" "$cmd" + # The target column is a glob on purpose, so it is unquoted below. + # shellcheck disable=SC2053 + if [ "$VERDICT" = "unavailable:no-wasmtime" ]; then + status=SKIP; n_skip=$((n_skip + 1)) + elif [ "$VERDICT" = "$baseline" ]; then + status=SAME; n_same=$((n_same + 1)) + elif [[ "$VERDICT" == $target ]]; then + status=IMPROVED; n_improved=$((n_improved + 1)) + else + status=CHANGED; n_changed=$((n_changed + 1)) + fi + observed["$probe"$'\t'"$cmd"]="$VERDICT" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$probe" "$cmd" "$CODE" "$VERDICT" "$status" "$baseline" "$target" \ + "$(diagnostic "$TEXT")" >>"$tsv" +done <"$EXPECT" + +[ "$rows" -gt 0 ] || die "no rows in $EXPECT" + +if [ -n "$out" ]; then cp "$tsv" "$out"; else cat "$tsv"; fi + +echo "probe-battery: $rows rows: $n_same SAME, $n_improved IMPROVED, $n_changed CHANGED, $n_skip SKIP" >&2 + +if [ "$update" -eq 1 ]; then + new="$(mktemp -p "$stage")" + while IFS= read -r line; do + IFS=$'\t' read -r probe cmd baseline target ref <<<"$line" + key="$probe"$'\t'"$cmd" + case "$probe" in ''|'#'*|probe) printf '%s\n' "$line" >>"$new"; continue ;; esac + v="${observed[$key]:-$baseline}" + [ "$v" = "unavailable:no-wasmtime" ] && v="$baseline" + printf '%s\t%s\t%s\t%s\t%s\n' "$probe" "$cmd" "$v" "$target" "$ref" >>"$new" + done <"$EXPECT" + cp "$new" "$EXPECT" + echo "probe-battery: baseline column of $EXPECT updated" >&2 + exit 0 +fi + +if [ "$n_changed" -gt 0 ]; then exit 1; fi +if [ "$n_improved" -gt 0 ]; then exit 2; fi +exit 0 diff --git a/tests/probes/dup2.eph.txt b/tests/probes/dup2.eph.txt new file mode 100644 index 00000000..f409edf0 --- /dev/null +++ b/tests/probes/dup2.eph.txt @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe (2026-11-04). Stored as .eph.txt so the repo-wide .eph corpus gate ignores it. +fn main(): I32 = + let! x = 5 + let a = x + let b = x + a + b diff --git a/tests/probes/dup4.eph.txt b/tests/probes/dup4.eph.txt new file mode 100644 index 00000000..3e612349 --- /dev/null +++ b/tests/probes/dup4.eph.txt @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe (2026-11-04). Stored as .eph.txt so the repo-wide .eph corpus gate ignores it. +fn use2(x: I32, y: I32): I32 = x + y +fn main(): I32 = + let! x = 5 in use2(x, x) diff --git a/tests/probes/env.wat b/tests/probes/env.wat new file mode 100644 index 00000000..b348eecc --- /dev/null +++ b/tests/probes/env.wat @@ -0,0 +1,7 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; Stub host module for scripts/probe-battery.sh `wasm-run` rows: the two +;; imports every ephapax-compiled module declares (see ephapax-wasm +;; emit_imports). Loaded with `wasmtime run --preload env=env.wat`. +(module + (func (export "print_i32") (param i32)) + (func (export "print_string") (param i32 i32))) diff --git a/tests/probes/expectations.tsv b/tests/probes/expectations.tsv new file mode 100644 index 00000000..828d52e8 --- /dev/null +++ b/tests/probes/expectations.tsv @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: MPL-2.0 +# Owner: Jonathan D.A. Jewell +# +# expectations.tsv — X1 probe battery baseline (scripts/probe-battery.sh). +# +# Columns (tab-separated): +# probe tests/probes/.eph.txt, or conformance/invalid/ +# command check | compile | run | wasm-run +# baseline the verdict recorded for the tree as it is (known-bad +# behaviour included); the battery reports SAME / IMPROVED / +# CHANGED against it +# target the plan's target verdict, a bash glob +# ref the plan rung or experiment that moves the row to target +# +# Baseline recorded 2026-11-09 on the w0-t0e branch (base 25cef13), before +# any W0 code fix. A PR that moves a row records the new baseline in the +# same PR with `scripts/probe-battery.sh --update`. +# Plan: docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc +probe command baseline target ref +dup2 check ok error:TypeError E0 tests/linearity_holes.rs: reuse of a linear value is a named TypeError +dup2 compile ok error:TypeError E0: compile runs the same checker +dup2 wasm-run ok:10 no-module E0: no module for a rejected program +dup4 check ok error:TypeError E0 tests/linearity_holes.rs: reuse of a linear value is a named TypeError +dup4 compile ok error:TypeError E0: compile runs the same checker +dup4 wasm-run ok:10 no-module E0: no module for a rejected program +pairdup check ok error:TypeError E0 tests/linearity_holes.rs: reuse of a linear value is a named TypeError +pairdup compile ok error:TypeError E0: compile runs the same checker +pairdup wasm-run ok:0 no-module E0: no module for a rejected program +lamcap check ok error:TypeError E0 tests/linearity_holes.rs: reuse of a linear value is a named TypeError +lamcap compile ok error:TypeError E0: compile runs the same checker +lamcap wasm-run ok:0 no-module E0: no module for a rejected program +rec1 check crash:134-stack-overflow error:DesugarError E0: recursive data is a named DesugarError, never a crash +rec1 compile crash:134-stack-overflow error:DesugarError E0 +rec1 wasm-run no-module no-module E0 +i64 check ok ok E5 (already accepted) +i64 compile error:InvalidWasm ok E5 typed side table: I64 binops lower to i64 +i64 wasm-run no-module ok:0 E5 +s1 check ok ok E5 (already accepted) +s1 compile error:InvalidWasm ok E5: String.len lowering +s1 wasm-run no-module ok:2 E5: wasm_e2e s1 returns the length of "hi" +s6 check ok ok E5 (already accepted) +s6 compile error:InvalidWasm ok E5: String.len on a String@r parameter +s6 wasm-run no-module ok:0 E5 +t3 check ok ok E5 (already accepted) +t3 compile error:InvalidWasm ok E5: n-ary tuples +t3 wasm-run no-module ok:0 E5 +perform check ok error:CodegenError E1 + E3: check runs the same unsupported-feature validation as codegen +perform compile ok error:CodegenError E1 tests/fail_loud.rs: perform/handle is a CodegenError +perform wasm-run error:Trap:wasm_`unreachable`_instruction_executed no-module E1 +g5 check ok error:* E1: raw variadic __ffi rejected with a named error (kind name set by E1) +g5 compile error:InvalidWasm error:* E1: named raw-FFI error, not an invalid module +g5 wasm-run no-module no-module E1 +gen_angle check error:TypeError ok E7 generics: id instantiated at I32 +gen_angle compile error:TypeError ok E7 +gen_angle run ok:3 ok:3 E7 (interpreter already prints 3: check and run disagree, E3) +gen_angle wasm-run no-module ok:3 E7 +qa/app2 check error:DesugarError ok E7: qualified M.Ctor(x) across modules +qa/app2 compile error:DesugarError ok E7 +qa/app2 run error:TypeError ok:9 E7 + E3 (run goes through ephapax-driver::load_program) +qa/app2 wasm-run no-module ok:9 E7 +p1 check ok ok already accepted +p1 compile ok ok already compiles (959 B in the survey) +p1 run error:ParseError ok:162 E3/E4: ephapax run executes the wasm and prints 162 +p1 wasm-run ok:162 ok:162 survey: main returns 162 under wasmtime CLI 36.0.2 +total_loop check ok error:AnnotationNotEnforced E1: @total is an "annotation not enforced" error until enforced +total_loop compile ok error:AnnotationNotEnforced E1 +total_loop wasm-run ok:0 no-module E1 +conformance/invalid/branch_inconsistent check error:ParseError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: needs a v2-syntax rewrite (owner/T0d) +conformance/invalid/branch_inconsistent compile error:ParseError error:TypeError as above +conformance/invalid/linear_not_consumed check error:ParseError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: needs a v2-syntax rewrite (owner/T0d) +conformance/invalid/linear_not_consumed compile error:ParseError error:TypeError as above +conformance/invalid/linear_used_twice check error:ParseError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: needs a v2-syntax rewrite (owner/T0d) +conformance/invalid/linear_used_twice compile error:ParseError error:TypeError as above +conformance/invalid/region_escape check error:ParseError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: needs a v2-syntax rewrite (owner/T0d) +conformance/invalid/region_escape compile error:ParseError error:TypeError as above +conformance/invalid/region_linear_not_consumed check error:ParseError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: needs a v2-syntax rewrite (owner/T0d) +conformance/invalid/region_linear_not_consumed compile error:ParseError error:TypeError as above diff --git a/tests/probes/g5.eph.txt b/tests/probes/g5.eph.txt new file mode 100644 index 00000000..3d98b0d3 --- /dev/null +++ b/tests/probes/g5.eph.txt @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe (2026-11-04). Stored as .eph.txt so the repo-wide .eph corpus gate ignores it. +fn t(): I64 = + __ffi("foo", 1) diff --git a/tests/probes/gen_angle.eph.txt b/tests/probes/gen_angle.eph.txt new file mode 100644 index 00000000..eadee71f --- /dev/null +++ b/tests/probes/gen_angle.eph.txt @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe (2026-11-04). Stored as .eph.txt so the repo-wide .eph corpus gate ignores it. +fn id(x: a): a = x +fn main(): I32 = id(3) diff --git a/tests/probes/i64.eph.txt b/tests/probes/i64.eph.txt new file mode 100644 index 00000000..a3df9c5e --- /dev/null +++ b/tests/probes/i64.eph.txt @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe (2026-11-04). Stored as .eph.txt so the repo-wide .eph corpus gate ignores it. +fn f(x: I64): I64 = x + x +fn main(): I32 = 0 diff --git a/tests/probes/lamcap.eph.txt b/tests/probes/lamcap.eph.txt new file mode 100644 index 00000000..b72c69a6 --- /dev/null +++ b/tests/probes/lamcap.eph.txt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe (2026-11-04). Stored as .eph.txt so the repo-wide .eph corpus gate ignores it. +fn main(): I32 = + region r { + let! s = String.new@r("hi") in + let f = fn(u: ()) -> s in + let a = f(()) in + let b = f(()) in + drop(a); + drop(b); + 0 + } diff --git a/tests/probes/p1.eph.txt b/tests/probes/p1.eph.txt new file mode 100644 index 00000000..147f1c61 --- /dev/null +++ b/tests/probes/p1.eph.txt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe p1 (X1, recreated 2026-11-09 from the survey scratch copy). +// A small program using data/match, recursion and let!. 40 + 2 + 120 = 162. +// Survey: p1.wasm (959 B) validates and, with stub env imports, main +// returns 162 under the wasmtime CLI 36.0.2. +// Target (E3/E4): `ephapax run p1.eph` prints 162 via the wasm path. +module p1 + +data Option(a) = None | Some(a) + +fn unwrap_or(opt: Option(I32), d: I32): I32 = + match opt of + | None => d + | Some(v) => v + end + +fn fact(n: I32): I32 = + if n <= 1 then 1 else n * fact(n - 1) + +fn main(): I32 = + let! x = 40 + let y = unwrap_or(Some(2), 0) + let z = fact(5) + x + y + z diff --git a/tests/probes/pairdup.eph.txt b/tests/probes/pairdup.eph.txt new file mode 100644 index 00000000..f3883df6 --- /dev/null +++ b/tests/probes/pairdup.eph.txt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe (2026-11-04). Stored as .eph.txt so the repo-wide .eph corpus gate ignores it. +fn main(): I32 = + region r { + let! s = String.new@r("hi") in + let p = (s, 1) in + let a = fst(p) in + let b = fst(p) in + drop(a); + drop(b); + 0 + } diff --git a/tests/probes/perform.eph.txt b/tests/probes/perform.eph.txt new file mode 100644 index 00000000..b58a6f07 --- /dev/null +++ b/tests/probes/perform.eph.txt @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe (2026-11-04). Stored as .eph.txt so the repo-wide .eph corpus gate ignores it. +fn main(): I32 = + handle perform ask() with + | return(x) => x + | ask(resume) => 42 + end diff --git a/tests/probes/qa/app2.eph.txt b/tests/probes/qa/app2.eph.txt new file mode 100644 index 00000000..d9811070 --- /dev/null +++ b/tests/probes/qa/app2.eph.txt @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe qa/app2 (X1, recreated 2026-11-09 from the survey scratch +// copy). Imports a user data type and its constructor from a sibling module +// (qa/lib2.eph). Target (E7): compiles and runs, main returns 9. +module app2 +import lib2 +fn main(): I32 = lib2.unbox(lib2.MkBox(9)) diff --git a/tests/probes/qa/lib2.eph.txt b/tests/probes/qa/lib2.eph.txt new file mode 100644 index 00000000..dfd3cd9d --- /dev/null +++ b/tests/probes/qa/lib2.eph.txt @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MPL-2.0 +// Helper module for probe qa/app2 (X1, recreated 2026-11-09). +module lib2 +pub data Box = MkBox(I32) +pub fn unbox(b: Box): I32 = match b of | MkBox(v) => v end diff --git a/tests/probes/rec1.eph.txt b/tests/probes/rec1.eph.txt new file mode 100644 index 00000000..bb19d3a9 --- /dev/null +++ b/tests/probes/rec1.eph.txt @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe (2026-11-04). Stored as .eph.txt so the repo-wide .eph corpus gate ignores it. +data L = Nil | Cons(I32, L) +fn f(l: L): I32 = 0 +fn main(): I32 = 0 diff --git a/tests/probes/s1.eph.txt b/tests/probes/s1.eph.txt new file mode 100644 index 00000000..94d1a1ef --- /dev/null +++ b/tests/probes/s1.eph.txt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe s1 (X1, recreated 2026-11-09 from the survey scratch copy). +// String.len on a region-owned string: the checker accepts it, but codegen +// lowers String.len wrongly (plan: "String.len ... produce invalid wasm"). +// Target (E5): compiles to a valid module and main returns 2. +fn main(): I32 = + region r { + let! s = String.new@r("hi") + String.len(s) + } diff --git a/tests/probes/s6.eph.txt b/tests/probes/s6.eph.txt new file mode 100644 index 00000000..dc664551 --- /dev/null +++ b/tests/probes/s6.eph.txt @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe s6 (X1, recreated 2026-11-09 from the survey scratch copy). +// String.len on a String@r parameter, never called. Target (E5): valid wasm. +fn sl(s: String@r): I32 = String.len(s) +fn main(): I32 = 0 diff --git a/tests/probes/t3.eph.txt b/tests/probes/t3.eph.txt new file mode 100644 index 00000000..93956fc8 --- /dev/null +++ b/tests/probes/t3.eph.txt @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe t3 (X1, recreated 2026-11-09 from the survey scratch copy). +// A 3-tuple (n-ary tuples are lowered untyped today; plan: "3-tuples +// produce invalid wasm"). Target (E5): valid wasm, main returns 0. +fn main(): I32 = + let t = (1, 2, 3) + 0 diff --git a/tests/probes/total_loop.eph.txt b/tests/probes/total_loop.eph.txt new file mode 100644 index 00000000..c9e07cd2 --- /dev/null +++ b/tests/probes/total_loop.eph.txt @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MPL-2.0 +// Survey probe "the @total loop" (X1, written 2026-11-09 from the plan's +// description; the survey copy was lost). A function annotated @total that +// never terminates. Target (E1): rejected with the "annotation not +// enforced" error until @total is actually checked. +@total +fn spin(n: I32): I32 = spin(n) + +fn main(): I32 = 0 From 14c037900ea8d3b77aa3372c0cbd0a795eed710a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:28:41 +0000 Subject: [PATCH 08/26] feat(stdlib): ALib.eph and the aLib conformer (E6, X8) stdlib/ALib.eph implements the 19 aLib operations whose types bind onto what ephapax lowers correctly today (Number -> I32, Boolean -> Bool, if_then_else at A = I32): the arithmetic, comparison, logical and conditional groups. It compiles, so it joins the corpus allowlist. src/ephapax-cli/tests/alib_conformance.rs compiles it with `ephapax compile`, runs it under the wasmtime crate with the env imports the compiler emits, and classifies every vector of the pinned aLib vectors PASS / FAIL / SKIP-out-of-domain / SKIP-unimplemented, each SKIP with its reason. Condition arguments ("equal(5, 5)", "not(true)") are parsed per aLib's vectors/GRAMMAR.adoc and evaluated through the module's own exports. tests/alib_vectors/ holds byte-identical copies of aggregate-library a877c2e vectors/*.json (29 operations, 156 vectors), SHA-256 pinned in the test; PROVENANCE.adoc records the source. Result: 90 pass / 0 fail / 13 out of the I32 domain / 53 unimplemented. The ratchet fails on a pin change, PASS below 90, or any FAIL not in KNOWN_FAILS (empty). A second test undoes a877c2e's two vector changes (not#3 back to the contradictory literal; substring's two new boundary vectors dropped) and reproduces the survey's 89 / 1 / 13 / 51 exactly. Justfile: `just alib-conformance` and `just probe-battery`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- Cargo.lock | 1 + Justfile | 9 + src/ephapax-cli/Cargo.toml | 2 + src/ephapax-cli/tests/alib_conformance.rs | 765 ++++++++++++++++++++++ stdlib/ALib.eph | 82 +++ tests/alib_vectors/PROVENANCE.adoc | 53 ++ tests/alib_vectors/arithmetic.json | 1 + tests/alib_vectors/collection.json | 1 + tests/alib_vectors/comparison.json | 1 + tests/alib_vectors/conditional.json | 1 + tests/alib_vectors/logical.json | 1 + tests/alib_vectors/string.json | 1 + tests/eph-corpus-compiles.txt | 1 + 13 files changed, 919 insertions(+) create mode 100644 src/ephapax-cli/tests/alib_conformance.rs create mode 100644 stdlib/ALib.eph create mode 100644 tests/alib_vectors/PROVENANCE.adoc create mode 100644 tests/alib_vectors/arithmetic.json create mode 100644 tests/alib_vectors/collection.json create mode 100644 tests/alib_vectors/comparison.json create mode 100644 tests/alib_vectors/conditional.json create mode 100644 tests/alib_vectors/logical.json create mode 100644 tests/alib_vectors/string.json diff --git a/Cargo.lock b/Cargo.lock index 89306810..4c243a3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -769,6 +769,7 @@ dependencies = [ "ephapax-wasm", "proptest", "serde_json", + "sha2", "smol_str", "tempfile", "typed-wasm-verify", diff --git a/Justfile b/Justfile index 36b0208e..27ec3410 100644 --- a/Justfile +++ b/Justfile @@ -68,6 +68,15 @@ test: conformance: cargo test --test conformance +# Exit 2 means a row reached its plan target: record it with --update. +# X1 probe battery: survey-probe verdicts vs tests/probes/expectations.tsv +probe-battery *ARGS: + bash scripts/probe-battery.sh {{ARGS}} + +# E6 aLib conformer: pinned aLib vectors against stdlib/ALib.eph under wasmtime +alib-conformance: + cargo test -p ephapax-cli --test alib_conformance -- --nocapture + # Fail if proof/test counts in docs drift from repo state status-gate: ./scripts/status-gate.sh diff --git a/src/ephapax-cli/Cargo.toml b/src/ephapax-cli/Cargo.toml index 659a6ea4..9be2069d 100644 --- a/src/ephapax-cli/Cargo.toml +++ b/src/ephapax-cli/Cargo.toml @@ -58,3 +58,5 @@ default = ["typed-wasm-verify"] wasmtime.workspace = true proptest = { workspace = true } tempfile = "3" +# SHA-256 pin check of the vendored aLib vectors (tests/alib_conformance.rs). +sha2 = "0.10" diff --git a/src/ephapax-cli/tests/alib_conformance.rs b/src/ephapax-cli/tests/alib_conformance.rs new file mode 100644 index 00000000..6bf21787 --- /dev/null +++ b/src/ephapax-cli/tests/alib_conformance.rs @@ -0,0 +1,765 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// alib_conformance.rs — the aLib conformer (plan rung E6, experiment X8). +// +// Compiles stdlib/ALib.eph with the real `ephapax compile` pipeline, runs +// the module under wasmtime with the host imports the compiler emits +// (env.print_i32, env.print_string), and evaluates every pinned aLib vector +// in tests/alib_vectors/ (aggregate-library a877c2e; see PROVENANCE.adoc). +// +// Every vector is classified PASS, FAIL or SKIP, and every SKIP carries a +// reason: +// +// SKIP out-of-domain the vector's data is outside this binding's types +// (aLib Number is bound to I32 only, so a decimal +// argument or result is out; if_then_else is bound at +// A = I32 only, so string-valued vectors are out). +// SKIP unimplemented no binding for the operation (the string and +// collection groups: no host <-> wasm String or +// Collection ABI exists yet). +// +// Ratchet: the test fails if +// - the pinned vector files change (SHA-256 check), or +// - PASS drops below PASS_FLOOR, or +// - any vector is FAIL and not listed in KNOWN_FAILS. +// When PASS rises above the floor the test prints a notice; raise the floor +// in the PR that causes it. The full per-vector table is printed with +// `cargo test --test alib_conformance -- --nocapture`. + +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; +use wasmtime::{Engine, Instance, Linker, Module, Store, Val}; + +/// Recorded floor: 90 of 156 vectors pass at a877c2e (see the report in +/// tests/alib_vectors/PROVENANCE.adoc for the reconciliation with the +/// survey's 89/1/13/51 against 48b7694). +const PASS_FLOOR: usize = 90; + +/// Vectors that are known to FAIL, as `category/op#index` (1-based). +/// Empty: any FAIL is unexpected. +const KNOWN_FAILS: &[&str] = &[]; + +/// Pinned vector files and their SHA-256 (aggregate-library a877c2e). +const PINNED: &[(&str, &str)] = &[ + ( + "arithmetic.json", + "d38140c9b75f3fbbbad35ad2750ad6bc1336635cc201e4794f365133b7cc033d", + ), + ( + "collection.json", + "6e5bc689712509e02e27a6a1707966f429a23cfd862ede9cc761d1d9f14bbc93", + ), + ( + "comparison.json", + "40f90f945b15e91f29068a8845d7d1099d552ceecb340a10807065c45b453443", + ), + ( + "conditional.json", + "0e3a592286d3506741103240d40aa27c9c8b0ebe3ba7727316f6ea715683a0f5", + ), + ( + "logical.json", + "ce252d1c5e65b4625b3153d0c4e0a598a1f9ed2e1ae268ea664086134aaf4088", + ), + ( + "string.json", + "bee65a9ed2d2c9e44c5e6667ae5d6b101220bff36c33e9fe3c1ac1f9d3f42320", + ), +]; + +/// Total vectors and operations in the pinned files. +const PINNED_VECTORS: usize = 156; +const PINNED_OPERATIONS: usize = 29; + +// --------------------------------------------------------------------------- +// Binding: aLib operation -> ALib.eph export, with wasm-level value kinds. +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + /// aLib Number (or A) bound to ephapax I32. + I32, + /// aLib Boolean bound to ephapax Bool (wasm i32 0/1). + Bool, +} + +struct Binding { + export: &'static str, + params: &'static [Kind], + result: Kind, +} + +/// The binding table. Keyed by `category/op` because `length` names two +/// aLib operations. +fn binding(key: &str) -> Option { + use Kind::{Bool, I32}; + let b = |export, params, result| { + Some(Binding { + export, + params, + result, + }) + }; + match key { + "arithmetic/absolute" => b("absolute", &[I32], I32), + "arithmetic/add" => b("add", &[I32, I32], I32), + "arithmetic/divide" => b("divide", &[I32, I32], I32), + "arithmetic/max" => b("max", &[I32, I32], I32), + "arithmetic/min" => b("min", &[I32, I32], I32), + "arithmetic/modulo" => b("modulo", &[I32, I32], I32), + "arithmetic/multiply" => b("multiply", &[I32, I32], I32), + "arithmetic/negate" => b("negate", &[I32], I32), + "arithmetic/subtract" => b("subtract", &[I32, I32], I32), + "comparison/equal" => b("equal", &[I32, I32], Bool), + "comparison/not_equal" => b("not_equal", &[I32, I32], Bool), + "comparison/less_than" => b("less_than", &[I32, I32], Bool), + "comparison/less_equal" => b("less_equal", &[I32, I32], Bool), + "comparison/greater_than" => b("greater_than", &[I32, I32], Bool), + "comparison/greater_equal" => b("greater_equal", &[I32, I32], Bool), + "logical/and" => b("and_", &[Bool, Bool], Bool), + "logical/or" => b("or_", &[Bool, Bool], Bool), + "logical/not" => b("not_", &[Bool], Bool), + "conditional/if_then_else" => b("if_then_else", &[Bool, I32, I32], I32), + _ => None, + } +} + +/// Why a whole category has no binding yet. +fn unimplemented_reason(category: &str) -> &'static str { + match category { + "string" => "no host<->wasm String ABI; String.len lowering is wrong today (probes s1/s6)", + "collection" => "no host<->wasm Collection ABI and no lambda arguments (closures are E9)", + _ => "operation has no ALib.eph binding", + } +} + +// --------------------------------------------------------------------------- +// Values and the wasm runner +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq)] +enum V { + I32(i32), + Bool(bool), +} + +impl V { + fn to_val(self) -> Val { + match self { + V::I32(n) => Val::I32(n), + V::Bool(b) => Val::I32(i32::from(b)), + } + } +} + +impl std::fmt::Display for V { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + V::I32(n) => write!(f, "{n}"), + V::Bool(b) => write!(f, "{b}"), + } + } +} + +struct Runner { + store: Store<()>, + instance: Instance, +} + +/// Errors from calling into the module. A trap or an ill-typed result is a +/// FAIL of the vector, not a harness error. +impl Runner { + fn new(wasm: &[u8]) -> Self { + let engine = Engine::default(); + let module = Module::new(&engine, wasm).expect("compiled ALib module loads in wasmtime"); + let mut store = Store::new(&engine, ()); + let mut linker = Linker::new(&engine); + linker + .func_wrap("env", "print_i32", |_v: i32| {}) + .expect("define env.print_i32"); + linker + .func_wrap("env", "print_string", |_p: i32, _l: i32| {}) + .expect("define env.print_string"); + let instance = linker + .instantiate(&mut store, &module) + .expect("ALib module instantiates"); + Runner { store, instance } + } + + fn call(&mut self, b: &Binding, args: &[V]) -> Result { + let func = self + .instance + .get_func(&mut self.store, b.export) + .ok_or_else(|| format!("export `{}` missing from ALib.wasm", b.export))?; + let params: Vec = args.iter().map(|a| a.to_val()).collect(); + let mut results = [Val::I32(0)]; + func.call(&mut self.store, ¶ms, &mut results) + .map_err(|e| format!("trap in `{}`: {e}", b.export))?; + let raw = results[0] + .i32() + .ok_or_else(|| format!("`{}` returned a non-i32 value", b.export))?; + match b.result { + Kind::I32 => Ok(V::I32(raw)), + Kind::Bool => match raw { + 0 => Ok(V::Bool(false)), + 1 => Ok(V::Bool(true)), + n => Err(format!("`{}` returned {n}, not a Bool (0/1)", b.export)), + }, + } + } +} + +// --------------------------------------------------------------------------- +// Condition expressions (aLib vectors/GRAMMAR.adoc: `condition = expr`) +// --------------------------------------------------------------------------- + +#[derive(Debug)] +enum Expr { + Call(String, Vec), + Bool(bool), + Num(String), + Str(String), +} + +struct Parser<'a> { + s: &'a [u8], + i: usize, +} + +impl<'a> Parser<'a> { + fn ws(&mut self) { + while self.i < self.s.len() && (self.s[self.i] == b' ' || self.s[self.i] == b'\t') { + self.i += 1; + } + } + + fn expr(&mut self) -> Result { + self.ws(); + let c = *self.s.get(self.i).ok_or("unexpected end of condition")?; + if c == b'"' { + let start = self.i + 1; + let end = start + + self.s[start..] + .iter() + .position(|&b| b == b'"') + .ok_or("unterminated string")?; + self.i = end + 1; + return Ok(Expr::Str( + String::from_utf8_lossy(&self.s[start..end]).into(), + )); + } + if c == b'-' || c.is_ascii_digit() { + let start = self.i; + self.i += 1; + while self.i < self.s.len() + && (self.s[self.i].is_ascii_digit() || self.s[self.i] == b'.') + { + self.i += 1; + } + return Ok(Expr::Num( + String::from_utf8_lossy(&self.s[start..self.i]).into(), + )); + } + if c.is_ascii_alphabetic() || c == b'_' { + let start = self.i; + while self.i < self.s.len() + && (self.s[self.i].is_ascii_alphanumeric() || self.s[self.i] == b'_') + { + self.i += 1; + } + let name = String::from_utf8_lossy(&self.s[start..self.i]).into_owned(); + self.ws(); + if self.s.get(self.i) == Some(&b'(') { + self.i += 1; + let mut args = Vec::new(); + self.ws(); + if self.s.get(self.i) == Some(&b')') { + self.i += 1; + } else { + loop { + args.push(self.expr()?); + self.ws(); + match self.s.get(self.i) { + Some(b',') => self.i += 1, + Some(b')') => { + self.i += 1; + break; + } + _ => return Err("expected `,` or `)`".into()), + } + } + } + return Ok(Expr::Call(name, args)); + } + return match name.as_str() { + "true" => Ok(Expr::Bool(true)), + "false" => Ok(Expr::Bool(false)), + _ => Err(format!( + "identifier `{name}` in a condition (conditions have no parameters)" + )), + }; + } + Err(format!("unexpected character `{}`", c as char)) + } +} + +fn parse_condition(src: &str) -> Result { + let mut p = Parser { + s: src.as_bytes(), + i: 0, + }; + let e = p.expr()?; + p.ws(); + if p.i != p.s.len() { + return Err(format!("trailing input in condition `{src}`")); + } + Ok(e) +} + +/// Outcome of evaluating one vector. +enum Verdict { + Pass, + Fail(String), + SkipOutOfDomain(String), + SkipUnimplemented(String), +} + +/// A reason to stop evaluating a vector without a PASS/FAIL verdict. +enum Stop { + OutOfDomain(String), + Unimplemented(String), + Fail(String), +} + +/// Find the aLib category of an operation name used inside a condition. +/// `length` is ambiguous (string/collection); neither is bound, so it is +/// reported unimplemented. +fn category_of(op: &str, ops: &BTreeMap) -> Option { + ops.get(op).cloned() +} + +fn eval( + e: &Expr, + want: Kind, + runner: &mut Runner, + ops: &BTreeMap, +) -> Result { + match e { + Expr::Bool(b) => match want { + Kind::Bool => Ok(V::Bool(*b)), + Kind::I32 => Err(Stop::Fail(format!( + "Boolean literal where I32 expected: {b}" + ))), + }, + Expr::Num(s) => match want { + Kind::I32 => s + .parse::() + .map(V::I32) + .map_err(|_| Stop::OutOfDomain(format!("number `{s}` is not an I32"))), + Kind::Bool => Err(Stop::Fail(format!("number `{s}` where Boolean expected"))), + }, + Expr::Str(s) => Err(Stop::OutOfDomain(format!( + "string literal \"{s}\" in a condition" + ))), + Expr::Call(op, args) => { + let cat = category_of(op, ops) + .ok_or_else(|| Stop::Fail(format!("unknown operation `{op}`")))?; + let key = format!("{cat}/{op}"); + let b = binding(&key).ok_or_else(|| { + Stop::Unimplemented(format!( + "condition calls `{key}`: {}", + unimplemented_reason(&cat) + )) + })?; + if b.result != want { + return Err(Stop::Fail(format!( + "`{key}` returns {:?}, {want:?} expected", + b.result + ))); + } + if args.len() != b.params.len() { + return Err(Stop::Fail(format!( + "`{key}` called with {} argument(s)", + args.len() + ))); + } + let mut vals = Vec::new(); + for (a, k) in args.iter().zip(b.params) { + vals.push(eval(a, *k, runner, ops)?); + } + runner.call(&b, &vals).map_err(Stop::Fail) + } + } +} + +/// Lower one JSON vector argument at a declared parameter kind. +fn lower_arg( + v: &Value, + k: Kind, + runner: &mut Runner, + ops: &BTreeMap, +) -> Result { + match (k, v) { + (Kind::Bool, Value::Bool(b)) => Ok(V::Bool(*b)), + // A Boolean-typed string is a condition expression (GRAMMAR.adoc). + (Kind::Bool, Value::String(s)) => { + let e = parse_condition(s).map_err(|m| Stop::Fail(format!("bad condition: {m}")))?; + eval(&e, Kind::Bool, runner, ops) + } + (Kind::I32, Value::Number(n)) => n + .as_i64() + .and_then(|i| i32::try_from(i).ok()) + .map(V::I32) + .ok_or_else(|| { + Stop::OutOfDomain(format!( + "argument {n} is not an I32 (decimal or out of range)" + )) + }), + (Kind::I32, Value::String(s)) => Err(Stop::OutOfDomain(format!( + "argument \"{s}\" is a String; this binding is at I32" + ))), + (k, other) => Err(Stop::Fail(format!( + "argument {other} does not fit parameter kind {k:?}" + ))), + } +} + +fn lower_expected(v: &Value, k: Kind) -> Result { + match (k, v) { + (Kind::Bool, Value::Bool(b)) => Ok(V::Bool(*b)), + (Kind::I32, Value::Number(n)) => n + .as_i64() + .and_then(|i| i32::try_from(i).ok()) + .map(V::I32) + .ok_or_else(|| Stop::OutOfDomain(format!("expected result {n} is not an I32"))), + (Kind::I32, Value::String(s)) => Err(Stop::OutOfDomain(format!( + "expected result \"{s}\" is a String; this binding is at I32" + ))), + (k, other) => Err(Stop::Fail(format!( + "expected result {other} does not fit {k:?}" + ))), + } +} + +fn run_vector( + key: &str, + category: &str, + op: &Value, + vector: &Value, + runner: &mut Runner, + ops: &BTreeMap, +) -> Verdict { + let Some(b) = binding(key) else { + return Verdict::SkipUnimplemented(unimplemented_reason(category).into()); + }; + let result = (|| -> Result { + // Positional or keyed input; keyed order comes from `parameters`. + let params = op["parameters"].as_array().expect("parameters array"); + let input = &vector["input"]; + let raw: Vec<&Value> = match input { + Value::Array(a) => a.iter().collect(), + Value::Object(o) => params + .iter() + .map(|p| { + let name = p["name"].as_str().expect("keyed parameter has a name"); + o.get(name).expect("keyed input has every parameter") + }) + .collect(), + other => panic!("{key}: input is neither list nor object: {other}"), + }; + assert_eq!( + raw.len(), + b.params.len(), + "{key}: arity mismatch with binding" + ); + // Check the expected output's domain first, so a decimal result is a + // SKIP even when every argument is in the I32 domain. + let want = lower_expected(&vector["output"], b.result)?; + let mut args = Vec::new(); + for (v, k) in raw.iter().zip(b.params) { + args.push(lower_arg(v, *k, runner, ops)?); + } + let got = runner.call(&b, &args).map_err(Stop::Fail)?; + if got == want { + Ok(Verdict::Pass) + } else { + let shown: Vec = args.iter().map(ToString::to_string).collect(); + Ok(Verdict::Fail(format!( + "{}({}) = {got}, want {want}", + b.export, + shown.join(", ") + ))) + } + })(); + match result { + Ok(v) => v, + Err(Stop::OutOfDomain(m)) => Verdict::SkipOutOfDomain(m), + Err(Stop::Unimplemented(m)) => Verdict::SkipUnimplemented(m), + Err(Stop::Fail(m)) => Verdict::Fail(m), + } +} + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("repo root") +} + +fn compile_alib(root: &Path, out_dir: &Path) -> Vec { + let out = out_dir.join("ALib.wasm"); + let output = Command::new(env!("CARGO_BIN_EXE_ephapax")) + .current_dir(root) + .args([ + "compile", + "stdlib/ALib.eph", + "-o", + out.to_str().expect("utf8 temp path"), + ]) + .output() + .expect("run ephapax compile"); + assert!( + output.status.success(), + "`ephapax compile stdlib/ALib.eph` failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + std::fs::read(&out).expect("read ALib.wasm") +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().fold(String::new(), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +/// Load the pinned vector files, checking each SHA-256 and the format tag. +fn load_pinned(root: &Path) -> Vec { + let vec_dir = root.join("tests/alib_vectors"); + PINNED + .iter() + .map(|(name, sha)| { + let bytes = std::fs::read(vec_dir.join(name)).expect("read pinned vector file"); + let got = hex(&Sha256::digest(&bytes)); + assert_eq!( + &got, sha, + "pinned vector file {name} changed (re-pin deliberately)" + ); + let json: Value = serde_json::from_slice(&bytes).expect("vector file is JSON"); + assert_eq!( + json["format"], "alib-vectors-1", + "{name}: unexpected format" + ); + json + }) + .collect() +} + +/// Result of one conformance run. +#[derive(Default)] +struct Tally { + pass: usize, + fail: usize, + out_of_domain: usize, + unimplemented: usize, + operations: usize, + /// `category/op#n` of every FAIL, with its detail. + fails: Vec<(String, String)>, + /// One TSV line per vector: id, verdict, description, detail. + table: String, +} + +impl Tally { + fn total(&self) -> usize { + self.pass + self.fail + self.out_of_domain + self.unimplemented + } + + fn summary(&self) -> String { + format!( + "{} vectors: {} pass / {} fail / {} skip out-of-domain / {} skip unimplemented", + self.total(), + self.pass, + self.fail, + self.out_of_domain, + self.unimplemented + ) + } +} + +/// Evaluate every vector of `files` against the compiled module. +fn conform(files: &[Value], runner: &mut Runner) -> Tally { + // Operation name -> category, for condition calls. + let mut ops = BTreeMap::new(); + for f in files { + let cat = f["category"].as_str().expect("category").to_string(); + for op in f["operations"].as_array().expect("operations") { + ops.insert(op["name"].as_str().expect("name").to_string(), cat.clone()); + } + } + let mut t = Tally::default(); + for f in files { + let cat = f["category"].as_str().expect("category"); + for op in f["operations"].as_array().expect("operations") { + t.operations += 1; + let name = op["name"].as_str().expect("name"); + let key = format!("{cat}/{name}"); + for (i, v) in op["vectors"] + .as_array() + .expect("vectors") + .iter() + .enumerate() + { + let id = format!("{key}#{}", i + 1); + let desc = v["description"].as_str().unwrap_or(""); + let (tag, detail) = match run_vector(&key, cat, op, v, runner, &ops) { + Verdict::Pass => { + t.pass += 1; + ("PASS", String::new()) + } + Verdict::Fail(m) => { + t.fail += 1; + t.fails.push((id.clone(), m.clone())); + ("FAIL", m) + } + Verdict::SkipOutOfDomain(m) => { + t.out_of_domain += 1; + ("SKIP-out-of-domain", m) + } + Verdict::SkipUnimplemented(m) => { + t.unimplemented += 1; + ("SKIP-unimplemented", m) + } + }; + let _ = writeln!(t.table, "{id}\t{tag}\t{desc}\t{detail}"); + } + } + } + t +} + +/// Compile ALib.eph once per test and load it. +fn alib_runner(root: &Path) -> Runner { + let tmp = tempfile::tempdir().expect("tempdir"); + let wasm = compile_alib(root, tmp.path()); + Runner::new(&wasm) +} + +#[test] +fn alib_conformance_ratchet() { + let root = repo_root(); + let files = load_pinned(&root); + let mut runner = alib_runner(&root); + let t = conform(&files, &mut runner); + + println!("{}", t.table); + println!( + "aLib conformance (aggregate-library a877c2e): {}", + t.summary() + ); + + assert_eq!(t.operations, PINNED_OPERATIONS, "operation count"); + assert_eq!(t.total(), PINNED_VECTORS, "vector count"); + let unexpected: Vec = t + .fails + .iter() + .filter(|(id, _)| !KNOWN_FAILS.contains(&id.as_str())) + .map(|(id, m)| format!("{id}: {m}")) + .collect(); + assert!( + unexpected.is_empty(), + "unexpected FAIL (not in KNOWN_FAILS):\n{}", + unexpected.join("\n") + ); + for k in KNOWN_FAILS { + assert!( + t.table.lines().any(|l| l.starts_with(&format!("{k}\t"))), + "KNOWN_FAILS entry {k} names no vector" + ); + } + assert!( + t.pass >= PASS_FLOOR, + "PASS dropped below the recorded floor: {} < {PASS_FLOOR}", + t.pass + ); + if t.pass > PASS_FLOOR { + println!( + "NOTICE: {} pass > PASS_FLOOR {PASS_FLOOR}; raise PASS_FLOOR in this PR", + t.pass + ); + } +} + +/// X8: reproduce the survey's scratch measurement (89 pass / 1 fail / +/// 13 out of the I32 domain / 51 unimplemented, against aggregate-library +/// 48b7694) with this Rust/wasmtime conformer. +/// +/// 48b7694 has no JSON export, so the vectors are rebuilt from the pinned +/// a877c2e files by undoing exactly the two vector changes a877c2e made +/// (`git diff 48b7694 a877c2e -- specs/` touches only these): +/// - logical/not vector 3: input `["not(true)"]` back to the defective +/// literal `[true]` (output stays `true`), and +/// - string/substring: drop the two boundary vectors a877c2e appended +/// (vectors 7 and 8). +#[test] +fn alib_reproduces_survey_baseline_48b7694() { + let root = repo_root(); + let mut files = load_pinned(&root); + for f in &mut files { + let cat = f["category"].as_str().expect("category").to_string(); + for op in f["operations"].as_array_mut().expect("operations") { + let name = op["name"].as_str().expect("name").to_string(); + let vectors = op["vectors"].as_array_mut().expect("vectors"); + match (cat.as_str(), name.as_str()) { + ("logical", "not") => { + assert_eq!(vectors[2]["input"], serde_json::json!(["not(true)"])); + vectors[2]["input"] = serde_json::json!([true]); + } + ("string", "substring") => { + assert_eq!(vectors.len(), 8, "substring vector count at a877c2e"); + vectors.truncate(6); + } + _ => {} + } + } + } + let mut runner = alib_runner(&root); + let t = conform(&files, &mut runner); + println!("aLib conformance (reconstructed 48b7694): {}", t.summary()); + assert_eq!( + (t.pass, t.fail, t.out_of_domain, t.unimplemented), + (89, 1, 13, 51), + "survey baseline not reproduced" + ); + assert_eq!(t.fails.len(), 1); + assert_eq!( + t.fails[0].0, "logical/not#3", + "the one FAIL is the spec defect" + ); +} + +#[test] +fn condition_parser_accepts_the_grammar_examples() { + for src in [ + "greater_than(3, 5)", + "not(true)", + "equal(5, 5)", + "f()", + "g(-1.5, \"x\", h(true))", + ] { + parse_condition(src).unwrap_or_else(|e| panic!("{src}: {e}")); + } + for bad in ["x", "f(1", "f(1,)", "1 2"] { + assert!(parse_condition(bad).is_err(), "{bad} should be rejected"); + } +} diff --git a/stdlib/ALib.eph b/stdlib/ALib.eph new file mode 100644 index 00000000..80bb8274 --- /dev/null +++ b/stdlib/ALib.eph @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Ephapax.ALib — the scalar subset of aggregate-library (aLib) that +// ephapax can express and compile to wasm today (plan rung E6). +// +// aLib (hyperpolymath/aggregate-library) specifies a minimal "overlap" +// library as conformance vectors. This module implements the operations +// whose aLib types bind onto the types ephapax lowers correctly today: +// +// aLib Number -> I32 (vectors with a decimal argument or result are +// out of this binding's domain and are SKIPPED +// by the conformer with that reason) +// aLib Boolean -> Bool (lowered to wasm i32 0 / 1) +// aLib A -> I32 (if_then_else is bound at A = I32 only) +// +// Not implemented here (the conformer SKIPS them with a reason): +// - string/* : no host <-> wasm String ABI yet, and String.len +// lowering is wrong today (probes s1, s6). +// - collection/* : no host <-> wasm Collection ABI and no lambda +// arguments; closures are E9. +// - decimals : need the E5 typed side table and an F64 binding. +// +// Export names: wasm exports every top-level fn by name. `and`, `or` and +// `not` are spelled `and_`, `or_` and `not_` here so they cannot collide +// with operator keywords; the conformer's binding table maps the aLib +// names onto these. +// +// Conformance: src/ephapax-cli/tests/alib_conformance.rs runs the pinned +// vectors in tests/alib_vectors/ against the compiled module under +// wasmtime. + +module ALib + +// ---- arithmetic ------------------------------------------------------- + +pub fn add(a: I32, b: I32): I32 = a + b + +pub fn subtract(a: I32, b: I32): I32 = a - b + +pub fn multiply(a: I32, b: I32): I32 = a * b + +// Truncating integer division (wasm i32.div_s). Division by zero traps; +// aLib leaves it out of the conformance domain. +pub fn divide(a: I32, b: I32): I32 = a / b + +pub fn modulo(a: I32, b: I32): I32 = a % b + +pub fn negate(a: I32): I32 = 0 - a + +pub fn absolute(a: I32): I32 = if a < 0 then 0 - a else a + +pub fn max(a: I32, b: I32): I32 = if a > b then a else b + +pub fn min(a: I32, b: I32): I32 = if a < b then a else b + +// ---- comparison ------------------------------------------------------- + +pub fn equal(a: I32, b: I32): Bool = a == b + +pub fn not_equal(a: I32, b: I32): Bool = a != b + +pub fn less_than(a: I32, b: I32): Bool = a < b + +pub fn less_equal(a: I32, b: I32): Bool = a <= b + +pub fn greater_than(a: I32, b: I32): Bool = a > b + +pub fn greater_equal(a: I32, b: I32): Bool = a >= b + +// ---- logical ---------------------------------------------------------- + +pub fn and_(a: Bool, b: Bool): Bool = if a then b else false + +pub fn or_(a: Bool, b: Bool): Bool = if a then true else b + +pub fn not_(a: Bool): Bool = if a then false else true + +// ---- conditional ------------------------------------------------------ + +pub fn if_then_else(c: Bool, t: I32, e: I32): I32 = if c then t else e diff --git a/tests/alib_vectors/PROVENANCE.adoc b/tests/alib_vectors/PROVENANCE.adoc new file mode 100644 index 00000000..25bfef74 --- /dev/null +++ b/tests/alib_vectors/PROVENANCE.adoc @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += Pinned aLib conformance vectors + +The six `*.json` files here are byte-identical copies of `vectors/.json` +from hyperpolymath/aggregate-library, taken so that the ephapax conformer +(`src/ephapax-cli/tests/alib_conformance.rs`, plan rung E6) never reads another +repository at test time. + +[cols="1,3",options="header"] +|=== +|Field |Value +|Source repository |hyperpolymath/aggregate-library +|Source commit |`a877c2e9917d8617a70c86169d3cc9a7590f3d9c` (PR #65, branch `claude/ephapax-architecture-plan-gzyctr`) +|Format |`alib-vectors-1` (I-JSON, JCS form; layout and expression grammar in aLib `vectors/GRAMMAR.adoc`) +|Contents |29 operations, 156 vectors +|Licence |MPL-2.0 (the aggregate-library licence; JSON carries no header) +|=== + +Copied with +`git -C show a877c2e:vectors/.json > .json`. +The conformer checks each file's SHA-256 against the table below and fails if +a file changes, so a re-pin is always a deliberate edit of this file, the +hashes in the test, and the recorded counts together. + +[cols="1,3",options="header"] +|=== +|File |SHA-256 +|`arithmetic.json` |`d38140c9b75f3fbbbad35ad2750ad6bc1336635cc201e4794f365133b7cc033d` +|`collection.json` |`6e5bc689712509e02e27a6a1707966f429a23cfd862ede9cc761d1d9f14bbc93` +|`comparison.json` |`40f90f945b15e91f29068a8845d7d1099d552ceecb340a10807065c45b453443` +|`conditional.json` |`0e3a592286d3506741103240d40aa27c9c8b0ebe3ba7727316f6ea715683a0f5` +|`logical.json` |`ce252d1c5e65b4625b3153d0c4e0a598a1f9ed2e1ae268ea664086134aaf4088` +|`string.json` |`bee65a9ed2d2c9e44c5e6667ae5d6b101220bff36c33e9fe3c1ac1f9d3f42320` +|=== + +== Relation to the survey baseline + +The 2026-11-04 survey measured a scratch conformer against aggregate-library +`48b7694` (154 vectors, read from the spec YAML): 89 pass / 1 fail / 13 out of the +I32 domain / 51 unimplemented. `a877c2e` differs in exactly two ways that move +those numbers: `logical/not` vector 3 now passes the condition `"not(true)"` +instead of the contradictory literal `true` (the 1 fail becomes a pass), and +`string/substring` gains two boundary vectors (154 to 156, both in the +unimplemented string group). Both commits have 29 operations; "22" was the +aLib v0.1.0 core count in its docs, not a different spec set. + +Measured with the Rust/wasmtime conformer on these files: 90 pass / 0 fail / +13 skip out of the I32 domain (10 decimal + 3 string-valued `if_then_else`) / +53 skip unimplemented (30 collection + 23 string). The test +`alib_reproduces_survey_baseline_48b7694` undoes exactly the two vector changes +above in memory and asserts the survey's 89 / 1 / 13 / 51, with the one FAIL +being `logical/not#3`. diff --git a/tests/alib_vectors/arithmetic.json b/tests/alib_vectors/arithmetic.json new file mode 100644 index 00000000..f82d379b --- /dev/null +++ b/tests/alib_vectors/arithmetic.json @@ -0,0 +1 @@ +{"category":"arithmetic","format":"alib-vectors-1","operations":[{"name":"absolute","parameters":[{"type":"Number"}],"result":"Number","signature":"absolute: Number -> Number","source":"specs/arithmetic/absolute.adoc","vectors":[{"description":"Absolute value of positive number","input":[5],"output":5},{"description":"Absolute value of negative number","input":[-5],"output":5},{"description":"Absolute value of zero","input":[0],"output":0},{"description":"Absolute value of large negative","input":[-100],"output":100}]},{"name":"add","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Number","signature":"add: Number, Number -> Number","source":"specs/arithmetic/add.adoc","vectors":[{"description":"Basic addition of positive integers","input":[2,3],"output":5},{"description":"Addition with negative number","input":[-5,3],"output":-2},{"description":"Addition of zeros","input":[0,0],"output":0},{"description":"Addition of decimal numbers","input":[1.5,2.5],"output":4},{"description":"Addition of two negative numbers","input":[-10,-20],"output":-30}]},{"name":"divide","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Number","signature":"divide: Number, Number -> Number","source":"specs/arithmetic/divide.adoc","vectors":[{"description":"Basic division with exact result","input":[6,2],"output":3},{"description":"Division with fractional result","input":[7,2],"output":3.5},{"description":"Zero divided by non-zero","input":[0,5],"output":0},{"description":"Division with negative dividend","input":[-10,2],"output":-5},{"description":"Division with negative divisor","input":[10,-2],"output":-5},{"description":"Division of two negative numbers","input":[-12,-3],"output":4}]},{"name":"max","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Number","signature":"max: Number, Number -> Number","source":"specs/arithmetic/max.adoc","vectors":[{"description":"Maximum of two positive numbers","input":[5,3],"output":5},{"description":"Maximum with negative number","input":[-5,3],"output":3},{"description":"Maximum of equal numbers","input":[10,10],"output":10},{"description":"Maximum with zero","input":[0,-10],"output":0}]},{"name":"min","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Number","signature":"min: Number, Number -> Number","source":"specs/arithmetic/min.adoc","vectors":[{"description":"Minimum of two positive numbers","input":[5,3],"output":3},{"description":"Minimum with negative number","input":[-5,3],"output":-5},{"description":"Minimum of equal numbers","input":[10,10],"output":10},{"description":"Minimum with zero","input":[0,-10],"output":-10}]},{"name":"modulo","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Number","signature":"modulo: Number, Number -> Number","source":"specs/arithmetic/modulo.adoc","vectors":[{"description":"Basic modulo with positive integers","input":[7,3],"output":1},{"description":"Modulo with no remainder","input":[10,5],"output":0},{"description":"Modulo returning non-zero remainder","input":[15,4],"output":3},{"description":"Zero modulo non-zero","input":[0,5],"output":0},{"description":"Modulo with larger numbers","input":[100,7],"output":2}]},{"name":"multiply","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Number","signature":"multiply: Number, Number -> Number","source":"specs/arithmetic/multiply.adoc","vectors":[{"description":"Basic multiplication of positive integers","input":[2,3],"output":6},{"description":"Multiplication with negative number","input":[-5,3],"output":-15},{"description":"Multiplication by zero","input":[0,42],"output":0},{"description":"Multiplication by one (identity)","input":[7,1],"output":7},{"description":"Multiplication with decimal number","input":[2.5,4],"output":10},{"description":"Multiplication of two negative numbers","input":[-3,-4],"output":12}]},{"name":"negate","parameters":[{"type":"Number"}],"result":"Number","signature":"negate: Number -> Number","source":"specs/arithmetic/negate.adoc","vectors":[{"description":"Negate positive number","input":[5],"output":-5},{"description":"Negate negative number","input":[-3],"output":3},{"description":"Negate zero","input":[0],"output":0},{"description":"Negate large negative number","input":[-100],"output":100}]},{"name":"subtract","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Number","signature":"subtract: Number, Number -> Number","source":"specs/arithmetic/subtract.adoc","vectors":[{"description":"Basic subtraction of positive integers","input":[5,3],"output":2},{"description":"Subtraction resulting in negative number","input":[3,5],"output":-2},{"description":"Subtraction of zeros","input":[0,0],"output":0},{"description":"Subtracting positive from negative","input":[-5,3],"output":-8},{"description":"Subtraction of decimal numbers","input":[10.5,2.5],"output":8},{"description":"Subtracting negative from negative","input":[-10,-20],"output":10}]}]} diff --git a/tests/alib_vectors/collection.json b/tests/alib_vectors/collection.json new file mode 100644 index 00000000..445d3ec7 --- /dev/null +++ b/tests/alib_vectors/collection.json @@ -0,0 +1 @@ +{"category":"collection","format":"alib-vectors-1","operations":[{"name":"contains","parameters":[{"name":"collection","type":"Collection[A]"},{"name":"element","type":"A"}],"result":"Boolean","signature":"contains: Collection[A], A -> Boolean","source":"specs/collection/contains.adoc","vectors":[{"description":"Element is present in collection","input":{"collection":[1,2,3,4,5],"element":3},"output":true},{"description":"Element is not present in collection","input":{"collection":[1,2,3,4,5],"element":6},"output":false},{"description":"Empty collection contains no elements","input":{"collection":[],"element":1},"output":false},{"description":"Single element collection contains that element","input":{"collection":[1],"element":1},"output":true},{"description":"String element is present","input":{"collection":["a","b","c"],"element":"b"},"output":true},{"description":"Element appears multiple times (still returns true)","input":{"collection":[1,2,1,3,1],"element":1},"output":true}]},{"name":"empty","parameters":[{"type":"Collection[A]"}],"result":"Boolean","signature":"empty: Collection[A] -> Boolean","source":"specs/collection/empty.adoc","vectors":[{"description":"Empty collection is empty","input":[[]],"output":true},{"description":"Single-element collection is not empty","input":[[1]],"output":false},{"description":"Multiple-element collection is not empty","input":[[1,2,3]],"output":false},{"description":"String collection with elements is not empty","input":[["a","b"]],"output":false}]},{"name":"filter","parameters":[{"name":"collection","type":"Collection[A]"},{"name":"predicate","type":"Function[A -> Boolean]"}],"result":"Collection[A]","signature":"filter: Collection[A], Function[A -> Boolean] -> Collection[A]","source":"specs/collection/filter.adoc","vectors":[{"description":"Filter numbers greater than 2","input":{"collection":[1,2,3,4,5],"predicate":"x => greater_than(x, 2)"},"output":[3,4,5]},{"description":"Filter even numbers","input":{"collection":[1,2,3,4,5],"predicate":"x => equal(modulo(x, 2), 0)"},"output":[2,4]},{"description":"Filter with always-true predicate returns all elements","input":{"collection":[1,2,3],"predicate":"x => true"},"output":[1,2,3]},{"description":"Filter with always-false predicate returns empty collection","input":{"collection":[1,2,3],"predicate":"x => false"},"output":[]},{"description":"Filter empty collection returns empty collection","input":{"collection":[],"predicate":"x => true"},"output":[]}]},{"name":"fold","parameters":[{"name":"collection","type":"Collection[A]"},{"name":"initial","type":"B"},{"name":"fn","type":"Function[B, A -> B]"}],"result":"B","signature":"fold: Collection[A], B, Function[B, A -> B] -> B","source":"specs/collection/fold.adoc","vectors":[{"description":"Sum all numbers in collection","input":{"collection":[1,2,3,4],"fn":"acc, x => add(acc, x)","initial":0},"output":10},{"description":"Product of all numbers in collection","input":{"collection":[1,2,3,4],"fn":"acc, x => multiply(acc, x)","initial":1},"output":24},{"description":"Folding empty collection returns initial value","input":{"collection":[],"fn":"acc, x => add(acc, x)","initial":42},"output":42},{"description":"Folding single element","input":{"collection":[5],"fn":"acc, x => add(acc, x)","initial":10},"output":15},{"description":"Concatenate strings using fold","input":{"collection":["a","b","c"],"fn":"acc, s => concat(acc, s)","initial":""},"output":"abc"},{"description":"Count elements using fold","input":{"collection":[1,2,3],"fn":"acc, x => add(acc, 1)","initial":0},"output":3}]},{"name":"length","parameters":[{"type":"Collection[A]"}],"result":"Number","signature":"length: Collection[A] -> Number","source":"specs/collection/length.adoc","vectors":[{"description":"Length of five-element collection","input":[[1,2,3,4,5]],"output":5},{"description":"Length of empty collection","input":[[]],"output":0},{"description":"Length of single-element collection","input":[[42]],"output":1},{"description":"Length of string collection","input":[["a","b","c"]],"output":3}]},{"name":"map","parameters":[{"name":"collection","type":"Collection[A]"},{"name":"fn","type":"Function[A -> B]"}],"result":"Collection[B]","signature":"map: Collection[A], Function[A -> B] -> Collection[B]","source":"specs/collection/map.adoc","vectors":[{"description":"Double each number in collection","input":{"collection":[1,2,3],"fn":"x => multiply(x, 2)"},"output":[2,4,6]},{"description":"Add 10 to each number","input":{"collection":[1,2,3],"fn":"x => add(x, 10)"},"output":[11,12,13]},{"description":"Mapping over empty collection returns empty collection","input":{"collection":[],"fn":"x => multiply(x, 2)"},"output":[]},{"description":"Identity function returns same element","input":{"collection":[5],"fn":"x => x"},"output":[5]},{"description":"Map over string collection","input":{"collection":["a","b","c"],"fn":"s => concat(s, s)"},"output":["aa","bb","cc"]}]}]} diff --git a/tests/alib_vectors/comparison.json b/tests/alib_vectors/comparison.json new file mode 100644 index 00000000..b4b35bfe --- /dev/null +++ b/tests/alib_vectors/comparison.json @@ -0,0 +1 @@ +{"category":"comparison","format":"alib-vectors-1","operations":[{"name":"equal","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Boolean","signature":"equal: Number, Number -> Boolean","source":"specs/comparison/equal.adoc","vectors":[{"description":"Equal positive integers","input":[5,5],"output":true},{"description":"Unequal positive integers","input":[5,3],"output":false},{"description":"Zero equals zero","input":[0,0],"output":true},{"description":"Equal negative integers","input":[-5,-5],"output":true},{"description":"Negative and positive not equal","input":[-5,5],"output":false},{"description":"Equal decimal numbers","input":[2.5,2.5],"output":true},{"description":"Decimal and integer with same value","input":[1,1],"output":true}]},{"name":"greater_equal","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Boolean","signature":"greater_equal: Number, Number -> Boolean","source":"specs/comparison/greater_equal.adoc","vectors":[{"description":"First number is greater than second","input":[5,2],"output":true},{"description":"Numbers are equal","input":[5,5],"output":true},{"description":"First number is less than second","input":[2,5],"output":false},{"description":"Zero greater than or equal to negative","input":[0,-5],"output":true},{"description":"Zero greater than or equal to itself","input":[0,0],"output":true},{"description":"Equal negative numbers","input":[-5,-5],"output":true},{"description":"Comparing decimal numbers (greater than)","input":[2.5,1.5],"output":true}]},{"name":"greater_than","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Boolean","signature":"greater_than: Number, Number -> Boolean","source":"specs/comparison/greater_than.adoc","vectors":[{"description":"First number is greater than second","input":[5,2],"output":true},{"description":"First number is less than second","input":[2,5],"output":false},{"description":"Numbers are equal","input":[3,3],"output":false},{"description":"Zero greater than negative","input":[0,-5],"output":true},{"description":"Negative not greater than zero","input":[-5,0],"output":false},{"description":"Comparing two negative numbers","input":[-5,-10],"output":true},{"description":"Comparing decimal numbers","input":[2.5,1.5],"output":true}]},{"name":"less_equal","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Boolean","signature":"less_equal: Number, Number -> Boolean","source":"specs/comparison/less_equal.adoc","vectors":[{"description":"First number is less than second","input":[2,5],"output":true},{"description":"Numbers are equal","input":[5,5],"output":true},{"description":"First number is greater than second","input":[5,2],"output":false},{"description":"Negative less than or equal to zero","input":[-5,0],"output":true},{"description":"Zero less than or equal to itself","input":[0,0],"output":true},{"description":"Equal negative numbers","input":[-10,-10],"output":true},{"description":"Comparing decimal numbers (less than)","input":[1.5,2],"output":true}]},{"name":"less_than","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Boolean","signature":"less_than: Number, Number -> Boolean","source":"specs/comparison/less_than.adoc","vectors":[{"description":"First number is less than second","input":[2,5],"output":true},{"description":"First number is greater than second","input":[5,2],"output":false},{"description":"Numbers are equal","input":[3,3],"output":false},{"description":"Negative less than zero","input":[-5,0],"output":true},{"description":"Zero not less than negative","input":[0,-5],"output":false},{"description":"Comparing two negative numbers","input":[-10,-5],"output":true},{"description":"Comparing decimal numbers","input":[1.5,2],"output":true}]},{"name":"not_equal","parameters":[{"type":"Number"},{"type":"Number"}],"result":"Boolean","signature":"not_equal: Number, Number -> Boolean","source":"specs/comparison/not_equal.adoc","vectors":[{"description":"Unequal positive integers","input":[5,3],"output":true},{"description":"Equal positive integers","input":[5,5],"output":false},{"description":"Zero not equal to one","input":[0,1],"output":true},{"description":"Negative and positive are unequal","input":[-5,5],"output":true},{"description":"Equal negative integers","input":[-5,-5],"output":false},{"description":"Unequal decimal numbers","input":[2.5,2],"output":true},{"description":"Decimal and integer with same value are equal","input":[1,1],"output":false}]}]} diff --git a/tests/alib_vectors/conditional.json b/tests/alib_vectors/conditional.json new file mode 100644 index 00000000..165b20d3 --- /dev/null +++ b/tests/alib_vectors/conditional.json @@ -0,0 +1 @@ +{"category":"conditional","format":"alib-vectors-1","operations":[{"name":"if_then_else","parameters":[{"name":"condition","type":"Boolean"},{"name":"then_value","type":"A"},{"name":"else_value","type":"A"}],"result":"A","signature":"if_then_else: Boolean, A, A -> A","source":"specs/conditional/if_then_else.adoc","vectors":[{"description":"Condition is true, return then_value","input":{"condition":true,"else_value":20,"then_value":10},"output":10},{"description":"Condition is false, return else_value","input":{"condition":false,"else_value":20,"then_value":10},"output":20},{"description":"Works with string values","input":{"condition":true,"else_value":"no","then_value":"yes"},"output":"yes"},{"description":"Returns else branch for false condition","input":{"condition":false,"else_value":"no","then_value":"yes"},"output":"no"},{"description":"Condition from comparison operation","input":{"condition":"equal(5, 5)","else_value":0,"then_value":1},"output":1},{"description":"False comparison leads to else branch","input":{"condition":"greater_than(3, 5)","else_value":"smaller","then_value":"bigger"},"output":"smaller"}]}]} diff --git a/tests/alib_vectors/logical.json b/tests/alib_vectors/logical.json new file mode 100644 index 00000000..7bfdda8a --- /dev/null +++ b/tests/alib_vectors/logical.json @@ -0,0 +1 @@ +{"category":"logical","format":"alib-vectors-1","operations":[{"name":"and","parameters":[{"type":"Boolean"},{"type":"Boolean"}],"result":"Boolean","signature":"and: Boolean, Boolean -> Boolean","source":"specs/logical/and.adoc","vectors":[{"description":"Both values are true","input":[true,true],"output":true},{"description":"First is true, second is false","input":[true,false],"output":false},{"description":"First is false, second is true","input":[false,true],"output":false},{"description":"Both values are false","input":[false,false],"output":false}]},{"name":"not","parameters":[{"type":"Boolean"}],"result":"Boolean","signature":"not: Boolean -> Boolean","source":"specs/logical/not.adoc","vectors":[{"description":"Negation of true is false","input":[true],"output":false},{"description":"Negation of false is true","input":[false],"output":true},{"description":"Double negation returns original value: not(not(true)) = true","input":["not(true)"],"note":"Demonstrates double negation property. The argument is a condition expression (see vectors/GRAMMAR.adoc): it is evaluated to false first, so the call is not(false).","output":true}]},{"name":"or","parameters":[{"type":"Boolean"},{"type":"Boolean"}],"result":"Boolean","signature":"or: Boolean, Boolean -> Boolean","source":"specs/logical/or.adoc","vectors":[{"description":"Both values are true","input":[true,true],"output":true},{"description":"First is true, second is false","input":[true,false],"output":true},{"description":"First is false, second is true","input":[false,true],"output":true},{"description":"Both values are false","input":[false,false],"output":false}]}]} diff --git a/tests/alib_vectors/string.json b/tests/alib_vectors/string.json new file mode 100644 index 00000000..e23a272d --- /dev/null +++ b/tests/alib_vectors/string.json @@ -0,0 +1 @@ +{"category":"string","format":"alib-vectors-1","operations":[{"name":"concat","parameters":[{"type":"String"},{"type":"String"}],"result":"String","signature":"concat: String, String -> String","source":"specs/string/concat.adoc","vectors":[{"description":"Basic string concatenation","input":["hello","world"],"output":"helloworld"},{"description":"Concatenation with space","input":["hello"," world"],"output":"hello world"},{"description":"Concatenation with empty string (left)","input":["","test"],"output":"test"},{"description":"Concatenation with empty string (right)","input":["test",""],"output":"test"},{"description":"Concatenation of two empty strings","input":["",""],"output":""},{"description":"Concatenation of numeric strings","input":["123","456"],"output":"123456"}]},{"name":"length","parameters":[{"type":"String"}],"result":"Number","signature":"length: String -> Number","source":"specs/string/length.adoc","vectors":[{"description":"Length of simple ASCII string","input":["hello"],"output":5},{"description":"Length of empty string","input":[""],"output":0},{"description":"Length of single character","input":["a"],"output":1},{"description":"Length including space","input":["hello world"],"output":11},{"description":"Length of numeric string","input":["123"],"output":3}]},{"name":"reverse","parameters":[{"type":"String"}],"result":"String","signature":"reverse: String -> String","source":"specs/string/reverse.adoc","vectors":[{"description":"Reverse simple string","input":["hello"],"output":"olleh"},{"description":"Reverse empty string","input":[""],"output":""},{"description":"Reverse single character","input":["a"],"output":"a"},{"description":"Reverse numeric string","input":["12345"],"output":"54321"}]},{"name":"substring","parameters":[{"type":"String"},{"type":"Number"},{"type":"Number"}],"result":"String","signature":"substring: String, Number, Number -> String","source":"specs/string/substring.adoc","vectors":[{"description":"Extract entire string","input":["hello",0,5],"output":"hello"},{"description":"Extract middle portion","input":["hello",1,4],"output":"ell"},{"description":"Extract first character","input":["hello",0,1],"output":"h"},{"description":"Extract last character","input":["hello",4,5],"output":"o"},{"description":"Empty substring (start equals end)","input":["hello",2,2],"output":""},{"description":"Extract word from sentence","input":["hello world",6,11],"output":"world"},{"description":"Empty substring at the end boundary (start = end = length(s))","input":["hello",5,5],"output":""},{"description":"Only in-domain call on the empty string","input":["",0,0],"output":""}]}]} diff --git a/tests/eph-corpus-compiles.txt b/tests/eph-corpus-compiles.txt index 5631a29c..8c801320 100644 --- a/tests/eph-corpus-compiles.txt +++ b/tests/eph-corpus-compiles.txt @@ -18,6 +18,7 @@ # # Paths are repo-root-relative, one per line, '#' comments allowed. examples/pattern_matching.eph +stdlib/ALib.eph stdlib/Argv.eph tests/v2-grammar/fixtures/extern-abstract-types.eph tests/v2-grammar/fixtures/extern-callsite.eph From 88dc7667b3c2f35f05c8a4291a180285978ef3b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:33:48 +0000 Subject: [PATCH 09/26] docs(tests): count the three aLib conformance tests (504 -> 507) The status gate's all-target listing now reports 507. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- TEST-NEEDS.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index a59ceaa6..10c6ac37 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -21,9 +21,9 @@ compliance, 16 tests) performance, correctness, 13 tests) * Benchmarks: `+src/ephapax-parser/benches/parse_bench.rs+`, `+src/ephapax-vram-cache/benches/cache_bench.rs+` -* Total: *504 tests* (`+cargo test --workspace --all-targets+`); +* Total: *507 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 504 +* Documented all-target tests: 507 === CRG Testing Taxonomy — Status From 4735178e3369ab717cf879f7c6fa7d8a2178e3d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:30:30 +0000 Subject: [PATCH 10/26] fix(desugar): recursive data is a named DesugarError, not a stack overflow The binary-sum encoding expands every named type structurally, so a self-referential `data L = Nil | Cons(I32, L)` expanded forever and `ephapax check` aborted with exit 134 (survey probe rec1). Track the named type applications under expansion and fail with `DesugarError::RecursiveType` on a repeat (regular recursion) or past a fixed depth (non-regular recursion). Type aliases go through the same guard. Also lower irrefutable pair patterns (pairs of variables, wildcards and unit), including multi-field constructor payloads, to a one-arm core `match` instead of `fst`/`snd` re-reads of the scrutinee, so a linear pair is consumed exactly once and a discarded linear component is visible to the checker (v2 plan rung E0). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- src/ephapax-desugar/src/lib.rs | 109 ++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/src/ephapax-desugar/src/lib.rs b/src/ephapax-desugar/src/lib.rs index a18806ff..90ba43b6 100644 --- a/src/ephapax-desugar/src/lib.rs +++ b/src/ephapax-desugar/src/lib.rs @@ -54,6 +54,7 @@ //! Some(x) ⟹ inr(x) //! ``` +use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use ephapax_surface::{ @@ -124,6 +125,13 @@ pub enum DesugarError { #[error("member access on a non-module expression is not supported (only `Module.member` qualified access)")] FieldAccessOnNonModule, + + /// A `data` declaration (or `type` alias) refers to itself. The core + /// has no recursive types: the binary-sum encoding expands every + /// named type structurally, so a self-reference would expand forever + /// (probe `rec1` used to abort with a stack overflow). + #[error("recursive type `{name}` is not supported: the core type language has no recursive types (expansion cycle: {cycle})")] + RecursiveType { name: String, cycle: String }, } // ========================================================================= @@ -247,6 +255,9 @@ pub struct Desugarer { /// module reference at desugar time. Reset per-module by /// [`Desugarer::desugar_module`]. module_qualifiers: HashSet, + /// Named types currently being expanded by `desugar_named_type`, + /// innermost last. Re-entering one is a recursive type. + expanding: RefCell>, } impl Desugarer { @@ -255,6 +266,7 @@ impl Desugarer { Self { registry: DataRegistry::new(), module_qualifiers: HashSet::new(), + expanding: RefCell::new(Vec::new()), } } @@ -263,6 +275,7 @@ impl Desugarer { Self { registry, module_qualifiers: HashSet::new(), + expanding: RefCell::new(Vec::new()), } } @@ -714,7 +727,7 @@ impl Desugarer { got: args.len(), }); } - return self.desugar_ty(&aliased); + return self.with_expansion(name, args, |this| this.desugar_ty(&aliased)); } let (params, ctors) = self.registry.get_type_ctors(name.as_str()).ok_or_else(|| { @@ -735,7 +748,43 @@ impl Desugarer { let subst: HashMap<&SmolStr, &SurfaceTy> = params.iter().zip(args.iter()).collect(); // Build right-nested sum from constructor field types - self.build_sum_type(&ctors.clone(), &subst) + let ctors = ctors.clone(); + self.with_expansion(name, args, |this| this.build_sum_type(&ctors, &subst)) + } + + /// Run `f` with the named type application `name(args)` pushed on the + /// expansion stack. Fails with [`DesugarError::RecursiveType`] if the + /// same application is already being expanded (a regular recursive + /// type such as `data L = Nil | Cons(I32, L)`), or if the stack grows + /// past a fixed bound (a non-regular recursive type, whose + /// applications never repeat). + fn with_expansion( + &self, + name: &SmolStr, + args: &[SurfaceTy], + f: impl FnOnce(&Self) -> Result, + ) -> Result { + const MAX_EXPANSION_DEPTH: usize = 64; + let key: SmolStr = if args.is_empty() { + name.clone() + } else { + format!("{name}{args:?}").into() + }; + { + let stack = self.expanding.borrow(); + if stack.contains(&key) || stack.len() >= MAX_EXPANSION_DEPTH { + let mut cycle: Vec = stack.iter().map(|k| k.to_string()).collect(); + cycle.push(key.to_string()); + return Err(DesugarError::RecursiveType { + name: name.to_string(), + cycle: cycle.join(" -> "), + }); + } + } + self.expanding.borrow_mut().push(key); + let result = f(self); + self.expanding.borrow_mut().pop(); + result } /// Build the right-nested binary sum type for a list of constructors. @@ -1212,6 +1261,17 @@ impl Desugarer { match patterns.len() { 0 => Ok(body), 1 => self.bind_single_pattern(payload, &patterns[0], body, span), + // The payload is a right-nested product: destructure it with + // one core `match` on the equivalent right-nested pair + // pattern, so the payload is consumed once (see + // `bind_single_pattern`). + _ if patterns.iter().all(Self::is_irrefutable_pair_pattern) => { + let mut nested = patterns[patterns.len() - 1].clone(); + for p in patterns[..patterns.len() - 1].iter().rev() { + nested = Pattern::Pair(Box::new(p.clone()), Box::new(nested)); + } + self.bind_single_pattern(payload, &nested, body, span) + } _ => { // Multiple fields: payload is a right-nested product // Extract with fst/snd @@ -1269,6 +1329,23 @@ impl Desugarer { )), Pattern::Wildcard => Ok(body), Pattern::Unit => Ok(body), + // A pair pattern built only from pairs, variables, wildcards + // and unit lowers to a one-arm core `match`. The core checker + // then consumes the scrutinee exactly once, binds each + // component, and rejects a wildcard over a linear component + // (v2 plan E0). The `fst`/`snd` lowering below reads the + // scrutinee twice, which a linear pair does not allow. + Pattern::Pair(..) if Self::is_irrefutable_pair_pattern(pattern) => Ok(Expr::new( + ExprKind::Match { + scrutinee: Box::new(value.clone()), + arms: vec![ephapax_syntax::MatchArm { + pattern: Self::core_pair_pattern(pattern), + guard: None, + body, + }], + }, + span, + )), Pattern::Pair(l, r) => { let fst = Expr::new(ExprKind::Fst(Box::new(value.clone())), span); let snd = Expr::new(ExprKind::Snd(Box::new(value.clone())), span); @@ -1293,6 +1370,34 @@ impl Desugarer { } } + /// Whether `pattern` is built only from pairs, variables, wildcards + /// and unit (so it is irrefutable and has a direct core form). + fn is_irrefutable_pair_pattern(pattern: &Pattern) -> bool { + match pattern { + Pattern::Var(_) | Pattern::Wildcard | Pattern::Unit => true, + Pattern::Pair(l, r) => { + Self::is_irrefutable_pair_pattern(l) && Self::is_irrefutable_pair_pattern(r) + } + Pattern::Tuple(_) | Pattern::Constructor { .. } | Pattern::Literal(_) => false, + } + } + + /// Convert a pattern accepted by [`Self::is_irrefutable_pair_pattern`] + /// to the core pattern type. + fn core_pair_pattern(pattern: &Pattern) -> ephapax_syntax::Pattern { + use ephapax_syntax::Pattern as CorePattern; + match pattern { + Pattern::Var(v) => CorePattern::Var(v.clone()), + Pattern::Unit => CorePattern::Unit, + Pattern::Pair(l, r) => CorePattern::Pair( + Box::new(Self::core_pair_pattern(l)), + Box::new(Self::core_pair_pattern(r)), + ), + // Only reachable for `Wildcard` (the guard excludes the rest). + _ => CorePattern::Wildcard, + } + } + /// Generate a fresh variable name for a match arm's payload binding. fn fresh_var_for_arm(&self, arm: &MatchArm, ctor: &ConstructorDef) -> SmolStr { match &arm.pattern { From 570b69e9bf5941174ebb854c7e6a09674abb75da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:30:30 +0000 Subject: [PATCH 11/26] fix(typing): close the E0 linearity holes in the Rust checker v2 plan rung E0 ("Holes first"). Each item was accepted before: - `mark_used` forbids a second use when `demands_consumption()` holds (any `let!`, probes dup2/dup4) or the type is linear. - `Ty::is_linear` takes the join over Prod/Sum/Tuple/List (conservative reject, independent of D5; pairdup, sum/tuple/list duplication). This diverges from Coq `is_linear_ty` on TProd/TSum, the refuted F5 shape; recorded in docs/coq-rust-bridge/is_linear_ty.adoc. - `check_lambda` rejects a closure body that consumes a captured restricted binding (`LinearCaptureConsumed`, probe lamcap). - `check_deref` rejects moving a linear value out of a borrow or an unrestricted ref (`CannotMoveOutOfBorrow`). - Patterns: a wildcard over a linear component (`LinearPatternDiscard`) and a duplicate binder (`DuplicatePatternBinder`) are rejected; a `_` in a tuple binder is now a wildcard, not a variable named `_`. - Projection: `t.i`, `xs[i]`, `fst` and `snd` reject a container whose unselected components are linear (`LinearProjectionDiscard`). Gate: src/ephapax-cli/tests/linearity_holes.rs (19 tests; the survey probes are included verbatim from the evidence directory). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- docs/coq-rust-bridge/is_linear_ty.adoc | 30 +- src/ephapax-cli/tests/linearity_holes.rs | 475 +++++++++++++++++++++++ src/ephapax-parser/src/surface.rs | 14 +- src/ephapax-syntax/src/lib.rs | 78 +++- src/ephapax-typing/src/discipline.rs | 18 +- src/ephapax-typing/src/lib.rs | 161 +++++++- 6 files changed, 738 insertions(+), 38 deletions(-) create mode 100644 src/ephapax-cli/tests/linearity_holes.rs diff --git a/docs/coq-rust-bridge/is_linear_ty.adoc b/docs/coq-rust-bridge/is_linear_ty.adoc index 41e5b11b..ebbdac5f 100644 --- a/docs/coq-rust-bridge/is_linear_ty.adoc +++ b/docs/coq-rust-bridge/is_linear_ty.adoc @@ -5,6 +5,22 @@ :toc: :toclevels: 2 +[IMPORTANT] +.Divergence since v2 rung E0 (2026-11-09) +==== +The Rust `Ty::is_linear()` now takes the *join* over `Ty::Prod`, +`Ty::Sum`, `Ty::Tuple` and `Ty::List`: a compound type is linear as soon +as one component is. Coq `is_linear_ty` still returns `false` for +`TProd` and `TSum`, which is the shape refuted by +`pair_dup_linear_string` (F5, `docs/reports/2026-11-04-v2-survey/`). +The bridge claim below therefore *no longer holds* for `TProd` and +`TSum`; it still holds for every other kernel constructor. This is a +deliberate, conservative Rust-side reject (plan rung E0), independent of +decision D5, which may relax it in W1. The Coq side is not changed here. +The rows for `TProd` / `TSum` in the tables below record the Coq value +only; the Rust value is the join. +==== + This document closes proof-debt obligation **P28** from the comprehensive proof inventory: certify that the Rust-side `Ty::is_linear()` (at `src/ephapax-syntax/src/lib.rs:155-166`) @@ -46,6 +62,11 @@ pub fn is_linear(&self) -> bool { } => true, Ty::Region { inner, .. } => inner.is_linear(), Ty::ForAll { body, .. } => body.is_linear(), + Ty::Prod { left, right } | Ty::Sum { left, right } => { + left.is_linear() || right.is_linear() + } + Ty::Tuple(elements) => elements.iter().any(Ty::is_linear), + Ty::List(inner) => inner.is_linear(), _ => false, } } @@ -94,8 +115,8 @@ The four kernel cases compose as follows. | `TRegion r T'` | `Ty::Region { name: r, inner: T' }` | recurse | clauses 3 | `TBase _` | `Ty::Base(_)` | **false** | fallthrough | `TFun _ _` | `Ty::Fun { .. }` | **false** | fallthrough -| `TProd _ _` | `Ty::Prod { .. }` | **false** | fallthrough -| `TSum _ _` | `Ty::Sum { .. }` | **false** | fallthrough +| `TProd _ _` | `Ty::Prod { .. }` | Coq **false**; Rust join (diverges, E0) | `e0_prod_sum_join_diverges_from_coq` +| `TSum _ _` | `Ty::Sum { .. }` | Coq **false**; Rust join (diverges, E0) | `e0_prod_sum_join_diverges_from_coq` | `TBorrow _` | `Ty::Borrow { .. }` | **false** | fallthrough | `TEcho _` | (no kernel-Rust counterpart yet) | n/a | n/a @@ -119,8 +140,9 @@ on the witness. Recurses on `inner`. Same as Coq's `TRegion _ T'`. The bridge holds directly. -The other recursive forms (`Ty::Borrow { inner, .. }`, `Ty::Fun`, -`Ty::Prod`, `Ty::Sum`, `Ty::Effectful`, `Ty::List`, `Ty::Tuple`, +Since E0, `Ty::Prod`, `Ty::Sum`, `Ty::Tuple` and `Ty::List` recurse +(join) on the Rust side; see the divergence note at the top. The other +recursive forms (`Ty::Borrow { inner, .. }`, `Ty::Fun`, `Ty::Effectful`) are intentionally **NOT** recursive for `is_linear` on either side — they always return `false`. This is a deliberate kernel choice: a function `T1 → T2` is treated as a diff --git a/src/ephapax-cli/tests/linearity_holes.rs b/src/ephapax-cli/tests/linearity_holes.rs new file mode 100644 index 00000000..49702f97 --- /dev/null +++ b/src/ephapax-cli/tests/linearity_holes.rs @@ -0,0 +1,475 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + +//! linearity_holes.rs — the gate for rung E0 ("Holes first") of the v2 +//! plan (`docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc`). +//! +//! Every probe below was accepted by `ephapax check` before E0 (or, for +//! `rec1`, aborted the compiler with a stack overflow). Each one must now +//! be rejected with a *named* error kind, checked on the library pipeline +//! (surface parse → desugar → type check), and `ephapax check` must exit +//! with the compiler's normal failure status (1), never a signal. +//! +//! The survey probes are included verbatim from the evidence directory so +//! the gate cannot drift from the recorded finding. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use ephapax_desugar::{desugar, DesugarError}; +use ephapax_parser::{parse_module, parse_surface_module}; +use ephapax_syntax::{Expr, ExprKind, Ty}; +use ephapax_typing::{type_check_module, TypeChecker, TypeError}; + +const DUP2: &str = + include_str!("../../../docs/reports/2026-11-04-v2-survey/evidence/probes/dup2.eph.txt"); +const DUP4: &str = + include_str!("../../../docs/reports/2026-11-04-v2-survey/evidence/probes/dup4.eph.txt"); +const PAIRDUP: &str = + include_str!("../../../docs/reports/2026-11-04-v2-survey/evidence/probes/pairdup.eph.txt"); +const LAMCAP: &str = + include_str!("../../../docs/reports/2026-11-04-v2-survey/evidence/probes/lamcap.eph.txt"); +const REC1: &str = + include_str!("../../../docs/reports/2026-11-04-v2-survey/evidence/probes/rec1.eph.txt"); + +/// A sum holding a linear string, duplicated through two aliases. +const SUMDUP: &str = "\ +fn main(): I32 = + region r { + let s = String.new@r(\"hi\") in + let x = inr[I32](s) in + let a = x in + let b = x in + 0 + } +"; + +/// A three-element tuple holding a linear string, duplicated. +const TUPLEDUP: &str = "\ +fn main(): I32 = + region r { + let s = String.new@r(\"hi\") in + let t = (s, 1, 2) in + let a = t in + let b = t in + 0 + } +"; + +/// A list of linear strings, duplicated. +const LISTDUP: &str = "\ +fn main(): I32 = + region r { + let s = String.new@r(\"hi\") in + let xs = [s] in + let a = xs in + let b = xs in + 0 + } +"; + +/// `(_, y)` over a pair whose first component is linear: the wildcard +/// would discard the string. +const WILDPAIR: &str = "\ +fn main(): I32 = + region r { + let s = String.new@r(\"hi\") in + let p = (s, 1) in + let (_, y) = p in + y + } +"; + +/// A duplicate binder in one pattern: the second `x` would shadow (and +/// discard) the first. +const DUPBINDER: &str = "\ +fn main(): I32 = + let p = (1, 2) in + let (x, x) = p in + x +"; + +/// Tuple projection `t.0` from a tuple of two linear strings: `t.1` +/// would be lost. +const TUPLEIDX: &str = "\ +fn main(): I32 = + region r { + let a = String.new@r(\"a\") in + let b = String.new@r(\"b\") in + let t = (a, b) in + let c = t.0 in + drop(c); + 0 + } +"; + +/// List index from a list of linear strings: the other elements would +/// be lost. +const LISTIDX: &str = "\ +fn main(): I32 = + region r { + let a = String.new@r(\"a\") in + let b = String.new@r(\"b\") in + let xs = [a, b] in + let c = xs[0] in + drop(c); + 0 + } +"; + +/// Destructuring a linear pair is the supported way to take it apart, so +/// it must still type-check (and the string must be consumed). +const DESTRUCT_OK: &str = "\ +fn main(): I32 = + region r { + let s = String.new@r(\"hi\") in + let p = (s, 1) in + let (a, y) = p in + drop(a); + y + } +"; + +/// Pair destructuring lowers to a core `match`; its wasm must compute +/// the right value (3 + 4 * 10). +const DESTRUCT_VAL: &str = "\ +fn main(): I32 = + let p = (3, 4) in + let (a, b) = p in + a + b * 10 +"; + +/// A three-field constructor payload is destructured with one core +/// `match` on a right-nested pair pattern. +const DESTRUCT3: &str = "\ +data Tri = T(I32, I32, I32) +fn sum(t: Tri): I32 = + match t of + | T(a, b, c) => a + b * 10 + c * 100 + end +fn main(): I32 = sum(T(1, 2, 3)) +"; + +/// A closure that only reads an unrestricted capture is fine. +const LAMBDA_UNRESTRICTED_CAPTURE: &str = "\ +fn main(): I32 = + let n = 5 in + let f = fn(u: ()) -> n in + f(()) + f(()) +"; + +#[derive(Debug)] +enum PipelineError { + Desugar(DesugarError), + Type(TypeError), +} + +/// Library pipeline used by `ephapax check`: surface parse → desugar → +/// type check, falling back to the core parser when the surface parser +/// rejects the file (as `check_files` does; `pairdup` needs it). +fn pipeline(src: &str) -> Result<(), PipelineError> { + let core = match parse_surface_module(src, "probe") { + Ok(surface) => desugar(&surface).map_err(PipelineError::Desugar)?, + Err(_) => parse_module(src, "probe").expect("probe must parse"), + }; + type_check_module(&core).map_err(|e| PipelineError::Type(e.error)) +} + +fn type_error(src: &str) -> TypeError { + match pipeline(src) { + Err(PipelineError::Type(e)) => e, + other => panic!("expected a type error, got {other:?}"), + } +} + +fn ephapax_bin() -> String { + env!("CARGO_BIN_EXE_ephapax").to_string() +} + +fn write_probe(dir: &Path, name: &str, src: &str) -> PathBuf { + let path = dir.join(format!("{name}.eph")); + std::fs::write(&path, src).expect("write probe"); + path +} + +/// `ephapax check` must reject the probe with exit status 1 (the CLI's +/// normal error exit), not a crash or abort. +fn assert_cli_rejects(name: &str, src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let path = write_probe(dir.path(), name, src); + let out = Command::new(ephapax_bin()) + .arg("check") + .arg(&path) + .output() + .expect("run ephapax check"); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert_eq!( + out.status.code(), + Some(1), + "{name}: expected exit 1 (normal error), got {:?}\nstderr:\n{stderr}", + out.status + ); + stderr +} + +#[test] +fn dup2_let_bang_reused_rejected() { + assert!(matches!( + type_error(DUP2), + TypeError::LinearVariableReused(ref v) if v == "x" + )); + assert_cli_rejects("dup2", DUP2); +} + +#[test] +fn dup4_let_bang_passed_twice_rejected() { + assert!(matches!( + type_error(DUP4), + TypeError::LinearVariableReused(ref v) if v == "x" + )); + assert_cli_rejects("dup4", DUP4); +} + +#[test] +fn pairdup_rejected() { + // Escalation note (plan E0, "Depends"): this relies on the + // conservative compound join in `Ty::is_linear`; D5 (W1) may relax it. + assert!(matches!( + type_error(PAIRDUP), + TypeError::LinearVariableReused(ref v) if v == "p" + )); + assert_cli_rejects("pairdup", PAIRDUP); +} + +#[test] +fn lamcap_rejected() { + assert!(matches!( + type_error(LAMCAP), + TypeError::LinearCaptureConsumed(ref v) if v == "s" + )); + assert_cli_rejects("lamcap", LAMCAP); +} + +#[test] +fn sum_of_linear_duplication_rejected() { + assert!(matches!( + type_error(SUMDUP), + TypeError::LinearVariableReused(ref v) if v == "x" + )); + assert_cli_rejects("sumdup", SUMDUP); +} + +#[test] +fn tuple_of_linear_duplication_rejected() { + assert!(matches!( + type_error(TUPLEDUP), + TypeError::LinearVariableReused(ref v) if v == "t" + )); + assert_cli_rejects("tupledup", TUPLEDUP); +} + +#[test] +fn list_of_linear_duplication_rejected() { + assert!(matches!( + type_error(LISTDUP), + TypeError::LinearVariableReused(ref v) if v == "xs" + )); + assert_cli_rejects("listdup", LISTDUP); +} + +/// The surface has no dereference syntax, so the double-borrow probe is +/// built as core AST (the form desugar and the S-expression IR produce): +/// +/// ```text +/// region r { +/// let! s = String.new@r("hi") in +/// let b1 = &s in let b2 = &s in +/// drop(*b1); drop(*b2); drop(s); 0 } +/// ``` +#[test] +fn double_borrow_then_drop_rejected() { + let e = Expr::dummy; + let var = |n: &str| Expr::dummy(ExprKind::Var(n.into())); + let borrow_s = || { + Expr::dummy(ExprKind::Borrow { + inner: Box::new(var("s")), + mutable: false, + }) + }; + let drop_deref = |n: &str| { + Expr::dummy(ExprKind::Drop(Box::new(Expr::dummy(ExprKind::Deref( + Box::new(var(n)), + ))))) + }; + let body = e(ExprKind::Block(vec![ + drop_deref("b1"), + drop_deref("b2"), + e(ExprKind::Drop(Box::new(var("s")))), + e(ExprKind::Lit(ephapax_syntax::Literal::I32(0))), + ])); + let program = e(ExprKind::Region { + name: "r".into(), + body: Box::new(e(ExprKind::LetLin { + name: "s".into(), + ty: None, + value: Box::new(e(ExprKind::StringNew { + region: "r".into(), + value: "hi".into(), + })), + body: Box::new(e(ExprKind::Let { + name: "b1".into(), + ty: None, + value: Box::new(borrow_s()), + body: Box::new(e(ExprKind::Let { + name: "b2".into(), + ty: None, + value: Box::new(borrow_s()), + body: Box::new(body), + })), + })), + })), + }); + let err = TypeChecker::new() + .check(&program) + .expect_err("double borrow then drop must be rejected"); + assert!( + matches!(err.error, TypeError::CannotMoveOutOfBorrow(Ty::String(_))), + "got {:?}", + err.error + ); +} + +#[test] +fn deref_of_unrestricted_borrow_still_allowed() { + let mut tc = TypeChecker::new(); + tc.check(&Expr::dummy(ExprKind::Deref(Box::new(Expr::dummy( + ExprKind::Borrow { + inner: Box::new(Expr::dummy(ExprKind::Lit(ephapax_syntax::Literal::I32(7)))), + mutable: false, + }, + ))))) + .expect("deref of a borrow of an I32 copies it"); +} + +#[test] +fn wildcard_over_linear_pair_component_rejected() { + assert!(matches!( + type_error(WILDPAIR), + TypeError::LinearPatternDiscard(Ty::String(_)) + )); + assert_cli_rejects("wildpair", WILDPAIR); +} + +#[test] +fn duplicate_pattern_binder_rejected() { + assert!(matches!( + type_error(DUPBINDER), + TypeError::DuplicatePatternBinder(ref v) if v == "x" + )); + assert_cli_rejects("dupbinder", DUPBINDER); +} + +#[test] +fn tuple_projection_from_linear_tuple_rejected() { + assert!(matches!( + type_error(TUPLEIDX), + TypeError::LinearProjectionDiscard(_) + )); + assert_cli_rejects("tupleidx", TUPLEIDX); +} + +#[test] +fn list_index_from_linear_list_rejected() { + assert!(matches!( + type_error(LISTIDX), + TypeError::LinearProjectionDiscard(Ty::List(_)) + )); + assert_cli_rejects("listidx", LISTIDX); +} + +#[test] +fn rec1_is_a_named_desugar_error_not_a_crash() { + match pipeline(REC1) { + Err(PipelineError::Desugar(DesugarError::RecursiveType { name, .. })) => { + assert_eq!(name, "L"); + } + other => panic!("expected DesugarError::RecursiveType, got {other:?}"), + } + let stderr = assert_cli_rejects("rec1", REC1); + assert!( + stderr.contains("recursive type `L`"), + "stderr should name the recursive type:\n{stderr}" + ); +} + +#[test] +fn nested_data_application_is_not_mistaken_for_recursion() { + let src = "\ +data Option(a) = None | Some(a) +fn f(x: Option(Option(I32))): I32 = 0 +fn main(): I32 = 0 +"; + pipeline(src).expect("Option(Option(I32)) is not recursive"); +} + +#[test] +fn destructuring_a_linear_pair_still_checks() { + pipeline(DESTRUCT_OK).expect("destructuring is the supported way to split a linear pair"); +} + +#[test] +fn closure_reading_unrestricted_capture_still_checks() { + pipeline(LAMBDA_UNRESTRICTED_CAPTURE).expect("unrestricted captures may be reused"); +} + +/// Compile with the CLI and run `main` under wasmtime. +fn compile_and_run(name: &str, src: &str) -> i32 { + let dir = tempfile::tempdir().expect("tempdir"); + let path = write_probe(dir.path(), name, src); + let wasm_path = dir.path().join(format!("{name}.wasm")); + let out = Command::new(ephapax_bin()) + .arg("compile") + .arg(&path) + .arg("-o") + .arg(&wasm_path) + .output() + .expect("run ephapax compile"); + assert!( + out.status.success(), + "{name} must compile:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let bytes = std::fs::read(&wasm_path).expect("read wasm"); + let engine = wasmtime::Engine::default(); + let module = wasmtime::Module::new(&engine, &bytes).expect("module"); + let mut store = wasmtime::Store::new(&engine, ()); + let mut linker = wasmtime::Linker::new(&engine); + linker + .func_wrap("env", "print_i32", |_v: i32| {}) + .expect("print_i32"); + linker + .func_wrap("env", "print_string", |_p: i32, _l: i32| {}) + .expect("print_string"); + let instance = linker + .instantiate(&mut store, &module) + .expect("instantiate"); + let main = instance + .get_typed_func::<(), i32>(&mut store, "main") + .expect("main"); + main.call(&mut store, ()).expect("main must not trap") +} + +#[test] +fn pair_destructuring_lowering_computes_correct_value() { + assert_eq!(compile_and_run("destruct_val", DESTRUCT_VAL), 43); +} + +#[test] +fn multi_field_constructor_destructuring_checks() { + // Type-level only. The wasm for a nested pair payload is wrong on + // the base commit too (`fst((1, (2, 3)))` evaluates to 521 with the + // pre-E0 compiler), a codegen defect outside E0, so this gate does + // not assert the runtime value of a three-field payload. + pipeline(DESTRUCT3).expect("three-field constructor destructuring type-checks"); +} diff --git a/src/ephapax-parser/src/surface.rs b/src/ephapax-parser/src/surface.rs index be210923..b39a864a 100644 --- a/src/ephapax-parser/src/surface.rs +++ b/src/ephapax-parser/src/surface.rs @@ -692,11 +692,21 @@ fn match_arm_from_tuple_binder( span: Span, ) -> SurfaceExpr { // Build the pair pattern from the right: (a, b, c) → Pair(a, Pair(b, c)). + // A `_` binder is a wildcard, not a variable named `_`: it binds + // nothing, so the checker can see (and reject) a discarded linear + // component instead of reporting an unconsumed variable `_`. + let binder = |name: ephapax_surface::Var| { + if name.as_str() == "_" { + Pattern::Wildcard + } else { + Pattern::Var(name) + } + }; let mut iter = binders.into_iter().rev(); let last = iter.next().expect("at least 2 binders"); - let mut pat = Pattern::Var(last); + let mut pat = binder(last); for name in iter { - pat = Pattern::Pair(Box::new(Pattern::Var(name)), Box::new(pat)); + pat = Pattern::Pair(Box::new(binder(name)), Box::new(pat)); } SurfaceExpr::new( SurfaceExprKind::Match { diff --git a/src/ephapax-syntax/src/lib.rs b/src/ephapax-syntax/src/lib.rs index a1617e67..09204e68 100644 --- a/src/ephapax-syntax/src/lib.rs +++ b/src/ephapax-syntax/src/lib.rs @@ -152,6 +152,20 @@ impl Ty { /// Check if type is linear (must be used exactly once). /// + /// Compound data types take the *join* of their components + /// (v2 plan rung E0): a product, sum, tuple or list is linear as + /// soon as any component is. This is the conservative choice — it + /// rejects duplicating a container that holds a linear value + /// (`pairdup`), and D5 (compound-grade placement, W1) may relax it. + /// It deliberately diverges from Coq `is_linear_ty` on `TProd` / + /// `TSum`, which is the refuted F5 shape (`pair_dup_linear_string`); + /// see `docs/coq-rust-bridge/is_linear_ty.adoc`. + /// + /// Function types (`Fun`, `Effectful`) and borrows stay + /// unrestricted: a closure may not consume a captured linear value + /// (enforced in the type checker's `check_lambda`), and a borrow + /// never owns its referent. + /// /// Type variables (`Var`, `Unif`) are conservatively non-linear. /// Phase 2 will add linearity bounds (`T: Lin`) for generic linear code. pub fn is_linear(&self) -> bool { @@ -163,6 +177,11 @@ impl Ty { } => true, Ty::Region { inner, .. } => inner.is_linear(), Ty::ForAll { body, .. } => body.is_linear(), + Ty::Prod { left, right } | Ty::Sum { left, right } => { + left.is_linear() || right.is_linear() + } + Ty::Tuple(elements) => elements.iter().any(Ty::is_linear), + Ty::List(inner) => inner.is_linear(), _ => false, } } @@ -845,11 +864,11 @@ mod tests { } #[test] - fn coq_bridge_fun_prod_sum_borrow_fallthrough_unrestricted() { - // Coq: TFun / TProd / TSum / TBorrow all fall through → false - // regardless of constituent linearity. Linearity of CAPTURED - // resources is tracked at the judgment level (T_Lam_L1_*'s - // body context discipline), not at the type shape. + fn coq_bridge_fun_borrow_fallthrough_unrestricted() { + // Coq: TFun / TBorrow fall through → false regardless of + // constituent linearity. A closure may not consume a captured + // linear value (checked by the type checker's `check_lambda`), + // and a borrow does not own its referent. let lin = Box::new(Ty::String("r".into())); let unr = boxed_base(); @@ -857,25 +876,39 @@ mod tests { param: lin.clone(), ret: unr.clone(), }; - let prod = Ty::Prod { - left: lin.clone(), - right: unr.clone(), - }; - let sum = Ty::Sum { - left: lin.clone(), - right: unr.clone(), - }; let borrow = Ty::Borrow { inner: lin.clone(), mutable: false, }; assert!(!fun.is_linear(), "Coq bridge: TFun is NOT linear"); - assert!(!prod.is_linear(), "Coq bridge: TProd is NOT linear"); - assert!(!sum.is_linear(), "Coq bridge: TSum is NOT linear"); assert!(!borrow.is_linear(), "Coq bridge: TBorrow is NOT linear"); } + #[test] + fn e0_prod_sum_join_diverges_from_coq() { + // Deliberate divergence from Coq `is_linear_ty` (v2 plan E0): + // TProd / TSum take the join of their components. Coq's + // fallthrough to false is the refuted F5 shape + // (`pair_dup_linear_string`), so the bridge no longer claims + // agreement on these two constructors. + let lin = Box::new(Ty::String("r".into())); + let unr = boxed_base(); + for (l, r, expect) in [ + (lin.clone(), unr.clone(), true), + (unr.clone(), lin.clone(), true), + (unr.clone(), unr.clone(), false), + ] { + let prod = Ty::Prod { + left: l.clone(), + right: r.clone(), + }; + let sum = Ty::Sum { left: l, right: r }; + assert_eq!(prod.is_linear(), expect, "Prod join"); + assert_eq!(sum.is_linear(), expect, "Sum join"); + } + } + #[test] fn effectful_fallthrough_unrestricted() { // Rust-only behaviour, NOT a Coq bridge: `Ty::Effectful` is a @@ -913,13 +946,20 @@ mod tests { } #[test] - fn surface_only_var_unif_list_tuple_unrestricted() { + fn surface_only_var_unif_unrestricted() { // Conservative non-linear for unsubstituted type variables // (see is_linear() docstring). Coq kernel has no equivalent — - // surface-only. + // surface-only. (E7 makes unconstrained type parameters linear.) assert!(!Ty::Var("T".into()).is_linear()); assert!(!Ty::Unif(0).is_linear()); - assert!(!Ty::List(Box::new(Ty::String("r".into()))).is_linear()); - assert!(!Ty::Tuple(vec![Ty::String("r".into()), Ty::Base(BaseTy::I32)]).is_linear()); + } + + #[test] + fn e0_list_tuple_join() { + // Surface-only constructors take the join (v2 plan E0). + assert!(Ty::List(Box::new(Ty::String("r".into()))).is_linear()); + assert!(!Ty::List(Box::new(Ty::Base(BaseTy::I32))).is_linear()); + assert!(Ty::Tuple(vec![Ty::String("r".into()), Ty::Base(BaseTy::I32)]).is_linear()); + assert!(!Ty::Tuple(vec![Ty::Base(BaseTy::I32), Ty::Base(BaseTy::Bool)]).is_linear()); } } diff --git a/src/ephapax-typing/src/discipline.rs b/src/ephapax-typing/src/discipline.rs index a5aece21..fe7ac12d 100644 --- a/src/ephapax-typing/src/discipline.rs +++ b/src/ephapax-typing/src/discipline.rs @@ -54,14 +54,28 @@ //! //! | Discipline | Rule | Enforced at | //! |------------|------|-------------| -//! | **Affine** (scrutinee was `let`) | Wildcard = implicit drop (OK) | Case vars use `Param` — linear types still demand consumption, but affine scrutinee's components follow type discipline | -//! | **Linear** (scrutinee was `let!`) | Wildcard on linear component = error | Case vars use `Param` — linear types demand consumption via `demands_consumption()` | +//! | **Affine** (scrutinee was `let`) | Wildcard over an unrestricted component = implicit drop (OK); over a linear component = error | `check_pattern` — `LinearPatternDiscard` (v2 plan E0) | +//! | **Linear** (scrutinee was `let!`) | Wildcard on linear component = error | `check_pattern` — `LinearPatternDiscard`; case vars use `Param`, so linear types demand consumption via `demands_consumption()` | +//! +//! A duplicate binder in one pattern is rejected (`DuplicatePatternBinder`), +//! and so is a projection (`t.i`, `xs[i]`, `fst`, `snd`) whose unselected +//! components are linear (`LinearProjectionDiscard`): such containers must +//! be destructured. //! //! Note: The desugarer generates case variables with `BindingForm::Param`. //! The discipline check depends on the component TYPE, not the scrutinee's //! binding form. This is correct: `let` lets you drop the container, but //! destructured linear components still follow their type's discipline. //! +//! ## 4b. Reuse, closures and borrows (v2 plan E0) +//! +//! | Rule | Enforced at | +//! |------|-------------| +//! | A second use is an error when `demands_consumption()` holds (any `let!`) or the type is linear (an affine `let` of a linear value is at most once) | `Context::mark_used` via `CtxEntry::forbids_reuse` | +//! | A closure body may not consume a restricted binding it captures (function types are unrestricted, so the closure could run twice) | `check_lambda` — `LinearCaptureConsumed` | +//! | Dereferencing a borrow (or an unrestricted ref) of a linear value is an error (borrows are not loan-tracked) | `check_deref` — `CannotMoveOutOfBorrow` | +//! | Products, sums, tuples and lists are linear when any component is (conservative join; D5 may relax it) | `Ty::is_linear` | +//! //! ## 5. Effect Resume (future — not yet implemented) //! //! | Discipline | Rule | Planned enforcement | diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index a8176fd3..b9cd5ca6 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -95,6 +95,39 @@ pub enum TypeError { #[error("Match expression must have at least one arm")] EmptyMatch, + + /// A closure body consumes a linear value captured from the + /// enclosing scope. Function types are unrestricted, so calling the + /// closure twice would consume the capture twice (probe `lamcap`). + #[error("Closure consumes captured linear variable `{0}`; a closure may be called more than once, so it may only borrow a captured linear value")] + LinearCaptureConsumed(Var), + + /// Dereferencing a borrow of a linear value would yield an owned + /// copy of it while the owner keeps it too. + #[error("Cannot move a linear value of type {0:?} out of a borrow")] + CannotMoveOutOfBorrow(Ty), + + /// A pattern wildcard would discard a linear component. + #[error("Pattern wildcard discards a linear value of type {0:?}; bind it and consume it")] + LinearPatternDiscard(Ty), + + /// The same name is bound twice by one pattern; the second binding + /// would shadow (and silently discard) the first. + #[error("Variable `{0}` is bound more than once in the same pattern")] + DuplicatePatternBinder(Var), + + /// A projection (`t.i` or `xs[i]`) would consume the container and + /// return one component, discarding unselected linear components. + #[error( + "Projection from {0:?} would discard its other linear components; destructure it instead" + )] + LinearProjectionDiscard(Ty), + + /// Raw variadic `__ffi("sym", ...)` has no formal-core + /// representation and no sound wasm lowering (v2 plan E1); typed + /// `extern "abi" { fn ... }` declarations are the only FFI path. + #[error("Raw `__ffi(\"{0}\", ...)` is not supported: declare the function in a typed `extern \"abi\" {{ fn ... }}` block and call it by name")] + RawFfiNotSupported(SmolStr), } /// A type error with source location. @@ -141,6 +174,9 @@ struct CtxEntry { binding_form: BindingForm, /// Which region this variable was bound in (None = top-level / no region). region: Option, + /// Identity of this binding occurrence, so a later binding of the + /// same name (shadowing) is not mistaken for this one. + id: u64, } impl CtxEntry { @@ -152,6 +188,17 @@ impl CtxEntry { BindingForm::Param => self.ty.is_linear(), } } + + /// Whether a second use of this binding is an error. + /// + /// Routes through [`CtxEntry::demands_consumption`] (a `let!` of any + /// type is exactly-once, probes `dup2`/`dup4`), and additionally + /// forbids reuse of any linear-typed binding: an affine `let` of a + /// linear value is *at most* once, so it may be dropped but never + /// duplicated. + fn forbids_reuse(&self) -> bool { + self.demands_consumption() || self.ty.is_linear() + } } /// Typing context: tracks variables and their usage @@ -161,6 +208,8 @@ pub struct Context { vars: HashMap, /// Active regions regions: Vec, + /// Counter for [`CtxEntry::id`]. + next_id: u64, } impl Context { @@ -171,6 +220,8 @@ impl Context { /// Extend context with new binding, tagged with the current region and binding form. pub fn extend(&mut self, name: Var, ty: Ty, binding_form: BindingForm) { let region = self.regions.last().cloned(); + let id = self.next_id; + self.next_id += 1; self.vars.insert( name, CtxEntry { @@ -178,10 +229,21 @@ impl Context { used: false, binding_form, region, + id, }, ); } + /// Bindings that forbid reuse and are still unused, keyed by name, + /// with the identity of the binding occurrence. + fn unused_restricted(&self) -> Vec<(Var, u64)> { + self.vars + .iter() + .filter(|(_, e)| e.forbids_reuse() && !e.used) + .map(|(n, e)| (n.clone(), e.id)) + .collect() + } + /// Look up variable type pub fn lookup(&self, name: &Var) -> Option<&Ty> { self.vars.get(name).map(|e| &e.ty) @@ -190,7 +252,7 @@ impl Context { /// Mark variable as used (for linear variables) pub fn mark_used(&mut self, name: &Var) -> Result<(), TypeError> { if let Some(entry) = self.vars.get_mut(name) { - if entry.ty.is_linear() && entry.used { + if entry.forbids_reuse() && entry.used { return Err(TypeError::LinearVariableReused(name.clone())); } entry.used = true; @@ -1113,6 +1175,11 @@ impl TypeChecker { param_ty: &Ty, body: &Expr, ) -> Result { + // Restricted (linear / `let!`) outer bindings not yet consumed. + // A function type is unrestricted, so the closure body must not + // consume any of them: each call would consume it again. + let captured_before = self.ctx.unused_restricted(); + self.ctx .extend(param.clone(), param_ty.clone(), BindingForm::Param); let body_ty = self.check(body)?; @@ -1123,6 +1190,17 @@ impl TypeChecker { } } + for (name, id) in &captured_before { + if name == param { + continue; + } + if let Some(entry) = self.ctx.vars.get(name) { + if entry.id == *id && entry.used { + return Err(self.at(s, TypeError::LinearCaptureConsumed(name.clone()))); + } + } + } + Ok(Ty::Fun { param: Box::new(param_ty.clone()), ret: Box::new(body_ty), @@ -1371,9 +1449,10 @@ impl TypeChecker { match inner_ty { Ty::Prod { left, right } => { if right.is_linear() { - return Err( - self.at(s, TypeError::LinearVariableNotConsumed("_pair_snd".into())) - ); + return Err(self.at( + s, + TypeError::LinearProjectionDiscard(Ty::Prod { left, right }), + )); } Ok(*left) } @@ -1396,9 +1475,10 @@ impl TypeChecker { match inner_ty { Ty::Prod { left, right } => { if left.is_linear() { - return Err( - self.at(s, TypeError::LinearVariableNotConsumed("_pair_fst".into())) - ); + return Err(self.at( + s, + TypeError::LinearProjectionDiscard(Ty::Prod { left, right }), + )); } Ok(*right) } @@ -1534,6 +1614,12 @@ impl TypeChecker { self.ctx = ctx_after_scrutinee.clone(); let bound = self.check_pattern(s, &arm.pattern, &scrutinee_ty)?; + let mut seen: HashSet<&Var> = HashSet::new(); + for name in &bound { + if !seen.insert(name) { + return Err(self.at(s, TypeError::DuplicatePatternBinder(name.clone()))); + } + } if let Some(guard) = &arm.guard { let guard_ty = self.check(guard)?; @@ -1589,7 +1675,15 @@ impl TypeChecker { ) -> Result, SpannedTypeError> { let expected = self.resolve(expected_ty); match pattern { - Pattern::Wildcard => Ok(Vec::new()), + // A wildcard binds nothing, so it would silently discard a + // linear component. (An unresolved unification variable is + // treated as unrestricted, like `Ty::Var`.) + Pattern::Wildcard => { + if expected.is_linear() { + return Err(self.at(s, TypeError::LinearPatternDiscard(expected))); + } + Ok(Vec::new()) + } Pattern::Var(name) => { self.ctx.extend(name.clone(), expected, BindingForm::Param); Ok(vec![name.clone()]) @@ -1736,7 +1830,24 @@ impl TypeChecker { let inner_ty = self.check(inner)?; match inner_ty { - Ty::Borrow { inner: boxed, .. } => Ok(*boxed), + // A borrow does not own its referent: dereferencing it may + // only copy an unrestricted value out. Borrows are not + // loan-tracked, so yielding an owned linear value here would + // let both the borrow and the owner consume it. + Ty::Borrow { inner: boxed, .. } => { + if boxed.is_linear() { + return Err(self.at(s, TypeError::CannotMoveOutOfBorrow(*boxed))); + } + Ok(*boxed) + } + // An unrestricted reference may be dereferenced any number of + // times, so the same rule applies to it. A linear reference is + // itself consumed by the dereference, so it may yield its + // linear contents. + Ty::Ref { + linearity: ephapax_syntax::Linearity::Unrestricted, + inner, + } if inner.is_linear() => Err(self.at(s, TypeError::CannotMoveOutOfBorrow(*inner))), Ty::Ref { inner, .. } => Ok(*inner), _ => Err(self.at( s, @@ -1888,7 +1999,16 @@ impl TypeChecker { } match list_ty { - Ty::List(elem_ty) => Ok(*elem_ty), + // Indexing consumes the list and returns one element; every + // other element would be lost. A list of linear elements must + // be destructured instead (as `check_fst`/`check_snd` do for + // pairs). + Ty::List(elem_ty) => { + if elem_ty.is_linear() { + return Err(self.at(s, TypeError::LinearProjectionDiscard(Ty::List(elem_ty)))); + } + Ok(*elem_ty) + } _ => Err(self.at( s, TypeError::TypeMismatch { @@ -1932,6 +2052,25 @@ impl TypeChecker { ) -> Result { let tuple_ty = self.check(tuple)?; + // Projection consumes the container and returns one component; + // reject it when an unselected component is linear (it would be + // lost), as `check_fst` / `check_snd` already do for pairs. + let unselected_linear = match &tuple_ty { + Ty::Prod { left, right } => match index { + 0 => right.is_linear(), + 1 => left.is_linear(), + _ => false, + }, + Ty::Tuple(elems) if index < elems.len() => elems + .iter() + .enumerate() + .any(|(i, t)| i != index && t.is_linear()), + _ => false, + }; + if unselected_linear { + return Err(self.at(s, TypeError::LinearProjectionDiscard(tuple_ty))); + } + match &tuple_ty { Ty::Prod { left, right } => { if index == 0 { @@ -2701,7 +2840,7 @@ mod tests { })))); assert!(matches!( err_kind(&tc.check(&expr)), - Some(TypeError::LinearVariableNotConsumed(_)) + Some(TypeError::LinearProjectionDiscard(_)) )); } From 7879741247ebeed7ee1d71819fdc1858e2dc5db1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:40:20 +0000 Subject: [PATCH 12/26] feat(parser): reject @total, @pure and @comptime as not enforced Both parsers dropped every `@identifier` declaration annotation, so `@total` on a non-terminating function, `@pure` on an effectful one and `@comptime` were accepted with no check behind them. They now fail with `ParseError::AnnotationNotEnforced` (v2 plan rung E1). Other annotations are still accepted and ignored as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- src/ephapax-parser/src/error.rs | 34 +++++++++++++++++++++++++++++++ src/ephapax-parser/src/lib.rs | 9 ++++++++ src/ephapax-parser/src/surface.rs | 9 ++++++++ 3 files changed, 52 insertions(+) diff --git a/src/ephapax-parser/src/error.rs b/src/ephapax-parser/src/error.rs index cb12e95c..1d4169c4 100644 --- a/src/ephapax-parser/src/error.rs +++ b/src/ephapax-parser/src/error.rs @@ -29,6 +29,12 @@ pub enum ParseError { found: String, span: Span, }, + + /// A declaration annotation that names a property the compiler does + /// not check (`@total`, `@pure`, `@comptime`). Accepting it would + /// claim a guarantee that does not hold (v2 plan rung E1). + #[error("Annotation `@{name}` is not enforced: the compiler does not check it, so it is rejected rather than silently ignored")] + AnnotationNotEnforced { name: String, span: Span }, } impl ParseError { @@ -38,6 +44,7 @@ impl ParseError { ParseError::Syntax { span, .. } => *span, ParseError::UnexpectedEof { span } => *span, ParseError::Expected { span, .. } => *span, + ParseError::AnnotationNotEnforced { span, .. } => *span, } } @@ -49,6 +56,22 @@ impl ParseError { } } + /// Annotations that name a checked property the compiler does not + /// enforce. Any of these on a declaration is a parse error. + pub const UNENFORCED_ANNOTATIONS: &'static [&'static str] = &["total", "pure", "comptime"]; + + /// Reject an annotation pair if it names an unenforced property. + pub fn check_annotation(name: &str, span: Span) -> Result<(), ParseError> { + if Self::UNENFORCED_ANNOTATIONS.contains(&name) { + Err(ParseError::AnnotationNotEnforced { + name: name.to_string(), + span, + }) + } else { + Ok(()) + } + } + /// Convenience constructor for a missing required element with context. pub fn missing(expected: &str) -> Self { ParseError::Expected { @@ -154,6 +177,17 @@ impl Report { .with_color(Color::Red), ) .finish(), + + ParseError::AnnotationNotEnforced { name, .. } => { + AriadneReport::build(ReportKind::Error, self.source_name.clone(), range.start) + .with_message(format!("Annotation `@{}` is not enforced", name)) + .with_label( + Label::new(location) + .with_message("remove it: the compiler does not check this property") + .with_color(Color::Red), + ) + .finish() + } } } diff --git a/src/ephapax-parser/src/lib.rs b/src/ephapax-parser/src/lib.rs index 84bb75a2..d74785e8 100644 --- a/src/ephapax-parser/src/lib.rs +++ b/src/ephapax-parser/src/lib.rs @@ -140,6 +140,15 @@ pub(crate) fn parse_import(pair: pest::iterators::Pair) -> Result) -> Result { // Skip leading `@identifier` annotations — they're parser-only metadata // today; see the `annotation` rule in ephapax.pest. + // `@total`, `@pure` and `@comptime` name properties the compiler + // does not check; reject them instead of dropping them (v2 plan E1). + for ann in pair.clone().into_inner() { + if ann.as_rule() == Rule::annotation { + let span = span_from_pair(&ann); + let name = ann.as_str().trim_start_matches('@').trim(); + ParseError::check_annotation(name, span)?; + } + } let inner = pair .into_inner() .find(|p| p.as_rule() != Rule::annotation) diff --git a/src/ephapax-parser/src/surface.rs b/src/ephapax-parser/src/surface.rs index b39a864a..54140991 100644 --- a/src/ephapax-parser/src/surface.rs +++ b/src/ephapax-parser/src/surface.rs @@ -118,6 +118,15 @@ fn parse_surface_declaration(pair: pest::iterators::Pair) -> Result Date: Mon, 9 Nov 2026 14:40:20 +0000 Subject: [PATCH 13/26] fix(codegen,typing): effects and raw __ffi fail loudly instead of trapping v2 plan rung E1. - Codegen: `validate_supported` / `first_unsupported` reject `perform`, `handle` and raw `__ffi` with a `CodegenError` before any section is emitted; previously they compiled to an `unreachable` trap (effects) or to a call to import index 100+, outside the import section (probe g5, an invalid module). `compile_program` and `compile_module_with_main` now return `Result` and never fall back past an unsupported construct. The raw-FFI import plumbing (`ensure_ffi_import`, `ffi_imports`, `next_import_idx`) is removed, so every import is a builtin or an `extern` fn and the import index space is contiguous. - Typing: raw variadic `__ffi` is `TypeError::RawFfiNotSupported`; typed `extern` declarations are the only FFI path. - `compile --debug` now emits the `ephapax.debug.mode` section from `compile_ast_module` (it was only emitted on the expression path). - Migrated the two corpus files that used raw `__ffi` and must keep checking: `stdlib/Coproc.eph` (minimal: an `extern "ephapax_coproc"` block restating the call sites; the `CoprocBuf` linearity annotation and wire types stay with E10) and `conformance/valid/ffi_basic.eph`. Both now also compile, so the corpus ratchet lists them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- conformance/valid/ffi_basic.eph | 20 +- src/ephapax-cli/tests/contract_tests.rs | 2 +- src/ephapax-cli/tests/wasm_e2e.rs | 2 +- src/ephapax-meta/src/lib.rs | 4 +- src/ephapax-typing/src/lib.rs | 27 +-- src/ephapax-wasm/src/lib.rs | 293 +++++++++++++++--------- stdlib/Coproc.eph | 37 ++- tests/eph-corpus-compiles.txt | 2 + tools/coproc/README.adoc | 18 +- 9 files changed, 258 insertions(+), 147 deletions(-) diff --git a/conformance/valid/ffi_basic.eph b/conformance/valid/ffi_basic.eph index 9f78b750..162cbbb5 100644 --- a/conformance/valid/ffi_basic.eph +++ b/conformance/valid/ffi_basic.eph @@ -1,13 +1,23 @@ // SPDX-License-Identifier: MPL-2.0 // Owner: Jonathan D.A. Jewell -// Conformance test: FFI intrinsic with zero, single, and multi arguments. -// Expected: type-checks successfully in both affine and linear modes. +// Conformance test: typed FFI with zero, single, and multi arguments. +// Expected: type-checks successfully. +// +// Since v2 plan rung E1 raw variadic `__ffi("sym", ...)` is rejected +// (`RawFfiNotSupported`); a typed `extern` declaration is the only FFI +// path. This fixture was migrated from the raw form to `extern`. + +extern "c" { + fn clock_gettime(): I64 + fn abs(x: I32): I64 + fn write(fd: I32, buf: I64, len: I32): I64 +} fn get_time(): I64 = - __ffi("clock_gettime") + clock_gettime() fn compute(x: I32): I64 = - __ffi("abs", x) + abs(x) fn send_data(fd: I32, buf: I64, len: I32): I64 = - __ffi("write", fd, buf, len) + write(fd, buf, len) diff --git a/src/ephapax-cli/tests/contract_tests.rs b/src/ephapax-cli/tests/contract_tests.rs index 8f9f6d1f..a76578a0 100644 --- a/src/ephapax-cli/tests/contract_tests.rs +++ b/src/ephapax-cli/tests/contract_tests.rs @@ -69,7 +69,7 @@ fn invariant_well_typed_program_produces_valid_wasm() { let mut tc = TypeChecker::new(); tc.check(&expr).expect("type-check must succeed"); let mut codegen = Codegen::new(); - let wasm_bytes = codegen.compile_program(&expr); + let wasm_bytes = codegen.compile_program(&expr).unwrap(); assert!( !wasm_bytes.is_empty(), diff --git a/src/ephapax-cli/tests/wasm_e2e.rs b/src/ephapax-cli/tests/wasm_e2e.rs index 00ab4a06..5c34fa27 100644 --- a/src/ephapax-cli/tests/wasm_e2e.rs +++ b/src/ephapax-cli/tests/wasm_e2e.rs @@ -22,7 +22,7 @@ fn compile(source: &str) -> Vec { let mut tc = TypeChecker::new(); tc.check(&expr).expect("type-check failed"); let mut codegen = Codegen::new(); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); wasmparser::validate(&wasm).expect("emitted wasm failed wasmparser validation"); wasm } diff --git a/src/ephapax-meta/src/lib.rs b/src/ephapax-meta/src/lib.rs index 02c04403..a35b06c1 100644 --- a/src/ephapax-meta/src/lib.rs +++ b/src/ephapax-meta/src/lib.rs @@ -188,7 +188,9 @@ impl WasmStager { /// validity (the same floor the CLI enforces). pub fn compile_stage(&self, expr: &Expr) -> Result, MetaError> { let mut cg = Codegen::new(); - let wasm = cg.compile_program(expr); + let wasm = cg + .compile_program(expr) + .map_err(|e| MetaError::InvalidModule(e.to_string()))?; wasmparser::validate(&wasm).map_err(|e| MetaError::InvalidModule(e.to_string()))?; Ok(wasm) } diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index b9cd5ca6..8e51b45b 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -1066,7 +1066,7 @@ impl TypeChecker { ExprKind::ListIndex { list, index } => self.check_list_index(s, list, index), ExprKind::TupleLit(elements) => self.check_tuple_lit(s, elements), ExprKind::TupleIndex { tuple, index } => self.check_tuple_index(s, tuple, *index), - ExprKind::FFI { args, .. } => self.check_ffi(s, args), + ExprKind::FFI { symbol, .. } => self.check_ffi(s, symbol), ExprKind::Perform { op, args } => self.check_perform(s, op, args), ExprKind::Handle { body, clauses } => self.check_handle(s, body, clauses), ExprKind::Match { scrutinee, arms } => self.check_match(s, scrutinee, arms), @@ -2110,23 +2110,16 @@ impl TypeChecker { } } - /// Type-check an FFI call. + /// Reject a raw variadic FFI call. /// - /// String literal arguments are allowed WITHOUT region allocation. - /// Linear variables passed to FFI are consumed. - /// Return type is I64 (opaque C handle). - fn check_ffi(&mut self, _s: Span, args: &[Expr]) -> Result { - for arg in args { - match &arg.kind { - ExprKind::Lit(Literal::String(_)) => { - // OK — string literal is passed as C string pointer - } - _ => { - self.check(arg)?; - } - } - } - Ok(Ty::Base(BaseTy::I64)) + /// `__ffi("sym", args...)` has no declared signature: every argument + /// and the result were assumed to be `I64`, it has no formal-core + /// representation, and codegen indexed its import outside the import + /// section (probe g5 produced an invalid module). Typed + /// `extern "abi" { fn ... }` declarations are the only FFI path + /// (v2 plan rung E1). + fn check_ffi(&mut self, s: Span, symbol: &str) -> Result { + Err(self.at(s, TypeError::RawFfiNotSupported(SmolStr::new(symbol)))) } /// Type check a perform expression: `perform op(args...)`. diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index 8ad8e9a1..598d7d36 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -168,6 +168,106 @@ impl std::fmt::Display for CodegenError { impl std::error::Error for CodegenError {} +/// Message prefix of the [`CodegenError`] for a construct that has no +/// wasm lowering (v2 plan rung E1). Codegen fails before emitting any +/// module instead of planting an `unreachable` trap. +pub const UNSUPPORTED_PREFIX: &str = "unsupported in codegen"; + +/// Return the first construct in `expr` that codegen cannot lower: +/// effect `perform` / `handle` (no lowering until E17) and raw variadic +/// `__ffi` (no sound lowering at all; typed `extern` is the FFI path). +pub fn first_unsupported(expr: &Expr) -> Option { + fn unsupported(what: &str, span: ephapax_syntax::Span) -> CodegenError { + CodegenError(format!( + "{UNSUPPORTED_PREFIX}: {what} (at byte {}..{})", + span.start, span.end + )) + } + let here = match &expr.kind { + ExprKind::Perform { op, .. } => Some(unsupported( + &format!("effect operation `perform {op}` has no wasm lowering"), + expr.span, + )), + ExprKind::Handle { .. } => Some(unsupported( + "effect handler `handle ... with` has no wasm lowering", + expr.span, + )), + ExprKind::FFI { symbol, .. } => Some(unsupported( + &format!( + "raw `__ffi(\"{symbol}\", ...)`; declare it in a typed `extern` block instead" + ), + expr.span, + )), + _ => None, + }; + if here.is_some() { + return here; + } + let children: Vec<&Expr> = match &expr.kind { + ExprKind::Lit(_) | ExprKind::Var(_) | ExprKind::StringNew { .. } => vec![], + ExprKind::StringConcat { left, right } + | ExprKind::Pair { left, right } + | ExprKind::BinOp { left, right, .. } => vec![left, right], + ExprKind::StringLen(e) + | ExprKind::Fst(e) + | ExprKind::Snd(e) + | ExprKind::Deref(e) + | ExprKind::Drop(e) + | ExprKind::Copy(e) => vec![e], + ExprKind::Let { value, body, .. } | ExprKind::LetLin { value, body, .. } => { + vec![value, body] + } + ExprKind::Lambda { body, .. } | ExprKind::Region { body, .. } => vec![body], + ExprKind::App { func, arg } => vec![func, arg], + ExprKind::Inl { value, .. } | ExprKind::Inr { value, .. } => vec![value], + ExprKind::Case { + scrutinee, + left_body, + right_body, + .. + } => vec![scrutinee, left_body, right_body], + ExprKind::Match { scrutinee, arms } => { + let mut v: Vec<&Expr> = vec![scrutinee]; + for arm in arms { + if let Some(g) = &arm.guard { + v.push(g); + } + v.push(&arm.body); + } + v + } + ExprKind::If { + cond, + then_branch, + else_branch, + } => vec![cond, then_branch, else_branch], + ExprKind::Borrow { inner, .. } => vec![inner], + ExprKind::Block(es) | ExprKind::ListLit(es) | ExprKind::TupleLit(es) => es.iter().collect(), + ExprKind::FFI { args, .. } | ExprKind::Perform { args, .. } => args.iter().collect(), + ExprKind::Handle { body, clauses } => { + let mut v: Vec<&Expr> = vec![body]; + v.extend(clauses.iter().map(|c| &c.body)); + v + } + ExprKind::UnaryOp { operand, .. } => vec![operand], + ExprKind::ListIndex { list, index } => vec![list, index], + ExprKind::TupleIndex { tuple, .. } => vec![tuple], + }; + children.into_iter().find_map(first_unsupported) +} + +/// Module-level [`first_unsupported`]: checks every function body. +pub fn validate_supported(module: &AstModule) -> Result<(), CodegenError> { + for decl in &module.decls { + if let Decl::Fn { name, body, .. } = decl { + if let Some(CodegenError(msg)) = first_unsupported(body) { + return Err(CodegenError(format!("{msg} in function `{name}`"))); + } + } + } + Ok(()) +} + // --------------------------------------------------------------------------- // Ty → OwnershipKind mapping // --------------------------------------------------------------------------- @@ -364,14 +464,6 @@ pub struct Codegen { /// Closure optimization enabled (minimal capture sets) optimize_closures: bool, - /// FFI import registry: maps symbol names to WASM function indices. - /// Each FFI call is imported from the "env" module. - ffi_imports: HashMap, - - /// Next available function index for imports. - /// Starts after the built-in function indices. - next_import_idx: u32, - /// Imports declared via `extern "abi" { ... }` blocks. Populated by /// `collect_extern_imports` before user-fn collection so the /// runtime helper and user-fn indices can be shifted past them. @@ -474,8 +566,6 @@ impl Codegen { lambda_fns: Vec::new(), debug_info: None, optimize_closures: false, - ffi_imports: HashMap::new(), - next_import_idx: 100, // Start after built-in function indices extern_imports: Vec::new(), extern_fn_indices: HashMap::new(), extern_type_names: std::collections::HashSet::new(), @@ -524,8 +614,9 @@ impl Codegen { // ----------------------------------------------------------------------- /// Total number of imports the module emits: builtins + extern. - /// FFI imports are NOT yet included — see the phase-2C note in - /// `ensure_ffi_import`. + /// Raw `__ffi` is rejected before codegen (v2 plan E1), so every + /// import is a builtin or an `extern` fn and the import index space + /// is contiguous. fn import_count(&self) -> u32 { NUM_BUILTIN_IMPORTS + self.extern_imports.len() as u32 } @@ -569,30 +660,6 @@ impl Codegen { self.import_count() + NUM_RUNTIME_FNS } - /// Register an FFI symbol as a WASM import from the "env" module. - /// Returns the WASM function index for the import. - /// If the symbol is already registered, returns the existing index. - /// - /// The import signature is: all args are i64, return is i64. - /// This matches Ephapax's FFI calling convention where all values - /// are passed as 64-bit integers at the ABI boundary. - fn ensure_ffi_import(&mut self, symbol: &str, arg_count: usize) -> u32 { - if let Some(&idx) = self.ffi_imports.get(symbol) { - return idx; - } - - let idx = self.next_import_idx; - self.next_import_idx += 1; - self.ffi_imports.insert(symbol.to_string(), idx); - - // Register the type: (i64, i64, ...) -> i64 - let params: Vec = (0..arg_count).map(|_| ValType::I64).collect(); - let results = vec![ValType::I64]; - self.extra_types.push((params, results)); - - idx - } - /// Enable closure optimization (minimal capture sets) pub fn enable_closure_optimization(&mut self) { self.optimize_closures = true; @@ -641,7 +708,14 @@ impl Codegen { } /// Compile a single standalone expression as a `main` function. - pub fn compile_program(&mut self, main_expr: &Expr) -> Vec { + /// + /// Fails with a [`CodegenError`] (and emits nothing) if the + /// expression contains a construct with no wasm lowering + /// ([`first_unsupported`]). + pub fn compile_program(&mut self, main_expr: &Expr) -> Result, CodegenError> { + if let Some(err) = first_unsupported(main_expr) { + return Err(err); + } self.emit_types(); self.emit_imports(); @@ -692,7 +766,7 @@ impl Codegen { self.emit_ownership_section(); self.emit_carrier_sections(); self.emit_debug_sections(); - self.module.clone().finish() + Ok(self.module.clone().finish()) } /// Compile a module encoded as an S-expression string. @@ -708,6 +782,10 @@ impl Codegen { /// 2. Emits the type, import, memory, function, and code sections. /// 3. If a `main` function exists, exports it. pub fn compile_ast_module(&mut self, ast: &AstModule) -> Result, CodegenError> { + // ----- Phase 0: reject constructs with no wasm lowering ------------ + // (v2 plan E1: fail loudly, before any section is emitted.) + validate_supported(ast)?; + // ----- Phase 1: collect function metadata -------------------------- // Extern imports MUST be collected first so the import_count // is correct before any runtime helper or user fn indices are @@ -744,16 +822,28 @@ impl Codegen { self.emit_data_section(); self.emit_ownership_section(); self.emit_carrier_sections(); + // `ephapax.debug.mode` custom section, only when `enable_debug` + // was called (`compile --debug`). + self.emit_debug_sections(); Ok(self.module.clone().finish()) } /// Compile a module that has a `main` function with a body expression. /// (Legacy helper retained for backward compatibility.) - pub fn compile_module_with_main(&mut self, module: &AstModule, main_body: &Expr) -> Vec { + pub fn compile_module_with_main( + &mut self, + module: &AstModule, + main_body: &Expr, + ) -> Result, CodegenError> { + // An unsupported construct is a hard error, never a fallback. + validate_supported(module)?; + if let Some(err) = first_unsupported(main_body) { + return Err(err); + } // Try full module compilation first; fall back to expression mode. if let Ok(bytes) = self.compile_ast_module(module) { - return bytes; + return Ok(bytes); } // Fallback: just compile the main body as a standalone expression. @@ -792,7 +882,7 @@ impl Codegen { self.module.section(&code_sec); self.emit_data_section(); self.emit_debug_sections(); - self.module.clone().finish() + Ok(self.module.clone().finish()) } // ----------------------------------------------------------------------- @@ -1971,31 +2061,14 @@ impl Codegen { ExprKind::ListIndex { list, index } => self.compile_list_index(func, list, index), ExprKind::TupleLit(elements) => self.compile_tuple_lit(func, elements), ExprKind::TupleIndex { tuple, index } => self.compile_tuple_index(func, tuple, *index), - ExprKind::FFI { symbol, args } => { - // FFI calls in WASM become imported functions. - // The symbol name is registered as an import from the "env" module - // in the module preamble. The import index is tracked in self.ffi_imports. - // - // Register the import if not already done. - let import_idx = self.ensure_ffi_import(symbol, args.len()); - - // Compile all arguments — they become the WASM call parameters. - for arg in args { - self.compile_expr(func, arg); - } - - // Call the imported function by index. - func.instruction(&wasm_encoder::Instruction::Call(import_idx)); - } - - ExprKind::Perform { .. } => { - // Effect perform not yet implemented — emit WASM unreachable - // instead of panicking the compiler at codegen time. - func.instruction(&wasm_encoder::Instruction::Unreachable); - } - ExprKind::Handle { .. } => { - // Effect handle not yet implemented — emit WASM unreachable - // instead of panicking the compiler at codegen time. + // Raw FFI and effects have no lowering. Every public entry + // point rejects them up front with a `CodegenError` + // (`first_unsupported`, v2 plan E1), so these arms are only + // reached by a caller driving `compile_expr` directly; they + // trap instead of calling an import index that does not exist + // (the old raw-FFI lowering called index 100+, outside the + // import section, probe g5). + ExprKind::FFI { .. } | ExprKind::Perform { .. } | ExprKind::Handle { .. } => { func.instruction(&wasm_encoder::Instruction::Unreachable); } ExprKind::Match { scrutinee, arms } => self.compile_match(func, scrutinee, arms), @@ -3459,7 +3532,9 @@ mod tests { #[test] fn compile_literal_i32() { let mut codegen = Codegen::new(); - let wasm = codegen.compile_program(&e(ExprKind::Lit(Literal::I32(42)))); + let wasm = codegen + .compile_program(&e(ExprKind::Lit(Literal::I32(42)))) + .unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3467,7 +3542,9 @@ mod tests { #[test] fn compile_literal_bool_true() { let mut codegen = Codegen::new(); - let wasm = codegen.compile_program(&e(ExprKind::Lit(Literal::Bool(true)))); + let wasm = codegen + .compile_program(&e(ExprKind::Lit(Literal::Bool(true)))) + .unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3475,7 +3552,9 @@ mod tests { #[test] fn compile_literal_bool_false() { let mut codegen = Codegen::new(); - let wasm = codegen.compile_program(&e(ExprKind::Lit(Literal::Bool(false)))); + let wasm = codegen + .compile_program(&e(ExprKind::Lit(Literal::Bool(false)))) + .unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3483,7 +3562,9 @@ mod tests { #[test] fn compile_literal_unit() { let mut codegen = Codegen::new(); - let wasm = codegen.compile_program(&e(ExprKind::Lit(Literal::Unit))); + let wasm = codegen + .compile_program(&e(ExprKind::Lit(Literal::Unit))) + .unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3500,7 +3581,7 @@ mod tests { left: Box::new(e(ExprKind::Lit(Literal::I32(1)))), right: Box::new(e(ExprKind::Lit(Literal::I32(2)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3513,7 +3594,7 @@ mod tests { left: Box::new(e(ExprKind::Lit(Literal::I32(10)))), right: Box::new(e(ExprKind::Lit(Literal::I32(3)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3526,7 +3607,7 @@ mod tests { left: Box::new(e(ExprKind::Lit(Literal::I32(6)))), right: Box::new(e(ExprKind::Lit(Literal::I32(7)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3539,7 +3620,7 @@ mod tests { left: Box::new(e(ExprKind::Lit(Literal::I32(100)))), right: Box::new(e(ExprKind::Lit(Literal::I32(5)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3552,7 +3633,7 @@ mod tests { left: Box::new(e(ExprKind::Lit(Literal::I32(17)))), right: Box::new(e(ExprKind::Lit(Literal::I32(5)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3565,7 +3646,7 @@ mod tests { left: Box::new(e(ExprKind::Lit(Literal::I32(1)))), right: Box::new(e(ExprKind::Lit(Literal::I32(2)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3578,7 +3659,7 @@ mod tests { left: Box::new(e(ExprKind::Lit(Literal::I32(5)))), right: Box::new(e(ExprKind::Lit(Literal::I32(5)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3591,7 +3672,7 @@ mod tests { left: Box::new(e(ExprKind::Lit(Literal::Bool(true)))), right: Box::new(e(ExprKind::Lit(Literal::Bool(false)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3604,7 +3685,7 @@ mod tests { left: Box::new(e(ExprKind::Lit(Literal::Bool(false)))), right: Box::new(e(ExprKind::Lit(Literal::Bool(true)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3620,7 +3701,7 @@ mod tests { op: UnaryOp::Not, operand: Box::new(e(ExprKind::Lit(Literal::Bool(true)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3632,7 +3713,7 @@ mod tests { op: UnaryOp::Neg, operand: Box::new(e(ExprKind::Lit(Literal::I32(42)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3649,7 +3730,7 @@ mod tests { then_branch: Box::new(e(ExprKind::Lit(Literal::I32(1)))), else_branch: Box::new(e(ExprKind::Lit(Literal::I32(2)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3667,7 +3748,7 @@ mod tests { })), else_branch: Box::new(e(ExprKind::Lit(Literal::I32(30)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3693,7 +3774,7 @@ mod tests { right: Box::new(e(ExprKind::Lit(Literal::I32(50)))), })), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3711,7 +3792,7 @@ mod tests { value: Box::new(e(ExprKind::Lit(Literal::I32(42)))), body: Box::new(e(ExprKind::Var("x".into()))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3735,7 +3816,7 @@ mod tests { })), })), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3758,7 +3839,7 @@ mod tests { else_branch: Box::new(e(ExprKind::Lit(Literal::I32(0)))), })), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3774,7 +3855,7 @@ mod tests { left: Box::new(e(ExprKind::Lit(Literal::I32(1)))), right: Box::new(e(ExprKind::Lit(Literal::I32(2)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3787,7 +3868,7 @@ mod tests { left: Box::new(e(ExprKind::Lit(Literal::I32(10)))), right: Box::new(e(ExprKind::Lit(Literal::I32(20)))), })))); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3800,7 +3881,7 @@ mod tests { left: Box::new(e(ExprKind::Lit(Literal::I32(10)))), right: Box::new(e(ExprKind::Lit(Literal::I32(20)))), })))); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3816,7 +3897,7 @@ mod tests { ty: Ty::Base(BaseTy::Bool), value: Box::new(e(ExprKind::Lit(Literal::I32(42)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3828,7 +3909,7 @@ mod tests { ty: Ty::Base(BaseTy::I32), value: Box::new(e(ExprKind::Lit(Literal::Bool(true)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3847,7 +3928,7 @@ mod tests { right_var: "y".into(), right_body: Box::new(e(ExprKind::Lit(Literal::I32(0)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3861,7 +3942,7 @@ mod tests { let mut codegen = Codegen::new(); // copy(42) let expr = e(ExprKind::Copy(Box::new(e(ExprKind::Lit(Literal::I32(42)))))); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3874,7 +3955,7 @@ mod tests { fn compile_empty_block() { let mut codegen = Codegen::new(); let expr = e(ExprKind::Block(vec![])); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3888,7 +3969,7 @@ mod tests { e(ExprKind::Lit(Literal::I32(2))), e(ExprKind::Lit(Literal::I32(3))), ])); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3905,7 +3986,7 @@ mod tests { name: "r".into(), body: Box::new(e(ExprKind::Lit(Literal::I32(42)))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -3922,7 +4003,7 @@ mod tests { inner: Box::new(e(ExprKind::Lit(Literal::I32(42)))), mutable: false, })))); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -4186,7 +4267,7 @@ mod tests { value: Box::new(e(ExprKind::Lit(Literal::I32(42)))), body: Box::new(e(ExprKind::Drop(Box::new(e(ExprKind::Var("x".into())))))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -4201,7 +4282,7 @@ mod tests { value: Box::new(e(ExprKind::Lit(Literal::I32(42)))), body: Box::new(e(ExprKind::Var("x".into()))), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -4249,7 +4330,7 @@ mod tests { })), })), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -4276,7 +4357,7 @@ mod tests { })), }), ])); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -4292,7 +4373,7 @@ mod tests { region: "r".into(), value: "hello".to_string(), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -4310,7 +4391,7 @@ mod tests { value: "world".to_string(), })), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -4326,7 +4407,7 @@ mod tests { value: "test".to_string(), })), }); - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); } @@ -4360,7 +4441,7 @@ mod tests { }, span: Span::dummy(), }; - let wasm = codegen.compile_program(&expr); + let wasm = codegen.compile_program(&expr).unwrap(); assert_wasm_header(&wasm); validate_wasm(&wasm); diff --git a/stdlib/Coproc.eph b/stdlib/Coproc.eph index 33e02da4..60f07e81 100644 --- a/stdlib/Coproc.eph +++ b/stdlib/Coproc.eph @@ -15,13 +15,27 @@ // The shared seam is a thin C ABI: `libephapax_coproc.so` (see // `tools/coproc/ephapax_coproc.zig`) exports flat `eph_coproc_*` symbols // that dispatch to whatever native coprocessor backend is linked behind -// them. ephapax reaches them through its ordinary `__ffi(...)` path (load -// with `-L libephapax_coproc.so`). When the library is absent each call -// falls through to ephapax's `[ffi:stub]` -> 0 path, so the surface stays -// usable with no coprocessor present. +// them. ephapax reaches them through the typed `extern "ephapax_coproc"` +// block below. Raw variadic `__ffi(...)` is rejected since v2 plan rung E1: +// a typed `extern` declaration is the only FFI path. This is a minimal +// migration that restates the old call sites (the capability tag stays I32 +// and is widened to the shim's i64 at the boundary, as before). The full +// port, including the linearity annotation on `CoprocBuf`, the wire types +// and a host binding for the `ephapax_coproc` import module, is rung E10. +// The interpreter's `-L` / `[ffi:stub]` path served only raw `__ffi`, so +// it no longer reaches this seam. module Coproc +// ── The C-ABI seam (`tools/coproc/ephapax_coproc.zig`) ──────────────────── +extern "ephapax_coproc" { + fn eph_coproc_available(cap_tag: I32): I64 + fn eph_coproc_upload(cap_tag: I32, data_ptr: I64, len: I64): I64 + fn eph_coproc_dispatch(buf_id: I64, op: I64, arg0: I64, arg1: I64): I64 + fn eph_coproc_download(buf_id: I64, out_ptr: I64): I64 + fn eph_coproc_release(buf_id: I64): I64 +} + // ── Capabilities ────────────────────────────────────────────────────────── // // The ten coprocessor classes (the coverage this design targets). The @@ -54,8 +68,9 @@ pub data CoprocBuf = // The stable i64 tag for the shim's dispatch switch. Kept explicit (not // reliant on the runtime's sum-discriminant encoding) so the wire contract // survives any change to how nullary variants are represented: Audio=0 .. -// Fpga=9. (Returns I32 — integer literals default to I32, and `__ffi` -// accepts any integer arg, widening to the i64 C ABI at the boundary.) +// Fpga=9. (Returns I32 — integer literals default to I32; the extern +// declarations take the tag as I32, widened to the i64 C ABI at the +// boundary.) fn cap_tag(c: Capability): I32 = match c of | Audio => 0 @@ -76,7 +91,7 @@ fn cap_tag(c: Capability): I32 = // 0 otherwise (including when `libephapax_coproc.so` is not linked). Lets a // caller branch to a pure path BEFORE allocating any linear `CoprocBuf`. fn available(c: Capability): I64 = - __ffi("eph_coproc_available", cap_tag(c)) + eph_coproc_available(cap_tag(c)) // ── Upload: host buffer -> LINEAR device handle ─────────────────────────── // @@ -86,7 +101,7 @@ fn available(c: Capability): I64 = // application is currently only accepted in match-arm tail position, the // same shape `decode_msg` uses in the v2 fixtures.) fn upload(c: Capability, data_ptr: I64, len: I64): CoprocBuf = - let raw = __ffi("eph_coproc_upload", cap_tag(c), data_ptr, len) + let raw = eph_coproc_upload(cap_tag(c), data_ptr, len) match raw of | _ => CoprocBuf(raw) end @@ -98,7 +113,7 @@ fn upload(c: Capability, data_ptr: I64, len: I64): CoprocBuf = // streaming retry. fn dispatch(buf: CoprocBuf, op: I64, arg0: I64, arg1: I64): CoprocBuf = match buf of - | CoprocBuf(id) => CoprocBuf(__ffi("eph_coproc_dispatch", id, op, arg0, arg1)) + | CoprocBuf(id) => CoprocBuf(eph_coproc_dispatch(id, op, arg0, arg1)) end // ── Download: TERMINAL consumer that pulls the result ───────────────────── @@ -108,7 +123,7 @@ fn dispatch(buf: CoprocBuf, op: I64, arg0: I64, arg1: I64): CoprocBuf = // reusing it is a static linear-use-after-consume error. fn download(buf: CoprocBuf, out_ptr: I64): I64 = match buf of - | CoprocBuf(id) => __ffi("eph_coproc_download", id, out_ptr) + | CoprocBuf(id) => eph_coproc_download(id, out_ptr) end // ── Release: TERMINAL abort consumer ────────────────────────────────────── @@ -118,5 +133,5 @@ fn download(buf: CoprocBuf, out_ptr: I64): I64 = // device buffer is ever leaked. fn release(buf: CoprocBuf): I64 = match buf of - | CoprocBuf(id) => __ffi("eph_coproc_release", id) + | CoprocBuf(id) => eph_coproc_release(id) end diff --git a/tests/eph-corpus-compiles.txt b/tests/eph-corpus-compiles.txt index 8c801320..647ea075 100644 --- a/tests/eph-corpus-compiles.txt +++ b/tests/eph-corpus-compiles.txt @@ -17,9 +17,11 @@ # passing files IN THE SAME PR. # # Paths are repo-root-relative, one per line, '#' comments allowed. +conformance/valid/ffi_basic.eph examples/pattern_matching.eph stdlib/ALib.eph stdlib/Argv.eph +stdlib/Coproc.eph tests/v2-grammar/fixtures/extern-abstract-types.eph tests/v2-grammar/fixtures/extern-callsite.eph tests/v2-grammar/fixtures/hypatia-port/bridge.eph diff --git a/tools/coproc/README.adoc b/tools/coproc/README.adoc index b555c6b6..633bb5e6 100644 --- a/tools/coproc/README.adoc +++ b/tools/coproc/README.adoc @@ -22,7 +22,7 @@ deliberately clean (no framework internals cross into the language): |=== | `stdlib/Coproc.eph` | The typed ephapax surface: a `Capability` sum type (the ten classes), a `cap_tag` wire-tag map, and a LINEAR `CoprocBuf` device-buffer handle with an `upload → dispatch* → (download \| release)` lifecycle. Type-checks under `ephapax check`. | `tools/coproc/ephapax_coproc.zig` | The single C-ABI seam: a Zig shared library exporting flat `eph_coproc_*` symbols (`i64 (...) -> i64`) that dispatch to whatever native coprocessor backend is linked behind them. The in-tree build is a STUB (reports unavailable, with one loopback for the seam test). No external framework, no Julia, no Axiom runtime. -| ephapax `__ffi` path | ephapax reaches the seam through its ordinary foreign-call mechanism — no new language feature. Load with `-L libephapax_coproc.so`. +| typed `extern` block | `Coproc.eph` declares the five `eph_coproc_*` symbols in an `extern "ephapax_coproc" { ... }` block. Raw variadic `__ffi` is rejected since v2 plan rung E1, so this is the only FFI path. The full port (wire types, a host binding for the `ephapax_coproc` import module, the `CoprocBuf` linearity annotation) is rung E10. |=== == The safety story (why a language, not a library, owns this) @@ -43,6 +43,12 @@ linear guarantee. == Build & use +NOTE: Since v2 plan rung E1 (2026-11-09) the type checker rejects raw +`__ffi`, so `ephapax run ... -L libephapax_coproc.so` no longer reaches the +shim: the interpreter's `-L` loader serves only raw `__ffi` calls and does +not yet bind `extern` declarations. The 42-vs-0 proof below is historical. +Re-wiring the seam through `extern` is rung E10. + [source,sh] ---- # Build the (stub) seam: @@ -60,8 +66,11 @@ shim. == Wiring status (honest) -*WIRED:* the seam build target (`just build-coproc` / `just test-coproc`), -and the raw `__ffi` path reaching it (the 42-vs-0 proof above). +*WIRED:* the seam build target (`just build-coproc` / `just test-coproc`). +The raw `__ffi` path that reached it (the 42-vs-0 proof above) was retired +by rung E1; `Coproc.eph` now declares the symbols in a typed `extern` +block, which type-checks and compiles to wasm imports from the +`ephapax_coproc` module, but no host binds that module yet (rung E10). *NOT YET WIRED:* no `.eph` program can use the *typed* `Coproc` API. The v2 toolchain has no working module-import + qualified-member access, so @@ -69,8 +78,7 @@ toolchain has no working module-import + qualified-member access, so "construct_expr") nor resolves (`import` does not bring members into scope → "unknown constructor"). This is the **same keystone gap that strands the whole stdlib** (Region/Stream/… are equally un-consumable) — see -`docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc`. Until module-import lands, -the seam is reachable only through raw `__ffi("eph_coproc_*", …)`. The typed +`docs/v2-grammar-and-codegen-gaps-2026-06-16.adoc`. The typed surface is a *foundation*, not yet a consumable feature. == Wiring a real backend From 3ddf9897a7bb76ce1cf077d9ba611f5cd036ee49 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:40:20 +0000 Subject: [PATCH 14/26] feat(cli): -O, --debug and --mode take effect or are rejected; E1 gate v2 plan rung E1. - `compile -O` with n > 0 is rejected (no optimiser exists); `-O0` is the default and accepted. - `compile --debug` uses `compile_module_with_debug`, so the module carries the `ephapax.debug.mode` section, and still writes the `.wasm.map` source map. - `check --mode` and `compile --mode` are rejected when given: the discipline is per binding (`let` affine, `let!` linear), so a global mode cannot take effect. - `check` runs the same unsupported-construct validation as codegen, so it no longer accepts a file that `compile` must reject. Gate: src/ephapax-cli/tests/fail_loud.rs (14 tests): the perform/handle probes are a CodegenError with no module written; g5 is the named raw-FFI error; its `extern` rewrite compiles to a module whose function imports are contiguous and which wasmparser and wasmtime both validate; the @total loop, @pure-with-effect and @comptime are each rejected as not enforced; -O, --debug and --mode each have a test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- CHANGELOG.adoc | 25 ++ src/ephapax-cli/src/main.rs | 98 ++++--- src/ephapax-cli/tests/fail_loud.rs | 445 +++++++++++++++++++++++++++++ 3 files changed, 535 insertions(+), 33 deletions(-) create mode 100644 src/ephapax-cli/tests/fail_loud.rs diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index ce82d17a..4714c3ef 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -4,6 +4,31 @@ All notable changes to Ephapax are documented here. === https://github.com/hyperpolymath/ephapax/compare/v0.8.0...HEAD[Unreleased] +==== v2 plan rungs E0 + E1 in the Rust implementation (2026-11-09) + +* *E0, holes first* (gate `+src/ephapax-cli/tests/linearity_holes.rs+`): +a `+let!+` of any type, and any linear-typed binding, may not be used +twice; `+Ty::is_linear+` takes the join over products, sums, tuples and +lists (a deliberate, conservative divergence from Coq `+is_linear_ty+`, +recorded in `+docs/coq-rust-bridge/is_linear_ty.adoc+`); a closure may +not consume a captured linear value; a borrow of a linear value may not +be dereferenced into an owned value; a pattern wildcard over a linear +component and a duplicate pattern binder are rejected; tuple/list +projection that would discard a linear component is rejected; recursive +`+data+` is the named `+DesugarError::RecursiveType+` instead of a stack +overflow. Pair destructuring now lowers to a core `+match+`. +* *E1, fail loudly* (gate `+src/ephapax-cli/tests/fail_loud.rs+`): +`+perform+`/`+handle+` are a `+CodegenError+` (no module is written, and +`+check+` reports the same error); raw variadic `+__ffi+` is rejected +(`+RawFfiNotSupported+`), so typed `+extern+` is the only FFI path and +the import index space is contiguous; `+@total+`, `+@pure+` and +`+@comptime+` are a parse error (`+AnnotationNotEnforced+`); `+-O+` above 0 +and `+--mode+` are rejected; `+--debug+` now adds the `+ephapax.debug.mode+` +section as well as writing the source map. `+stdlib/Coproc.eph+` and +`+conformance/valid/ffi_basic.eph+` were migrated to typed `+extern+` +blocks; the interpreter's `+-L+` FFI loader no longer has a reachable +caller. + ==== Governance, CI & security-scan infrastructure (2026-06-24) * *Governance model formalised* (PR #314): added `+.github/CODEOWNERS+` diff --git a/src/ephapax-cli/src/main.rs b/src/ephapax-cli/src/main.rs index 2e471a79..335680e6 100644 --- a/src/ephapax-cli/src/main.rs +++ b/src/ephapax-cli/src/main.rs @@ -71,9 +71,10 @@ enum Commands { #[arg(required = true)] files: Vec, - /// Type checking mode (linear or affine) - #[arg(short, long, default_value = "linear")] - mode: String, + /// Rejected if given: the discipline is chosen per binding (`let` + /// is affine, `let!` is linear), not by a global mode. + #[arg(short, long)] + mode: Option, }, /// Compile to WebAssembly. @@ -92,17 +93,20 @@ enum Commands { #[arg(short, long)] output: Option, - /// Optimization level (0-3) + /// Optimization level. No optimiser exists, so only 0 (the + /// default) is accepted; any other level is rejected. #[arg(short = 'O', long, default_value = "0")] opt_level: u8, - /// Enable debug information generation + /// Emit debug information: an `ephapax.debug.mode` custom section + /// in the module and a `.wasm.map` source map. #[arg(long)] debug: bool, - /// Compilation mode (linear or affine) - #[arg(short, long, default_value = "linear")] - mode: String, + /// Rejected if given: the discipline is chosen per binding (`let` + /// is affine, `let!` is linear), not by a global mode. + #[arg(short, long)] + mode: Option, /// Run the typed-wasm L7+L10 verifier on the emitted module. /// Reads the `typedwasm.ownership` custom section and @@ -192,7 +196,7 @@ fn main() -> ExitCode { ffi_libs, args: _, }) => run_file(&file, &ffi_libs, cli.verbose), - Some(Commands::Check { files, mode }) => check_files(&files, &mode, cli.verbose), + Some(Commands::Check { files, mode }) => check_files(&files, mode.as_deref(), cli.verbose), Some(Commands::Compile { file, output, @@ -206,7 +210,7 @@ fn main() -> ExitCode { output, opt_level, debug, - &mode, + mode.as_deref(), verify_ownership, stdlib, cli.verbose, @@ -334,9 +338,35 @@ fn run_file(path: &PathBuf, ffi_libs: &[String], verbose: bool) -> Result<(), St } } -fn check_files(files: &[PathBuf], _mode_str: &str, verbose: bool) -> Result<(), String> { - // The dyadic property is per-binding (let vs let!), not a global mode. - // The mode_str parameter is accepted but ignored for backward compatibility. +/// `--mode` used to be accepted and ignored. The discipline is chosen per +/// binding (`let` affine, `let!` linear), so a global mode cannot take +/// effect: the flag is rejected instead (v2 plan E1). +fn reject_mode_flag(mode: Option<&str>) -> Result<(), String> { + match mode { + None => Ok(()), + Some(m) => Err(format!( + "--mode {m} is not supported: the discipline is chosen per binding \ + (`let` is affine, `let!` is linear), so there is no global mode; \ + remove the flag" + )), + } +} + +/// `-O` used to be accepted and ignored. No optimiser exists, so any level +/// above 0 is rejected (v2 plan E1). +fn reject_opt_level(opt_level: u8) -> Result<(), String> { + if opt_level == 0 { + Ok(()) + } else { + Err(format!( + "-O{opt_level} is not supported: no optimiser is implemented; \ + only -O0 (the default) is accepted" + )) + } +} + +fn check_files(files: &[PathBuf], mode: Option<&str>, verbose: bool) -> Result<(), String> { + reject_mode_flag(mode)?; let mut errors = 0; let mut checked = 0; @@ -383,8 +413,18 @@ fn check_files(files: &[PathBuf], _mode_str: &str, verbose: bool) -> Result<(), } }; - // Type check - match type_check_module(&module) { + // Type check, then the same unsupported-construct validation + // codegen runs (effects, raw FFI), so `check` never accepts a + // file that `compile` must reject (v2 plan E1). + let outcome = type_check_module(&module).map_err(|e| { + report_type_error(filename, &content, &e); + }); + let outcome = outcome.and_then(|()| { + ephapax_wasm::validate_supported(&module).map_err(|e| { + eprintln!("{} {}: Codegen error: {}", "✗".red(), path.display(), e); + }) + }); + match outcome { Ok(()) => { if verbose { println!( @@ -396,8 +436,7 @@ fn check_files(files: &[PathBuf], _mode_str: &str, verbose: bool) -> Result<(), } checked += 1; } - Err(e) => { - report_type_error(filename, &content, &e); + Err(()) => { errors += 1; } } @@ -425,14 +464,15 @@ fn compile_file( output: Option, opt_level: u8, debug: bool, - _mode_str: &str, + mode: Option<&str>, verify_ownership: bool, stdlib: Option, verbose: bool, ) -> Result<(), String> { let filename = path.to_str().unwrap_or("input").to_string(); - // The mode_str parameter is accepted but ignored — dyadic property is per-binding. + reject_mode_flag(mode)?; + reject_opt_level(opt_level)?; let base_dir = path .parent() @@ -584,7 +624,8 @@ fn finish_compile( ) -> Result<(), String> { // Compile to WASM (with or without debug info) let wasm_bytes = if debug { - ephapax_wasm::compile_module(&module).map_err(|e| format!("Codegen error: {}", e))? + ephapax_wasm::compile_module_with_debug(&module, filename) + .map_err(|e| format!("Codegen error: {}", e))? } else { ephapax_wasm::compile_module(&module).map_err(|e| format!("Codegen error: {}", e))? }; @@ -597,19 +638,10 @@ fn finish_compile( ); } - // Optimize if requested - let wasm_bytes = if opt_level > 0 { - if verbose { - println!( - "{} Optimization level {} (not yet implemented)", - "⚠".yellow(), - opt_level - ); - } - wasm_bytes - } else { - wasm_bytes - }; + // No optimiser exists: `compile_file` rejects any `-O` above 0 before + // this point (`reject_opt_level`), so the bytes are written as + // generated. + debug_assert_eq!(opt_level, 0); // Always-on structural validation floor: never write an invalid // module to disk. In-process (wasmparser) twin of the `wasm-tools diff --git a/src/ephapax-cli/tests/fail_loud.rs b/src/ephapax-cli/tests/fail_loud.rs new file mode 100644 index 00000000..65e60976 --- /dev/null +++ b/src/ephapax-cli/tests/fail_loud.rs @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: MPL-2.0 +// Owner: Jonathan D.A. Jewell +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + +//! fail_loud.rs — the gate for rung E1 ("Fail loudly") of the v2 plan +//! (`docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc`). +//! +//! Before E1 each of these was accepted and silently ignored or +//! miscompiled: `perform`/`handle` compiled to an `unreachable` trap, raw +//! `__ffi` called an import index outside the import section (probe g5), +//! `@total`/`@pure`/`@comptime` were dropped by the parser, and `-O`, +//! `--debug` and `--mode` were accepted with no effect. Each one must now +//! either take effect or be rejected with a named error. +//! +//! `compile_match` still emits an `unreachable` fallback after its arms; +//! that is legitimate (the type checker proves it dead), so this gate does +//! not ban `unreachable` globally. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use ephapax_desugar::desugar; +use ephapax_parser::{parse_module, parse_surface_module, ParseError}; +use ephapax_syntax::{Decl, Expr, ExprKind, Literal, Module, Ty, Visibility}; +use ephapax_typing::{type_check_module, TypeError}; +use ephapax_wasm::{compile_module, Codegen, UNSUPPORTED_PREFIX}; +use wasmparser::{Operator, Parser, Payload, TypeRef}; + +const PERFORM: &str = + include_str!("../../../docs/reports/2026-11-04-v2-survey/evidence/probes/perform.eph.txt"); +const G5: &str = + include_str!("../../../docs/reports/2026-11-04-v2-survey/evidence/probes/g5.eph.txt"); + +const PERFORM_ONLY: &str = "fn main(): I32 = perform ask()\n"; + +const HANDLE_ONLY: &str = "\ +fn main(): I32 = + handle 1 with + | return(x) => x + end +"; + +/// Probe g5 rewritten with a typed `extern` declaration (plus a second +/// import, so contiguity is tested across more than one extern fn). +const G5_EXTERN: &str = "\ +extern \"env\" { + fn foo(x: I32): I64 + fn bar(): I32 +} +fn t(): I64 = foo(bar()) +fn main(): I32 = 0 +"; + +/// The `@total` loop: `spin` never terminates. +const TOTAL_LOOP: &str = "\ +@total +fn spin(n: I32): I32 = spin(n) +fn main(): I32 = 0 +"; + +/// An `@pure` function that calls an effectful extern. +const PURE_WITH_EFFECT: &str = "\ +extern \"env\" { + fn log(x: I32): I32 +} +@pure +fn f(x: I32): I32 = log(x) +fn main(): I32 = f(1) +"; + +const COMPTIME: &str = "\ +@comptime +fn k(): I32 = 42 +fn main(): I32 = k() +"; + +const PLAIN: &str = "fn main(): I32 = 1 + 2\n"; + +fn ephapax(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_ephapax")) + .args(args) + .output() + .expect("run ephapax") +} + +fn write(dir: &Path, name: &str, src: &str) -> PathBuf { + let p = dir.join(name); + std::fs::write(&p, src).expect("write source"); + p +} + +fn stderr(out: &Output) -> String { + String::from_utf8_lossy(&out.stderr).to_string() +} + +/// Surface parse → desugar → type check (must succeed), falling back to +/// the core parser when the surface parser rejects the file, as +/// `ephapax check` does (the surface parser has no effect syntax). +fn checked_core(src: &str) -> Module { + let core = match parse_surface_module(src, "probe") { + Ok(surface) => desugar(&surface).expect("desugar"), + Err(_) => parse_module(src, "probe").expect("core parse"), + }; + type_check_module(&core).expect("type check"); + core +} + +/// `ephapax compile` must fail with exit 1 and must not write a module. +fn assert_compile_rejected(name: &str, src: &str, needle: &str) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = write(dir.path(), &format!("{name}.eph"), src); + let wasm = dir.path().join(format!("{name}.wasm")); + let out = ephapax(&[ + "compile", + path.to_str().unwrap(), + "-o", + wasm.to_str().unwrap(), + ]); + let err = stderr(&out); + assert_eq!(out.status.code(), Some(1), "{name}: exit\n{err}"); + assert!( + err.contains(needle), + "{name}: expected `{needle}` in\n{err}" + ); + assert!(!wasm.exists(), "{name}: no module may be written"); +} + +// --------------------------------------------------------------------------- +// perform / handle +// --------------------------------------------------------------------------- + +fn assert_codegen_unsupported(src: &str, what: &str) { + let core = checked_core(src); + let err = compile_module(&core).expect_err("codegen must refuse"); + assert!( + err.0.starts_with(UNSUPPORTED_PREFIX) && err.0.contains(what), + "unexpected CodegenError: {}", + err.0 + ); +} + +#[test] +fn perform_probe_is_a_codegen_error_without_a_module() { + assert_codegen_unsupported(PERFORM, "handle"); + assert_compile_rejected("perform", PERFORM, UNSUPPORTED_PREFIX); +} + +#[test] +fn perform_alone_is_a_codegen_error() { + assert_codegen_unsupported(PERFORM_ONLY, "perform ask"); + assert_compile_rejected("perform_only", PERFORM_ONLY, "perform ask"); +} + +#[test] +fn handle_alone_is_a_codegen_error() { + assert_codegen_unsupported(HANDLE_ONLY, "handle"); + assert_compile_rejected("handle_only", HANDLE_ONLY, "handle"); +} + +#[test] +fn compile_program_refuses_perform() { + let expr = Expr::dummy(ExprKind::Perform { + op: "ask".into(), + args: vec![], + }); + let err = Codegen::new() + .compile_program(&expr) + .expect_err("compile_program must refuse perform"); + assert!(err.0.starts_with(UNSUPPORTED_PREFIX), "{}", err.0); +} + +#[test] +fn check_rejects_what_compile_rejects() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = write(dir.path(), "perform.eph", PERFORM); + let out = ephapax(&["check", path.to_str().unwrap()]); + assert_eq!(out.status.code(), Some(1), "{}", stderr(&out)); + assert!(stderr(&out).contains(UNSUPPORTED_PREFIX)); +} + +// --------------------------------------------------------------------------- +// raw FFI (probe g5) and the extern rewrite +// --------------------------------------------------------------------------- + +#[test] +fn g5_raw_ffi_is_a_named_error() { + let surface = parse_surface_module(G5, "g5").expect("parse"); + let core = desugar(&surface).expect("desugar"); + let err = type_check_module(&core).expect_err("raw __ffi must be rejected"); + assert!( + matches!(err.error, TypeError::RawFfiNotSupported(ref s) if s == "foo"), + "got {:?}", + err.error + ); + + let dir = tempfile::tempdir().expect("tempdir"); + let path = write(dir.path(), "g5.eph", G5); + let out = ephapax(&["check", path.to_str().unwrap()]); + assert_eq!(out.status.code(), Some(1), "{}", stderr(&out)); + assert!(stderr(&out).contains("Raw `__ffi(\"foo\", ...)` is not supported")); + assert_compile_rejected("g5", G5, "RawFfiNotSupported"); +} + +/// Codegen refuses raw FFI even when handed an unchecked module (the +/// type checker is not the only line of defence). +#[test] +fn codegen_refuses_raw_ffi_in_unchecked_module() { + let module = Module { + name: "g5".into(), + imports: vec![], + decls: vec![Decl::Fn { + name: "t".into(), + visibility: Visibility::Private, + type_params: vec![], + params: vec![], + ret_ty: Ty::Base(ephapax_syntax::BaseTy::I64), + body: Expr::dummy(ExprKind::FFI { + symbol: "foo".into(), + args: vec![Expr::dummy(ExprKind::Lit(Literal::I32(1)))], + }), + }], + }; + let err = compile_module(&module).expect_err("raw FFI must not lower"); + assert!( + err.0.starts_with(UNSUPPORTED_PREFIX) && err.0.contains("__ffi"), + "{}", + err.0 + ); +} + +#[test] +fn g5_extern_rewrite_has_contiguous_imports_and_validates() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = write(dir.path(), "g5ext.eph", G5_EXTERN); + let wasm_path = dir.path().join("g5ext.wasm"); + let out = ephapax(&[ + "compile", + path.to_str().unwrap(), + "-o", + wasm_path.to_str().unwrap(), + ]); + assert!(out.status.success(), "compile failed:\n{}", stderr(&out)); + let bytes = std::fs::read(&wasm_path).expect("read wasm"); + + // Structural validation, twice: wasmparser, and wasmtime's own + // validator + compiler (the in-process twin of `wasmtime compile`). + wasmparser::validate(&bytes).expect("wasmparser validation"); + let engine = wasmtime::Engine::default(); + wasmtime::Module::new(&engine, &bytes).expect("wasmtime validates and compiles"); + + // Function imports, in index order. + let mut func_imports: Vec<(String, String)> = Vec::new(); + let mut calls: Vec = Vec::new(); + let mut defined_funcs = 0u32; + for payload in Parser::new(0).parse_all(&bytes) { + match payload.expect("parse") { + Payload::ImportSection(reader) => { + for import in reader { + let import = import.expect("import"); + if let TypeRef::Func(_) = import.ty { + func_imports.push((import.module.to_string(), import.name.to_string())); + } + } + } + Payload::FunctionSection(reader) => defined_funcs = reader.count(), + Payload::CodeSectionEntry(body) => { + let mut ops = body.get_operators_reader().expect("ops"); + while !ops.eof() { + if let Operator::Call { function_index } = ops.read().expect("op") { + calls.push(function_index); + } + } + } + _ => {} + } + } + assert_eq!( + func_imports, + vec![ + ("env".to_string(), "print_i32".to_string()), + ("env".to_string(), "print_string".to_string()), + ("env".to_string(), "foo".to_string()), + ("env".to_string(), "bar".to_string()), + ], + "imports must be the builtins followed by the extern fns, contiguously" + ); + let total = func_imports.len() as u32 + defined_funcs; + assert!( + calls.iter().all(|&i| i < total), + "every call targets an existing function: {calls:?} (total {total})" + ); + assert!(calls.contains(&2), "t calls foo at import index 2"); + assert!(calls.contains(&3), "t calls bar at import index 3"); +} + +// --------------------------------------------------------------------------- +// @total / @pure / @comptime +// --------------------------------------------------------------------------- + +fn assert_annotation_rejected(src: &str, name: &str) { + for (parser, result) in [ + ("surface", parse_surface_module(src, "a").map(|_| ())), + ("core", parse_module(src, "a").map(|_| ())), + ] { + let errs = result.expect_err("annotation must be rejected"); + assert!( + errs.iter().any(|e| matches!( + e, + ParseError::AnnotationNotEnforced { name: n, .. } if n == name + )), + "{parser} parser: expected AnnotationNotEnforced(@{name}), got {errs:?}" + ); + } + let dir = tempfile::tempdir().expect("tempdir"); + let path = write(dir.path(), "a.eph", src); + let out = ephapax(&["check", path.to_str().unwrap()]); + assert_eq!(out.status.code(), Some(1), "{}", stderr(&out)); + assert!( + stderr(&out).contains(&format!("Annotation `@{name}` is not enforced")), + "{}", + stderr(&out) + ); + assert_compile_rejected("a", src, "is not enforced"); +} + +#[test] +fn total_loop_rejected_as_not_enforced() { + assert_annotation_rejected(TOTAL_LOOP, "total"); +} + +#[test] +fn pure_with_effect_rejected_as_not_enforced() { + assert_annotation_rejected(PURE_WITH_EFFECT, "pure"); +} + +#[test] +fn comptime_rejected_as_not_enforced() { + assert_annotation_rejected(COMPTIME, "comptime"); +} + +// --------------------------------------------------------------------------- +// -O, --debug, --mode +// --------------------------------------------------------------------------- + +#[test] +fn opt_level_above_zero_is_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = write(dir.path(), "p.eph", PLAIN); + let wasm = dir.path().join("p.wasm"); + let out = ephapax(&[ + "compile", + path.to_str().unwrap(), + "-O2", + "-o", + wasm.to_str().unwrap(), + ]); + assert_eq!(out.status.code(), Some(1), "{}", stderr(&out)); + assert!( + stderr(&out).contains("-O2 is not supported"), + "{}", + stderr(&out) + ); + assert!(!wasm.exists()); + + let out = ephapax(&[ + "compile", + path.to_str().unwrap(), + "-O0", + "-o", + wasm.to_str().unwrap(), + ]); + assert!(out.status.success(), "-O0 is accepted: {}", stderr(&out)); + assert!(wasm.exists()); +} + +fn custom_section_names(bytes: &[u8]) -> Vec { + Parser::new(0) + .parse_all(bytes) + .filter_map(|p| match p.expect("parse") { + Payload::CustomSection(c) => Some(c.name().to_string()), + _ => None, + }) + .collect() +} + +#[test] +fn debug_flag_takes_effect() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = write(dir.path(), "p.eph", PLAIN); + + let plain = dir.path().join("plain.wasm"); + let out = ephapax(&[ + "compile", + path.to_str().unwrap(), + "-o", + plain.to_str().unwrap(), + ]); + assert!(out.status.success(), "{}", stderr(&out)); + let plain_bytes = std::fs::read(&plain).expect("read"); + assert!(!custom_section_names(&plain_bytes).contains(&"ephapax.debug.mode".to_string())); + assert!(!plain.with_extension("wasm.map").exists()); + + let dbg = dir.path().join("dbg.wasm"); + let out = ephapax(&[ + "compile", + path.to_str().unwrap(), + "--debug", + "-o", + dbg.to_str().unwrap(), + ]); + assert!(out.status.success(), "{}", stderr(&out)); + let dbg_bytes = std::fs::read(&dbg).expect("read"); + wasmparser::validate(&dbg_bytes).expect("debug module validates"); + assert!( + custom_section_names(&dbg_bytes).contains(&"ephapax.debug.mode".to_string()), + "--debug must add the ephapax.debug.mode section" + ); + let map = std::fs::read_to_string(dbg.with_extension("wasm.map")).expect("source map"); + assert!(!map.trim().is_empty(), "--debug must write a source map"); +} + +#[test] +fn mode_flag_is_rejected_by_check_and_compile() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = write(dir.path(), "p.eph", PLAIN); + let p = path.to_str().unwrap(); + + let out = ephapax(&["check", p]); + assert!(out.status.success(), "{}", stderr(&out)); + + for mode in ["affine", "linear"] { + let out = ephapax(&["check", "--mode", mode, p]); + assert_eq!(out.status.code(), Some(1), "check --mode {mode}"); + assert!( + stderr(&out).contains(&format!("--mode {mode} is not supported")), + "{}", + stderr(&out) + ); + + let wasm = dir.path().join(format!("{mode}.wasm")); + let out = ephapax(&["compile", p, "--mode", mode, "-o", wasm.to_str().unwrap()]); + assert_eq!(out.status.code(), Some(1), "compile --mode {mode}"); + assert!(!wasm.exists()); + } +} From c0ff0a0813592ee4595481ea352c311aa9a03c94 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:43:00 +0000 Subject: [PATCH 15/26] test(probes): record the E0/E1 results; count the new gates (507 -> 542) The probe battery now reports 22 rows IMPROVED against the X1 baseline: dup2, dup4, pairdup and lamcap are TypeErrors; rec1 is a DesugarError; perform is a CodegenError; g5's raw __ffi is a TypeError; total_loop's @total is ParseError::AnnotationNotEnforced. The baseline column is rewritten with --update, and total_loop's target is spelled as the error kind the battery classifies (ParseError), with the variant in the note. The battery now reports 58/58 SAME. TEST-NEEDS: 542 all-target tests (status gate). The plan's E0/E1 rows name the gate files where they live, src/ephapax-cli/tests/, since the top-level tests/ directory belongs to no crate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- TEST-NEEDS.adoc | 4 +- .../EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc | 4 +- tests/probes/expectations.tsv | 44 +++++++++---------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index 10c6ac37..530be8d8 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -21,9 +21,9 @@ compliance, 16 tests) performance, correctness, 13 tests) * Benchmarks: `+src/ephapax-parser/benches/parse_bench.rs+`, `+src/ephapax-vram-cache/benches/cache_bench.rs+` -* Total: *507 tests* (`+cargo test --workspace --all-targets+`); +* Total: *542 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 507 +* Documented all-target tests: 542 === CRG Testing Taxonomy — Status diff --git a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc index 9e726f46..71772e06 100644 --- a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc +++ b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc @@ -868,13 +868,13 @@ These are kept off the critical path. |E0 Holes first |`mark_used` routes through `demands_consumption` (lib.rs:191). `check_lambda` rejects consuming a captured linear value. `Ty::is_linear` takes the join over Prod/Sum/Tuple/List (conservative *reject*, D5-independent; D5 in W1 may relax it). Recursive `data` gives a `DesugarError`, not an overflow. Dereferencing a borrow of a linear value may not consume ownership (today `check_deref` returns the owned inner type, `ephapax-typing/src/lib.rs:1739`, and borrows are not loan-tracked). A pattern may not discard a linear payload: a wildcard over a linear component, or a duplicate binder, is rejected (today a wildcard binds nothing, `lib.rs:1592`). Projection may not discard one either: `check_tuple_index` and `check_list_index` reject a projection from a container whose unselected fields or elements are linear (today they consume the container and return one element, so the rest are lost), as `check_fst`/`check_snd` already do; such containers must be destructured. -|`tests/linearity_holes.rs`: dup2, dup4, pairdup, lamcap, sum-, tuple- and list-of-linear duplication probes, a double-borrow-then-drop probe, a `(_, y)` match over a linear pair, a tuple projection `t.0` from a tuple of two linear strings and a list index from a list of linear strings are each rejected with a named `TypeError` kind; rec1 fails with the named `DesugarError` and the compiler's normal error exit, never a crash or abort; `cargo test --workspace` green (≥ 507) +|`src/ephapax-cli/tests/linearity_holes.rs`: dup2, dup4, pairdup, lamcap, sum-, tuple- and list-of-linear duplication probes, a double-borrow-then-drop probe, a `(_, y)` match over a linear pair, a tuple projection `t.0` from a tuple of two linear strings and a list index from a list of linear strings are each rejected with a named `TypeError` kind; rec1 fails with the named `DesugarError` and the compiler's normal error exit, never a crash or abort; `cargo test --workspace` green (≥ 507) |none (escalation note for pairdup) |S–M |E1 Fail loudly |Perform/Handle give a `CodegenError`. `@total`, `@pure` and `@comptime` give an "annotation not enforced" error. Raw variadic `__ffi` (probe g5) is rejected with a named error; typed `extern` declarations are the only FFI path, since only they have a formal-core representation (Φ). The import-index bug (lib.rs:478, 579) is fixed for `extern`. `-O`, `--debug` and `check --mode` take effect or are rejected. -|`tests/fail_loud.rs`: the perform/handle probes return `CodegenError` without producing a module. g5 fails with the named raw-FFI error; an `extern`-based rewrite of g5 compiles to a module whose imports are contiguous and which passes structural validation. The `@total` loop, an `@pure` function with an effect and an `@comptime` declaration are each rejected with the "annotation not enforced" error. `-O`, `--debug` and `check --mode` each have a test showing they either take effect or are rejected. (`compile_match` legitimately emits an `unreachable` fallback, so the gate does not ban `unreachable` globally.) +|`src/ephapax-cli/tests/fail_loud.rs`: the perform/handle probes return `CodegenError` without producing a module. g5 fails with the named raw-FFI error; an `extern`-based rewrite of g5 compiles to a module whose imports are contiguous and which passes structural validation. The `@total` loop, an `@pure` function with an effect and an `@comptime` declaration are each rejected with the "annotation not enforced" error. `-O`, `--debug` and `check --mode` each have a test showing they either take effect or are rejected. (`compile_match` legitimately emits an `unreachable` fallback, so the gate does not ban `unreachable` globally.) |none |S diff --git a/tests/probes/expectations.tsv b/tests/probes/expectations.tsv index 828d52e8..a596537e 100644 --- a/tests/probes/expectations.tsv +++ b/tests/probes/expectations.tsv @@ -17,20 +17,20 @@ # same PR with `scripts/probe-battery.sh --update`. # Plan: docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc probe command baseline target ref -dup2 check ok error:TypeError E0 tests/linearity_holes.rs: reuse of a linear value is a named TypeError -dup2 compile ok error:TypeError E0: compile runs the same checker -dup2 wasm-run ok:10 no-module E0: no module for a rejected program -dup4 check ok error:TypeError E0 tests/linearity_holes.rs: reuse of a linear value is a named TypeError -dup4 compile ok error:TypeError E0: compile runs the same checker -dup4 wasm-run ok:10 no-module E0: no module for a rejected program -pairdup check ok error:TypeError E0 tests/linearity_holes.rs: reuse of a linear value is a named TypeError -pairdup compile ok error:TypeError E0: compile runs the same checker -pairdup wasm-run ok:0 no-module E0: no module for a rejected program -lamcap check ok error:TypeError E0 tests/linearity_holes.rs: reuse of a linear value is a named TypeError -lamcap compile ok error:TypeError E0: compile runs the same checker -lamcap wasm-run ok:0 no-module E0: no module for a rejected program -rec1 check crash:134-stack-overflow error:DesugarError E0: recursive data is a named DesugarError, never a crash -rec1 compile crash:134-stack-overflow error:DesugarError E0 +dup2 check error:TypeError error:TypeError E0 tests/linearity_holes.rs: reuse of a linear value is a named TypeError +dup2 compile error:TypeError error:TypeError E0: compile runs the same checker +dup2 wasm-run no-module no-module E0: no module for a rejected program +dup4 check error:TypeError error:TypeError E0 tests/linearity_holes.rs: reuse of a linear value is a named TypeError +dup4 compile error:TypeError error:TypeError E0: compile runs the same checker +dup4 wasm-run no-module no-module E0: no module for a rejected program +pairdup check error:TypeError error:TypeError E0 tests/linearity_holes.rs: reuse of a linear value is a named TypeError +pairdup compile error:TypeError error:TypeError E0: compile runs the same checker +pairdup wasm-run no-module no-module E0: no module for a rejected program +lamcap check error:TypeError error:TypeError E0 tests/linearity_holes.rs: reuse of a linear value is a named TypeError +lamcap compile error:TypeError error:TypeError E0: compile runs the same checker +lamcap wasm-run no-module no-module E0: no module for a rejected program +rec1 check error:DesugarError error:DesugarError E0: recursive data is a named DesugarError, never a crash +rec1 compile error:DesugarError error:DesugarError E0 rec1 wasm-run no-module no-module E0 i64 check ok ok E5 (already accepted) i64 compile error:InvalidWasm ok E5 typed side table: I64 binops lower to i64 @@ -44,11 +44,11 @@ s6 wasm-run no-module ok:0 E5 t3 check ok ok E5 (already accepted) t3 compile error:InvalidWasm ok E5: n-ary tuples t3 wasm-run no-module ok:0 E5 -perform check ok error:CodegenError E1 + E3: check runs the same unsupported-feature validation as codegen -perform compile ok error:CodegenError E1 tests/fail_loud.rs: perform/handle is a CodegenError -perform wasm-run error:Trap:wasm_`unreachable`_instruction_executed no-module E1 -g5 check ok error:* E1: raw variadic __ffi rejected with a named error (kind name set by E1) -g5 compile error:InvalidWasm error:* E1: named raw-FFI error, not an invalid module +perform check error:CodegenError error:CodegenError E1 + E3: check runs the same unsupported-feature validation as codegen +perform compile error:CodegenError error:CodegenError E1 tests/fail_loud.rs: perform/handle is a CodegenError +perform wasm-run no-module no-module E1 +g5 check error:TypeError error:* E1: raw variadic __ffi rejected with a named error (kind name set by E1) +g5 compile error:TypeError error:* E1: named raw-FFI error, not an invalid module g5 wasm-run no-module no-module E1 gen_angle check error:TypeError ok E7 generics: id instantiated at I32 gen_angle compile error:TypeError ok E7 @@ -62,9 +62,9 @@ p1 check ok ok already accepted p1 compile ok ok already compiles (959 B in the survey) p1 run error:ParseError ok:162 E3/E4: ephapax run executes the wasm and prints 162 p1 wasm-run ok:162 ok:162 survey: main returns 162 under wasmtime CLI 36.0.2 -total_loop check ok error:AnnotationNotEnforced E1: @total is an "annotation not enforced" error until enforced -total_loop compile ok error:AnnotationNotEnforced E1 -total_loop wasm-run ok:0 no-module E1 +total_loop check error:ParseError error:ParseError E1: @total is ParseError::AnnotationNotEnforced until enforced +total_loop compile error:ParseError error:ParseError E1: ParseError::AnnotationNotEnforced +total_loop wasm-run no-module no-module E1 conformance/invalid/branch_inconsistent check error:ParseError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: needs a v2-syntax rewrite (owner/T0d) conformance/invalid/branch_inconsistent compile error:ParseError error:TypeError as above conformance/invalid/linear_not_consumed check error:ParseError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: needs a v2-syntax rewrite (owner/T0d) From c2f6abfd7dfa12ecb7f285ad89ceae67973b819c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:53:35 +0000 Subject: [PATCH 16/26] fix(typing): scope every binder so shadowing cannot hide a linear obligation A binder that reused an outer name overwrote the outer context entry and never gave it back. The outer binding's consumption check then ran against the inner entry, or against nothing (case/match deleted the name), so `ephapax check` accepted programs that leak a `let!` value: let! s = String.new@r("hi") in let s = 5 in s -- accepted, s leaked The same bug made the dual wrong: `let! s = ..; let s = drop(s) in 0` was rejected as "s not consumed" because the unused inner `s` was checked in place of the consumed outer one. A closure that consumed a capture and then shadowed its name escaped LinearCaptureConsumed, and two parameters of one function silently hid the first (linear) one. Context gains bind_scoped/end_scope: the shadowed entry is saved and reinstated when the inner scope closes, and each scope-exit check inspects its own binding. Used by check_let, check_let_lin, check_lambda, check_case, check_match and handler-clause parameters. Duplicate function parameters are rejected (new DuplicateParameter). linearity_holes.rs gains 11 cases (8 negative, 3 positive); all the negative ones except the dual and the capture case were accepted on c0ff0a0. TEST-NEEDS count 542 -> 553. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- TEST-NEEDS.adoc | 4 +- src/ephapax-cli/tests/linearity_holes.rs | 219 +++++++++++++++++++++++ src/ephapax-typing/src/discipline.rs | 15 ++ src/ephapax-typing/src/lib.rs | 125 ++++++++++--- 4 files changed, 336 insertions(+), 27 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index 530be8d8..e50149fe 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -21,9 +21,9 @@ compliance, 16 tests) performance, correctness, 13 tests) * Benchmarks: `+src/ephapax-parser/benches/parse_bench.rs+`, `+src/ephapax-vram-cache/benches/cache_bench.rs+` -* Total: *542 tests* (`+cargo test --workspace --all-targets+`); +* Total: *553 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 542 +* Documented all-target tests: 553 === CRG Testing Taxonomy — Status diff --git a/src/ephapax-cli/tests/linearity_holes.rs b/src/ephapax-cli/tests/linearity_holes.rs index 49702f97..ac35b768 100644 --- a/src/ephapax-cli/tests/linearity_holes.rs +++ b/src/ephapax-cli/tests/linearity_holes.rs @@ -473,3 +473,222 @@ fn multi_field_constructor_destructuring_checks() { // not assert the runtime value of a three-field payload. pipeline(DESTRUCT3).expect("three-field constructor destructuring type-checks"); } + +// --------------------------------------------------------------------- +// Shadowing (W0 follow-up). Before the fix a binder that reused an outer +// name overwrote the outer context entry and never gave it back, so the +// outer binding's consumption check ran against the inner entry (or +// against nothing): a shadowed `let!` could leak, and a consumed `let!` +// shadowed by an unused `let` was falsely reported as unconsumed. Every +// negative probe below was accepted by `ephapax check` (exit 0) on the +// base commit c0ff0a0, except `SHADOW_LAMBDA_CAPTURE` (rejected for the +// wrong reason, `UnnecessaryDrop`) and `SHADOW_INNER_UNCONSUMED` (the +// dual, which must keep working). +// --------------------------------------------------------------------- + +/// A linear string shadowed by an affine `let` and never consumed. +const SHADOW_LET_LEAK: &str = "\ +fn main(): I32 = + region r { + let! s = String.new@r(\"hi\") in + let s = 5 in + s + } +"; + +/// A linear string shadowed by a second `let!` that *is* consumed: the +/// outer string still leaks. +const SHADOW_LET_BANG_LEAK: &str = "\ +fn main(): I32 = + region r { + let! s = String.new@r(\"hi\") in + let! s = String.new@r(\"ho\") in + drop(s); + 0 + } +"; + +/// `let!` of an unrestricted value, shadowed and consumed through the +/// inner name only. +const SHADOW_LET_BANG_INT_LEAK: &str = "\ +fn main(): I32 = + let! x = 5 in + let! x = 6 in + x +"; + +/// The dual: the outer `let!` is consumed by the inner value, and the +/// inner `let!` itself is never consumed — its own check must remain. +const SHADOW_INNER_UNCONSUMED: &str = "\ +fn main(): I32 = + let! x = 5 in + let! x = x + 1 in + 0 +"; + +/// A pair-pattern binder that shadows a linear string, which then leaks. +const SHADOW_PATTERN_LEAK: &str = "\ +fn main(): I32 = + region r { + let! s = String.new@r(\"hi\") in + let p = (1, 2) in + let (s, y) = p in + y + } +"; + +/// A `case` binder that shadows a linear string, which then leaks. +const SHADOW_CASE_LEAK: &str = "\ +fn main(): I32 = + region r { + let! s = String.new@r(\"hi\") in + let v = inl[I32](1) in + case v of inl(s) -> s inr(t) -> t end + } +"; + +/// A lambda parameter that shadows a linear string, which then leaks. +const SHADOW_LAMBDA_PARAM_LEAK: &str = "\ +fn main(): I32 = + region r { + let! s = String.new@r(\"hi\") in + let f = fn(n: I32) -> n + 1 in + let g = fn(s: I32) -> s + 1 in + f(1) + g(2) + } +"; + +/// A closure that frees its capture and then shadows the name: the +/// capture check must still see the captured binding (else the string is +/// freed by every call and again by the enclosing scope). +const SHADOW_LAMBDA_CAPTURE: &str = "\ +fn main(): I32 = + region r { + let! s = String.new@r(\"hi\") in + let f = fn(u: ()) -> let s = drop(s) in 0 in + drop(s); + 0 + } +"; + +/// Two parameters with one name: the second hides the first (linear) +/// one for the whole body. +const DUPLICATE_PARAMETER_LEAK: &str = "\ +fn f(s: String@r, s: I32): I32 = s +fn main(): I32 = + region r { + let! a = String.new@r(\"hi\") in + f(a, 2) + } +"; + +/// Consuming a `let!` in the value of a shadowing `let` is fine; the +/// base commit wrongly reported the outer `s` as unconsumed. +const SHADOW_CONSUMED_THEN_REBOUND_OK: &str = "\ +fn main(): I32 = + region r { + let! s = String.new@r(\"hi\") in + let s = drop(s) in + 0 + } +"; + +/// Ordinary shadowing of unrestricted values keeps working. +const SHADOW_UNRESTRICTED_OK: &str = "\ +fn main(): I32 = + let x = 5 in + let x = x + 1 in + let f = fn(x: I32) -> x + 1 in + f(x) + x +"; + +/// Pattern and `case` binders that do not shadow anything linear, with +/// the outer string consumed after them. +const SHADOW_SCOPED_BINDERS_OK: &str = "\ +fn main(): I32 = + region r { + let! s = String.new@r(\"hi\") in + let v = inl[I32](1) in + let n = case v of inl(s) -> s inr(t) -> t end in + let p = (1, 2) in + let (s2, y) = p in + drop(s); + n + s2 + y + } +"; + +fn assert_not_consumed(name: &str, src: &str, var: &str) { + let err = type_error(src); + assert!( + matches!(err, TypeError::LinearVariableNotConsumed(ref v) if v == var), + "{name}: expected LinearVariableNotConsumed({var}), got {err:?}" + ); + assert_cli_rejects(name, src); +} + +#[test] +fn shadowed_let_bang_by_let_must_still_be_consumed() { + assert_not_consumed("shadow_let_leak", SHADOW_LET_LEAK, "s"); +} + +#[test] +fn shadowed_let_bang_by_let_bang_must_still_be_consumed() { + assert_not_consumed("shadow_let_bang_leak", SHADOW_LET_BANG_LEAK, "s"); + assert_not_consumed("shadow_let_bang_int_leak", SHADOW_LET_BANG_INT_LEAK, "x"); +} + +#[test] +fn shadowing_let_bang_keeps_its_own_consumption_check() { + assert_not_consumed("shadow_inner_unconsumed", SHADOW_INNER_UNCONSUMED, "x"); +} + +#[test] +fn shadowed_by_pattern_binder_must_still_be_consumed() { + assert_not_consumed("shadow_pattern_leak", SHADOW_PATTERN_LEAK, "s"); +} + +#[test] +fn shadowed_by_case_binder_must_still_be_consumed() { + assert_not_consumed("shadow_case_leak", SHADOW_CASE_LEAK, "s"); +} + +#[test] +fn shadowed_by_lambda_parameter_must_still_be_consumed() { + assert_not_consumed("shadow_lambda_param_leak", SHADOW_LAMBDA_PARAM_LEAK, "s"); +} + +#[test] +fn closure_consuming_then_shadowing_a_capture_rejected() { + let err = type_error(SHADOW_LAMBDA_CAPTURE); + assert!( + matches!(err, TypeError::LinearCaptureConsumed(ref v) if v == "s"), + "expected LinearCaptureConsumed(s), got {err:?}" + ); + assert_cli_rejects("shadow_lambda_capture", SHADOW_LAMBDA_CAPTURE); +} + +#[test] +fn duplicate_function_parameter_rejected() { + let err = type_error(DUPLICATE_PARAMETER_LEAK); + assert!( + matches!(err, TypeError::DuplicateParameter(ref v) if v == "s"), + "expected DuplicateParameter(s), got {err:?}" + ); + assert_cli_rejects("duplicate_parameter_leak", DUPLICATE_PARAMETER_LEAK); +} + +#[test] +fn rebinding_a_consumed_let_bang_still_checks() { + pipeline(SHADOW_CONSUMED_THEN_REBOUND_OK) + .expect("the outer `s` is consumed by the shadowing binding's value"); +} + +#[test] +fn shadowing_unrestricted_values_still_checks() { + pipeline(SHADOW_UNRESTRICTED_OK).expect("unrestricted shadowing is ordinary scoping"); +} + +#[test] +fn scoped_pattern_and_case_binders_still_check() { + pipeline(SHADOW_SCOPED_BINDERS_OK).expect("binders are scoped to their arm"); +} diff --git a/src/ephapax-typing/src/discipline.rs b/src/ephapax-typing/src/discipline.rs index fe7ac12d..f8ae49bc 100644 --- a/src/ephapax-typing/src/discipline.rs +++ b/src/ephapax-typing/src/discipline.rs @@ -76,6 +76,21 @@ //! | Dereferencing a borrow (or an unrestricted ref) of a linear value is an error (borrows are not loan-tracked) | `check_deref` — `CannotMoveOutOfBorrow` | //! | Products, sums, tuples and lists are linear when any component is (conservative join; D5 may relax it) | `Ty::is_linear` | //! +//! ## 4c. Scoping and shadowing (W0 follow-up) +//! +//! Every binder (`let`, `let!`, lambda parameter, `case` binder, pattern +//! binder, handler-clause parameter) is scoped: `Context::bind_scoped` +//! saves the binding it shadows and `Context::end_scope` removes the inner +//! binding and reinstates the outer one. A shadowed `let!` therefore keeps +//! its consumption obligation, and each scope-exit check (`check_let_lin`, +//! `check_lambda`, `check_case`, `check_match`) inspects its own binding +//! rather than whatever entry now has the same name. Two parameters of one +//! function may not share a name (`DuplicateParameter`). +//! +//! Because a `let!` scope always closes before the enclosing region does, +//! an unconsumed `let!` in a region is reported at its own scope exit as +//! `LinearVariableNotConsumed`; the region-exit check (§3) is a backstop. +//! //! ## 5. Effect Resume (future — not yet implemented) //! //! | Discipline | Rule | Planned enforcement | diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index 8e51b45b..457c59fa 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -116,6 +116,11 @@ pub enum TypeError { #[error("Variable `{0}` is bound more than once in the same pattern")] DuplicatePatternBinder(Var), + /// Two parameters of one function share a name; the second would + /// shadow the first, hiding its consumption obligation. + #[error("Parameter `{0}` is declared more than once in the same function")] + DuplicateParameter(Var), + /// A projection (`t.i` or `xs[i]`) would consume the container and /// return one component, discarding unselected linear components. #[error( @@ -234,6 +239,31 @@ impl Context { ); } + /// Bind `name` for a lexical scope that the caller closes with + /// [`Context::end_scope`]. Returns the binding it shadows, if any, + /// which must be handed back to `end_scope` so the outer binding — + /// and its consumption obligation — is restored when the inner + /// scope ends. Without this, a shadowed `let!` would be checked + /// against the inner binding's entry and could leak. + fn bind_scoped(&mut self, name: Var, ty: Ty, binding_form: BindingForm) -> Option { + let shadowed = self.vars.get(&name).cloned(); + self.extend(name, ty, binding_form); + shadowed + } + + /// Close the scope opened by [`Context::bind_scoped`]: remove the + /// inner binding of `name` (returned, so its own consumption can be + /// checked) and reinstate the binding it shadowed. The outer binding + /// could not be named inside the inner scope, so it is restored + /// exactly as it was when it was shadowed. + fn end_scope(&mut self, name: &Var, shadowed: Option) -> Option { + let inner = self.vars.remove(name); + if let Some(outer) = shadowed { + self.vars.insert(name.clone(), outer); + } + inner + } + /// Bindings that forbid reuse and are still unused, keyed by name, /// with the identity of the binding occurrence. fn unused_restricted(&self) -> Vec<(Var, u64)> { @@ -1161,8 +1191,11 @@ impl TypeChecker { value_ty }; - self.ctx.extend(name.clone(), resolved_ty, BindingForm::Let); + let shadowed = self + .ctx + .bind_scoped(name.clone(), resolved_ty, BindingForm::Let); let body_ty = self.check(body)?; + self.ctx.end_scope(name, shadowed); // `let` is AFFINE — unconsumed bindings are allowed (implicit drop). Ok(body_ty) @@ -1180,16 +1213,20 @@ impl TypeChecker { // consume any of them: each call would consume it again. let captured_before = self.ctx.unused_restricted(); - self.ctx - .extend(param.clone(), param_ty.clone(), BindingForm::Param); + let shadowed = self + .ctx + .bind_scoped(param.clone(), param_ty.clone(), BindingForm::Param); let body_ty = self.check(body)?; - if let Some(entry) = self.ctx.vars.get(param) { + if let Some(entry) = self.ctx.end_scope(param, shadowed) { if entry.demands_consumption() && !entry.used { return Err(self.at(s, TypeError::LinearVariableNotConsumed(param.clone()))); } } + // Inner bindings of the body have been scoped out, so every + // captured name now resolves to its outer binding again — a + // capture consumed and then shadowed inside the body is caught. for (name, id) in &captured_before { if name == param { continue; @@ -1419,12 +1456,16 @@ impl TypeChecker { } } - self.ctx - .extend(name.clone(), value_ty.clone(), BindingForm::LetBang); + let shadowed = self + .ctx + .bind_scoped(name.clone(), value_ty.clone(), BindingForm::LetBang); let body_ty = self.check(body)?; - // let! bindings MUST be consumed — regardless of type. - if let Some(entry) = self.ctx.vars.get(name) { + // let! bindings MUST be consumed — regardless of type. The entry + // checked is this binding's own (closing the scope also restores + // any binding it shadowed, whose obligation is checked at the end + // of that binding's own scope). + if let Some(entry) = self.ctx.end_scope(name, shadowed) { if !entry.used { return Err(self.at(s, TypeError::LinearVariableNotConsumed(name.clone()))); } @@ -1528,12 +1569,16 @@ impl TypeChecker { Ty::Sum { left, right } => { let ctx_after_scrutinee = self.ctx.snapshot(); - // Check left branch - self.ctx - .extend(left_var.clone(), *left.clone(), BindingForm::Param); + // Check left branch. The case binder is scoped to its arm: + // closing it restores any outer binding of the same name + // (rather than deleting it), so a shadowed linear outer + // binding keeps its consumption obligation. + let shadowed = + self.ctx + .bind_scoped(left_var.clone(), *left.clone(), BindingForm::Param); let left_result_ty = self.check(left_body)?; - if let Some(entry) = self.ctx.vars.get(left_var) { + if let Some(entry) = self.ctx.end_scope(left_var, shadowed) { if entry.demands_consumption() && !entry.used { return Err( self.at(s, TypeError::LinearVariableNotConsumed(left_var.clone())) @@ -1541,17 +1586,17 @@ impl TypeChecker { } } - let mut ctx_after_left = self.ctx.snapshot(); - ctx_after_left.vars.remove(left_var); + let ctx_after_left = self.ctx.snapshot(); // Check right branch self.ctx = ctx_after_scrutinee; - self.ctx - .extend(right_var.clone(), *right.clone(), BindingForm::Param); + let shadowed = + self.ctx + .bind_scoped(right_var.clone(), *right.clone(), BindingForm::Param); let right_result_ty = self.check(right_body)?; - if let Some(entry) = self.ctx.vars.get(right_var) { + if let Some(entry) = self.ctx.end_scope(right_var, shadowed) { if entry.demands_consumption() && !entry.used { return Err( self.at(s, TypeError::LinearVariableNotConsumed(right_var.clone())) @@ -1559,8 +1604,7 @@ impl TypeChecker { } } - let mut ctx_after_right = self.ctx.snapshot(); - ctx_after_right.vars.remove(right_var); + let ctx_after_right = self.ctx.snapshot(); self.unify(s, &left_result_ty, &right_result_ty)?; @@ -1636,9 +1680,13 @@ impl TypeChecker { } } + // Scope the pattern binders out of the arm. A binder that + // shadowed an outer binding gives the outer one back (as it + // was after the scrutinee: it could not be named in the arm), + // so its consumption obligation is not lost. let mut ctx_after_arm = self.ctx.snapshot(); for name in &bound { - ctx_after_arm.vars.remove(name); + ctx_after_arm.end_scope(name, ctx_after_scrutinee.vars.get(name).cloned()); } if let Some(prev_ty) = &arm_result_ty { @@ -2171,18 +2219,27 @@ impl TypeChecker { if clause.op.is_empty() { // Return clause: return(x) => body // x has the body's type - if let Some(param) = clause.params.first() { - self.ctx - .extend(param.clone(), body_ty.clone(), BindingForm::Let); - } + let scoped = clause.params.first().map(|param| { + let shadowed = + self.ctx + .bind_scoped(param.clone(), body_ty.clone(), BindingForm::Let); + (param, shadowed) + }); let clause_ty = self.check(&clause.body)?; + if let Some((param, shadowed)) = scoped { + self.ctx.end_scope(param, shadowed); + } result_ty = Some(clause_ty); } else { // Operation clause: op(params..., resume?) => body // Parameters get fresh unification variables + let mut scoped = Vec::with_capacity(clause.params.len()); for param in &clause.params { let param_ty = self.fresh_unif(); - self.ctx.extend(param.clone(), param_ty, BindingForm::Let); + let shadowed = self + .ctx + .bind_scoped(param.clone(), param_ty, BindingForm::Let); + scoped.push((param, shadowed)); } // If resume mode is specified, add a resume callback to scope @@ -2203,6 +2260,11 @@ impl TypeChecker { } let clause_ty = self.check(&clause.body)?; + // Close the parameter scopes innermost-first, so a repeated + // parameter name restores the outer binding last. + for (param, shadowed) in scoped.into_iter().rev() { + self.ctx.end_scope(param, shadowed); + } // All clauses must produce the same type if let Some(ref rt) = result_ty { @@ -2533,6 +2595,19 @@ fn type_check_module_inner(tc: &mut TypeChecker, module: &Module) -> Result<(), let saved_ctx = tc.ctx.clone(); let saved_unif = tc.next_unif; + // A repeated parameter name would shadow the earlier + // parameter for the whole body, so a linear earlier + // parameter could never be consumed or checked. + let mut seen_params: HashSet<&Var> = HashSet::new(); + for (param_name, _) in params { + if !seen_params.insert(param_name) { + return Err(SpannedTypeError { + error: TypeError::DuplicateParameter(param_name.clone()), + span: body.span, + }); + } + } + for (param_name, param_ty) in params { tc.ctx .extend(param_name.clone(), param_ty.clone(), BindingForm::Param); From f6c88d8f098d19920c27aebe4c34c70c080a8279 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:53:36 +0000 Subject: [PATCH 17/26] test(conformance): negative corpus fails in the type checker, per error kind Every conformance/invalid/*.eph was v1 syntax (one had a `#` comment), so each was rejected by a ParseError and invalid_corpus_stays_invalid passed for the wrong reason. The five files are rewritten in the current surface syntax so each parses, desugars and is rejected by the type checker with the error its name documents: branch_inconsistent BranchLinearityMismatchDetailed { var: x } linear_not_consumed LinearVariableNotConsumed(x) linear_used_twice LinearVariableReused(x) region_escape RegionEscape { region: r } region_linear_not_consumed LinearVariableNotConsumed(conn) (or RegionLinearNotConsumed) The gate now requires a surface parse and a successful desugar, matches the TypeError variant and payload per file (an unlisted file fails), and requires `ephapax compile` to exit 1 reporting that same error. Against the old corpus it fails with five ParseErrors. probe-battery: the ten conformance/invalid rows move ParseError -> TypeError (their recorded target); baseline updated, 58 SAME. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- conformance/invalid/branch_inconsistent.eph | 14 +- conformance/invalid/linear_not_consumed.eph | 11 +- conformance/invalid/linear_used_twice.eph | 13 +- conformance/invalid/region_escape.eph | 15 +- .../invalid/region_linear_not_consumed.eph | 16 +- src/ephapax-cli/tests/eph_corpus_gate.rs | 143 +++++++++++++++++- tests/probes/expectations.tsv | 20 +-- 7 files changed, 177 insertions(+), 55 deletions(-) diff --git a/conformance/invalid/branch_inconsistent.eph b/conformance/invalid/branch_inconsistent.eph index b58b0bf9..243e07df 100644 --- a/conformance/invalid/branch_inconsistent.eph +++ b/conformance/invalid/branch_inconsistent.eph @@ -2,13 +2,9 @@ // Owner: Jonathan D.A. Jewell // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell // Conformance test: branches consume different linear bindings — must be rejected. -// Expected: compile error E005 "branches consume different linear resources" +// Expected: TypeError::BranchLinearityMismatchDetailed (E005, "branches +// consume different linear resources"), not a ParseError. -fn example(cond: bool) -> i32 { - let! x = 42; - if cond { - x // consumes x in true branch - } else { - 0 // does NOT consume x in false branch — ERROR - } -} +fn example(cond: Bool): I32 = + let! x = 42 + if cond then x else 0 diff --git a/conformance/invalid/linear_not_consumed.eph b/conformance/invalid/linear_not_consumed.eph index e772c2a1..6724b975 100644 --- a/conformance/invalid/linear_not_consumed.eph +++ b/conformance/invalid/linear_not_consumed.eph @@ -1,11 +1,8 @@ // SPDX-License-Identifier: MPL-2.0 -# Owner: Jonathan D.A. Jewell // Owner: Jonathan D.A. Jewell // Conformance test: linear binding NOT consumed — must be rejected. -// Expected: compile error E001 "linear variable 'x' not consumed" +// Expected: TypeError::LinearVariableNotConsumed(x) (E001), not a ParseError. -fn example() -> i32 { - let! x = 42; - // x is never used — linear violation! - 0 -} +fn example(): I32 = + let! x = 42 + 0 diff --git a/conformance/invalid/linear_used_twice.eph b/conformance/invalid/linear_used_twice.eph index a9d7ac94..6bee9028 100644 --- a/conformance/invalid/linear_used_twice.eph +++ b/conformance/invalid/linear_used_twice.eph @@ -1,11 +1,10 @@ // SPDX-License-Identifier: MPL-2.0 // Owner: Jonathan D.A. Jewell // Conformance test: linear binding used twice — must be rejected. -// Expected: compile error E002 "linear variable 'x' already consumed" +// Expected: TypeError::LinearVariableReused(x) (E002), not a ParseError. -fn example() -> i32 { - let! x = 42; - let a = x; // first use — consumes x - let b = x; // second use — ERROR: x already consumed - a + b -} +fn example(): I32 = + let! x = 42 + let a = x + let b = x + a + b diff --git a/conformance/invalid/region_escape.eph b/conformance/invalid/region_escape.eph index b6f9bd8b..0d0e2aec 100644 --- a/conformance/invalid/region_escape.eph +++ b/conformance/invalid/region_escape.eph @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MPL-2.0 // Owner: Jonathan D.A. Jewell // Conformance test: value escaping its region — must be rejected. -// Expected: compile error E003 "value in region 'r' escapes to outer scope" +// Expected: TypeError::RegionEscape for region `r` (E003), not a ParseError. +// The string is allocated in `r`, so the region block's result type +// `String@r` references `r` and would outlive it. -fn example() -> String { - region r: - let s = String.new@r("hello") - s // ERROR: s is allocated in r, cannot be returned - // (return type String would need to outlive r) -} +fn example(): I32 = + region r { + let s = String.new@r("hello") + s + } diff --git a/conformance/invalid/region_linear_not_consumed.eph b/conformance/invalid/region_linear_not_consumed.eph index 29c026e9..0ee7b719 100644 --- a/conformance/invalid/region_linear_not_consumed.eph +++ b/conformance/invalid/region_linear_not_consumed.eph @@ -1,11 +1,13 @@ // SPDX-License-Identifier: MPL-2.0 // Owner: Jonathan D.A. Jewell // Conformance test: linear variable in region NOT consumed before exit. -// Expected: compile error E004 "linear variable 'conn' in region 'r' not consumed" +// Expected: a linear-not-consumed TypeError for `conn` (E004), not a +// ParseError. Today the checker reports it at the end of the `let!` scope +// (TypeError::LinearVariableNotConsumed), which closes before the region +// does; TypeError::RegionLinearNotConsumed is accepted too. -fn bad_region() -> i32 { - region r: - let! conn = Db.connect@r("postgres://localhost/test") - // conn is linear but never closed — region exit with unconsumed linear! - 42 -} +fn bad_region(): I32 = + region r { + let! conn = String.new@r("postgres://localhost/test") + 42 + } diff --git a/src/ephapax-cli/tests/eph_corpus_gate.rs b/src/ephapax-cli/tests/eph_corpus_gate.rs index b1ff988f..16a6fdd4 100644 --- a/src/ephapax-cli/tests/eph_corpus_gate.rs +++ b/src/ephapax-cli/tests/eph_corpus_gate.rs @@ -16,8 +16,9 @@ // (ratchet: a newly-supported file must be flipped deliberately, // in the same PR as the feature that enabled it). // -// It also enforces the negative corpus: conformance/invalid/*.eph must -// NOT compile. +// It also enforces the negative corpus: each conformance/invalid/*.eph must +// parse and desugar, and then be rejected by the type checker with the +// specific error it documents (a ParseError no longer satisfies it). // // The known-debt count is printed on every run — no silent caps. @@ -25,6 +26,10 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::process::Command; +use ephapax_desugar::desugar; +use ephapax_parser::parse_surface_module; +use ephapax_typing::{type_check_module, TypeError}; + fn repo_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") @@ -132,6 +137,49 @@ fn eph_corpus_ratchet() { ); } +/// A human description of the expected error and a predicate for it. +type Expectation = (&'static str, fn(&TypeError) -> bool); + +/// The error each negative-corpus file must be rejected with: the +/// discipline violation its name documents (conformance/README.adoc, +/// E001–E005). Every file in conformance/invalid/ needs an entry here. +fn invalid_expectation(rel: &str) -> Option { + use TypeError as E; + let entry: Expectation = match rel { + "conformance/invalid/branch_inconsistent.eph" => ( + "E005 BranchLinearityMismatchDetailed { var: x, .. }", + |e| matches!(e, E::BranchLinearityMismatchDetailed { var, .. } if var == "x"), + ), + "conformance/invalid/linear_not_consumed.eph" => ( + "E001 LinearVariableNotConsumed(x)", + |e| matches!(e, E::LinearVariableNotConsumed(v) if v == "x"), + ), + "conformance/invalid/linear_used_twice.eph" => ( + "E002 LinearVariableReused(x)", + |e| matches!(e, E::LinearVariableReused(v) if v == "x"), + ), + "conformance/invalid/region_escape.eph" => ( + "E003 RegionEscape { region: r, .. }", + |e| matches!(e, E::RegionEscape { region, .. } if region == "r"), + ), + // The `let!` scope closes before the region does, so the checker + // reports the leak there; the region-exit form is accepted too. + "conformance/invalid/region_linear_not_consumed.eph" => ( + "E004 LinearVariableNotConsumed(conn) or RegionLinearNotConsumed { var: conn, .. }", + |e| { + matches!(e, E::LinearVariableNotConsumed(v) if v == "conn") + || matches!(e, E::RegionLinearNotConsumed { var, .. } if var == "conn") + }, + ), + _ => return None, + }; + Some(entry) +} + +/// The negative corpus must be rejected by the TYPE CHECKER, with the +/// specific error each file documents. A file that fails to parse (or +/// to desugar) does not satisfy this gate: it would stay "invalid" for +/// the wrong reason and stop testing the discipline it names. #[test] fn invalid_corpus_stays_invalid() { let root = repo_root(); @@ -144,12 +192,91 @@ fn invalid_corpus_stays_invalid() { !invalid.is_empty(), "conformance/invalid corpus missing — gate misconfigured" ); - let wrongly_accepted: Vec<_> = invalid - .iter() - .filter(|rel| compiles(&root, rel, tmp.path())) - .collect(); + + let mut failures = Vec::new(); + for rel in &invalid { + let Some((want, matches_expected)) = invalid_expectation(rel) else { + failures.push(format!( + "{rel}: no expected error recorded in invalid_expectation()" + )); + continue; + }; + let src = std::fs::read_to_string(root.join(rel)).expect("read negative-corpus file"); + + // Library pipeline: it must parse (surface grammar, no legacy + // core-parser fallback) and desugar, and the type checker must + // reject it with the documented error. + let surface = match parse_surface_module(&src, rel) { + Ok(m) => m, + Err(e) => { + failures.push(format!("{rel}: ParseError, want {want}: {e:?}")); + continue; + } + }; + let core = match desugar(&surface) { + Ok(m) => m, + Err(e) => { + failures.push(format!("{rel}: DesugarError, want {want}: {e}")); + continue; + } + }; + let err = match type_check_module(&core) { + Ok(()) => { + failures.push(format!("{rel}: ACCEPTED by the type checker, want {want}")); + continue; + } + Err(e) => e.error, + }; + if !matches_expected(&err) { + failures.push(format!("{rel}: rejected with {err:?}, want {want}")); + continue; + } + + // CLI: `ephapax compile` must fail normally (exit 1) and report + // this same type error, not some other failure. + let out = Command::new(ephapax_bin()) + .current_dir(&root) + .args([ + "compile", + rel, + "-o", + tmp.path() + .join("gate.wasm") + .to_str() + .expect("utf8 tmp path"), + ]) + .output() + .expect("run ephapax compile"); + let stderr = String::from_utf8_lossy(&out.stderr); + let want_text = format!("{err:?}"); + if out.status.code() != Some(1) + || !stderr.contains("Type error") + || !stderr.contains(&want_text) + { + failures.push(format!( + "{rel}: `ephapax compile` exit {:?}, want exit 1 with \"Type error\" and \ + {want_text}; stderr:\n{stderr}", + out.status.code() + )); + } + } + + for name in [ + "branch_inconsistent", + "linear_not_consumed", + "linear_used_twice", + "region_escape", + "region_linear_not_consumed", + ] { + let rel = format!("conformance/invalid/{name}.eph"); + if !invalid.contains(&rel) { + failures.push(format!("{rel}: expected negative-corpus file is missing")); + } + } + assert!( - wrongly_accepted.is_empty(), - "negative-corpus files were ACCEPTED by the compiler: {wrongly_accepted:?}" + failures.is_empty(), + "negative corpus not rejected for the documented reason:\n{}", + failures.join("\n") ); } diff --git a/tests/probes/expectations.tsv b/tests/probes/expectations.tsv index a596537e..65439f76 100644 --- a/tests/probes/expectations.tsv +++ b/tests/probes/expectations.tsv @@ -65,13 +65,13 @@ p1 wasm-run ok:162 ok:162 survey: main returns 162 under wasmtime CLI 36.0.2 total_loop check error:ParseError error:ParseError E1: @total is ParseError::AnnotationNotEnforced until enforced total_loop compile error:ParseError error:ParseError E1: ParseError::AnnotationNotEnforced total_loop wasm-run no-module no-module E1 -conformance/invalid/branch_inconsistent check error:ParseError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: needs a v2-syntax rewrite (owner/T0d) -conformance/invalid/branch_inconsistent compile error:ParseError error:TypeError as above -conformance/invalid/linear_not_consumed check error:ParseError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: needs a v2-syntax rewrite (owner/T0d) -conformance/invalid/linear_not_consumed compile error:ParseError error:TypeError as above -conformance/invalid/linear_used_twice check error:ParseError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: needs a v2-syntax rewrite (owner/T0d) -conformance/invalid/linear_used_twice compile error:ParseError error:TypeError as above -conformance/invalid/region_escape check error:ParseError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: needs a v2-syntax rewrite (owner/T0d) -conformance/invalid/region_escape compile error:ParseError error:TypeError as above -conformance/invalid/region_linear_not_consumed check error:ParseError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: needs a v2-syntax rewrite (owner/T0d) -conformance/invalid/region_linear_not_consumed compile error:ParseError error:TypeError as above +conformance/invalid/branch_inconsistent check error:TypeError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: rewritten in v2 syntax (W0 fix-up); eph_corpus_gate.rs asserts the exact TypeError +conformance/invalid/branch_inconsistent compile error:TypeError error:TypeError as above +conformance/invalid/linear_not_consumed check error:TypeError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: rewritten in v2 syntax (W0 fix-up); eph_corpus_gate.rs asserts the exact TypeError +conformance/invalid/linear_not_consumed compile error:TypeError error:TypeError as above +conformance/invalid/linear_used_twice check error:TypeError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: rewritten in v2 syntax (W0 fix-up); eph_corpus_gate.rs asserts the exact TypeError +conformance/invalid/linear_used_twice compile error:TypeError error:TypeError as above +conformance/invalid/region_escape check error:TypeError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: rewritten in v2 syntax (W0 fix-up); eph_corpus_gate.rs asserts the exact TypeError +conformance/invalid/region_escape compile error:TypeError error:TypeError as above +conformance/invalid/region_linear_not_consumed check error:TypeError error:TypeError rejected for the discipline violation it documents (E001-E005), not a ParseError: rewritten in v2 syntax (W0 fix-up); eph_corpus_gate.rs asserts the exact TypeError +conformance/invalid/region_linear_not_consumed compile error:TypeError error:TypeError as above From be2cdfbd262416ee35aacc38b8cb1bb960f42781 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 15:26:21 +0000 Subject: [PATCH 18/26] fix(no-false-gate): census Definitions and Fixpoints; skip sealed modules; reject Admit The Print Assumptions census now covers Definition, Fixpoint and CoFixpoint (so a result closed with Defined is checked), skips the body of a module sealed by ": S" (its fields may be hidden; "<: S" stays censused), and treats a "with Definition x := ..." constraint as part of a module header rather than an alias. The assumption scan rejects "Admit Obligations" across whitespace. New self-test fixtures cover definitions, sealed modules, Admit Obligations and an unclosed scope without a mismatched End. Census: 446/446 closed (was 299 theorem-class results). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- docs/proof-debt.adoc | 4 +- .../EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc | 2 +- formal/scripts/coq-decls.awk | 40 +++++++++++++++---- formal/scripts/no-false-gate.sh | 11 +++-- formal/scripts/no-false-lib.sh | 5 ++- formal/scripts/test-no-false-gate.sh | 14 +++++++ 6 files changed, 62 insertions(+), 14 deletions(-) diff --git a/docs/proof-debt.adoc b/docs/proof-debt.adoc index 9dba3b86..5355ae10 100644 --- a/docs/proof-debt.adoc +++ b/docs/proof-debt.adoc @@ -19,7 +19,9 @@ kernel-check bypass. `+formal/scripts/no-false-gate.sh+` enforces this in CI (`+coq-build.yml+`): a comment-aware token scan, a `+Print Assumptions+` census of every named result (each must be closed under the global context), and an independent `+coqchk+` recheck of the compiled libraries. Its census on -2026-11-09 found 299 of 299 named results closed, including the P2 +2026-11-09 found 446 of 446 named results closed (every theorem-class +result and every `+Definition+`, `+Fixpoint+` and `+CoFixpoint+`; the earlier +tally of 299 counted theorem-class results only), including the P2 regression families in `+formal/regress/+`. `+coqchk+` lists `+Coq.Logic.Eqdep.Eq_rect_eq.eq_rect_eq+` among the axioms of the *loaded standard library*; the census shows no Ephapax result depends on it. diff --git a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc index 71772e06..01ab6241 100644 --- a/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc +++ b/docs/reports/2026-11-04-v2-survey/EPHAPAX-V2-ARCHITECTURE-AND-PLAN.adoc @@ -20,7 +20,7 @@ Idris2, Lean, Isabelle, zig, julia, z3 and the wasmtime CLI are not installed. E [NOTE] .Re-check, 2026-11-09 (toolchains installed in a later session) ==== -* *P2 landed (T0c).* EscapeCex, L2Comp and LetCheck were re-created against the current judgment and land with F2–F6 in `formal/regress/` (in `_CoqProject`): 299 of 299 named results in `formal/` are closed under the global context. The re-created statements differ from the reported ones in two places. EscapeCex is a *progress* failure: `S_Region_Exit`'s guard catches the captured `ELoc`, so the typed configuration is stuck rather than stepping to an untypable one (`escaped_value_untypable_at_type` shows the unguarded exit would be untypable at the original type). LetCheck is broader than reported: every `ELet` whose bound type is non-linear is untypable, in both `has_type_l1` and the legacy `has_type`. +* *P2 landed (T0c).* EscapeCex, L2Comp and LetCheck were re-created against the current judgment and land with F2–F6 in `formal/regress/` (in `_CoqProject`): 446 of 446 named results in `formal/` (theorem-class results plus every `Definition`, `Fixpoint` and `CoFixpoint`; 299 of them theorem-class) are closed under the global context. The re-created statements differ from the reported ones in two places. EscapeCex is a *progress* failure: `S_Region_Exit`'s guard catches the captured `ELoc`, so the typed configuration is stuck rather than stepping to an untypable one (`escaped_value_untypable_at_type` shows the unguarded exit would be untypable at the original type). LetCheck is broader than reported: every `ELet` whose bound type is non-linear is untypable, in both `has_type_l1` and the legacy `has_type`. * *Idris2 0.8.0.* `ephapax-abi.ipkg` and `ephapax-formal.ipkg` build (exit 0). The ABI's 12 clause-less `0 name : …` declarations are postulates (Idris2 accepts them under `%default total`); they are jointly consistent and assert nothing about Ephapax (`docs/proof-debt.adoc`). `idris2/ephapax-affine.ipkg` needs the `proven` package, which is not installed: still UNVERIFIED. * *Zig.* `zig test tools/coproc/ephapax_coproc.zig`: 2/2 pass (Zig 0.14.0). typed-wasm `ffi/zig`: 7/7 pass with Zig 0.15.1, the version its CI uses (it does not build with 0.14). * *wasmtime CLI 36.0.2.* `p1.wasm` (959 B) validates; with stub `env.print_i32`/`env.print_string` imports, `main` returns 162. diff --git a/formal/scripts/coq-decls.awk b/formal/scripts/coq-decls.awk index 7fef52fb..8dee2b9b 100644 --- a/formal/scripts/coq-decls.awk +++ b/formal/scripts/coq-decls.awk @@ -9,10 +9,25 @@ # into Coq sentences at a "." followed by whitespace or end of input, so a # keyword and its name may sit on different lines. # +# A named result is a Theorem, Lemma, Corollary, Proposition, Fact, +# Remark, Example, named Instance, Definition, Fixpoint or CoFixpoint +# (with any Program/Local/Global prefix), so a proof closed with +# "Defined." or a definition built by tactics is censused like a lemma. +# For a mutual "Fixpoint f ... with g ...", only f is listed: f and g are +# bodies of one mutual block, so Print Assumptions on f reaches g's +# dependencies. "Let" is not listed: inside a Section it is discharged +# and has no name afterwards. +# # Module and Section scopes are tracked. A Module adds its name to the -# qualification; a Section does not. Results inside a Module Type or a -# functor (a Module with parameters) are skipped, because they cannot be -# named from outside. "Module X := ..." opens no scope. +# qualification; a Section does not. "Module X := ..." opens no scope. +# Results are skipped, because the census cannot name them from outside, +# inside a Module Type, inside a functor (a Module with parameters), and +# inside a module sealed by a signature ("Module M : S."), whose body +# fields need not be visible. A transparently ascribed module +# ("Module M <: S.") keeps every field and is censused. Skipped bodies are +# still covered by gate checks 1, 2 and 4 (no assumption vernacular or +# admit, no kernel bypass, no Ephapax axiom under coqchk); check 3 does +# not see a dependency of a skipped result on a standard-library axiom. # # Usage: awk -v prefix=Ephapax.File -f coq-decls.awk stripped.v BEGIN { buf = ""; depth = 0 } @@ -30,7 +45,7 @@ END { function trim(x) { sub(/^ +/, "", x); sub(/ +$/, "", x); return x } -function sentence(s, w, k, i, name, kind, skip, path) { +function sentence(s, w, k, i, name, kind, skip, path, rest) { s = trim(s) # Drop attributes and locality/flag prefixes. while (match(s, /^#\[[^]]*\] */)) s = substr(s, RLENGTH + 1) @@ -45,19 +60,30 @@ function sentence(s, w, k, i, name, kind, skip, path) { if (w[i] == "Import" || w[i] == "Export") i++ name = w[i]; sub(/[(:].*$/, "", name) if (name == "") return - if (index(s, ":=") > 0) return # alias / definition: no scope + # ":=" outside a "with Definition/Module x := ..." constraint makes + # this an alias or a module defined by an expression: no scope. + rest = s + while (match(rest, /with +(Definition|Module) +[A-Za-z0-9_'.]+ *:=/)) + rest = substr(rest, 1, RSTART - 1) substr(rest, RSTART + RLENGTH) + if (index(rest, ":=") > 0) return kind = (w[i] ~ /\(/ || w[i + 1] ~ /^\(/) ? "functor" : "module" + if (kind == "module") { + # Text after the name; a lone ":" (not "<:") seals the module. + rest = s; sub(/^Module +((Import|Export) +)?/, "", rest) + rest = substr(rest, length(name) + 1) + if (rest ~ /^ *:([^=]|$)/) kind = "sealed" + } push(name, kind); return } if (w[1] == "Section") { push(w[2], "section"); return } if (w[1] == "End") { pop(w[2]); return } - if (w[1] ~ /^(Theorem|Lemma|Corollary|Proposition|Fact|Remark|Example|Instance)$/) { + if (w[1] ~ /^(Theorem|Lemma|Corollary|Proposition|Fact|Remark|Example|Instance|Definition|Fixpoint|CoFixpoint)$/) { name = w[2]; sub(/[^A-Za-z0-9_'].*$/, "", name) if (name !~ /^[A-Za-z_][A-Za-z0-9_']*$/) return # anonymous Instance skip = 0; path = prefix for (i = 1; i <= depth; i++) { - if (kinds[i] == "mtype" || kinds[i] == "functor") skip = 1 + if (kinds[i] == "mtype" || kinds[i] == "functor" || kinds[i] == "sealed") skip = 1 if (kinds[i] == "module") path = path "." names[i] } if (!skip) print path "." name diff --git a/formal/scripts/no-false-gate.sh b/formal/scripts/no-false-gate.sh index 4d386c49..407f88a8 100755 --- a/formal/scripts/no-false-gate.sh +++ b/formal/scripts/no-false-gate.sh @@ -17,10 +17,13 @@ # (guard, positivity, universe checking, Bypass Check, type-in-type), # so a flag split across lines is caught; # 3. census: `Print Assumptions` on every Theorem/Lemma/Corollary/ -# Proposition/Fact/Remark/Example/Instance, found by sentence (so a -# keyword and its name may be on different lines) and qualified -# through nested Modules (coq-decls.awk); each must print -# "Closed under the global context", and the count must match; +# Proposition/Fact/Remark/Example/Instance/Definition/Fixpoint/ +# CoFixpoint, found by sentence (so a keyword and its name may be on +# different lines) and qualified through nested Modules +# (coq-decls.awk); each must print "Closed under the global context", +# and the count must match. Results in a Module Type, a functor or a +# ":"-sealed module cannot be named and are not censused; checks 1, +# 2 and 4 still cover them; # 4. independent recheck of the compiled .vo files with coqchk: no # axiom in the Ephapax namespace, nothing relying on type-in-type, # unsafe (co)fixpoints or assumed positivity. diff --git a/formal/scripts/no-false-lib.sh b/formal/scripts/no-false-lib.sh index f5132b42..c9448258 100644 --- a/formal/scripts/no-false-lib.sh +++ b/formal/scripts/no-false-lib.sh @@ -31,7 +31,10 @@ nofalse_strip() { # Assumption vernacular and admit tactics: single tokens, so a line scan # with word boundaries is enough. Prints file:line:text for each hit. -NOFALSE_ASSUME='(^|[^A-Za-z0-9_'"'"'.])(Admitted|admit|give_up|Axiom|Axioms|Parameter|Parameters|Conjecture|Hypothesis|Hypotheses|Variable|Variables|Context)([^A-Za-z0-9_'"'"']|$)' +# "Admit" catches "Admit Obligations" however the two words are split +# across whitespace; with no obligation pending it would leave no axiom +# for checks 3 and 4 to find. +NOFALSE_ASSUME='(^|[^A-Za-z0-9_'"'"'.])(Admitted|Admit|admit|give_up|Axiom|Axioms|Parameter|Parameters|Conjecture|Hypothesis|Hypotheses|Variable|Variables|Context)([^A-Za-z0-9_'"'"']|$)' nofalse_assume_scan() { grep -nHE "$NOFALSE_ASSUME" "$@" || true; } # Kernel bypass flags. These are multi-word, so each stripped file is diff --git a/formal/scripts/test-no-false-gate.sh b/formal/scripts/test-no-false-gate.sh index c4e1eaae..a929306c 100755 --- a/formal/scripts/test-no-false-gate.sh +++ b/formal/scripts/test-no-false-gate.sh @@ -48,7 +48,10 @@ expect_no_assume() { } expect_assume a_axiom $'Axiom\n bad : False.' expect_assume a_admitted $'Lemma x : False.\nProof. Admitted.' +expect_assume a_admit_obl $'Program Definition p : nat := _.\nAdmit Obligations.' +expect_assume a_admit_obl_split $'Program Definition p : nat := _.\nAdmit\n Obligations of p.' expect_no_assume a_qualified 'Lemma x : True. Proof. exact Foo.Axiom_free. Qed.' +expect_no_assume a_admit_ident 'Definition Admit_free := 0. Definition Admittance := 1.' # --- declaration census ------------------------------------------------ expect_decls() { # name fragment expected(newline-separated) @@ -64,9 +67,20 @@ expect_decls d_skip $'Module Type MT.\n Lemma hidden : True. Proof. exact I. Qed $'P.named\nP.loc' expect_decls d_qualid $'Lemma q : True.\nProof. apply Coq.Init.Logic.I. Qed.\nLemma r : True. Proof. exact I. Qed.' \ $'P.q\nP.r' +# Definitions, fixpoints and tactic-built definitions closed with Defined. +expect_decls d_defs $'Definition d1 : nat := 0.\nDefinition\n d2 : True.\nProof. exact I. Defined.\nProgram Definition d3 : nat := 0.\nFixpoint f (n : nat) : nat := match n with 0 => 0 | S m => g m end\nwith g (n : nat) : nat := match n with 0 => 1 | S m => f m end.\nCoFixpoint c : nat := 0.\nSection S.\n Let hidden := 0.\n Definition d4 := hidden.\nEnd S.' \ + $'P.d1\nP.d2\nP.d3\nP.f\nP.c\nP.d4' +# A module sealed by ":" hides its body; "<:" keeps every field visible. +expect_decls d_sealed $'Module Type S.\n Parameter t : Type.\nEnd S.\nModule M : S.\n Definition t := nat.\n Lemma hidden : True. Proof. exact I. Qed.\nEnd M.\nModule N <: S.\n Definition t := nat.\n Lemma shown : True. Proof. exact I. Qed.\nEnd N.\nModule W : S with Definition t := nat.\n Definition t := nat.\n Lemma hidden2 : True. Proof. exact I. Qed.\nEnd W.\nModule V:S.\n Definition t := nat.\nEnd V.\nModule A := N.' \ + $'P.N.t\nP.N.shown' # An unmatched End must fail loudly, not mis-qualify. f=$(frag d_bad $'Module A.\nLemma x : True. Proof. exact I. Qed.\nEnd B.') if nofalse_decls P "$f" >/dev/null 2>&1; then bad "unmatched End accepted"; else ok; fi +# A scope left open (no End at all) must fail too, independently of d_bad. +f=$(frag d_unclosed $'Module A.\nLemma x : True. Proof. exact I. Qed.') +if nofalse_decls P "$f" >/dev/null 2>&1; then bad "unclosed scope accepted"; else ok; fi +f=$(frag d_unclosed_sec $'Section S.\nLemma x : True. Proof. exact I. Qed.') +if nofalse_decls P "$f" >/dev/null 2>&1; then bad "unclosed Section accepted"; else ok; fi echo "no-false-gate self-test: $pass passed, $failn failed" [ "$failn" -eq 0 ] From 077db1d32880f5adbf0a659a44528f96892829f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 15:26:22 +0000 Subject: [PATCH 19/26] fix(probe-battery): report a compile timeout or crash in wasm-run rows wasm-run kept only whether the compile succeeded, so a compile that timed out or died by a signal was reported as no-module and could pass as SAME. It now keeps the compiler's status and output: an ordinary error is no-module, a timeout or signal is timeout / crash:. PROBE_BATTERY_DIR and PROBE_TIMEOUT make the runner testable; scripts/test-probe-battery.sh checks the three cases with a stub compiler (just probe-battery-selftest). Tests use [[ ]]. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- Justfile | 4 +++ scripts/probe-battery.sh | 43 ++++++++++++++++++++------- scripts/test-probe-battery.sh | 56 +++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 scripts/test-probe-battery.sh diff --git a/Justfile b/Justfile index 27ec3410..8ebaf43c 100644 --- a/Justfile +++ b/Justfile @@ -73,6 +73,10 @@ conformance: probe-battery *ARGS: bash scripts/probe-battery.sh {{ARGS}} +# Self-test of the probe runner's verdicts (stub compiler; no cargo, no wasmtime) +probe-battery-selftest: + bash scripts/test-probe-battery.sh + # E6 aLib conformer: pinned aLib vectors against stdlib/ALib.eph under wasmtime alib-conformance: cargo test -p ephapax-cli --test alib_conformance -- --nocapture diff --git a/scripts/probe-battery.sh b/scripts/probe-battery.sh index 1c58c12d..392496df 100755 --- a/scripts/probe-battery.sh +++ b/scripts/probe-battery.sh @@ -25,7 +25,9 @@ # (compile always runs wasmparser validation before writing) # run ephapax run .eph (today: the interpreter) # wasm-run compile, then `wasmtime run --preload env=env.wat --invoke main` -# with the wasmtime CLI; verdict `no-module` if compile fails, +# with the wasmtime CLI; verdict `no-module` if compile fails +# with an ordinary error, `timeout` or `crash:` if the +# compile times out or dies by a signal, and # `unavailable:no-wasmtime` (status SKIP) if the CLI is absent. # # Verdicts: ok | ok: | error: | crash:[-stack-overflow] @@ -52,10 +54,14 @@ # target/debug/ephapax) # --update rewrite the baseline column of expectations.tsv with the # observed verdicts (targets are kept); SKIP rows unchanged +# Environment: PROBE_BATTERY_DIR replaces tests/probes (the self-test +# scripts/test-probe-battery.sh uses it); PROBE_TIMEOUT is the per-command +# limit in seconds (default 60). set -uo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PROBES="$ROOT_DIR/tests/probes" +PROBES="${PROBE_BATTERY_DIR:-$ROOT_DIR/tests/probes}" +PROBE_TIMEOUT="${PROBE_TIMEOUT:-60}" EXPECT="$PROBES/expectations.tsv" ENV_WAT="$PROBES/env.wat" @@ -67,7 +73,7 @@ while [ $# -gt 0 ]; do --bin) bin="$2"; shift 2 ;; --out) out="$2"; shift 2 ;; --update) update=1; shift ;; - -h|--help) sed -n '2,52p' "$0"; exit 0 ;; + -h|--help) sed -n '2,59p' "$0"; exit 0 ;; *) echo "probe-battery: unknown argument $1" >&2; exit 3 ;; esac done @@ -158,34 +164,49 @@ source_of() { run_row() { # PROBE COMMAND -> sets CODE TEXT VERDICT local probe="$1" cmd="$2" src dir base wasm src="$(source_of "$probe")" - [ -f "$src" ] || die "missing probe source for $probe ($src)" + [[ -f "$src" ]] || die "missing probe source for $probe ($src)" dir="$(dirname "$src")" base="$(basename "$src")" wasm="$stage/out/${probe//\//_}.wasm" mkdir -p "$stage/out" case "$cmd" in check|run) - TEXT="$(cd "$dir" && timeout 60 "$bin" "$cmd" "$base" 2>&1)" + TEXT="$(cd "$dir" && timeout "$PROBE_TIMEOUT" "$bin" "$cmd" "$base" 2>&1)" CODE=$? ;; compile) - TEXT="$(cd "$dir" && timeout 60 "$bin" compile "$base" -o "$wasm" 2>&1)" + TEXT="$(cd "$dir" && timeout "$PROBE_TIMEOUT" "$bin" compile "$base" -o "$wasm" 2>&1)" CODE=$? ;; wasm-run) - if ! { (cd "$dir" && timeout 60 "$bin" compile "$base" -o "$wasm" >/dev/null 2>&1); } 2>/dev/null; then - CODE=1; TEXT="compile failed"; VERDICT="no-module"; return + # Keep the compiler's exit status and output. An ordinary compile + # error means there is no module to run (`no-module`); a compile + # that times out or dies by a signal is reported as such (`timeout`, + # `crash:`), so it cannot pass as SAME against a no-module row. + TEXT="$(cd "$dir" && timeout "$PROBE_TIMEOUT" "$bin" compile "$base" -o "$wasm" 2>&1)" + CODE=$? + if [[ "$CODE" -ne 0 ]]; then + if [[ "$CODE" -eq 126 || "$CODE" -eq 127 ]]; then + die "could not execute compile for $probe (exit $CODE): $TEXT" + fi + TEXT="$(strip_ansi <<<"$TEXT")" + if [[ "$CODE" -eq 124 || "$CODE" -ge 128 ]]; then + VERDICT="$(classify compile "$CODE" "$TEXT")" + else + VERDICT="no-module" + fi + return fi - if [ -z "$wasmtime_bin" ]; then + if [[ -z "$wasmtime_bin" ]]; then CODE=0; TEXT="wasmtime CLI not found"; VERDICT="unavailable:no-wasmtime"; return fi - TEXT="$(timeout 60 "$wasmtime_bin" run --preload "env=$ENV_WAT" --invoke main "$wasm" 2>&1)" + TEXT="$(timeout "$PROBE_TIMEOUT" "$wasmtime_bin" run --preload "env=$ENV_WAT" --invoke main "$wasm" 2>&1)" CODE=$? ;; *) die "unknown command '$cmd' in $EXPECT" ;; esac # 126/127: the command itself could not be executed; never a verdict. - if [ "$CODE" -eq 126 ] || [ "$CODE" -eq 127 ]; then + if [[ "$CODE" -eq 126 || "$CODE" -eq 127 ]]; then die "could not execute $cmd for $probe (exit $CODE): $TEXT" fi TEXT="$(strip_ansi <<<"$TEXT")" diff --git a/scripts/test-probe-battery.sh b/scripts/test-probe-battery.sh new file mode 100644 index 00000000..a6a1c8cb --- /dev/null +++ b/scripts/test-probe-battery.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Owner: Jonathan D.A. Jewell +# +# Self-test for scripts/probe-battery.sh: the wasm-run command must keep +# the compiler's exit status. An ordinary compile error is `no-module`; a +# compile that dies by a signal or times out must not be reported as +# `no-module` (it would then pass as SAME against a no-module baseline). +# +# Uses a stub compiler and a fixture probe directory, so it needs neither +# cargo nor wasmtime. Usage: bash scripts/test-probe-battery.sh +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT + +mkdir -p "$T/probes" +for p in p_err p_crash p_slow; do echo "fn main() -> I32 { 0 }" >"$T/probes/$p.eph.txt"; done +echo '(module)' >"$T/probes/env.wat" +# Every baseline and target is no-module: only the ordinary error may match. +printf '%s\n' '# fixture' \ + "$(printf 'p_err\twasm-run\tno-module\tno-module\tT')" \ + "$(printf 'p_crash\twasm-run\tno-module\tno-module\tT')" \ + "$(printf 'p_slow\twasm-run\tno-module\tno-module\tT')" >"$T/probes/expectations.tsv" + +cat >"$T/stub" <<'STUB' +#!/usr/bin/env bash +case "$2" in + p_err.eph) echo "Type error: fixture" >&2; exit 1 ;; + p_crash.eph) echo "fixture abort" >&2; kill -ABRT $$ ;; + p_slow.eph) sleep 10 ;; +esac +exit 1 +STUB +chmod +x "$T/stub" + +set +e +PROBE_BATTERY_DIR="$T/probes" PROBE_TIMEOUT=2 \ + bash "$ROOT_DIR/scripts/probe-battery.sh" --bin "$T/stub" --out "$T/out.tsv" 2>"$T/err" +code=$? +set -e + +pass=0; failn=0 +expect_row() { # probe verdict status + local got + got="$(awk -F'\t' -v p="$1" '$1 == p { print $4 "/" $5 }' "$T/out.tsv")" + if [[ "$got" == "$2/$3" ]]; then pass=$((pass + 1)); else failn=$((failn + 1)); echo "FAIL: $1: expected $2/$3, got $got"; fi +} +expect_row p_err no-module SAME +expect_row p_crash crash:134 CHANGED +expect_row p_slow timeout CHANGED +if [[ "$code" -eq 1 ]]; then pass=$((pass + 1)); else failn=$((failn + 1)); echo "FAIL: exit $code, expected 1"; cat "$T/err"; fi + +echo "probe-battery self-test: $pass passed, $failn failed" +[[ "$failn" -eq 0 ]] From b5ece281f6f868f4f73702ef96b94a1acc029c57 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 15:26:22 +0000 Subject: [PATCH 20/26] test(alib): freeze the historical binding set; pin the signed remainder The 48b7694 reproduction now binds only the operations bound when the survey measured 89/1/13/51, so a later binding cannot change that tally. A new test pins modulo as wasm i32.rem_s (the dividend's sign) through the compiled export, which the nonnegative pinned vectors do not. Tests: 553 -> 554. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- TEST-NEEDS.adoc | 4 +- src/ephapax-cli/tests/alib_conformance.rs | 86 ++++++++++++++++++++--- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index e50149fe..b01faafb 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -21,9 +21,9 @@ compliance, 16 tests) performance, correctness, 13 tests) * Benchmarks: `+src/ephapax-parser/benches/parse_bench.rs+`, `+src/ephapax-vram-cache/benches/cache_bench.rs+` -* Total: *553 tests* (`+cargo test --workspace --all-targets+`); +* Total: *554 tests* (`+cargo test --workspace --all-targets+`); pass/fail enforced by `+rust-ci.yml+` -* Documented all-target tests: 553 +* Documented all-target tests: 554 === CRG Testing Taxonomy — Status diff --git a/src/ephapax-cli/tests/alib_conformance.rs b/src/ephapax-cli/tests/alib_conformance.rs index 6bf21787..dcf6a4f4 100644 --- a/src/ephapax-cli/tests/alib_conformance.rs +++ b/src/ephapax-cli/tests/alib_conformance.rs @@ -130,6 +130,43 @@ fn binding(key: &str) -> Option { } } +/// A binding table: the current one, or the frozen historical one. +type Bind = fn(&str) -> Option; + +/// The operations bound when the survey measured 89/1/13/51 (48b7694). +/// Frozen: a binding added to `binding` later must not reach the +/// historical reproduction, or it could silently change that tally. +const HISTORICAL_BOUND: &[&str] = &[ + "arithmetic/absolute", + "arithmetic/add", + "arithmetic/divide", + "arithmetic/max", + "arithmetic/min", + "arithmetic/modulo", + "arithmetic/multiply", + "arithmetic/negate", + "arithmetic/subtract", + "comparison/equal", + "comparison/not_equal", + "comparison/less_than", + "comparison/less_equal", + "comparison/greater_than", + "comparison/greater_equal", + "logical/and", + "logical/or", + "logical/not", + "conditional/if_then_else", +]; + +/// The historical binding table: `binding`, restricted to `HISTORICAL_BOUND`. +fn historical_binding(key: &str) -> Option { + if HISTORICAL_BOUND.contains(&key) { + binding(key) + } else { + None + } +} + /// Why a whole category has no binding yet. fn unimplemented_reason(category: &str) -> &'static str { match category { @@ -349,6 +386,7 @@ fn eval( want: Kind, runner: &mut Runner, ops: &BTreeMap, + bind: Bind, ) -> Result { match e { Expr::Bool(b) => match want { @@ -371,7 +409,7 @@ fn eval( let cat = category_of(op, ops) .ok_or_else(|| Stop::Fail(format!("unknown operation `{op}`")))?; let key = format!("{cat}/{op}"); - let b = binding(&key).ok_or_else(|| { + let b = bind(&key).ok_or_else(|| { Stop::Unimplemented(format!( "condition calls `{key}`: {}", unimplemented_reason(&cat) @@ -391,7 +429,7 @@ fn eval( } let mut vals = Vec::new(); for (a, k) in args.iter().zip(b.params) { - vals.push(eval(a, *k, runner, ops)?); + vals.push(eval(a, *k, runner, ops, bind)?); } runner.call(&b, &vals).map_err(Stop::Fail) } @@ -404,13 +442,14 @@ fn lower_arg( k: Kind, runner: &mut Runner, ops: &BTreeMap, + bind: Bind, ) -> Result { match (k, v) { (Kind::Bool, Value::Bool(b)) => Ok(V::Bool(*b)), // A Boolean-typed string is a condition expression (GRAMMAR.adoc). (Kind::Bool, Value::String(s)) => { let e = parse_condition(s).map_err(|m| Stop::Fail(format!("bad condition: {m}")))?; - eval(&e, Kind::Bool, runner, ops) + eval(&e, Kind::Bool, runner, ops, bind) } (Kind::I32, Value::Number(n)) => n .as_i64() @@ -454,8 +493,9 @@ fn run_vector( vector: &Value, runner: &mut Runner, ops: &BTreeMap, + bind: Bind, ) -> Verdict { - let Some(b) = binding(key) else { + let Some(b) = bind(key) else { return Verdict::SkipUnimplemented(unimplemented_reason(category).into()); }; let result = (|| -> Result { @@ -483,7 +523,7 @@ fn run_vector( let want = lower_expected(&vector["output"], b.result)?; let mut args = Vec::new(); for (v, k) in raw.iter().zip(b.params) { - args.push(lower_arg(v, *k, runner, ops)?); + args.push(lower_arg(v, *k, runner, ops, bind)?); } let got = runner.call(&b, &args).map_err(Stop::Fail)?; if got == want { @@ -598,7 +638,7 @@ impl Tally { } /// Evaluate every vector of `files` against the compiled module. -fn conform(files: &[Value], runner: &mut Runner) -> Tally { +fn conform(files: &[Value], runner: &mut Runner, bind: Bind) -> Tally { // Operation name -> category, for condition calls. let mut ops = BTreeMap::new(); for f in files { @@ -622,7 +662,7 @@ fn conform(files: &[Value], runner: &mut Runner) -> Tally { { let id = format!("{key}#{}", i + 1); let desc = v["description"].as_str().unwrap_or(""); - let (tag, detail) = match run_vector(&key, cat, op, v, runner, &ops) { + let (tag, detail) = match run_vector(&key, cat, op, v, runner, &ops, bind) { Verdict::Pass => { t.pass += 1; ("PASS", String::new()) @@ -660,7 +700,7 @@ fn alib_conformance_ratchet() { let root = repo_root(); let files = load_pinned(&root); let mut runner = alib_runner(&root); - let t = conform(&files, &mut runner); + let t = conform(&files, &mut runner, binding); println!("{}", t.table); println!( @@ -711,6 +751,9 @@ fn alib_conformance_ratchet() { /// literal `[true]` (output stays `true`), and /// - string/substring: drop the two boundary vectors a877c2e appended /// (vectors 7 and 8). +/// +/// It binds only `HISTORICAL_BOUND`, so bindings added later leave this +/// tally unchanged; the ratchet test above tracks them. #[test] fn alib_reproduces_survey_baseline_48b7694() { let root = repo_root(); @@ -733,8 +776,14 @@ fn alib_reproduces_survey_baseline_48b7694() { } } } + for key in HISTORICAL_BOUND { + assert!( + binding(key).is_some(), + "historical binding {key} is gone from the binding table" + ); + } let mut runner = alib_runner(&root); - let t = conform(&files, &mut runner); + let t = conform(&files, &mut runner, historical_binding); println!("aLib conformance (reconstructed 48b7694): {}", t.summary()); assert_eq!( (t.pass, t.fail, t.out_of_domain, t.unimplemented), @@ -748,6 +797,25 @@ fn alib_reproduces_survey_baseline_48b7694() { ); } +/// The pinned aLib modulo vectors use only nonnegative operands, and aLib +/// leaves the sign of a remainder with negative operands to the +/// implementation. This binding's choice is the truncated remainder (wasm +/// `i32.rem_s`: the result takes the dividend's sign). An unsigned lowering +/// would still pass the pinned vectors, so pin the choice here, through the +/// compiled `modulo` export, without touching the pinned files. +#[test] +fn alib_modulo_is_the_signed_truncated_remainder() { + let root = repo_root(); + let mut runner = alib_runner(&root); + let b = binding("arithmetic/modulo").expect("modulo is bound"); + for (a, d, want) in [(-7, 3, -1), (7, -3, 1), (-7, -3, -1), (7, 3, 1)] { + let got = runner + .call(&b, &[V::I32(a), V::I32(d)]) + .unwrap_or_else(|e| panic!("modulo({a}, {d}) failed: {e}")); + assert_eq!(got, V::I32(want), "modulo({a}, {d})"); + } +} + #[test] fn condition_parser_accepts_the_grammar_examples() { for src in [ From 743ef886356cb10b52bda14b98908c30067cae0a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 15:26:22 +0000 Subject: [PATCH 21/26] docs: zero-admit baseline in the test plan, wiki and FRG profile The test strategy, the wiki home page and the FRG profile still pinned four or five admits and told maintainers to close the unrestricted preservation_l1. They now state the D0/D0b position: 0 Admitted, 0 Axiom, the refuted statements kept as Prop with their pinned refutations, and replacement theorems after an L1/L2 redesign. The FRG grade is not reassessed. L2Comp's comment no longer says an effect-typed application fits in no compound form: T_App_L2_Eff can nest them; the lemma covers pairing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- docs/testing-and-bench-strategy.adoc | 32 +++++----- docs/wikis/Home.md | 2 +- formal/regress/L2Comp.v | 7 +- spec/FRG-PROFILE.adoc | 95 ++++++++++++++++------------ 4 files changed, 78 insertions(+), 58 deletions(-) diff --git a/docs/testing-and-bench-strategy.adoc b/docs/testing-and-bench-strategy.adoc index 2e89867c..af5973f4 100644 --- a/docs/testing-and-bench-strategy.adoc +++ b/docs/testing-and-bench-strategy.adoc @@ -61,7 +61,7 @@ a| • P59 codec retraction • cap-tag agreement (all 10) • Idris2 `compileResultRoundTrip` a| • (!) `must-spdx-headers` `head -20` bug -• status-gate admit-count = 4 (not 0) +• status-gate admit-count = 0 + no-False gate • (!) licence single-source contradiction | *P2P* @@ -166,15 +166,15 @@ A "right result" must be right at L1 (regions), L2 (modality), L3 (echo residue) === 3.3 Dependability — tests + proofs, honestly -The joint claim: the compiler never crashes on input it accepts or rejects; the resource discipline is real (linear = exactly-once, region = no-escape); emitted bytes are valid wasm the sibling agrees on; it degrades gracefully. The honesty constraint dominates: `Counterexample.v` Qed-proves the legacy `preservation` *false* (5 Qed lemmas); `Semantics_L1.v` has 7 `Admitted` / 4 outer admits (48 Qed) with 4 proof-debt classes (L1.B list-vs-multiset *tractable*, L1.D-StringConcat *tractable*, L1.D-App and L1.D-Pair *genuine soundness gaps*, L1.C region-liveness); L3 (`Echo.v`, ~20 Qed) is forward-looking and *not wired into* `has_type_l1`. +The joint claim: the compiler never crashes on input it accepts or rejects; the resource discipline is real (linear = exactly-once, region = no-escape); emitted bytes are valid wasm the sibling agrees on; it degrades gracefully. The honesty constraint dominates: `Counterexample.v` Qed-proves the legacy preservation *false* (6 Qed lemmas, incl. `preservation_false`); `Semantics_L1.v` has no `Admitted`: its unrestricted `step_pop_disjoint_from_type_l1` and `preservation_l1` are retired to `…_stmt : Prop` and refuted by `Counterexample_StepPop.v` and `Counterexample_PreservationL1.v`, so the open L1 work is a judgment/step redesign (see `PROOF-NEEDS.adoc`), not admits; L3 (`Echo.v`, ~20 Qed) is forward-looking and *not wired into* `has_type_l1`. -*unit.* Parser/typechecker crash-freedom (`parse_fuzzer`/`typecheck_fuzzer`, un-run in CI — promote to a gated `cargo-fuzz` job with a committed corpus seeded from the 14 conformance files + regression dir). Ownership codec lenient-decode safety (proptest over arbitrary `&[u8]` 0..4096, never panic, never index past buf — bounds guards at `ownership.rs:148,157` are safety-critical). Discipline soundness witnesses (every `valid/` yields 0 violations, every `invalid/` ≥1). Interpreter graceful degradation (`__ffi` with no library returns `Ok(I64(0))` + `[ffi:stub]` line, not trap; `DivisionByZero` returns `RuntimeError` not panic). *The proof-debt ledger pin*: a host test re-deriving the admit/axiom census from the `.v` files that *fails if the false legacy theorem gets "fixed" or an admit silently appears/disappears* — making the honest-gap state itself a tested, version-controlled invariant. +*unit.* Parser/typechecker crash-freedom (`parse_fuzzer`/`typecheck_fuzzer`, un-run in CI — promote to a gated `cargo-fuzz` job with a committed corpus seeded from the 14 conformance files + regression dir). Ownership codec lenient-decode safety (proptest over arbitrary `&[u8]` 0..4096, never panic, never index past buf — bounds guards at `ownership.rs:148,157` are safety-critical). Discipline soundness witnesses (every `valid/` yields 0 violations, every `invalid/` ≥1). Interpreter graceful degradation (`__ffi` with no library returns `Ok(I64(0))` + `[ffi:stub]` line, not trap; `DivisionByZero` returns `RuntimeError` not panic). *The proof-debt ledger pin*: a host test re-deriving the admit/axiom census from the `.v` files that *fails if a refuted statement is turned back into a `Theorem` or any `Admitted`/`Axiom` appears* (the baseline is zero; `formal/scripts/no-false-gate.sh` already enforces it in CI) — making the honest-gap state itself a tested, version-controlled invariant. *P2P.* parser→typechecker fault propagation (malformed-but-parseable ⟹ typecheck-phase `Err` + non-empty diagnostic, never panic); codec↔codec + a *fault-injection variant* (flip one byte in a tw-built payload, assert `LenientReader` degrades deterministically); interp↔wasm differential (the interpreter *is* the reference for the literal/let/if/arith subset). *E2E.* Full-pipeline no-trap over *all* `conformance/valid` + supported `examples` (the open gap `TEST-NEEDS.md` flags: "compile and run all 98 `.eph` — not automated"); negative E2E (every `invalid/` rejected pre-codegen with non-empty diagnostic); graceful-degradation pair (`[ffi:stub]→0` with no shim; `dispatch(_,_,20,22)==42` with the shim); crash-resistance on 10k-nested inputs (exit error code, not SIGSEGV). -*endpoint.* CLI exit-code stability; emitted-wasm validity (external engines instantiate without error); the `OWNERSHIP_SECTION_NAME` constant as a CI invariant; Idris2 ABI seam *with E1/E5 honestly marked OWED* (erased 0-quantity postulates citing the Coq `Admitted` — the contract is they remain stated-as-owed, never silently asserted proven). +*endpoint.* CLI exit-code stability; emitted-wasm validity (external engines instantiate without error); the `OWNERSHIP_SECTION_NAME` constant as a CI invariant; Idris2 ABI seam *with E1/E5 honestly marked OWED* (erased 0-quantity postulates citing the Coq refutation `Counterexample.preservation_false` — the contract is they remain stated-as-owed, never silently asserted proven). *benches.* Fuzz throughput + crash-yield (0 new crashes / 24h before claiming CRG-A coverage); codec decode-safety throughput (constant memory on a forged `u32` count); parser/typechecker latency under depth; E2E compile+validate budget (+20% gate); `coqc` proof-check wall-clock (a runaway proof build is an evidence regression); `idris2 --check` ABI budget. @@ -182,7 +182,7 @@ The joint claim: the compiler never crashes on input it accepts or rejects; the Security is the claim that the substructural type system mechanically eliminates whole memory-error classes *before codegen* (`ephapax-linear` forbids contraction = double-free, weakening = leak, use-after-consume = UAF, region-escape), plus the integrity of three trust boundaries where the guarantee can leak: the FFI/dlopen path (transmute onto *any* symbol, no arity/signature/null check), the ownership-section seam, and the wasm sandbox. The trusted base is real and testable: `#![forbid(unsafe_code)]` on `ephapax-linear` + `ephapax-wasm`, and `Print Assumptions` seals in `TypingL2.v` asserting `weaken_modality`/`linear_to_affine` close with *zero axioms*. -*unit.* LinearChecker negative corpus, one focused test per `DisciplineViolation` asserting the *exact* enum (not merely non-empty) and that no false-negative path returns `Ok`. AffineChecker parity (double-use still `Contraction`; unconsumed binding allowed — pins the L2-modality boundary so affine relaxation can never re-admit contraction). Branch-agreement merge soundness (property test over N-arm `match`: the arm[0]-vs-arm[i] formulation must not miss a disagreement that exists only between arm[2] and arm[3]). The *axiom-budget unit*: `Print Assumptions weaken_modality`/`linear_to_affine` print NO axioms, and `preservation_l1` lists *exactly* the known admits and no NEW axiom name — turning the no-new-axioms directive into a diffable artifact. +*unit.* LinearChecker negative corpus, one focused test per `DisciplineViolation` asserting the *exact* enum (not merely non-empty) and that no false-negative path returns `Ok`. AffineChecker parity (double-use still `Contraction`; unconsumed binding allowed — pins the L2-modality boundary so affine relaxation can never re-admit contraction). Branch-agreement merge soundness (property test over N-arm `match`: the arm[0]-vs-arm[i] formulation must not miss a disagreement that exists only between arm[2] and arm[3]). The *axiom-budget unit*: `Print Assumptions weaken_modality`/`linear_to_affine` print NO axioms, and every named result prints "Closed under the global context" (the no-False gate census), so any NEW axiom name — turning the no-new-axioms directive into a diffable artifact. *P2P.* typecheck→codegen ownership lowering (P60: a Linear param surfaces as `OwnershipKind::Linear` in the artifact, not erased); codec↔codec cross-seam + `OWNERSHIP_SECTION_NAME` byte-identical across crates; interp→dlopen seam reaches the real shim (`dispatch(_,_,20,22)==42`) *and* a non-existent symbol hits the stub returning 0 without UB. @@ -190,7 +190,7 @@ Security is the claim that the substructural type system mechanically eliminates *endpoint.* `ephapax compile --verify-ownership` (clean → exit 0; feature off → warn+skip, no false "clean"; corrupted section → reject non-zero); `#![forbid(unsafe_code)]` as a grep/compile gate that can never be removed; cap-tag byte-match `.eph` ↔ Zig enum (a mismatch routes a buffer to the *wrong* coprocessor — a security-relevant misdispatch); *supply-chain*: `cargo-deny`/`cargo-audit` (currently *no such job* — highest-value add given the `libloading` unsafe surface), `#![forbid]` invariant, commit-signature verification. -*benches.* Checker rejection latency (linear in AST, no quadratic blowup from per-branch HashMap snapshot/restore clone); snapshot/restore cost under branch nesting; ownership-codec throughput; wasm-validation gate cost; fuzz coverage/throughput (coverage must not drop release-to-release; add an ownership-section fuzz target); *proof-check wall-time + axiom COUNT* (the security-load-bearing number: `weaken_modality=0`, `linear_to_affine=0`, `preservation_l1` axiom set frozen to the 3 known admits). +*benches.* Checker rejection latency (linear in AST, no quadratic blowup from per-branch HashMap snapshot/restore clone); snapshot/restore cost under branch nesting; ownership-codec throughput; wasm-validation gate cost; fuzz coverage/throughput (coverage must not drop release-to-release; add an ownership-section fuzz target); *proof-check wall-time + axiom COUNT* (the security-load-bearing number: `weaken_modality=0`, `linear_to_affine=0`, every named result closed: zero axioms, zero admits). === 3.5 Accessibility — the picturable error UX @@ -214,7 +214,7 @@ Consolidated in the dedicated Interoperability section below. This layer keeps the four-layer-proof / dyadic-grammar / typed-wasm-seam machine honest as it gestates. Several gates are *currently false-green or broken*, so the suite's first job is to make the gates themselves trustworthy. -*unit.* status-gate drift logic (`scripts/status-gate.sh`): assert the admit grep returns the *expected 4* (not 0) and does *not* count the deliberately-false `Theorem preservation` as closeable; a fixture with 5 Admitted must FAIL when `PROOF-NEEDS.md` claims 4. *(!) `must-spdx-headers` is structurally broken* — it pipes through `head -20`, scanning only the first 20 files; a missing-header file ranked ≥21 passes silently, and it never scans `.eph`/`.idr`/`.v`. Replace with a full-corpus check; a fixture where file #25 lacks a header must FAIL. SPDX comment-leader correctness (`guix.scm`/`pixi.toml`/`stapeln.toml` carry C-style `// Owner:` which is *wrong for Scheme/TOML* and a genuine `guix shell -f` parse hazard). Licence single-source: `guix/pixi/stapeln` say PMPL-1.0-or-later, 255 headers say MPL-2.0, only `LICENSES/MPL-2.0.txt` exists — a 3-way contradiction the test must *report* (any FIX is owner-manual, file-by-file; the test must not auto-edit). +*unit.* status-gate drift logic (`scripts/status-gate.sh`): assert the admit grep returns the *expected 0* and that the refuted statements stay `Prop` definitions (`preservation_stmt`, `step_pop_disjoint_from_type_l1_stmt`, `preservation_l1_stmt`), not closeable theorems; a fixture with 1 `Admitted` must FAIL when `PROOF-NEEDS.adoc` claims 0. *(!) `must-spdx-headers` is structurally broken* — it pipes through `head -20`, scanning only the first 20 files; a missing-header file ranked ≥21 passes silently, and it never scans `.eph`/`.idr`/`.v`. Replace with a full-corpus check; a fixture where file #25 lacks a header must FAIL. SPDX comment-leader correctness (`guix.scm`/`pixi.toml`/`stapeln.toml` carry C-style `// Owner:` which is *wrong for Scheme/TOML* and a genuine `guix shell -f` parse hazard). Licence single-source: `guix/pixi/stapeln` say PMPL-1.0-or-later, 255 headers say MPL-2.0, only `LICENSES/MPL-2.0.txt` exists — a 3-way contradiction the test must *report* (any FIX is owner-manual, file-by-file; the test must not auto-edit). *P2P.* contractile source → `contractile.just` byte-equality (catches generator/source drift); status-gate ↔ live `cargo test --list` + Coq admit grep ↔ `TEST-NEEDS.md`/`PROOF-NEEDS.md`; `pixi` tasks ↔ Justfile recipes (flag the lint asymmetry: pixi runs `clippy -D warnings`, Justfile has none); `reuse lint` ↔ LICENSES/ tree (would FAIL today: PMPL referenced but no `LICENSES/PMPL-*.txt`). @@ -222,7 +222,7 @@ This layer keeps the four-layer-proof / dyadic-grammar / typed-wasm-seam machine *endpoint.* `reuse lint` exit code (SBOM-consumable attestation); scorecard MIN_SCORE=5 (*but the SARIF score-parse currently defaults to 0 via a `// 0` fallback — a false sense of coverage the test must expose*); status-gate markers exactly-one occurrence; Containerfile pinning contract; `boj-build` trigger payload (currently *malformed JSON*, unbalanced quotes/braces, `continue-on-error: true` swallows failures). -*benches.* Gate latency (`status-gate` list-only path < 30s on warm cache, must not trigger a full rebuild); cold reproducible-build time (+20% gate); validate-wasm corpus throughput (count of `[valid]` fixtures should *monotonically rise*); SPDX/REUSE coverage ratio (hard number: `.rs` 68/68, `.idr` 20/20, `.v` 12/12, `.eph` 113/117 — 4 missing); admit/drift ledger (declared vs actual `Admitted.`, should trend *down*, never silently up); pinned-action freshness. +*benches.* Gate latency (`status-gate` list-only path < 30s on warm cache, must not trigger a full rebuild); cold reproducible-build time (+20% gate); validate-wasm corpus throughput (count of `[valid]` fixtures should *monotonically rise*); SPDX/REUSE coverage ratio (hard number: `.rs` 68/68, `.idr` 20/20, `.v` 12/12, `.eph` 113/117 — 4 missing); admit/drift ledger (declared vs actual `Admitted.`, pinned at 0, never silently up); pinned-action freshness. == 4. Interoperability (consolidated) @@ -250,7 +250,7 @@ Interop is where ephapax's one load-bearing feature — substructural ownership | (5) Idris2 ABI contract | `src/abi/Ephapax/ABI/{Types,Foreign,Invariants}.idr` re-states the Rust boundary types, proves `compileResultRoundTrip` (Qed) and *OWES* `compileOkImpliesWasmTyped` (declared `0`-erased, no witness — PROOF-NEEDS E5). Exists so a SPARK/Ada consumer sees the obligation interface — "Rust is never the ABI". -| *Round-trip proven; correctness OWED.* `idris2 --build` gate keeps the result-code bijection from rotting to `believe_me`. The honesty discipline travels the seam: E1/E5 cite the Coq `Admitted` rather than asserting preservation, so no downstream consumer is misled. +| *Round-trip proven; correctness OWED.* `idris2 --build` gate keeps the result-code bijection from rotting to `believe_me`. The honesty discipline travels the seam: E1 cites the Coq refutation `Counterexample.preservation_false` and E5 is marked OWED rather than asserting preservation, so no downstream consumer is misled. |=== *Portability invariants.* The wire format is endian-pinned LE + byte-aligned specifically so a SPARK/Ada/browser/node consumer can read it without the Rust crate. Seam-name agreement is a CI invariant. The accessibility seams are *protocol* seams (LSP/DAP JSON-RPC, tested black-box over the wire so any client interoperates; keep the LSP keyword list and `tree-sitter` `highlights.scm` in sync). Docs interop is with the open web (WCAG 2.1 AA via pa11y/axe-core — independently checkable, not self-asserted). `NO_COLOR` is the cross-tool knob for terminal/screen-reader/CI-log degradation. A perf number is only meaningful for a module that *validates and runs* on a standard engine — emitted-wasm validity is a prerequisite of every E2E/endpoint bench. @@ -273,13 +273,13 @@ Each artifact *moves* a class of failure from a late/expensive boundary to an ea | "tested on examples" → "proven" — *for the closed layers only* | ⚠ partial -| `Counterexample.v` (5 Qed) -| *false security* from trusting `Semantics.v`'s `Admitted` theorem +| `Counterexample.v` (6 Qed) +| *false security* from trusting the legacy preservation in `Semantics.v` (now `preservation_stmt : Prop`) | silent false-confidence → *proven-false* (load-bearing negative knowledge) | ✅ | Proof-debt ledger pin test -| regression on the *evidence* (a silently-closed admit or "fixed" false theorem) +| regression on the *evidence* (a new admit or axiom, or a refuted statement turned back into a theorem) | invisible drift → loud CI break | ✅ (new — build this) @@ -296,7 +296,7 @@ Each artifact *moves* a class of failure from a late/expensive boundary to an ea | Region free-cost bench + L1 region-liveness proof | unbounded GC-pause latency; leak | tail-risk to monitor → measured+regression-gated (bench) → impossible (proof) -| ⚠ bench ✅, proof OWED (L1.C admit) +| ⚠ bench ✅, proof OWED (L1 redesign; no admit) | `recompute_count` integer assertions (`ephapax-query`) | interactive latency regression @@ -394,7 +394,7 @@ Three toolchain keystones gate large swaths of the suite. Naming them once: | FFI-over-wasm E2E (K3); idiomatic coproc-over-imports (K1); linearity-in-signature contract (K2/G1); real Axiom.jl dispatch parity (stub backend). | *Maint/Supply/Compliance* -| Fix + test `must-spdx-headers` (`head -20` bug); status-gate admit-count pin; contractile byte-equality; `reuse lint` *reporting* (fix owner-manual); validate-wasm floor. +| Fix + test `must-spdx-headers` (`head -20` bug); status-gate admit-count pin (0); contractile byte-equality; `reuse lint` *reporting* (fix owner-manual); validate-wasm floor. | Hermetic guix build (`source #f`); language-wide codegen repro (K3); package-graph multi-module repro (K1); CI execution itself (estate Actions *billing wall* — owner-only; bag-of-actions escape, but `boj-build` payload is malformed); SBOM (no job); READINESS.md (absent → CRG badge vacuously `X`). |=== @@ -402,7 +402,7 @@ Three toolchain keystones gate large swaths of the suite. Naming them once: Sequence honours the estate directive *truth → fix → only then build*: make false-green gates trustworthy first, then build the one oracle that carries the most transferred risk, then widen. -. *Tier 0 — make the gates honest (do first, days).* These currently lie. (a) Fix `must-spdx-headers` `head -20`; (b) pin the status-gate admit-count to the *exact* expected (4 L1 admits + the 1 deliberately-false legacy = documented total, *not zero*); (c) build the proof-debt ledger pin test; (d) expose the scorecard SARIF `// 0` score-parse so the supply-chain gate stops passing vacuously. *Why first:* §5 shows two of these are illusory transfers — they must fire before anything downstream trusts them. +. *Tier 0 — make the gates honest (do first, days).* These currently lie. (a) Fix `must-spdx-headers` `head -20`; (b) pin the status-gate admit-count to the *exact* expected total, which is *zero* since D0/D0b (2026-11-05); the refuted statements are `Prop` definitions, not admits, and `formal/scripts/no-false-gate.sh` fails on any `Admitted`/`Axiom`; (c) build the proof-debt ledger pin test; (d) expose the scorecard SARIF `// 0` score-parse so the supply-chain gate stops passing vacuously. *Why first:* §5 shows two of these are illusory transfers — they must fire before anything downstream trusts them. . *Tier 1 — the differential interp-vs-wasm oracle (the keystone test).* Both backends exist; blocked on nothing. This single E2E (Functionality/E2E) carries the most-transferred functional risk: it converts codegen-miscompilation from a silent wrong-answer into a CI failure. *Directly serves codegen-audit task #12* — it is the executable definition of "does codegen compute the right value", not just "does it validate". Pair with upgrading `wasm_e2e.rs` from no-trap to value-correctness. @@ -416,4 +416,4 @@ Sequence honours the estate directive *truth → fix → only then build*: make . *Tier 6 — blocked-but-specced.* Park behind K1/K2/K3: idiomatic coproc-over-imports, linearity-in-signature contracts, FFI-over-wasm E2E, DAP endpoints, `ephapax-doc` WCAG gate, hermetic guix. Keep each as an `#[ignore]`d test with a "blocked: " note so the gap is visible and un-claimable. -*Guardrails.* No automated licence/SPDX edits — every compliance test is *report-only*; the PMPL-vs-MPL contradiction is surfaced, never auto-resolved (owner-manual, file-by-file). The proof-debt boundary (admits + the deliberately-false theorem) is a *tested, version-controlled invariant* — do not "fix" the false theorem or patch admits with proof tricks. \ No newline at end of file +*Guardrails.* No automated licence/SPDX edits — every compliance test is *report-only*; the PMPL-vs-MPL contradiction is surfaced, never auto-resolved (owner-manual, file-by-file). The proof-debt boundary (zero admits; the refuted statements kept as `Prop` with their pinned refutations) is a *tested, version-controlled invariant* — do not prove or restate a refuted statement, and do not reintroduce `Admitted` or `Axiom`. \ No newline at end of file diff --git a/docs/wikis/Home.md b/docs/wikis/Home.md index c3816e35..c10b4552 100644 --- a/docs/wikis/Home.md +++ b/docs/wikis/Home.md @@ -45,7 +45,7 @@ Both compile to [typed-wasm](https://github.com/hyperpolymath/typed-wasm). The c ## Current formal proof status (2026-06-05) -> 🔄 **Foundational redesign in progress.** On 2026-05-26, a verified Coq counterexample ([`formal/Counterexample.v`](https://github.com/hyperpolymath/ephapax/blob/main/formal/Counterexample.v), 5 Qed lemmas) showed that preservation as stated in the legacy typing judgment is **provably false**. The old `Theorem preservation` in `Semantics.v` stays `Admitted` — this is correct. The work is the **four-layer principled redesign**. +> 🔄 **Foundational redesign in progress.** On 2026-05-26, a verified Coq counterexample ([`formal/Counterexample.v`](https://github.com/hyperpolymath/ephapax/blob/main/formal/Counterexample.v), now 6 Qed lemmas) showed that preservation as stated in the legacy typing judgment is **provably false**. Since 2026-11-05 (D0/D0b) the legacy statement in `Semantics.v` is `preservation_stmt : Prop`, refuted by `Counterexample.preservation_false`; `formal/` has no `Admitted` and no `Axiom`. The work is the **four-layer principled redesign**. The four layers and their current state: diff --git a/formal/regress/L2Comp.v b/formal/regress/L2Comp.v index 46e06af8..0bfeb8e3 100644 --- a/formal/regress/L2Comp.v +++ b/formal/regress/L2Comp.v @@ -5,8 +5,11 @@ [L2_lift_l1] (any [has_type_l1] derivation) and [T_App_L2_Eff] (application of a [TFunEff]). There is no L2-level pair / let / lambda / region rule, and [has_type_l1] has no rule eliminating - [TFunEff]. So an effect-typed application, once formed by - [T_App_L2_Eff], cannot be placed inside any compound form. + [TFunEff]. So an effect-typed application formed by [T_App_L2_Eff] + can appear only as an operand of another [T_App_L2_Eff] (compatible + L2 applications do nest); it cannot be placed inside a pair, let, + lambda body, region or any other form typed only by [has_type_l1]. + The lemma below shows the pairing case. "Compose" here means the L1 pairing rule [T_Pair_L1], lifted to L2: L2 ⊢ e1 : T1 [R→R1, G→G1] and L2 ⊢ e2 : T2 [R1→R2, G1→G2] diff --git a/spec/FRG-PROFILE.adoc b/spec/FRG-PROFILE.adoc index d0996d8b..0dd4cac9 100644 --- a/spec/FRG-PROFILE.adoc +++ b/spec/FRG-PROFILE.adoc @@ -12,7 +12,7 @@ | Language | Ephapax (dyadic: `ephapax-linear` + `ephapax-affine` sublanguages) | Repository | https://github.com/hyperpolymath/ephapax -| Current FRG Grade | *D* (preservation stated; admits explicitly tracked; layered redesign committed) +| Current FRG Grade | *D* (preservation stated; refuted statements retired to `Prop`, 0 admits since 2026-11-05; layered redesign committed) | Assessed | 2026-05-28 | Assessor | Jonathan D.A. Jewell | FRG Spec Version | 1.0 @@ -79,14 +79,14 @@ target ARG-A). | *M3 — Operational or denotational semantics formalised* | YES | `formal/Semantics.v` (legacy archaeology with provably-false - preservation); `formal/Semantics_L1.v` (canonical, currently - with 3 Admitted supporting lemmas regressed during L2-β bullet - rewrite — recoverable from commit 56f592f) + preservation); `formal/Semantics_L1.v` (canonical; no `Admitted` since D0/D0b, 2026-11-05) | *M4 — Preservation theorem stated* | YES -| `formal/Semantics_L1.v` `Theorem preservation_l1` (stated - `forall m`, both branches currently Admitted post-L2-β) +| `formal/Semantics_L1.v` `preservation_l1_stmt : Prop` (stated + `forall m`). Refuted as stated by + `formal/Counterexample_PreservationL1.v`; retired to `Prop`, not an + open obligation. A replacement theorem awaits the L1 redesign. | *M5 — Progress theorem stated* | YES @@ -94,10 +94,14 @@ target ARG-A). | *P1 — Preservation closed (0 admits, 0 axioms, 0 sorries)* | NO -| 4 Admitted + 1 region-liveness Axiom currently outstanding (per - PRs #92/#102/#106/#114/#116 narrative; legacy `Semantics.v` - preservation NOT to be closed — provably false). The canonical - closure path is per-layer derivation, not single-layer patch. +| No preservation theorem is closed for L1 or L2. `formal/` has 0 + `Admitted` and 0 `Axiom` (the `region_liveness_at_split_l1` axiom was + removed 2026-06-26), but the unrestricted `preservation_l1` and its + supporting `step_pop_disjoint_from_type_l1` are refuted and retired to + `Prop`, and no unconditional `preservation_l2` exists. The legacy + `Semantics.v` preservation is refuted (`preservation_false`). Closing + P1 needs a redesigned judgment/step relation and a new theorem with a + stated boundary, not the old statements. | *P2 — Progress closed* | PARTIAL @@ -105,9 +109,11 @@ target ARG-A). | *P3 — Counterexample regression suite (≥ 3 Qed lemmas)* | YES -| `formal/Counterexample.v` — three lemmas all `Qed`'d, establishing - that the original preservation statement is FALSE. The canonical - example for FRG §6.5. +| `formal/Counterexample.v` — six lemmas, all `Qed`, including + `preservation_false : ~ preservation_stmt`, establishing that the + original preservation statement is FALSE. `Counterexample_StepPop.v` + and `Counterexample_PreservationL1.v` refute the unrestricted L1 + statements. The canonical example for FRG §6.5. | *P4 — Layered orthogonal decomposition* | YES @@ -147,15 +153,18 @@ target ARG-A). | | *B0 — Zero banned constructs in proof tree* -| NO (D-permissive) -| 4 tracked Admitted in Semantics_L1.v + 1 Axiom; each enumerated - in PROOF-NEEDS.md with discharge plan; FRG-D permits tracked - admits. +| YES +| 0 `Admitted`, 0 `admit`, 0 `Axiom`, 0 `Parameter` in the files of + `formal/_CoqProject` since D0/D0b (2026-11-05); every named result + is closed under the global context (no-False gate census). | *B1 — CI banned-construct lint active* -| PARTIAL -| Coq build CI runs; admit-count check exists in workflow but - does not yet block merge for new admits. +| YES +| `formal/scripts/no-false-gate.sh` runs in `coq-build.yml`; it fails + the `coq-build` job, and so the required `coq-build-gate` check, on + any assumption vernacular or `admit`, any kernel-check bypass, any + named result not closed under the global context, or any Ephapax + axiom found by `coqchk`. | *Operational equivalence O1* | NO @@ -170,11 +179,13 @@ target ARG-A). == Open proof obligations -* *Tracked admits:* 4 in `formal/Semantics_L1.v` (regressed during - L2-β bullet-structure rewrite; orig bodies at commit `56f592f`) -* *Tracked axioms:* 1 (`region_liveness_at_split_l1` — cross-layer - gap that closes at L2 per `PRESERVATION-DESIGN.md §5.1`; owner - directive 2026-05-27 forbids closing via proof tricks) +* *Tracked admits:* 0 (the former L1 admits were refuted statements, + retired to `Prop` by D0/D0b on 2026-11-05) +* *Tracked axioms:* 0 (`region_liveness_at_split_l1` was a false + statement and was removed 2026-06-26) +* *Open:* an owner-approved L1 judgment/step redesign with an explicit + temporal invariant, then a replacement preservation theorem with a + stated boundary; likewise for L2 (see `PROOF-NEEDS.adoc`) * *PROOF-NEEDS.md:* `PROOF-NEEDS.md` (top-level) * *PROOF-STATUS.a2ml:* `formal/PROOF-STATUS.a2ml` @@ -182,8 +193,9 @@ target ARG-A). For Ephapax, the following FRG tightening applies: -* *Grade C tightening:* Preservation closed *per layer* - (`preservation_l1`, `preservation_l2`, `preservation_l3`), with +* *Grade C tightening:* Preservation closed *per layer* by sound + replacement theorems (the unrestricted `preservation_l1` is refuted + and is not the target), with cross-layer composition closed under explicit invariants — *not* via patched single-layer admits in legacy `Semantics.v`. The owner directive 2026-05-27 forbids the legacy-patching anti-pattern. @@ -195,9 +207,10 @@ For Ephapax, the following FRG tightening applies: == What is NOT yet met (honest gaps) -* *P1 / P2 not closed*: 4 Admitted + 1 Axiom outstanding. Closing - these is gated on the L1/L2/L3 per-layer derivation work (see - `project_ephapax_post_slice4_plan.md` in author's session memory). +* *P1 / P2 not closed*: no L1 or L2 preservation theorem holds; the + unrestricted statements are refuted. Closing P1 is gated on the L1 + and L2 redesign (an explicit temporal invariant in the judgment or + step relation) and on new theorems stated with their boundaries. * *No cross-prover replication.* Rocq only. * *Echidna not wired into CI.* * *No reference implementation alongside the Rust production @@ -207,14 +220,17 @@ For Ephapax, the following FRG tightening applies: == Path to next grade (C — preservation closed) -* Close 4 Admitted in Semantics_L1.v (recoverable from commit 56f592f - + bullet-structure rewrite for 3 new Affine-only constructors). -* Close `region_liveness_at_split_l1` Axiom via L2 mechanism per - PRESERVATION-DESIGN.md §5.1 (NOT via proof tricks — owner directive). -* Close `preservation_l1` both branches under `forall m`. -* Close `preservation_l2` per modality layer. +* Keep 0 `Admitted` and 0 `Axiom`; the no-False gate enforces it. +* Do *not* try to prove `preservation_l1_stmt` or + `step_pop_disjoint_from_type_l1_stmt`: both are refuted by pinned + counterexamples (`Counterexample_PreservationL1.v`, + `Counterexample_StepPop.v`). +* Agree with the owner an L1 judgment/step redesign that encodes the + temporal invariant the counterexamples expose, then state and prove a + replacement L1 preservation theorem with an explicit boundary. +* Do the same for L2: a replacement `preservation_l2` that keeps the + fresh-region and nested-`TFunEff` counterexamples visible. * Stand up PROOF-STATUS.a2ml grade-attestation block. -* Wire CI banned-construct lint to BLOCK merge on new admits. *Realistic timeline estimate:* 2-4 months given sequential per-layer work (sequence A → B → C → D in `project_ephapax_post_slice4_plan`). @@ -232,8 +248,8 @@ work (sequence A → B → C → D in `project_ephapax_post_slice4_plan`). == Demotion risk -* *Lowest:* Adding a 5th Admitted to formal/ — drops to E (or - arguably remains D if still tracked). +* *Lowest:* Adding any `Admitted` or `Axiom` to `formal/` — the + no-False gate fails the build; if forced through, drops to E. * *Medium:* Removing the four-layer redesign commitment without replacement — drops to F (the very state ephapax evacuated on 2026-05-27). @@ -250,6 +266,7 @@ work (sequence A → B → C → D in `project_ephapax_post_slice4_plan`). | 2026-03-28 | F | Original `Semantics.v` `Theorem preservation` stated, `Counterexample.v` `Qed`'d refutation present, NO redesign committed. FRG-F by §4.2 definition. | 2026-05-27 | D | Owner directive lockdown of four-layer redesign as canonical; legacy preservation marked archaeology; F evacuated. | 2026-05-28 | D | Initial FRG assessment. Tracked admits = 4 + 1 Axiom. +| 2026-11-09 | D | Facts updated, grade not reassessed: D0/D0b (2026-11-05) retired the refuted statements to `Prop`; 0 `Admitted`, 0 `Axiom`; no-False gate in CI. |=== == Review cycle From 688bc7daf23d5128ef26c48df2b5f24d5289b96b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 15:45:32 +0000 Subject: [PATCH 22/26] fix(gate): census exposed results of ":"-sealed modules The census skipped every result inside a "Module M : S." body. A field the signature exposes can be named, and Print Assumptions on it reports the axioms its implementation uses, so a standard-library axiom behind an exposed field went unchecked. coq-decls.awk now lists results in a sealed body as optional names ("?" prefix, qualified through the sealed module). The gate checks each one alone with nofalse_sealed_check: "not found" means the signature hides it and it is skipped; anything else must print "Closed under the global context". The self-test compiles a sealed fixture with an exposed funext-using field (open), an exposed clean field (closed) and a hidden field (hidden). Reverting the parser to skip sealed bodies fails 2 cases. formal/ has no sealed modules today: census 446/446, 0 sealed results. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDjUNwStGwcKTtAQ3NY --- .github/workflows/coq-build.yml | 5 +-- formal/scripts/coq-decls.awk | 33 +++++++++++-------- formal/scripts/no-false-gate.sh | 26 +++++++++++++-- formal/scripts/no-false-lib.sh | 21 ++++++++++++ formal/scripts/test-no-false-gate.sh | 48 +++++++++++++++++++++++++--- 5 files changed, 111 insertions(+), 22 deletions(-) diff --git a/.github/workflows/coq-build.yml b/.github/workflows/coq-build.yml index bd58581a..8b7ea240 100644 --- a/.github/workflows/coq-build.yml +++ b/.github/workflows/coq-build.yml @@ -118,8 +118,9 @@ jobs: - name: No-False gate scanner self-test working-directory: formal - # Fixtures for the gate's source scanners: split bypass flags, - # split declarations, nested Module qualification. No Coq needed. + # Fixtures for the gate's scanners: split bypass flags, split + # declarations, nested Module qualification, and (using the Coq just + # installed) the census of results in ":"-sealed modules. run: bash scripts/test-no-false-gate.sh - name: Build formal/ via coq_makefile (the merge-oracle command) diff --git a/formal/scripts/coq-decls.awk b/formal/scripts/coq-decls.awk index 8dee2b9b..037866ca 100644 --- a/formal/scripts/coq-decls.awk +++ b/formal/scripts/coq-decls.awk @@ -20,14 +20,20 @@ # # Module and Section scopes are tracked. A Module adds its name to the # qualification; a Section does not. "Module X := ..." opens no scope. -# Results are skipped, because the census cannot name them from outside, -# inside a Module Type, inside a functor (a Module with parameters), and -# inside a module sealed by a signature ("Module M : S."), whose body -# fields need not be visible. A transparently ascribed module -# ("Module M <: S.") keeps every field and is censused. Skipped bodies are -# still covered by gate checks 1, 2 and 4 (no assumption vernacular or -# admit, no kernel bypass, no Ephapax axiom under coqchk); check 3 does -# not see a dependency of a skipped result on a standard-library axiom. +# Results inside a Module Type or a functor (a Module with parameters) are +# skipped, because the census cannot name them from outside. Gate checks +# 1, 2 and 4 still cover them (no assumption vernacular or admit, no kernel +# bypass, no Ephapax axiom under coqchk); check 3 does not see a dependency +# of such a result on a standard-library axiom. +# +# Inside a module sealed by a signature ("Module M : S."), a field the +# signature exposes can be named, and Print Assumptions on it reports the +# axioms its implementation uses; a field the signature hides cannot be +# named. The parser cannot tell the two apart, so it lists every result in +# a sealed body with a leading "?" (an optional name). The gate checks each +# optional name and skips it only when Coq reports that it was not found. +# A transparently ascribed module ("Module M <: S.") keeps every field and +# is censused normally. # # Usage: awk -v prefix=Ephapax.File -f coq-decls.awk stripped.v BEGIN { buf = ""; depth = 0 } @@ -45,7 +51,7 @@ END { function trim(x) { sub(/^ +/, "", x); sub(/ +$/, "", x); return x } -function sentence(s, w, k, i, name, kind, skip, path, rest) { +function sentence(s, w, k, i, name, kind, skip, opt, path, rest) { s = trim(s) # Drop attributes and locality/flag prefixes. while (match(s, /^#\[[^]]*\] */)) s = substr(s, RLENGTH + 1) @@ -81,12 +87,13 @@ function sentence(s, w, k, i, name, kind, skip, path, rest) { if (w[1] ~ /^(Theorem|Lemma|Corollary|Proposition|Fact|Remark|Example|Instance|Definition|Fixpoint|CoFixpoint)$/) { name = w[2]; sub(/[^A-Za-z0-9_'].*$/, "", name) if (name !~ /^[A-Za-z_][A-Za-z0-9_']*$/) return # anonymous Instance - skip = 0; path = prefix + skip = 0; opt = ""; path = prefix for (i = 1; i <= depth; i++) { - if (kinds[i] == "mtype" || kinds[i] == "functor" || kinds[i] == "sealed") skip = 1 - if (kinds[i] == "module") path = path "." names[i] + if (kinds[i] == "mtype" || kinds[i] == "functor") skip = 1 + if (kinds[i] == "sealed") opt = "?" + if (kinds[i] == "module" || kinds[i] == "sealed") path = path "." names[i] } - if (!skip) print path "." name + if (!skip) print opt path "." name } } diff --git a/formal/scripts/no-false-gate.sh b/formal/scripts/no-false-gate.sh index 407f88a8..56f9b477 100755 --- a/formal/scripts/no-false-gate.sh +++ b/formal/scripts/no-false-gate.sh @@ -21,9 +21,10 @@ # CoFixpoint, found by sentence (so a keyword and its name may be on # different lines) and qualified through nested Modules # (coq-decls.awk); each must print "Closed under the global context", -# and the count must match. Results in a Module Type, a functor or a -# ":"-sealed module cannot be named and are not censused; checks 1, -# 2 and 4 still cover them; +# and the count must match. Results in a Module Type or a functor +# cannot be named and are not censused; checks 1, 2 and 4 still cover +# them. A result in a ":"-sealed module is checked on its own and must +# be closed, unless Coq reports that the signature hides it; # 4. independent recheck of the compiled .vo files with coqchk: no # axiom in the Ephapax namespace, nothing relying on type-in-type, # unsafe (co)fixpoints or assumed positivity. @@ -66,6 +67,10 @@ for f in $FILES; do nofalse_decls "Ephapax.$m" "$WORK/$f" >> "$NAMES" \ || { echo "no-False gate [3]: could not read the declarations of $f -> FAIL"; fail=1; } done +# Optional names ("?" prefix) come from ":"-sealed module bodies; see below. +grep '^?' "$NAMES" | sed 's/^?//' > "$WORK/optional" || true +grep -v '^?' "$NAMES" > "$WORK/required" || true +mv "$WORK/required" "$NAMES" total=$(wc -l < "$NAMES") # The headline results must stay in the census under these names. for h in Counterexample.preservation_false \ @@ -86,6 +91,20 @@ fi if [ "$closed" -ne "$total" ]; then echo "no-False gate [3]: $closed of $total named results closed -> FAIL"; fail=1 fi +# A result in a ":"-sealed module body is reachable only if the signature +# exposes it. Check each one alone: "not found" means the signature hides +# it and it is skipped; any other outcome must be "Closed". +sealed_closed=0; sealed_hidden=0 +while IFS= read -r n; do + [ -n "$n" ] || continue + out=$(nofalse_sealed_check Ephapax "$MODS" "$n") + case "$out" in + hidden) sealed_hidden=$((sealed_hidden + 1)) ;; + closed) sealed_closed=$((sealed_closed + 1)) ;; + *) printf '%s\n' "$out" | head -20 + echo "no-False gate [3]: sealed-module result $n is not closed -> FAIL"; fail=1 ;; + esac +done < "$WORK/optional" # 4. Independent kernel recheck of the compiled libraries. QUAL=$(for m in $MODS; do printf 'Ephapax.%s ' "$m"; done) @@ -102,6 +121,7 @@ for k in 'type-in-type' 'unsafe (co)fixpoints' 'positivity is assumed'; do done echo "--- census: $closed/$total named results closed under the global context" +echo "--- sealed-module results: $sealed_closed exposed and closed, $sealed_hidden hidden by their signature" echo "--- coqchk environment axioms (loaded libraries, not necessarily used):" sed 's/^/ /' "$WORK/axioms" if [ "$fail" -ne 0 ]; then echo "no-False gate: FAIL"; exit 1; fi diff --git a/formal/scripts/no-false-lib.sh b/formal/scripts/no-false-lib.sh index c9448258..ec9ebdd3 100644 --- a/formal/scripts/no-false-lib.sh +++ b/formal/scripts/no-false-lib.sh @@ -51,3 +51,24 @@ nofalse_bypass_scan() { # Fully qualified named results of one stripped file (see coq-decls.awk). nofalse_decls() { awk -v prefix="$1" -f "$NOFALSE_DIR/coq-decls.awk" "$2"; } + +# Check one result from a ":"-sealed module body. A sealed body may hide a +# field or expose it through the signature; Print Assumptions on an exposed +# field reports the axioms of its implementation. Usage: +# nofalse_sealed_check "" +# Run from the load-path root. Prints "hidden" when Coq reports that the name +# was not found, "closed" when it is closed under the global context, and +# otherwise "open" followed by Coq's output. +nofalse_sealed_check() { + local out + out=$(printf 'From %s Require %s.\nPrint Assumptions %s.\n' "$1" "$2" "$3" \ + | coqtop -quiet -R . "$1" 2>&1 || true) + if printf '%s\n' "$out" | grep -qF "The reference $3 was not found"; then + echo hidden + elif printf '%s\n' "$out" | grep -qF 'Closed under the global context' \ + && ! printf '%s\n' "$out" | grep -qE '^Axioms:|Error'; then + echo closed + else + echo open; printf '%s\n' "$out" + fi +} diff --git a/formal/scripts/test-no-false-gate.sh b/formal/scripts/test-no-false-gate.sh index a929306c..4dbc5d62 100755 --- a/formal/scripts/test-no-false-gate.sh +++ b/formal/scripts/test-no-false-gate.sh @@ -2,8 +2,9 @@ # SPDX-License-Identifier: MPL-2.0 # Owner: Jonathan D.A. Jewell # -# Self-test for the source scanners of no-false-gate.sh. Needs only bash, -# awk, grep, sed and tr (no Coq), so it runs before the build. Each case is +# Self-test for the scanners of no-false-gate.sh. The source cases need only bash, +# awk, grep, sed and tr; the sealed-module cases also need coqc and coqtop +# (installed in CI before this step runs). Each case is # a small Coq fragment and the output the scanner must give for it. # # Usage: bash formal/scripts/test-no-false-gate.sh @@ -70,9 +71,10 @@ expect_decls d_qualid $'Lemma q : True.\nProof. apply Coq.Init.Logic.I. Qed.\nLe # Definitions, fixpoints and tactic-built definitions closed with Defined. expect_decls d_defs $'Definition d1 : nat := 0.\nDefinition\n d2 : True.\nProof. exact I. Defined.\nProgram Definition d3 : nat := 0.\nFixpoint f (n : nat) : nat := match n with 0 => 0 | S m => g m end\nwith g (n : nat) : nat := match n with 0 => 1 | S m => f m end.\nCoFixpoint c : nat := 0.\nSection S.\n Let hidden := 0.\n Definition d4 := hidden.\nEnd S.' \ $'P.d1\nP.d2\nP.d3\nP.f\nP.c\nP.d4' -# A module sealed by ":" hides its body; "<:" keeps every field visible. +# Results in a ":"-sealed body are optional ("?"): the signature may hide +# them. "<:" keeps every field visible, so its results are required. expect_decls d_sealed $'Module Type S.\n Parameter t : Type.\nEnd S.\nModule M : S.\n Definition t := nat.\n Lemma hidden : True. Proof. exact I. Qed.\nEnd M.\nModule N <: S.\n Definition t := nat.\n Lemma shown : True. Proof. exact I. Qed.\nEnd N.\nModule W : S with Definition t := nat.\n Definition t := nat.\n Lemma hidden2 : True. Proof. exact I. Qed.\nEnd W.\nModule V:S.\n Definition t := nat.\nEnd V.\nModule A := N.' \ - $'P.N.t\nP.N.shown' + $'?P.M.t\n?P.M.hidden\nP.N.t\nP.N.shown\n?P.W.t\n?P.W.hidden2\n?P.V.t' # An unmatched End must fail loudly, not mis-qualify. f=$(frag d_bad $'Module A.\nLemma x : True. Proof. exact I. Qed.\nEnd B.') if nofalse_decls P "$f" >/dev/null 2>&1; then bad "unmatched End accepted"; else ok; fi @@ -82,5 +84,43 @@ if nofalse_decls P "$f" >/dev/null 2>&1; then bad "unclosed scope accepted"; els f=$(frag d_unclosed_sec $'Section S.\nLemma x : True. Proof. exact I. Qed.') if nofalse_decls P "$f" >/dev/null 2>&1; then bad "unclosed Section accepted"; else ok; fi + +# --- sealed-module census (needs coqc/coqtop; CI installs Coq first) ---- +# An exposed field of a ":"-sealed module that uses a standard-library axiom +# must be reported open; a hidden field must be reported hidden; an exposed +# axiom-free field must be reported closed. +if command -v coqc >/dev/null 2>&1 && command -v coqtop >/dev/null 2>&1; then + mkdir -p "$T/coq" + cat > "$T/coq/Sealed.v" <<'COQ' +Require Import Coq.Logic.FunctionalExtensionality. +Module Type SIG. + Parameter uses_funext : forall f g : nat -> nat, (forall x, f x = g x) -> f = g. + Parameter clean : True. +End SIG. +Module M : SIG. + Lemma uses_funext : forall f g : nat -> nat, (forall x, f x = g x) -> f = g. + Proof. intros; apply functional_extensionality; auto. Qed. + Lemma clean : True. Proof. exact I. Qed. + Lemma hidden : forall f g : nat -> nat, (forall x, f x = g x) -> f = g. + Proof. intros; apply functional_extensionality; auto. Qed. +End M. +COQ + if (cd "$T/coq" && coqc -q -R . NFT Sealed.v) >/dev/null 2>&1; then + names=$(nofalse_strip "$T/coq/Sealed.v" > "$T/coq/Sealed.stripped" \ + && nofalse_decls NFT.Sealed "$T/coq/Sealed.stripped" | tr '\n' ' ') + if [ "$names" = "?NFT.Sealed.M.uses_funext ?NFT.Sealed.M.clean ?NFT.Sealed.M.hidden " ]; then ok + else bad "sealed census names: $names"; fi + for c in "M.uses_funext open" "M.clean closed" "M.hidden hidden"; do + read -r nm want <<< "$c" + got=$(cd "$T/coq" && nofalse_sealed_check NFT Sealed "NFT.Sealed.$nm" | head -1) + if [ "$got" = "$want" ]; then ok; else bad "sealed check $nm: expected $want, got $got"; fi + done + else + bad "could not compile the sealed-module fixture" + fi +else + echo "SKIP: sealed-module census cases (coqc/coqtop not installed)" +fi + echo "no-false-gate self-test: $pass passed, $failn failed" [ "$failn" -eq 0 ] From dc3da8466435f8b80b8daf82289002e9610ca428 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:24:22 +0000 Subject: [PATCH 23/26] Update src/ephapax-wasm/src/lib.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- src/ephapax-wasm/src/lib.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index 598d7d36..448ff54a 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -259,10 +259,13 @@ pub fn first_unsupported(expr: &Expr) -> Option { /// Module-level [`first_unsupported`]: checks every function body. pub fn validate_supported(module: &AstModule) -> Result<(), CodegenError> { for decl in &module.decls { - if let Decl::Fn { name, body, .. } = decl { - if let Some(CodegenError(msg)) = first_unsupported(body) { - return Err(CodegenError(format!("{msg} in function `{name}`"))); - } + let (name, expr, kind) = match decl { + Decl::Fn { name, body, .. } => (name, body, "function"), + Decl::Const { name, value, .. } => (name, value, "constant"), + _ => continue, + }; + if let Some(CodegenError(msg)) = first_unsupported(expr) { + return Err(CodegenError(format!("{msg} in {kind} `{name}`"))); } } Ok(()) From 90a1bab09b8a161dd1a17ca5125a0ac5cca463a1 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:24:49 +0000 Subject: [PATCH 24/26] Update scripts/test-probe-battery.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/test-probe-battery.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-probe-battery.sh b/scripts/test-probe-battery.sh index a6a1c8cb..df76c4d4 100644 --- a/scripts/test-probe-battery.sh +++ b/scripts/test-probe-battery.sh @@ -29,7 +29,7 @@ cat >"$T/stub" <<'STUB' case "$2" in p_err.eph) echo "Type error: fixture" >&2; exit 1 ;; p_crash.eph) echo "fixture abort" >&2; kill -ABRT $$ ;; - p_slow.eph) sleep 10 ;; + p_slow.eph) exec sleep 10 ;; esac exit 1 STUB From 8dadf825b553efdc811fbc5b3192e16a0248ea63 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:26:32 +0000 Subject: [PATCH 25/26] docs: clarify desugaring, annotation checks, variable usage, and WASM compilation contracts --- src/ephapax-desugar/src/lib.rs | 15 +++++++++++---- src/ephapax-parser/src/error.rs | 4 +++- src/ephapax-typing/src/lib.rs | 7 ++++++- src/ephapax-wasm/src/lib.rs | 15 +++++++++++++-- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/ephapax-desugar/src/lib.rs b/src/ephapax-desugar/src/lib.rs index 90ba43b6..44d34c47 100644 --- a/src/ephapax-desugar/src/lib.rs +++ b/src/ephapax-desugar/src/lib.rs @@ -756,8 +756,9 @@ impl Desugarer { /// expansion stack. Fails with [`DesugarError::RecursiveType`] if the /// same application is already being expanded (a regular recursive /// type such as `data L = Nil | Cons(I32, L)`), or if the stack grows - /// past a fixed bound (a non-regular recursive type, whose - /// applications never repeat). + /// to 64 active applications before this expansion begins (also bounding + /// non-regular recursion, whose applications may never repeat). Errors + /// returned by `f` propagate after this expansion is removed from the stack. fn with_expansion( &self, name: &SmolStr, @@ -1249,7 +1250,9 @@ impl Desugarer { /// Bind pattern variables from a constructor's payload. /// /// For `Some(x)` with payload `p`: `let x = p in body` - /// For `Pair(a, b)` with payload `p`: `let a = p.0 in let b = p.1 in body` + /// For `Pair(a, b)` with payload `p`: `match p { (a, b) => body }`. + /// Multiple irrefutable fields destructure a right-nested product in one + /// match; other field patterns use projections. No patterns returns `body`. fn bind_pattern_vars( &self, payload: &Expr, @@ -1309,7 +1312,11 @@ impl Desugarer { } } - /// Bind a single pattern variable. + /// Wrap `body` with bindings extracted from `value` by `pattern`. + /// + /// Irrefutable pair patterns use one core match; other pairs and tuples + /// are destructured recursively. Wildcards, unit, constructors and literals + /// return `body` without adding bindings or checking the pattern. fn bind_single_pattern( &self, value: &Expr, diff --git a/src/ephapax-parser/src/error.rs b/src/ephapax-parser/src/error.rs index 1d4169c4..648bedba 100644 --- a/src/ephapax-parser/src/error.rs +++ b/src/ephapax-parser/src/error.rs @@ -60,7 +60,9 @@ impl ParseError { /// enforce. Any of these on a declaration is a parse error. pub const UNENFORCED_ANNOTATIONS: &'static [&'static str] = &["total", "pure", "comptime"]; - /// Reject an annotation pair if it names an unenforced property. + /// Reject `total`, `pure` or `comptime` with + /// [`ParseError::AnnotationNotEnforced`] at `span`. Other names are accepted; + /// acceptance does not mean their properties are enforced. pub fn check_annotation(name: &str, span: Span) -> Result<(), ParseError> { if Self::UNENFORCED_ANNOTATIONS.contains(&name) { Err(ParseError::AnnotationNotEnforced { diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index 457c59fa..d31b3111 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -279,7 +279,12 @@ impl Context { self.vars.get(name).map(|e| &e.ty) } - /// Mark variable as used (for linear variables) + /// Record a use of the current binding of `name`. + /// + /// Returns [`TypeError::UnboundVariable`] if the name is absent, or + /// [`TypeError::LinearVariableReused`] on a second use of a linear-typed + /// binding or any binding that demands consumption (including `let!`). + /// Unrestricted bindings may be used repeatedly. pub fn mark_used(&mut self, name: &Var) -> Result<(), TypeError> { if let Some(entry) = self.vars.get_mut(name) { if entry.forbids_reuse() && entry.used { diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index 448ff54a..0e616cb9 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -256,7 +256,10 @@ pub fn first_unsupported(expr: &Expr) -> Option { children.into_iter().find_map(first_unsupported) } -/// Module-level [`first_unsupported`]: checks every function body. +/// Check every function body using [`first_unsupported`]. +/// +/// Returns the first unsupported-construct error in declaration order, with +/// the function name added. Non-function declarations are ignored. pub fn validate_supported(module: &AstModule) -> Result<(), CodegenError> { for decl in &module.decls { let (name, expr, kind) = match decl { @@ -784,6 +787,9 @@ impl Codegen { /// 1. Collects function signatures and assigns WASM indices. /// 2. Emits the type, import, memory, function, and code sections. /// 3. If a `main` function exists, exports it. + /// + /// Returns a [`CodegenError`] for unsupported constructs before emitting + /// sections, and propagates errors from function compilation. pub fn compile_ast_module(&mut self, ast: &AstModule) -> Result, CodegenError> { // ----- Phase 0: reject constructs with no wasm lowering ------------ // (v2 plan E1: fail loudly, before any section is emitted.) @@ -832,8 +838,13 @@ impl Codegen { Ok(self.module.clone().finish()) } - /// Compile a module that has a `main` function with a body expression. + /// Try full module compilation, falling back to `main_body` as a standalone + /// `main` expression if compilation fails. /// (Legacy helper retained for backward compatibility.) + /// + /// Unsupported constructs in either `module` or `main_body` return a + /// [`CodegenError`] before compilation, even when `main_body` would not be + /// used. Other module-compilation errors are caught to select the fallback. pub fn compile_module_with_main( &mut self, module: &AstModule, From 1ec49eedacf9ed1d6601f4807da513fb10a54017 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:58:41 +0000 Subject: [PATCH 26/26] fix: enforce linear usage, restore let scopes, reject raw FFI, and correct type expansion, annotation checks, and Coq census --- CHANGELOG.adoc | 4 +- docs/coq-rust-bridge/is_linear_ty.adoc | 30 ++++-- formal/regress/L2Comp.v | 11 +- formal/scripts/coq-decls.awk | 12 ++- formal/scripts/test-no-false-gate.sh | 8 +- src/ephapax-desugar/src/lib.rs | 122 ++++++++++++++++++---- src/ephapax-interp/src/lib.rs | 58 +++++------ src/ephapax-parser/src/lib.rs | 31 +++++- src/ephapax-parser/src/surface.rs | 7 +- src/ephapax-typing/src/lib.rs | 135 +++++++++++++++++++++++-- src/ephapax-wasm/src/lib.rs | 38 +++++++ 11 files changed, 364 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 4714c3ef..c452eac2 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -21,7 +21,9 @@ overflow. Pair destructuring now lowers to a core `+match+`. `+perform+`/`+handle+` are a `+CodegenError+` (no module is written, and `+check+` reports the same error); raw variadic `+__ffi+` is rejected (`+RawFfiNotSupported+`), so typed `+extern+` is the only FFI path and -the import index space is contiguous; `+@total+`, `+@pure+` and +the import index space is contiguous. The interpreter does not bind typed +`+extern+` declarations: calling one fails with an unbound-variable error +even with `+-L+`; `+@total+`, `+@pure+` and `+@comptime+` are a parse error (`+AnnotationNotEnforced+`); `+-O+` above 0 and `+--mode+` are rejected; `+--debug+` now adds the `+ephapax.debug.mode+` section as well as writing the source map. `+stdlib/Coproc.eph+` and diff --git a/docs/coq-rust-bridge/is_linear_ty.adoc b/docs/coq-rust-bridge/is_linear_ty.adoc index ebbdac5f..6b5db870 100644 --- a/docs/coq-rust-bridge/is_linear_ty.adoc +++ b/docs/coq-rust-bridge/is_linear_ty.adoc @@ -14,14 +14,17 @@ as one component is. Coq `is_linear_ty` still returns `false` for `TProd` and `TSum`, which is the shape refuted by `pair_dup_linear_string` (F5, `docs/reports/2026-11-04-v2-survey/`). The bridge claim below therefore *no longer holds* for `TProd` and -`TSum`; it still holds for every other kernel constructor. This is a +`TSum`, including under `TRegion` when a component is linear; it +still holds for the other kernel constructors, with region recursion +restricted to the agreeing subset. This is a deliberate, conservative Rust-side reject (plan rung E0), independent of decision D5, which may relax it in W1. The Coq side is not changed here. The rows for `TProd` / `TSum` in the tables below record the Coq value only; the Rust value is the join. ==== -This document closes proof-debt obligation **P28** from the +Proof-debt obligation **P28** remains **open**. This document records +partial evidence for the obligation from the comprehensive proof inventory: certify that the Rust-side `Ty::is_linear()` (at `src/ephapax-syntax/src/lib.rs:155-166`) agrees with the Coq-side `is_linear_ty` (at `formal/Syntax.v:467-473`) @@ -93,13 +96,16 @@ Specifically: The **kernel subset** is the image of an elaboration function `erase_surface_to_kernel : Rust::Ty → option Coq::ty` that fails on -the surface-only constructors. The bridge claim is then: +the surface-only constructors. Restrict the bridge claim further by +excluding `TProd` and `TSum` and requiring a `TRegion` inner type to +belong to this restricted subset. The remaining claim is: [NOTE] .Bridge claim (P28-kernel) ==== For every `t : Rust::Ty` and `t_kernel : Coq::ty` such that -`erase_surface_to_kernel(t) = Some(t_kernel)`, +`erase_surface_to_kernel(t) = Some(t_kernel)` and `t_kernel` belongs +to the restricted subset above, `Rust::is_linear(t) == Coq::is_linear_ty(t_kernel)`. ==== @@ -138,7 +144,7 @@ on the witness. `Ty::Region { inner, .. }`:: Recurses on `inner`. Same as Coq's `TRegion _ T'`. The bridge holds -directly. +when the inner type belongs to the restricted agreeing subset. Since E0, `Ty::Prod`, `Ty::Sum`, `Ty::Tuple` and `Ty::List` recurse (join) on the Rust side; see the divergence note at the top. The other @@ -153,21 +159,23 @@ context discipline), not at the type-shape level. == Mechanical verification -Two artefacts pin the bridge claim: +Two artefacts provide definition-level and sampled test evidence; +neither establishes a universal Rust–Coq correspondence: 1. **`formal/Syntax.v:467-473`** — the canonical Coq definition; build oracle `coqc 8.18.0` is authoritative. 2. **`src/ephapax-syntax/src/lib.rs`, `mod tests`** — Rust unit tests - that hardcode the truth table for every kernel `Ty` variant and - assert `Ty::is_linear()` matches. Added in the same PR as this - document. + that sample constructor behaviour and explicitly pin the product/sum + divergence. These tests do not prove the restricted claim universally. -Future work for the full Rust ↔ Coq reflection arc (P25–P27): +Remaining work for P28 and the full Rust ↔ Coq reflection arc (P25–P27): * Define `erase_surface_to_kernel` formally in either Coq (as a Definition over a Coq-side AST of `Rust::Ty`) or in a separate Idris2 module that bridges both. -* Lift this lemma from `is_linear_ty` to the full typing judgment: +* Prove the restricted predicate correspondence, and resolve the + product/sum divergence before claiming agreement on the full kernel. +* Lift the eventual lemma from `is_linear_ty` to the full typing judgment: if Rust's `LinearChecker::check(e)` accepts, then the elaborated expression types in `has_type_l1 Linear`. diff --git a/formal/regress/L2Comp.v b/formal/regress/L2Comp.v index 0bfeb8e3..0857af02 100644 --- a/formal/regress/L2Comp.v +++ b/formal/regress/L2Comp.v @@ -5,11 +5,12 @@ [L2_lift_l1] (any [has_type_l1] derivation) and [T_App_L2_Eff] (application of a [TFunEff]). There is no L2-level pair / let / lambda / region rule, and [has_type_l1] has no rule eliminating - [TFunEff]. So an effect-typed application formed by [T_App_L2_Eff] - can appear only as an operand of another [T_App_L2_Eff] (compatible - L2 applications do nest); it cannot be placed inside a pair, let, - lambda body, region or any other form typed only by [has_type_l1]. - The lemma below shows the pairing case. + [TFunEff]. An application expression may also have an L1 derivation, + which can be used by an L1 compound rule such as pairing. An L2-only + derivation cannot directly satisfy an L1 premise. Compatible L2 + applications do nest via [T_App_L2_Eff]. The theorem below proves + unpairability for the specific witness [app] at the stated inputs; + it does not classify all application expressions. "Compose" here means the L1 pairing rule [T_Pair_L1], lifted to L2: L2 ⊢ e1 : T1 [R→R1, G→G1] and L2 ⊢ e2 : T2 [R1→R2, G1→G2] diff --git a/formal/scripts/coq-decls.awk b/formal/scripts/coq-decls.awk index 037866ca..49aa772f 100644 --- a/formal/scripts/coq-decls.awk +++ b/formal/scripts/coq-decls.awk @@ -10,7 +10,7 @@ # keyword and its name may sit on different lines. # # A named result is a Theorem, Lemma, Corollary, Proposition, Fact, -# Remark, Example, named Instance, Definition, Fixpoint or CoFixpoint +# Remark, Example, named Instance, Definition, Fixpoint, CoFixpoint or Function # (with any Program/Local/Global prefix), so a proof closed with # "Defined." or a definition built by tactics is censused like a lemma. # For a mutual "Fixpoint f ... with g ...", only f is listed: f and g are @@ -51,7 +51,7 @@ END { function trim(x) { sub(/^ +/, "", x); sub(/ +$/, "", x); return x } -function sentence(s, w, k, i, name, kind, skip, opt, path, rest) { +function sentence(s, w, k, i, name, kind, skip, opt, path, rest, valid) { s = trim(s) # Drop attributes and locality/flag prefixes. while (match(s, /^#\[[^]]*\] */)) s = substr(s, RLENGTH + 1) @@ -84,9 +84,11 @@ function sentence(s, w, k, i, name, kind, skip, opt, path, rest) { if (w[1] == "Section") { push(w[2], "section"); return } if (w[1] == "End") { pop(w[2]); return } - if (w[1] ~ /^(Theorem|Lemma|Corollary|Proposition|Fact|Remark|Example|Instance|Definition|Fixpoint|CoFixpoint)$/) { - name = w[2]; sub(/[^A-Za-z0-9_'].*$/, "", name) - if (name !~ /^[A-Za-z_][A-Za-z0-9_']*$/) return # anonymous Instance + if (w[1] ~ /^(Theorem|Lemma|Corollary|Proposition|Fact|Remark|Example|Instance|Definition|Fixpoint|CoFixpoint|Function)$/) { + name = w[2]; sub(/[(:{].*$/, "", name) + # Accept non-ASCII identifier bytes intact; never census a prefix. + valid = name; gsub(/[A-Za-z0-9_']/, "", valid) + if (name == "" || name ~ /^[0-9']/ || valid ~ /[[:cntrl:][:punct:][:space:]]/) return skip = 0; opt = ""; path = prefix for (i = 1; i <= depth; i++) { if (kinds[i] == "mtype" || kinds[i] == "functor") skip = 1 diff --git a/formal/scripts/test-no-false-gate.sh b/formal/scripts/test-no-false-gate.sh index 4dbc5d62..a99c4a20 100755 --- a/formal/scripts/test-no-false-gate.sh +++ b/formal/scripts/test-no-false-gate.sh @@ -75,6 +75,8 @@ expect_decls d_defs $'Definition d1 : nat := 0.\nDefinition\n d2 : True.\nProof # them. "<:" keeps every field visible, so its results are required. expect_decls d_sealed $'Module Type S.\n Parameter t : Type.\nEnd S.\nModule M : S.\n Definition t := nat.\n Lemma hidden : True. Proof. exact I. Qed.\nEnd M.\nModule N <: S.\n Definition t := nat.\n Lemma shown : True. Proof. exact I. Qed.\nEnd N.\nModule W : S with Definition t := nat.\n Definition t := nat.\n Lemma hidden2 : True. Proof. exact I. Qed.\nEnd W.\nModule V:S.\n Definition t := nat.\nEnd V.\nModule A := N.' \ $'?P.M.t\n?P.M.hidden\nP.N.t\nP.N.shown\n?P.W.t\n?P.W.hidden2\n?P.V.t' +expect_decls d_function $'Module M.\n Function f (n : nat) := n.\nEnd M.' 'P.M.f' +expect_decls d_accent $'Module M : S.\n Definition café := 0.\nEnd M.' '?P.M.café' # An unmatched End must fail loudly, not mis-qualify. f=$(frag d_bad $'Module A.\nLemma x : True. Proof. exact I. Qed.\nEnd B.') if nofalse_decls P "$f" >/dev/null 2>&1; then bad "unmatched End accepted"; else ok; fi @@ -96,11 +98,13 @@ Require Import Coq.Logic.FunctionalExtensionality. Module Type SIG. Parameter uses_funext : forall f g : nat -> nat, (forall x, f x = g x) -> f = g. Parameter clean : True. + Parameter café : True. End SIG. Module M : SIG. Lemma uses_funext : forall f g : nat -> nat, (forall x, f x = g x) -> f = g. Proof. intros; apply functional_extensionality; auto. Qed. Lemma clean : True. Proof. exact I. Qed. + Lemma café : True. Proof. exact I. Qed. Lemma hidden : forall f g : nat -> nat, (forall x, f x = g x) -> f = g. Proof. intros; apply functional_extensionality; auto. Qed. End M. @@ -108,9 +112,9 @@ COQ if (cd "$T/coq" && coqc -q -R . NFT Sealed.v) >/dev/null 2>&1; then names=$(nofalse_strip "$T/coq/Sealed.v" > "$T/coq/Sealed.stripped" \ && nofalse_decls NFT.Sealed "$T/coq/Sealed.stripped" | tr '\n' ' ') - if [ "$names" = "?NFT.Sealed.M.uses_funext ?NFT.Sealed.M.clean ?NFT.Sealed.M.hidden " ]; then ok + if [ "$names" = "?NFT.Sealed.M.uses_funext ?NFT.Sealed.M.clean ?NFT.Sealed.M.café ?NFT.Sealed.M.hidden " ]; then ok else bad "sealed census names: $names"; fi - for c in "M.uses_funext open" "M.clean closed" "M.hidden hidden"; do + for c in "M.uses_funext open" "M.clean closed" "M.café closed" "M.hidden hidden"; do read -r nm want <<< "$c" got=$(cd "$T/coq" && nofalse_sealed_check NFT Sealed "NFT.Sealed.$nm" | head -1) if [ "$got" = "$want" ]; then ok; else bad "sealed check $nm: expected $want, got $got"; fi diff --git a/src/ephapax-desugar/src/lib.rs b/src/ephapax-desugar/src/lib.rs index 44d34c47..05deaf09 100644 --- a/src/ephapax-desugar/src/lib.rs +++ b/src/ephapax-desugar/src/lib.rs @@ -745,7 +745,11 @@ impl Desugarer { } // Build substitution map: type param → concrete type - let subst: HashMap<&SmolStr, &SurfaceTy> = params.iter().zip(args.iter()).collect(); + let core_args = args + .iter() + .map(|arg| self.desugar_ty(arg)) + .collect::, _>>()?; + let subst: HashMap<&SmolStr, &Ty> = params.iter().zip(core_args.iter()).collect(); // Build right-nested sum from constructor field types let ctors = ctors.clone(); @@ -754,26 +758,20 @@ impl Desugarer { /// Run `f` with the named type application `name(args)` pushed on the /// expansion stack. Fails with [`DesugarError::RecursiveType`] if the - /// same application is already being expanded (a regular recursive - /// type such as `data L = Nil | Cons(I32, L)`), or if the stack grows - /// to 64 active applications before this expansion begins (also bounding - /// non-regular recursion, whose applications may never repeat). Errors - /// returned by `f` propagate after this expansion is removed from the stack. + /// same declaration is already being expanded, including non-regular + /// recursion with changing arguments. Arguments are elaborated before + /// entering the declaration, so finite nested applications remain valid. + /// Errors propagate after removing this expansion from the stack. fn with_expansion( &self, name: &SmolStr, - args: &[SurfaceTy], + _args: &[SurfaceTy], f: impl FnOnce(&Self) -> Result, ) -> Result { - const MAX_EXPANSION_DEPTH: usize = 64; - let key: SmolStr = if args.is_empty() { - name.clone() - } else { - format!("{name}{args:?}").into() - }; + let key = name.clone(); { let stack = self.expanding.borrow(); - if stack.contains(&key) || stack.len() >= MAX_EXPANSION_DEPTH { + if stack.contains(&key) { let mut cycle: Vec = stack.iter().map(|k| k.to_string()).collect(); cycle.push(key.to_string()); return Err(DesugarError::RecursiveType { @@ -798,7 +796,7 @@ impl Desugarer { fn build_sum_type( &self, ctors: &[ConstructorDef], - subst: &HashMap<&SmolStr, &SurfaceTy>, + subst: &HashMap<&SmolStr, &Ty>, ) -> Result { assert!( !ctors.is_empty(), @@ -826,7 +824,7 @@ impl Desugarer { fn build_payload_type( &self, fields: &[SurfaceTy], - subst: &HashMap<&SmolStr, &SurfaceTy>, + subst: &HashMap<&SmolStr, &Ty>, ) -> Result { match fields.len() { 0 => Ok(Ty::Base(BaseTy::Unit)), @@ -847,12 +845,12 @@ impl Desugarer { fn subst_and_desugar_ty( &self, ty: &SurfaceTy, - subst: &HashMap<&SmolStr, &SurfaceTy>, + subst: &HashMap<&SmolStr, &Ty>, ) -> Result { match ty { SurfaceTy::Var(v) => { if let Some(replacement) = subst.get(v) { - self.desugar_ty(replacement) + Ok((*replacement).clone()) } else { Ok(Ty::Var(v.clone())) } @@ -898,7 +896,7 @@ impl Desugarer { let payload = self.build_payload_expr(args, span)?; // Get the full sum type for the "other side" annotations - let empty_subst: HashMap<&SmolStr, &SurfaceTy> = HashMap::new(); + let empty_subst: HashMap<&SmolStr, &Ty> = HashMap::new(); let (_, ctor_defs) = self .registry .get_type_ctors(info.data_name.as_str()) @@ -940,7 +938,7 @@ impl Desugarer { payload: Expr, index: usize, ctors: &[ConstructorDef], - subst: &HashMap<&SmolStr, &SurfaceTy>, + subst: &HashMap<&SmolStr, &Ty>, span: Span, ) -> Result { let total = ctors.len(); @@ -2130,3 +2128,87 @@ mod tests { } } } + +#[cfg(test)] +mod expansion_regressions { + use super::*; + fn named(name: &str) -> SurfaceTy { + SurfaceTy::Named { + name: name.into(), + args: vec![], + } + } + #[test] + fn nested_finite_application_and_nonregular_recursion() { + let mut registry = DataRegistry::new(); + registry.types.insert( + "Id".into(), + ( + vec!["a".into()], + vec![ConstructorDef { + name: "IdValue".into(), + fields: vec![SurfaceTy::Var("a".into())], + }], + ), + ); + let inner = SurfaceTy::Named { + name: "Id".into(), + args: vec![SurfaceTy::Base(BaseTy::I32)], + }; + let nested = SurfaceTy::Named { + name: "Id".into(), + args: vec![inner], + }; + assert_eq!( + Desugarer::with_registry(registry.clone()) + .desugar_ty(&nested) + .unwrap(), + Ty::Base(BaseTy::I32) + ); + registry.types.insert( + "Grow".into(), + ( + vec!["a".into()], + vec![ConstructorDef { + name: "More".into(), + fields: vec![SurfaceTy::Named { + name: "Grow".into(), + args: vec![SurfaceTy::List(Box::new(SurfaceTy::Var("a".into())))], + }], + }], + ), + ); + let recursive = SurfaceTy::Named { + name: "Grow".into(), + args: vec![SurfaceTy::Base(BaseTy::I32)], + }; + assert!(matches!( + Desugarer::with_registry(registry).desugar_ty(&recursive), + Err(DesugarError::RecursiveType { .. }) + )); + } + #[test] + fn long_finite_alias_chain_and_cycle() { + let mut registry = DataRegistry::new(); + for i in 0..65 { + registry.register_type_alias( + format!("A{i}").into(), + if i == 64 { + SurfaceTy::Base(BaseTy::I32) + } else { + named(&format!("A{}", i + 1)) + }, + ); + } + let desugarer = Desugarer::with_registry(registry.clone()); + assert_eq!( + desugarer.desugar_ty(&named("A0")).unwrap(), + Ty::Base(BaseTy::I32) + ); + registry.register_type_alias("A64".into(), named("A0")); + assert!(matches!( + Desugarer::with_registry(registry).desugar_ty(&named("A0")), + Err(DesugarError::RecursiveType { .. }) + )); + } +} diff --git a/src/ephapax-interp/src/lib.rs b/src/ephapax-interp/src/lib.rs index 09214f8d..a90be8e7 100644 --- a/src/ephapax-interp/src/lib.rs +++ b/src/ephapax-interp/src/lib.rs @@ -528,41 +528,9 @@ impl Interpreter { )), } } - ExprKind::FFI { symbol, args } => { - // Evaluate all arguments to i64 values for C ABI. - // CStrings are kept alive in this vec until the call completes. - let mut cstrings = Vec::new(); - let mut c_args: Vec = Vec::new(); - for arg in args { - // String literals in FFI context: create Value::String - // directly (bypass region requirement) - let val = match &arg.kind { - ExprKind::Lit(Literal::String(s)) => Value::String { - data: s.clone(), - region: "ffi".into(), - }, - _ => self.eval(arg)?, - }; - c_args.push(self.value_to_i64_with_cstrings(&val, &mut cstrings)); - } - - // Try to find the symbol in loaded FFI libraries - if let Some(func) = self.find_ffi_symbol(symbol) { - // Pad args to 6 (max C ABI args we support) - let a = |i: usize| *c_args.get(i).unwrap_or(&0); - // SAFETY: `func` was resolved via find_ffi_symbol from a - // loaded library to the 6-arg i64 C-ABI signature it is - // called with here; args are padded to 6 so all params are - // supplied. Soundness ultimately rests on the caller having - // loaded a trustworthy library (see load_ffi_library). - let result = unsafe { func(a(0), a(1), a(2), a(3), a(4), a(5)) }; - Ok(Value::I64(result)) - } else { - // No library loaded or symbol not found — stub mode - eprintln!("[ffi:stub] {}({:?})", symbol, c_args); - Ok(Value::I64(0)) - } - } + ExprKind::FFI { .. } => Err(RuntimeError::Unimplemented( + "raw __ffi is not supported; use typed extern declarations".into(), + )), ExprKind::Perform { .. } => Err(RuntimeError::Unimplemented( "effect perform not yet implemented in interpreter".into(), @@ -1889,3 +1857,23 @@ mod tests { assert!(matches!(result, Value::I32(999)), "got {:?}", result); } } + +#[cfg(test)] +mod ffi_regression { + use super::*; + use ephapax_syntax::Span; + #[test] + fn raw_ffi_is_rejected_before_arguments_are_evaluated() { + let call = Expr::new( + ExprKind::FFI { + symbol: "unchecked".into(), + args: vec![Expr::new(ExprKind::Var("missing".into()), Span::dummy())], + }, + Span::dummy(), + ); + assert!(matches!( + Interpreter::new().eval(&call), + Err(RuntimeError::Unimplemented(_)) + )); + } +} diff --git a/src/ephapax-parser/src/lib.rs b/src/ephapax-parser/src/lib.rs index d74785e8..f63e2679 100644 --- a/src/ephapax-parser/src/lib.rs +++ b/src/ephapax-parser/src/lib.rs @@ -145,8 +145,11 @@ fn parse_declaration(pair: pest::iterators::Pair) -> Result) -> Result, /// Solved unification variables: id -> type. unif_solutions: HashMap, /// Data declarations visible to the current module. @@ -870,6 +875,7 @@ impl TypeChecker { Self { ctx: Context::new(), next_unif: 0, + wildcard_obligations: Vec::new(), unif_solutions: HashMap::new(), data_registry: DataCtorRegistry::new(), } @@ -912,6 +918,16 @@ impl TypeChecker { ty.resolve(&self.unif_solutions) } + fn validate_wildcards(&self) -> Result<(), SpannedTypeError> { + for (span, ty) in &self.wildcard_obligations { + let resolved = self.resolve(ty); + if resolved.is_linear() { + return Err(self.at(*span, TypeError::LinearPatternDiscard(resolved))); + } + } + Ok(()) + } + /// Unify two types. On success, updates unification solutions. /// On failure, returns a TypeMismatch error at the given span. fn unify(&mut self, s: Span, a: &Ty, b: &Ty) -> Result<(), SpannedTypeError> { @@ -934,7 +950,7 @@ impl TypeChecker { )); } self.unif_solutions.insert(*id, b); - Ok(()) + self.validate_wildcards() } (_, Ty::Unif(id)) => { if a.contains_unif(*id) { @@ -947,7 +963,7 @@ impl TypeChecker { )); } self.unif_solutions.insert(*id, a); - Ok(()) + self.validate_wildcards() } // Structural cases @@ -1179,7 +1195,10 @@ impl TypeChecker { let value_ty = self.check(value)?; if let Some(declared) = ty_ann { - self.unify(s, declared, &value_ty).map_err(|_| { + self.unify(s, declared, &value_ty).map_err(|error| { + if matches!(error.error, TypeError::LinearPatternDiscard(_)) { + return error; + } self.at( s, TypeError::AnnotationMismatch { @@ -1199,8 +1218,9 @@ impl TypeChecker { let shadowed = self .ctx .bind_scoped(name.clone(), resolved_ty, BindingForm::Let); - let body_ty = self.check(body)?; + let body_ty = self.check(body); self.ctx.end_scope(name, shadowed); + let body_ty = body_ty?; // `let` is AFFINE — unconsumed bindings are allowed (implicit drop). Ok(body_ty) @@ -1310,6 +1330,7 @@ impl TypeChecker { .map_err(|e| self.at(s, e))?; self.ctx = ctx_after_then; + self.ctx.merge_branches(&ctx_after_else); Ok(self.resolve(&then_ty)) } @@ -1618,6 +1639,7 @@ impl TypeChecker { .map_err(|e| self.at(s, e))?; self.ctx = ctx_after_left; + self.ctx.merge_branches(&ctx_after_right); Ok(self.resolve(&left_result_ty)) } @@ -1699,11 +1721,12 @@ impl TypeChecker { } arm_result_ty = Some(body_ty); - match &first_post_ctx { + match &mut first_post_ctx { Some(first) => { first .check_branch_agreement(&ctx_after_arm) .map_err(|e| self.at(s, e))?; + first.merge_branches(&ctx_after_arm); } None => first_post_ctx = Some(ctx_after_arm), } @@ -1729,12 +1752,12 @@ impl TypeChecker { let expected = self.resolve(expected_ty); match pattern { // A wildcard binds nothing, so it would silently discard a - // linear component. (An unresolved unification variable is - // treated as unrestricted, like `Ty::Var`.) + // linear component. Inferred types are rechecked after unification. Pattern::Wildcard => { if expected.is_linear() { return Err(self.at(s, TypeError::LinearPatternDiscard(expected))); } + self.wildcard_obligations.push((s, expected)); Ok(Vec::new()) } Pattern::Var(name) => { @@ -2636,6 +2659,7 @@ fn type_check_module_inner(tc: &mut TypeChecker, module: &Module) -> Result<(), tc.ctx = saved_ctx; tc.next_unif = saved_unif; tc.unif_solutions.clear(); + tc.wildcard_obligations.clear(); } Decl::Type { .. } => {} Decl::Const { .. } => {} // Constants are handled in module registration @@ -4814,3 +4838,96 @@ mod tests { } } } + +#[cfg(test)] +mod review_regressions { + use super::*; + fn expr(kind: ExprKind) -> Expr { + Expr::new(kind, Span::dummy()) + } + #[test] + fn failed_let_restores_outer_binding() { + let mut tc = TypeChecker::new(); + tc.ctx + .extend("x".into(), Ty::Base(BaseTy::Bool), BindingForm::Let); + assert!(tc + .check_let( + Span::dummy(), + &"x".into(), + None, + &expr(ExprKind::Lit(Literal::I32(1))), + &expr(ExprKind::Var("missing".into())) + ) + .is_err()); + assert_eq!( + tc.check(&expr(ExprKind::Var("x".into()))).unwrap(), + Ty::Base(BaseTy::Bool) + ); + } + #[test] + fn wildcard_obligation_follows_unification() { + let mut tc = TypeChecker::new(); + let ty = tc.fresh_unif(); + tc.check_pattern(Span::dummy(), &Pattern::Wildcard, &ty) + .unwrap(); + assert!(matches!( + tc.unify(Span::dummy(), &ty, &Ty::String("r".into())) + .unwrap_err() + .error, + TypeError::LinearPatternDiscard(_) + )); + } + #[test] + fn match_join_reaches_closure_capture_check() { + let mut tc = TypeChecker::new(); + tc.ctx.extend( + "x".into(), + Ty::Prod { + left: Box::new(Ty::String("r".into())), + right: Box::new(Ty::Base(BaseTy::Unit)), + }, + BindingForm::Let, + ); + let body = expr(ExprKind::Match { + scrutinee: Box::new(expr(ExprKind::Lit(Literal::I32(0)))), + arms: vec![ + MatchArm { + pattern: Pattern::Literal(Literal::I32(0)), + guard: None, + body: expr(ExprKind::Lit(Literal::Unit)), + }, + MatchArm { + pattern: Pattern::Literal(Literal::I32(1)), + guard: None, + body: expr(ExprKind::Drop(Box::new(expr(ExprKind::Var("x".into()))))), + }, + MatchArm { + pattern: Pattern::Wildcard, + guard: None, + body: expr(ExprKind::Lit(Literal::Unit)), + }, + ], + }); + let result = tc.check_lambda(Span::dummy(), &"arg".into(), &Ty::Base(BaseTy::Unit), &body); + assert!(matches!( + result.unwrap_err().error, + TypeError::LinearCaptureConsumed(_) + )); + } + #[test] + fn affine_branch_consumption_is_joined() { + let mut tc = TypeChecker::new(); + tc.ctx + .extend("x".into(), Ty::String("r".into()), BindingForm::Let); + let branch = expr(ExprKind::If { + cond: Box::new(expr(ExprKind::Lit(Literal::Bool(true)))), + then_branch: Box::new(expr(ExprKind::Lit(Literal::Unit))), + else_branch: Box::new(expr(ExprKind::Drop(Box::new(expr(ExprKind::Var( + "x".into(), + )))))), + }); + tc.check(&branch).unwrap(); + assert!(tc.ctx.vars["x"].used); + assert!(tc.check(&expr(ExprKind::Var("x".into()))).is_err()); + } +} diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index 0e616cb9..5924f7dd 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -2217,12 +2217,23 @@ impl Codegen { self.compile_expr(func, value); // Bind it to a local + let outer_idx = self.locals.name_to_idx.get(name).copied(); + let outer_ty = self.locals.name_to_type.get(name).copied(); let local_idx = self.locals.bind_typed(name, is_linear, local_ty); func.instruction(&Instruction::LocalSet(local_idx)); // Compile the body self.compile_expr(func, body); + self.locals.name_to_idx.remove(name); + self.locals.name_to_type.remove(name); + if let Some(idx) = outer_idx { + self.locals.name_to_idx.insert(name.to_string(), idx); + } + if let Some(ty) = outer_ty { + self.locals.name_to_type.insert(name.to_string(), ty); + } + // Handle unconsumed linear locals (mode-aware): // - Linear mode: Type checker already validated consumption, no action needed // - Affine mode: Could emit implicit drop, but type checker allows it @@ -3797,6 +3808,33 @@ mod tests { // Let bindings // ----------------------------------------------------------------------- + #[test] + fn let_restores_shadowed_local_maps() { + let mut codegen = Codegen::new(); + let outer = codegen.locals.bind_typed("x", false, ValType::I64); + let mut func = Function::new([]); + codegen.compile_let( + &mut func, + "x", + None, + &e(ExprKind::Lit(Literal::I32(1))), + &e(ExprKind::Var("x".into())), + false, + ); + assert_eq!(codegen.locals.get("x"), Some(outer)); + assert_eq!(codegen.locals.get_type("x"), Some(ValType::I64)); + codegen.compile_let( + &mut func, + "y", + None, + &e(ExprKind::Lit(Literal::I32(1))), + &e(ExprKind::Var("y".into())), + false, + ); + assert_eq!(codegen.locals.get("y"), None); + assert_eq!(codegen.locals.get_type("y"), None); + } + #[test] fn compile_let_x_42_in_x() { let mut codegen = Codegen::new();