Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-lean
- uses: actions/setup-python@v5
with:
python-version: '3.x'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

python-version: '3.x' plus an unpinned pip install pyyaml on line 25 sits oddly in a PR whose whole subject is making the toolchain deterministic. Both float, and either one can change the generated RuleData.lean (or break gen-rule-data.py) without a commit here. If phino is worth pinning to a four-part version, Python and PyYAML are worth pinning to at least a minor.

- name: Regenerate Rules.lean and RuleData.lean from pinned phino

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

rules-in-sync had git diff --exit-code; this regen-and-overwrite never compares against the committed files. Committed Rules.lean/RuleData.lean can now drift silently from what CI builds, and a local lake build uses different sources than CI. Add a diff check after regen, or stop committing the generated files.

run: |
pip install pyyaml
bash scripts/regen-rules.sh
bash scripts/regen-rule-data.sh

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Regenerating into the working tree right before lake build means the build compiles files that may differ from the committed ones, and says nothing about the difference. Restoring the deleted gate here is cheap and keeps the new pinning:

          bash scripts/regen-rules.sh
          bash scripts/regen-rule-data.sh
          git diff --exit-code PhiConfluence/Rules.lean PhiConfluence/RuleData.lean \
            || { echo "::error::generated rules are out of sync — run the regen scripts and commit"; exit 1; }

That way the build still compiles phino-derived rules, and a stale committed file fails instead of being silently replaced.

- name: Build the library and executables
run: |
lake build
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/difftest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install phino (latest)
- name: Install phino (pinned via .phino-version)
run: |
sudo curl -fsSL -o /usr/local/bin/phino http://phino.objectionary.com/releases/ubuntu-24.04/phino-latest
version="$(cat .phino-version)"
sudo curl -fsSL -o /usr/local/bin/phino "http://phino.objectionary.com/releases/ubuntu-24.04/phino-$version"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since this line is being rewritten anyway: it fetches an executable over plain http://, chmod +xes it and installs it into /usr/local/bin, and the only integrity check is that phino --version runs. Anyone able to answer that request controls what the differential test measures — and this PR is specifically about trusting phino as the reference. https:// if the host serves it, and ideally a checksum next to .phino-version that the workflow verifies.

sudo chmod +x /usr/local/bin/phino
phino --version
- uses: ./.github/actions/setup-lean
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/ruff.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 Objectionary.com
# SPDX-License-Identifier: MIT
---
name: ruff
'on':
push:
branches:
- master
pull_request:
branches:
- master
jobs:
ruff:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: astral-sh/ruff-action@v3

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nice to have lint on the two scripts. astral-sh/ruff-action@v3 resolves the ruff version at run time, though, so a new ruff release can turn this red without a commit in the repo — the same floating-dependency problem the rest of the PR is fixing. Pass an explicit version: here, and consider a [tool.ruff] section so the rule set is visible in-repo rather than being ruff's rolling default.

29 changes: 0 additions & 29 deletions .github/workflows/rules-in-sync.yml

This file was deleted.

1 change: 1 addition & 0 deletions .phino-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.0.0.74

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0.0.0.74 was phino's tip when this PR was opened (tag 0.0.74, 2026-06-03), but phino ships fast: it is at 0.0.106 now (2026-07-23), 32 releases later. Nothing in this repo will ever tell you that — there is no renovate config, and after this PR every consumer of phino (regen-rules.sh, regen-rule-data.sh, difftest.sh, confluence-probe.sh) reads this file, so they all agree with each other and none of them agrees with reality.

Since difftest is what pins Step to phino behaviorally, a stale pin quietly narrows the theorem's meaning: it becomes confluence-versus-June-phino. Please add something that fails on staleness — a scheduled workflow diffing this file against gh api repos/objectionary/phino/tags, or a renovate rule — otherwise the loud failure this PR removes doesn't come back anywhere.

