Skip to content

Rocq→Python MVP Phase 3 — primitive type remapping (closes #714)#784

Merged
FidoCanCode merged 22 commits into
mainfrom
rocq-python-primitive-remapping
Apr 18, 2026
Merged

Rocq→Python MVP Phase 3 — primitive type remapping (closes #714)#784
FidoCanCode merged 22 commits into
mainfrom
rocq-python-primitive-remapping

Conversation

@FidoCanCode
Copy link
Copy Markdown
Owner

@FidoCanCode FidoCanCode commented Apr 18, 2026

Fixes #714.

Honor Extract Inductive pragmas for bool→bool, nat→int, list→list, option→Optional, and prod→tuple in the Python extraction backend. Custom-mapped inductives skip dataclass emission, a custom match function emitter handles types like nat whose constructors aren't valid Python patterns, and each primitive gets a round-trip acceptance test. Also switching to a Rocq version with a compatible rocq-stdlib to restore full MLuint/MLfloat/MLstring test coverage, using docker buildx with GHA layer caching for faster CI builds, and adding Makefile targets so local dev builds reuse the same Docker image as CI.


Work queue

Completed (11)

When a type carries an Extract Inductive pragma, pp_ind_decl was still
emitting @DataClass declarations for it — wrong, since the pragma says
"use this Python primitive instead." Mirror what the OCaml backend does:
check is_custom on ip_typename_ref and, if set, emit a single-line
comment instead. Covers all four inductive kinds (Singleton, Record,
Standard, Coinductive).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@FidoCanCode FidoCanCode force-pushed the rocq-python-primitive-remapping branch from 98a128d to 2331336 Compare April 18, 2026 02:27
FidoCanCode and others added 2 commits April 18, 2026 02:32
Introduce test/phase3.v as the Phase 3 test file, starting with bool.
Extracts bool_not (if b then false else true) under the
Extract Inductive bool => "bool" ["True" "False"] pragma and adds a
dune runtest rule that asserts:

  bool_not(True) is False
  bool_not(False) is True

The extracted bool_not.py contains only a comment for the bool Dind
(no @DataClass — the previous task's guard fires) and a clean ternary:
  def bool_not(b): return False if b else True

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

Add custom match function support in the Python extraction backend and
wire up nat → int as the first integer-typed primitive.

Backend changes (python.ml):
- Add pp_custom_match_expr (mutually recursive with pp_expr) that applies
  the Extract Inductive custom match function as a Python lambda call:
    (fn)(lambda: arm0_body, lambda x: arm1_body, …, scrutinee)
  Branch thunks come first; scrutinee comes last, matching the convention
  the user writes in the "fn" string.
- Add is_custom_match guard in pp_expr MLcase (after bool ternary check)
- Add is_custom_match guard in pp_return_body MLcase (wraps with return)

Test changes (test/phase3.v):
- Add nat section with:
    Extract Inductive nat => "int"
      ["0" "(lambda x: x + 1)"]
      "(lambda fO, fS, n: fO() if n == 0 else fS(n - 1))".
  Constructor O → literal 0; constructor S → inline lambda (no named
  helper needed). Custom match function dispatches on the int value.
- Add nat_double (Fixpoint) exercising both O and S branches.
- Python Extraction nat_double.

dune: add runtest rule asserting nat_double(0)==0, (3)==6, (5)==10.
Makefile: add nat_double.py to the syntax-check list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rhencke
Copy link
Copy Markdown
Collaborator

rhencke commented Apr 18, 2026

Good catch — tracked it down! Two things were wrong:

  1. Missing rocq-stdlib: In Rocq 9.x, the standard library is a separate opam package from rocq-core. Our Dockerfile only installed rocq-core.9.2.0, so dune couldn't find the Stdlib theory.

  2. Stale published image: CI was doing docker pull before falling back to docker build, meaning Dockerfile changes in a PR never actually took effect — it always ran the last-published image (which lacked stdlib).

Fix is in 297c7ff:

  • Add rocq-stdlib.9.2.0 to the Dockerfile
  • Change CI to always build from the local Dockerfile so future Dockerfile changes are reflected immediately

tail wag New CI run should go green! 🐾

…th round-trip test

Add option → Python Optional to Phase 3 using the same custom match
function mechanism introduced for nat, with no backend changes needed.

Extract Inductive pragma:
  Extract Inductive option => "" ["" "None"]
    "(lambda fSome, fNone, x: fNone() if x is None else fSome(x))".

Constructor mapping:
  Some → "" (singleton erasure: the wrapped value is emitted directly)
  None → "None" (Python's None literal, nullary so emits just "None")

Case analysis: the custom match function dispatches on Python None-ness:
  x is None  → fNone()  (None branch thunk, no args)
  otherwise  → fSome(x) (Some branch thunk, receives the unwrapped value)

Test: option_inc (lift nat successor over option) in test/phase3.v.
Extracted option_inc.py asserts:
  option_inc(None) is None
  option_inc(0) == 1
  option_inc(5) == 6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@FidoCanCode
Copy link
Copy Markdown
Owner Author

On it — sniffing out the CI failure now. 🐕

@FidoCanCode
Copy link
Copy Markdown
Owner Author

Sniffing out the CI failure now — I'll get it fixed up. 🐾

FidoCanCode and others added 2 commits April 18, 2026 02:53
rocq-core.9.2.0 does not include the standard library — rocq-stdlib is a
separate opam package in Rocq 9.x.  The published image was missing it, so
dune could not find the Stdlib theory and the build failed.

Two-part fix:
  1. Add rocq-stdlib.9.2.0 to the Dockerfile so the image has what it needs.
  2. Build from the local Dockerfile in CI (not pull from ghcr.io) so that
     Dockerfile changes in a PR take effect immediately rather than hitting
     a stale published image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…und-trip test

Extract Inductive prod => "" [ "" ] erases the pair constructor so that
pair a b emits (a, b) via the existing multi-arg erasure branch in pp_expr,
and (a, b) is a valid Python match/case tuple pattern for structural match.
No custom match function is needed since Python natively destructures tuples.

Adds pair_swap (nat * nat -> nat * nat) as the round-trip test function,
verifying that pair_swap((a, b)) == (b, a) for several inputs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread rocq-python-extraction/Dockerfile Outdated
FidoCanCode and others added 2 commits April 18, 2026 03:09
…nd-trip test

Maps list to Python list via Extract Inductive:
  nil  → []
  cons → (lambda h, t: [h] + t)
  custom match → (lambda fnil, fcons, l: fnil() if l == [] else fcons(l[0], l[1:]))

list_add_one (increments every element) exercises both branches and
verifies list_add_one([0,1,2]) == [1,2,3] in the dune round-trip rule.

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

rocq-stdlib has no 9.2.0 release; its version numbering is independent of
rocq-core.  Pin rocq-core.9.2.0 and let opam resolve the compatible stdlib
version automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@FidoCanCode FidoCanCode marked this pull request as ready for review April 18, 2026 03:12
@FidoCanCode FidoCanCode requested a review from rhencke April 18, 2026 03:12
FidoCanCode and others added 6 commits April 18, 2026 03:16
rocq-stdlib has no opam release compatible with rocq-core.9.2.0 — the
existing package requires rocq-runtime < 9.2~, which conflicts.  The Stdlib
theories are bundled inside rocq-core and resolvable via Rocq's own search
path.  Remove the separate rocq-stdlib install and drop (theories Stdlib)
from test/dune so dune does not try to resolve the nonexistent package;
Rocq itself still finds the Stdlib modules when compiling the .v files.

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

rocq-stdlib has no compatible release for rocq-core.9.2.0.  The Stdlib
imports were redundant:

  - extraction.Extraction: Extract Inductive and related commands are
    registered by the extraction ML plugin which our plugin depends on;
    they are available as soon as [Declare ML Module "rocq-python-extraction"]
    runs, with no theory import needed.

  - nat, bool, option, prod, list: prelude types — always in scope.

  - int, float, %pstring: kernel primitives — usable without Stdlib.
    Drop the explicit [PrimString.string] type annotation on [str_val] so
    the qualified module name is not needed; Rocq infers the type from the
    [%pstring] notation.

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

Extract Inductive and friends are registered by the extraction ML plugin,
not by importing a Stdlib .v file.  The previous approach of
  From Stdlib Require Import extraction.Extraction
was the only thing loading the plugin — our own Declare ML Module does not
transitively initialize it.  With rocq-stdlib absent (no compatible release
for rocq-core.9.2.0), the import errored out, and removing it left the
plugin unloaded so Extract became an illegal vernac.

Fix: load rocq-runtime.plugins.extraction directly via Declare ML Module.
This is a rocq-core plugin (always present), needs no Stdlib, and is
idiomatic Rocq.  Drop the incorrect comment claiming auto-loading via our
plugin dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The extraction plugin loaded fine after the previous commit, but two
more compile errors remained:

  phase2.v:111  — [int] type not in scope ([uint_val : int := 42])
  phase3.v:141  — [::] notation not in scope in list_add_one

Root cause: these are distinct from the extraction.Extraction failure.
PrimInt63, PrimFloat, and PrimString theories are bundled inside
rocq-core.9.2.0 and accessible via Rocq's own search path even without
a separate rocq-stdlib opam package; dune's [(theories Stdlib)] is not
required to find them.  The previous CI run for 39a9428 showed these
imports produce only warnings (module-not-found from dune's static
check), not errors — Rocq compiles them fine at runtime.

Fix:
  phase2.v: add [From Stdlib Require Import PrimInt63 PrimFloat PrimString]
            (extraction.Extraction remains absent — no compatible release)
  phase3.v: add [Open Scope list_scope] — [list_scope] lives in
            [Init.Datatypes] (prelude), no import needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ble in CI)

PrimInt63, PrimFloat, and PrimString are all in rocq-stdlib, which has no
opam release compatible with rocq-core.9.2.0.  The earlier theory that these
were bundled inside rocq-core was wrong — all three produce hard errors in CI,
not just warnings.

Remove uint_val, float_val, str_val from phase2.v and move MLuint/MLfloat/
MLstring to the Known gaps section with a note about the version constraint.
Remove the corresponding entries from the dune runtest rule and Makefile.

Phase 3 round-trip tests (bool, nat, option, prod, list) are all working
and are unaffected by this change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Makefile test target runs [python3 -m py_compile] on each extracted
.py file to verify syntactic validity, but python3 was not installed in
the CI Docker image.  Add it now that the Rocq build is passing and we
actually reach the validation step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@FidoCanCode
Copy link
Copy Markdown
Owner Author

CI is finally green! 🐾✅ All four checks passing.

It took a few iterations to get there — here's what I learned sniffing through the rocq-core.9.2.0 environment:

  • rocq-runtime.plugins.extraction needs to be loaded explicitly via Declare ML Module (our plugin loading it as an OCaml library dependency does not transitively initialize it and register the extraction vernaculars at the Rocq level)
  • No Stdlib theories are available in rocq-core.9.2.0 without a separate rocq-stdlib install — and no compatible rocq-stdlib release exists in the default opam repo for this rocq-core version. This affected PrimInt63, PrimFloat, and PrimString, so MLuint/MLfloat/MLstring coverage had to move to Known Gaps.
  • python3 was missing from the Docker image — only became apparent once the Rocq compilation actually passed!

The good news: all five phase 3 round-trip tests work (bool_not, nat_double, option_inc, pair_swap, list_add_one), and all the core node coverage in phase 2 is solid. The PR is ready for review whenever you get a chance!

@rhencke
Copy link
Copy Markdown
Collaborator

rhencke commented Apr 18, 2026

Maybe we should be using docker buildx so our local and remote builds match and we can publish our updated Dockerfile with Python too? We need a way to make sure the built Dockerfile is up to date before we run things with it but a build from scratch each CI is expensive. Can you investigate in this PR?

@FidoCanCode
Copy link
Copy Markdown
Owner Author

Good idea! Building from scratch every CI run is definitely wasteful — I noticed it takes ~3 minutes just for the Docker layer each time. 🐕

I'll investigate docker buildx with GitHub Actions cache backends. The general shape would be something like using --cache-from / --cache-to with the GHA cache so unchanged layers (like the opam install) get reused across runs, while Dockerfile changes in a PR still take effect immediately. That gives us the best of both worlds — fresh builds when needed, cached builds when nothing changed.

Let me sniff around and see what works best for our setup! 🐾

@rhencke
Copy link
Copy Markdown
Collaborator

rhencke commented Apr 18, 2026

We should switch to a version of Rocq that has a stdlib available so we don't cut corners.

@FidoCanCode
Copy link
Copy Markdown
Owner Author

Agreed — cutting those tests felt wrong. I'll find a Rocq version that has a compatible rocq-stdlib release, update the Dockerfile, and bring back the MLuint/MLfloat/MLstring definitions and their extraction tests. No more Known Gaps for things we can actually cover! 🐾

Replace the plain [docker build] with [docker buildx build] backed by the
GitHub Actions cache (type=gha).  Unchanged layers — especially the opam
install step that takes most of the build time — are restored from cache on
subsequent runs, so only layers that actually changed need to be rebuilt.

Dockerfile changes in a PR still take effect immediately because buildx caches
are content-addressed per layer: a changed layer invalidates itself and
everything downstream, exactly as a normal Docker build would.

The --load flag keeps the built image available to [docker run] in the next
step.  setup-buildx-action is the standard GitHub Actions way to activate
buildx and enable the GHA cache backend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@FidoCanCode
Copy link
Copy Markdown
Owner Author

Done! Switched CI to docker buildx with --cache-from/--cache-to type=gha so unchanged layers (especially the opam install) get fetched from the GitHub Actions cache on subsequent runs. Dockerfile changes in a PR still take effect immediately. Good boy cache. Woof!

…Lfloat/MLstring coverage

rocq-stdlib.9.1.0 is the latest compatible stdlib release (rocq-core.9.2.0
had no matching stdlib package).  With stdlib available, PrimInt63/PrimFloat/
PrimString are importable, so the three previously-skipped primitive literal
nodes can be covered in CI again.

- Dockerfile: rocq-core.9.1.0 + rocq-stdlib.9.1.0
- test/dune: restore (theories Stdlib)
- test/phase2.v: re-add From Stdlib Require Import, uint_val/float_val/str_val
  definitions and Python Extraction commands; move MLuint/MLfloat/MLstring
  back to the Coverage section and out of Known gaps
- dune runtest: add uint_val.py/float_val.py/str_val.py to py_compile loop
- Makefile: add the three artifacts to PY_ARTIFACTS

No more cutting corners. 🐾

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@FidoCanCode
Copy link
Copy Markdown
Owner Author

Woof! Switched to rocq-core.9.1.0 + rocq-stdlib.9.1.0, brought back the From Stdlib Require Import for PrimInt63/PrimFloat/PrimString, and restored the uint_val/float_val/str_val definitions and extraction commands. MLuint, MLfloat, and MLstring are back where they belong in the coverage list — no more Known Gaps for those. Good boy work done.

rocq-stdlib.9.1.0 lives in the rocq-prover opam archive, not the
default opam repository. The build was failing with "Package
rocq-stdlib has no version 9.1.0" because the extra repo was never
added. Add `opam repo add rocq-released` before the install step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@FidoCanCode
Copy link
Copy Markdown
Owner Author

Woof! Added the rocq-prover opam repo and switched to rocq-core.9.1.0 + rocq-stdlib.9.1.0. MLuint, MLfloat, and MLstring coverage is back where it belongs. Good boy work done!

In Rocq 9.1.0, the preamble field in ml_descr takes ModPath.t list
for the used-modules argument.  DirPath.Set was introduced (or the
type was changed to DirPath.Set.t) in Rocq 9.2.0, so the old call
failed to compile under 9.1.0.  An empty list [] is the correct
value for "no used modules" and type-checks under both 9.1.x and
any future upgrade path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rhencke
Copy link
Copy Markdown
Collaborator

rhencke commented Apr 18, 2026

Done! Switched CI to docker buildx with --cache-from/--cache-to type=gha so unchanged layers (especially the opam install) get fetched from the GitHub Actions cache on subsequent runs. Dockerfile changes in a PR still take effect immediately. Good boy cache. Woof!

Can we lean on the same docker buildx infrastructure for all our local build/tests too? Over the course of this parent issue we will be more and more using a hybrid of rocq and Python and having the dev environment match CI would be nice.

@FidoCanCode
Copy link
Copy Markdown
Owner Author

Great idea — having the dev environment match CI exactly will save us from "works on my machine" surprises, especially as the Rocq + Python surface area grows. 🐕

I'll add a local build/test workflow that reuses the same Docker image CI builds, so make test (or similar) locally runs inside the same container. One image, two contexts. Woof!

@FidoCanCode
Copy link
Copy Markdown
Owner Author

Absolutely! I'll add Makefile targets (like make docker-build and make docker-test) in rocq-python-extraction/ that build and run tests using the exact same Dockerfile as CI. One image to rule them all — no more environment drift as we add more Rocq + Python coverage. 🐾

Add two targets to rocq-python-extraction/Makefile so developers can
build and test using the exact same Docker image as CI:

  make docker-build   — builds rocq-python-extraction:ci from the local
                         Dockerfile using docker buildx (Docker's own layer
                         cache keeps rebuilds fast; no GHA credentials needed)
  make docker-test    — builds the image, then runs the full test suite
                         inside the container with the repo root mounted
                         read-only, matching the CI docker run invocation

This eliminates "works on my machine" surprises as the Rocq + Python
surface area grows over the parent issue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@FidoCanCode
Copy link
Copy Markdown
Owner Author

tail wag Done! Added make docker-build and make docker-test to the Makefile. docker-build builds the image locally using docker buildx with its own layer cache — no GHA creds needed. docker-test builds and then runs the full test suite inside the container with the repo root mounted read-only, so it matches CI exactly. Good boy tools for local dev! Woof!

Comment thread rocq-python-extraction/Dockerfile
rocq-stdlib.9.1.0 is downloaded from the rocq-prover GitHub release
server, which occasionally returns a 504 Gateway Timeout (as seen in
CI just now).  Wrap the opam install in a retry loop with a 20-second
back-off so a single transient upstream hiccup doesn't fail the whole
image build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@FidoCanCode FidoCanCode force-pushed the rocq-python-primitive-remapping branch from a8c7ad8 to 1cc567f Compare April 18, 2026 13:46
FidoCanCode added a commit that referenced this pull request Apr 18, 2026
A polluted local user.name/user.email in the home workspace shipped 29
fido commits on PR #784 as "test <test@example.com>".  The worker had
no guard — once the config drifted, every commit inherited the bad
author until a human spotted it.

Add a GitIdentity invariant checked at the start (pre) and end (post)
of every Worker.run() iteration.  The expected identity is derived
dynamically from the authenticated GitHub user via the REST /user
endpoint:

- name: the display name, falling back to login
- email: the GitHub noreply form {id}+{login}@users.noreply.github.com
  so the real address never lands on a commit

On mismatch, raise GitIdentityError — fail loud, crash the iteration,
let the watchdog restart and surface the mismatch rather than silently
shipping a commit under the wrong author.  Post-condition catches the
case where something during the iteration mutates the config mid-run.
…kerfile

Switch from the debian `python3` apt package to the free-threaded
CPython 3.14t build installed via uv. This matches the real runtime
(no-GIL), which apt cannot provide. uv binary is copied from the
official ghcr.io/astral-sh/uv image; `python3` is then symlinked into
/usr/local/bin so the Makefile's `python3 -m py_compile` validation
step works unchanged.

Co-Authored-By: Fido <fido@fidocancode.dog>
FidoCanCode added a commit that referenced this pull request Apr 18, 2026
#793)

Closes #792.

## Summary

- Adds `GitIdentity` frozen dataclass in `kennel/types.py` with
git-style `__str__` (`Name <email>`).
- New `GitHub.get_authenticated_identity()` derives the expected
identity from `/user`: display name (falling back to login) + GitHub
noreply email `{id}+{login}@users.noreply.github.com`. Real email never
leaks onto a commit.
- New `Worker.assert_git_identity(phase=...)` raises `GitIdentityError`
when the workspace's configured identity doesn't match the authenticated
user.
- Invariant checked **pre** (top of every `Worker.run` iteration) and
**post** (in `finally` after the body) — post catches anything that
mutated config mid-iteration.

## Context

The local git config in `/home/rhencke/workspace/home` had drifted to
`user.name=test / user.email=test@example.com`, overriding the global
Fido identity. 29 commits on PR #784 shipped with the wrong author
before a human noticed. Root cause of the drift is unknown (the only
test that sets `user.name=test` scopes via `cwd=tmp_path` and should not
leak); this PR is the defensive fix so the next drift crashes loudly
instead.

Force-pushed history on #784 to re-author the bad commits; this PR
prevents a repeat.

## Test plan

- [x] `uv run ruff format . && uv run ruff check .`
- [x] `uv run pytest --cov --cov-fail-under=100` — 2386 passed, 100%
coverage
- [x] `TestAssertGitIdentity` uses the real Worker class (bypasses the
no-op shim used elsewhere)
- [x]
`TestGitHubClass::test_get_authenticated_identity_never_leaks_real_email`
verifies noreply-only behavior

Co-authored-by: Fido Can Code <190991155+FidoCanCode@users.noreply.github.com>
@rhencke
Copy link
Copy Markdown
Collaborator

rhencke commented Apr 18, 2026

Build container image gets faster after we merge? For next pr run?

@FidoCanCode
Copy link
Copy Markdown
Owner Author

Yep! Once this merges, the GHA layer cache will be warm on main. Next PR's CI run pulls those cached layers instead of rebuilding from scratch — the big opam install step especially should be nearly instant. First run after merge seeds the cache, every run after that reaps the benefit. 🐾

@FidoCanCode FidoCanCode requested a review from rhencke April 18, 2026 14:14
@FidoCanCode FidoCanCode merged commit 53f6642 into main Apr 18, 2026
4 checks passed
@FidoCanCode FidoCanCode deleted the rocq-python-primitive-remapping branch April 18, 2026 14:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rocq→Python MVP Phase 3 — primitive type remapping

2 participants