2 changes: 2 additions & 0 deletions PhiConfluence.lean
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import PhiConfluence.Step
import PhiConfluence.Reduce
import PhiConfluence.Render
import PhiConfluence.Rules
import PhiConfluence.RuleSchema
import PhiConfluence.RuleData

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The description says the Lean layer "ships here as a reviewed draft only — it is not imported into PhiConfluence.lean, so the build and the [propext, Quot.sound] axiom gate are unchanged until it's compiled (I didn't have a Lean toolchain on hand)". These two imports are that import, and the build check is green, so the files did compile in CI.

No objection to importing them — the build proves they are fine. But please correct the description: a reviewer taking that caveat at face value would assume the root module is untouched and skim these files, which is the opposite of what should happen.

import PhiConfluence.Parallel
import PhiConfluence.Preservation
import PhiConfluence.Diamond
Expand Down
33 changes: 33 additions & 0 deletions PhiConfluence/RuleData.lean
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-- SPDX-FileCopyrightText: Copyright (c) 2026 Objectionary.com
-- SPDX-License-Identifier: MIT

-- AUTO-GENERATED by scripts/gen-rule-data.py from objectionary/phino resources/*.yaml.
-- DO NOT EDIT BY HAND. Regenerate after phino's rules change.

import PhiConfluence.RuleSchema

/-!
# Normalization rules as structured data, generated from phino

The eleven rules as `RuleEntry` tags (types in PhiConfluence/RuleSchema.lean), emitted
by the fidelity-lock deriver `scripts/gen-rule-data.py`. The CI build regenerates this
file from pinned phino and compiles it, so the proof builds against phino's rules.
-/

namespace PhiConfluence

/-- The φ-calculus normalization rules as structured, interpretable data. -/
def normalizationRuleData : List RuleEntry :=
[ { name := "alpha", shape := .appForm, conds := [.alphaVoidOrdinal], rhs := .alphaRename }
, { name := "copy", shape := .appForm, conds := [.slotVoid, .argXiFree, .argNf], rhs := .copyFill }
, { name := "dc", shape := .appBot, conds := [], rhs := .bot }
, { name := "dd", shape := .dispatchBot, conds := [], rhs := .bot }
, { name := "dot", shape := .dispatchForm, conds := [.slotAttached, .valNf], rhs := .dotFeedback }
, { name := "miss", shape := .appForm, conds := [.slotAbsent, .attrNotAlpha], rhs := .bot }
, { name := "null", shape := .dispatchForm, conds := [.slotVoid], rhs := .bot }
, { name := "over", shape := .appForm, conds := [.slotAttached, .attrNeRho], rhs := .bot }
, { name := "phi", shape := .dispatchForm, conds := [.phiPresent, .slotAbsent], rhs := .phiExpand }
, { name := "stay", shape := .appForm, conds := [.attrIsRho, .slotAttached], rhs := .formSame }
, { name := "stop", shape := .dispatchForm, conds := [.slotAbsent, .phiAbsent, .noLambda], rhs := .bot } ]

end PhiConfluence
43 changes: 43 additions & 0 deletions PhiConfluence/RuleSchema.lean
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
-- SPDX-FileCopyrightText: Copyright (c) 2026 Objectionary.com
-- SPDX-License-Identifier: MIT

/-!
# Schema for the generated rule data

The typed vocabulary that `PhiConfluence/RuleData.lean` (generated from phino by
`scripts/gen-rule-data.py`) is written in: each normalization rule is a `RuleEntry` of
tags — the redex it fires on, its side-conditions, and its contractum. These are *data*
describing the eleven `phino` rules; the proof relation `Step` (`Step.lean`) is the
authoritative hand-written object, and `RuleData.lean` is kept identical to phino by the
`rule-data-in-sync` CI job.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

References the rule-data-in-sync CI job, dropped in 82fe64d — the build job does this now.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"kept identical to phino by the rule-data-in-sync CI job" — there is no such job. This PR deletes rules-in-sync.yml and adds ruff.yml; nothing named rule-data-in-sync exists here or on master. As written, the module doc promises a guarantee that no workflow provides, which is exactly the kind of claim this file should be careful about. Either add the job (see my build.yml comment) or describe what actually happens: the build regenerates and compiles it.

-/

namespace PhiConfluence

/-- Which redex a rule fires on: `⊥.a`, `⊥(a↦arg)`, `⟦bs⟧.a`, or `⟦bs⟧(a↦arg)`. -/
inductive RedexShape where
| dispatchBot | appBot | dispatchForm | appForm
deriving Repr, DecidableEq

/-- A side condition on the redex's formation, attribute, and argument. -/
inductive Cond where
| slotVoid | slotAttached | slotAbsent
| attrNeRho | attrIsRho | attrNotAlpha
| phiAbsent | phiPresent | noLambda
| valNf | argXiFree | argNf | alphaVoidOrdinal
deriving Repr, DecidableEq

/-- The contractum a rule rewrites its redex to. -/
inductive Contractum where
| bot | formSame | phiExpand | dotFeedback | copyFill | alphaRename
deriving Repr, DecidableEq

/-- One normalization rule as structured data. (Named `RuleEntry`, not `RuleSpec`,
to avoid colliding with `Render.lean`'s display-string `RuleSpec`.) -/
structure RuleEntry where
name : String
shape : RedexShape
conds : List Cond
rhs : Contractum

end PhiConfluence
1 change: 1 addition & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ version = 1
[[annotations]]
path = [
".gitignore",
".phino-version",
".yamllint",
"**.json",
"**.md",
Expand Down
18 changes: 10 additions & 8 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ independent directions:
* **Tiny, human-readable trusted surface.** Only `Syntax`, `Step`, `↝∗`, and the
`confluence` statement must be read and endorsed (a few dozen lines — the M0 spec).
* **Rule transcription vs phino.** The eleven-rule *display* table (`Rules.lean`) is
**generated** from phino's `resources/*.yaml` by `gen-rules.py` and pinned by the
`rules-in-sync` CI job, so the displayed rules cannot drift from phino/the paper. The
**generated** from phino's `resources/*.yaml` by `gen-rules.py` and regenerated from
pinned phino in the CI build, so the displayed rules cannot drift from phino/the paper. The
*proof relation* `Step` is hand-written (constructors are needed for case-analysis) and
pinned to phino **behaviorally** by `difftest`.
* **Differential testing against phino.** `Difftest.lean` + `scripts/difftest.sh` normalize
Expand Down Expand Up @@ -232,13 +232,15 @@ relation the confluence theorem governs.
from phino's `resources/*.yaml` — the same source the paper's Fig. 4 is rendered from — so
the displayed rules cannot drift from phino/the paper by construction.

**CI** (single-purpose workflows, each on push to `master` + PRs, tracking phino-latest):
**CI** (single-purpose workflows, each on push to `master` + PRs, against pinned phino):

* **build** — install elan, `lake exe cache get`, `lake build`; then a `sorry`/`admit`/
`axiom` source gate **and** a `#print axioms` gate on the headline results.
* **rules-in-sync** — `scripts/regen-rules.sh` clones `objectionary/phino` and regenerates
`Rules.lean`; `git diff --exit-code` fails if our committed table has drifted from phino.
* **difftest** — install the `phino-latest` binary, build `difftest`, run
* **build** — install elan and `lake exe cache get`; regenerate `Rules.lean` and
`RuleData.lean` from the pinned phino (`scripts/regen-rules.sh`,
`scripts/regen-rule-data.sh`) so the proof compiles against phino-derived rules; `lake
build`; then a `sorry`/`admit`/`axiom` source gate **and** a `#print axioms` gate on the
headline results. (`gen-rule-data.py`'s fidelity lock fails the build on rule-structure
drift.)
* **difftest** — install the pinned phino binary (`.phino-version`), build `difftest`, run
`scripts/difftest.sh`; fails if our reducer disagrees with phino.

The two Lean workflows share a composite action (`.github/actions/setup-lean`) that caches
Expand Down
9 changes: 5 additions & 4 deletions scripts/confluence-probe.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ cd "$(dirname "$0")/.."
ITERS="${ITERS:-24}"

command -v phino >/dev/null 2>&1 || { echo "phino not on PATH; skipping" >&2; exit 0; }
PHINO_VERSION="$(cat .phino-version)"

RESOURCES="${PHINO_RESOURCES:-}"
if [ -z "$RESOURCES" ]; then
Expand All @@ -50,7 +51,7 @@ total=0
# Normal form of a file, or the sentinel <diverge>/<error> for an unfinished/failed run.
norm() {
local out ec
out=$($TO phino rewrite --normalize --depth-sensitive --flat "$1" 2>&1); ec=$?
out=$($TO phino --pin="$PHINO_VERSION" rewrite --normalize --depth-sensitive --flat "$1" 2>&1); ec=$?
if [ "$ec" = 124 ]; then echo "<diverge>"
elif [ "$ec" = 0 ]; then echo "$out"
elif printf '%s' "$out" | grep -q 'depth-sensitive'; then echo "<diverge>"
Expand All @@ -71,16 +72,16 @@ probe() {

local i s ec
for ((i = 1; i <= ITERS; i++)); do
s=$($TO phino rewrite --normalize --shuffle --depth-sensitive --flat "$file" 2>&1); ec=$?
s=$($TO phino --pin="$PHINO_VERSION" rewrite --normalize --shuffle --depth-sensitive --flat "$file" 2>&1); ec=$?
[ "$ec" = 0 ] && [ -n "$s" ] && samples+=("shuffle $s")
done

local canon; canon=$($TO phino rewrite --flat "$file" 2>&1)
local canon; canon=$($TO phino --pin="$PHINO_VERSION" rewrite --flat "$file" 2>&1)
local rule forced nfr tmp
tmp="$(mktemp)"
for rule in $RULES; do
[ -f "$RESOURCES/$rule.yaml" ] || continue
forced=$($TO phino rewrite --rule "$RESOURCES/$rule.yaml" --max-depth 1 --max-cycles 1 --flat "$file" 2>&1)
forced=$($TO phino --pin="$PHINO_VERSION" rewrite --rule "$RESOURCES/$rule.yaml" --max-depth 1 --max-cycles 1 --flat "$file" 2>&1)
{ [ -z "$forced" ] || [ "$forced" = "$canon" ]; } && continue
printf '%s\n' "$forced" > "$tmp"
nfr=$(norm "$tmp")
Expand Down
6 changes: 4 additions & 2 deletions scripts/difftest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
#
# Differential test: for each example program, check that phino's normal form
# (`phino rewrite --normalize`) equals our reducer's normal form. The behavioral
# pin between this prover's Step relation and phino. Requires `phino` on PATH.
# pin between this prover's Step relation and phino. Requires `phino` on PATH,
# pinned via `--pin` to the version in .phino-version (phino aborts on a mismatch).

set -uo pipefail
cd "$(dirname "$0")/.."
export PATH="$HOME/.elan/bin:$PATH"
PHINO_VERSION="$(cat .phino-version)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This reads .phino-version before the command -v phino guard below, and the script runs under set -uo pipefail without -e. If the file is missing or renamed, cat writes to stderr, PHINO_VERSION becomes empty, and every invocation gets --pin= — so the failure surfaces as phino complaining about an empty version, once per corpus entry, rather than as one clear message here. A [ -f .phino-version ] || { echo "FATAL: .phino-version missing" >&2; exit 1; } in the same style as the phino guard would keep the fail-fast behaviour the header advertises.


if ! command -v phino >/dev/null 2>&1; then
echo "FATAL: phino not found on PATH; the differential test requires it (fail-fast, not skip)" >&2
Expand All @@ -22,7 +24,7 @@ count=0
while IFS=$'\t' read -r input expected; do
[ -z "${input:-}" ] && continue
count=$((count + 1))
out=$(printf '%s\n' "$input" | phino rewrite --normalize --flat 2>/dev/null)
out=$(printf '%s\n' "$input" | phino --pin="$PHINO_VERSION" rewrite --normalize --flat 2>/dev/null)
pn=$(printf '%s' "$out" | tr -d '[:space:]'); pn=${pn#Φ↦}
ours=$(printf '%s' "$expected" | tr -d '[:space:]')
if [ "$pn" = "$ours" ]; then
Expand Down
Loading
Loading