diff --git a/DESIGN.md b/DESIGN.md index 0e802d6..674e20d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -47,3 +47,9 @@ - Decision: make `scripts/install-desktop.sh` remove a destination `.desktop` file if it exists but is not writable, then reinstall with deterministic modes (`444` in app menu, `555` on Desktop/Shortcuts). - Tradeoff: replacement is explicit rather than in-place update, but content and permissions remain deterministic from template + destination policy. - Reasoning: GNU `install` can fail on existing read-only targets; KDE/Plasma desktop launchers also need execute bits to avoid launch aborts. + +## 2026-06-18 — Faces same-cube profile (`faces`) + +- Decision: add a `faces` profile (`profiles/faces.md`), a same-cube corpus (`examples/same-cube/`), and a verifier (`scripts/verify-same-cube.sh`) that grounds the AffineScript "different faces, same cube" invariant — every face's `preview-*` lowering must normalise to the same canonical text. +- Tradeoff: v1 compares normalised canonical *text*, not ASTs, and SKIPs when no `affinescript` binary is reachable (grounding happens in CI). It can't yet catch two different canonical texts that typecheck to the same cube. +- Reasoning: per-face snapshot tests (`affinescript/tests/faces/`) catch drift *within* a face but never compare face A's cube against face B's. The cross-face equality is the load-bearing claim; this profile is the claim-path debugger that locates which face breaks it. Doc-profile shape (markdown + script), so no change to the Rust core or CLI — consistent with the `pmpl` / `standards-docs` profiles. diff --git a/Justfile b/Justfile index 722b9d5..c0ee2a6 100644 --- a/Justfile +++ b/Justfile @@ -15,6 +15,10 @@ build: run-cli: cargo run --release --bin invariant-path-cli +# Ground the AffineScript "different faces, same cube" invariant (faces profile) +same-cube corpus="examples/same-cube/greet": + ./scripts/verify-same-cube.sh {{corpus}} --out .machine_readable/audits/same-cube.jsonl + # Run the TUI run-tui: ./invariant-path-launcher --auto diff --git a/README.adoc b/README.adoc index a37616d..a1dccc1 100644 --- a/README.adoc +++ b/README.adoc @@ -21,8 +21,9 @@ It is a claim-path debugger, not a truth engine. * `schemas/annotation.schema.json` — JSON schema for persisted annotations * `docs/ARCHITECTURE.md` — minimal architecture proposal * `docs/EXTENDING.md` — extension guide for invariant types and heuristics -* `examples/` — seeded and realistic examples -* `profiles/` — profile notes for `echidna`, `panll`, and `hypatia` +* `examples/` — seeded and realistic examples (incl. `examples/same-cube/` — the AffineScript faces corpus) +* `profiles/` — profile notes for `echidna`, `panll`, `hypatia`, `pmpl`, and `faces` (AffineScript "different faces, same cube") +* `scripts/verify-same-cube.sh` — grounds the faces same-cube invariant (see `profiles/faces.md`) == Quick Start diff --git a/examples/same-cube/README.md b/examples/same-cube/README.md new file mode 100644 index 0000000..8dc4245 --- /dev/null +++ b/examples/same-cube/README.md @@ -0,0 +1,47 @@ + + +# Same-cube corpus + +Each subdirectory here is **one program written in every AffineScript face**. +The files are deliberately the *same* program (identical strings, identical +structure) — not the demonstrative, different-string examples in +`affinescript/examples/faces/`. That is the whole point: if they are the same +program, every face must lower to the **same canonical cube**. + +``` +greet/ + canonical.affine face: canonical (the reference cube) + rattle.affine face: rattlescript -> preview-python + jaffa.affine face: jaffascript -> preview-js + pseudo.affine face: pseudoscript -> preview-pseudocode + lucid.affine face: lucidscript -> preview-lucid + cafe.affine face: cafescripto -> preview-cafe +``` + +## Ground the invariant + +```sh +scripts/verify-same-cube.sh examples/same-cube/greet \ + --out .machine_readable/audits/same-cube.jsonl +``` + +The verifier detects each file's face from its `face:` pragma, runs the +matching `preview-*` transformer, normalises the result (modulo comments and +whitespace), and compares it to `canonical.affine`. Output is a per-face +table plus invariant-path claim records (`Grounded` / `Ungrounded`). + +This is a **claim-path debugger**, not a rubber stamp: a `DIFF` line does not +mean the tool failed — it means the tool located the face that diverges from +the cube, and prints exactly where. Grounding requires an `affinescript` +binary (PATH, `--affinescript`, `AFFINESCRIPT`, or `../affinescript/_build`); +without one the verifier SKIPs, because the invariant is grounded in CI where +the compiler is built. + +## Adding a program + +Add a new sibling directory (e.g. `counter/`) with one file per face you want +to cover, each carrying the right `face:` pragma and encoding the identical +program. Keep to surface features the transformers handle today (see the +"Known transformer gaps" table in `affinescript/examples/faces/README.adoc`); +a face that uses an unsupported construct will show up as a `DIFF`, which is +useful signal, not noise. diff --git a/examples/same-cube/greet/cafe.affine b/examples/same-cube/greet/cafe.affine new file mode 100644 index 0000000..b4e9981 --- /dev/null +++ b/examples/same-cube/greet/cafe.affine @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# Same-cube corpus "greet" — CafeScripto (CoffeeScript) face. +# Lowers via `preview-cafe` to the canonical cube in ./canonical.affine. +# face: cafescripto + +effect IO { + fn println(s: String) -> (); +} + +fn main() -{IO}-> () { + println("Hello, faces!"); +} diff --git a/examples/same-cube/greet/canonical.affine b/examples/same-cube/greet/canonical.affine new file mode 100644 index 0000000..3c74e66 --- /dev/null +++ b/examples/same-cube/greet/canonical.affine @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Same-cube corpus "greet" — the reference (canonical) face. +// Every sibling file in this directory is the SAME program written in a +// different face. Each face's `preview-*` lowering must normalise to the +// canonical text below ("different faces, same cube"). +// face: canonical + +effect IO { + fn println(s: String) -> (); +} + +fn main() -{IO}-> () { + println("Hello, faces!"); +} diff --git a/examples/same-cube/greet/jaffa.affine b/examples/same-cube/greet/jaffa.affine new file mode 100644 index 0000000..5854912 --- /dev/null +++ b/examples/same-cube/greet/jaffa.affine @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// Same-cube corpus "greet" — JaffaScript (JavaScript / TypeScript) face. +// Lowers via `preview-js` to the canonical cube in ./canonical.affine. +// face: jaffascript + +effect IO { + fn println(s: String) -> (); +} + +function main() -{IO}-> () { + println("Hello, faces!"); +} diff --git a/examples/same-cube/greet/lucid.affine b/examples/same-cube/greet/lucid.affine new file mode 100644 index 0000000..0ae8647 --- /dev/null +++ b/examples/same-cube/greet/lucid.affine @@ -0,0 +1,15 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +-- +-- Same-cube corpus "greet" — LucidScript (PureScript / Haskell) face. +-- Lowers via `preview-lucid` to the canonical cube in ./canonical.affine. +-- face: lucidscript + +module Greet where + +effect IO { + fn println(s: String) -> (); +} + +main :: -{IO}-> () +main () = println("Hello, faces!") diff --git a/examples/same-cube/greet/pseudo.affine b/examples/same-cube/greet/pseudo.affine new file mode 100644 index 0000000..95c7dd1 --- /dev/null +++ b/examples/same-cube/greet/pseudo.affine @@ -0,0 +1,14 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +-- +-- Same-cube corpus "greet" — PseudoScript (pseudocode) face. +-- Lowers via `preview-pseudocode` to the canonical cube in ./canonical.affine. +-- face: pseudoscript + +effect IO { + fn println(s: String) -> (); +} + +function main() -{IO}-> () do + output "Hello, faces!" +end diff --git a/examples/same-cube/greet/rattle.affine b/examples/same-cube/greet/rattle.affine new file mode 100644 index 0000000..48b3e25 --- /dev/null +++ b/examples/same-cube/greet/rattle.affine @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# Same-cube corpus "greet" — RattleScript (Python) face. +# Lowers via `preview-python` to the canonical cube in ./canonical.affine. +# face: rattlescript + +effect IO: + fn println(s: String) -> (); + +def main() -{IO}-> (): + println("Hello, faces!") diff --git a/profiles/faces.md b/profiles/faces.md new file mode 100644 index 0000000..d29f2db --- /dev/null +++ b/profiles/faces.md @@ -0,0 +1,83 @@ + + +# Profile: faces + +**Target corpus:** the AffineScript face family — +`affinescript/examples/faces/`, the `examples/same-cube/` corpus in this +repo, and the face brand-surface repos (`rattlescript`, `jaffascript`, +`pseudoscript`, `lucidscript`, `cafescripto`). +**Mode:** invariant-grounding (behavioural equality, not doc-claims) +**Purpose:** Keep the load-bearing AffineScript claim — *"different faces, +same cube"* — grounded in the actual behaviour of the face transformers. + +## Why this profile exists + +AffineScript is one canonical language (the "cube"). Each *face* (ADR-010) +is an alternative surface syntax — RattleScript (Python), JaffaScript +(JS/TS), PseudoScript (pseudocode), LucidScript (PureScript/Haskell), +CafeScripto (CoffeeScript) — lowered to canonical AffineScript by a pure +text transformer (`lib/_face.ml`) and previewed with `preview-*`. + +The whole architecture rests on one invariant: + +> For a given program, **every face lowers to the same canonical cube** +> (modulo whitespace and comment placement), and therefore to the same +> typed-wasm output. + +That is a *claim*. Like any claim, it can rot: a transformer edit can +silently make one face diverge while the others still pass their own +round-trip tests. Per-face snapshot tests (`affinescript/tests/faces/`) +catch *drift within a face* but not *divergence between faces* — they +never compare face A's cube against face B's. This profile grounds the +cross-face equality directly: it is a claim-path debugger for the +same-cube invariant, locating *which* face breaks the cube and *where*. + +## The claim path + +``` +face source (greet/rattle.affine, …) ← evidence / specification + │ preview- ← transition (transformer T) + ▼ +canonical text (normalised) ← intermediate target + │ byte-equality vs canonical.affine ← grounding check + ▼ +"same cube" holds for this program ← conclusion (Grounded | Ungrounded) +``` + +## Invocation + +```sh +# Ground the bundled same-cube corpus (needs an `affinescript` binary on +# PATH, or a dune build of it — see the script's resolver). +scripts/verify-same-cube.sh examples/same-cube/greet \ + --out .machine_readable/audits/same-cube.jsonl + +# Any directory of sibling face files for ONE program works: +scripts/verify-same-cube.sh path/to/-faces/ +``` + +A face brand-surface repo grounds its own examples by pointing the shared +workspace at its corpus (the `tools/invariant-path/` hook), e.g.: + +```sh +just invariant-path same-cube examples/ +``` + +## What v1 catches + +- A face whose `preview-*` lowering no longer normalises to the canonical + cube (the cross-face equality break the per-face snapshots miss). +- A face example that fails to parse after lowering (round-trip break). +- A corpus where one face silently encodes a *different* program (e.g. a + changed string literal or dropped statement). + +## What v1 does NOT catch (yet) + +- *Semantic* divergence below the canonical-text level — two different + canonical texts that nevertheless typecheck to the same cube. v1 + compares normalised canonical text, not ASTs. (AST-level equality is + the planned v2 grounding, once the compiler exposes a stable AST dump.) +- typed-wasm equality. The chain "same canonical ⇒ same wasm" is asserted + by the compiler, not re-checked here. +- Effect-handler lowering soundness (tracked in affinescript #555); a + face that exercises `handle` may converge in text yet diverge at runtime. diff --git a/scripts/verify-same-cube.sh b/scripts/verify-same-cube.sh new file mode 100755 index 0000000..2bbcfc9 --- /dev/null +++ b/scripts/verify-same-cube.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# verify-same-cube.sh — ground the AffineScript "different faces, same cube" +# invariant (invariant-path `faces` profile). +# +# Given a directory of sibling AffineScript face files that are meant to be +# the SAME program written in different faces, this: +# 1. detects each file's face from its `face:` pragma, +# 2. runs the matching `preview-*` transformer to get canonical text, +# 3. normalises (drops comment-only lines, blank lines, collapses +# whitespace) and compares each face's lowering against the canonical +# reference (canonical.affine / the `face: canonical` file), +# 4. round-trip parses each face file, +# 5. prints a table and (optionally) appends JSONL claim records. +# +# A break is not a crash — it is the tool's output: it tells you WHICH face +# diverges from the cube. Exit 1 on any divergence/parse failure, 0 when the +# cube holds (or when no affinescript binary is available to ground it). +# +# Usage: +# verify-same-cube.sh [--out FILE] [--affinescript PATH] +# +# Example: +# verify-same-cube.sh examples/same-cube/greet --out /tmp/same-cube.jsonl + +set -uo pipefail + +CORPUS="${1:-}" +shift || true +OUT="" +AS_BIN="${AFFINESCRIPT:-}" +while [ $# -gt 0 ]; do + case "$1" in + --out) OUT="$2"; shift 2 ;; + --affinescript) AS_BIN="$2"; shift 2 ;; + -h|--help) sed -n '3,30p' "$0"; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +if [ -z "$CORPUS" ] || [ ! -d "$CORPUS" ]; then + echo "usage: verify-same-cube.sh [--out FILE] [--affinescript PATH]" >&2 + exit 2 +fi + +# --- resolve the affinescript compiler ------------------------------------ +resolve_as() { + if [ -n "$AS_BIN" ] && command -v "$AS_BIN" >/dev/null 2>&1; then echo "$AS_BIN"; return; fi + if command -v affinescript >/dev/null 2>&1; then echo "affinescript"; return; fi + for cand in \ + "../affinescript/_build/default/bin/main.exe" \ + "$HOME/affinescript/_build/default/bin/main.exe"; do + [ -x "$cand" ] && { echo "$cand"; return; } + done + echo "" # not found +} +AS="$(resolve_as)" + +if [ -z "$AS" ]; then + echo "SKIP: no 'affinescript' binary found (PATH, --affinescript, AFFINESCRIPT," >&2 + echo " or ../affinescript/_build). The same-cube invariant is grounded in" >&2 + echo " CI where the compiler is built; nothing to verify locally." >&2 + exit 0 +fi + +# --- face pragma -> preview subcommand ------------------------------------ +preview_subcmd() { + case "$1" in + rattlescript|rattle|python|py) echo "preview-python" ;; + jaffascript|jaffa|js|javascript|ts) echo "preview-js" ;; + pseudoscript|pseudo|pseudocode) echo "preview-pseudocode" ;; + lucidscript|lucid|purescript|haskell) echo "preview-lucid" ;; + cafescripto|cafe|coffee|coffeescript) echo "preview-cafe" ;; + canonical|affinescript|affine) echo "CANONICAL" ;; + *) echo "" ;; + esac +} + +face_of() { # read the `face:` pragma from a file + grep -oE '(^|[[:space:]])face:[[:space:]]*[A-Za-z]+' "$1" 2>/dev/null \ + | head -1 | sed -E 's/.*face:[[:space:]]*//' +} + +normalise() { # canonical text -> comparable form (modulo comments + whitespace) + sed -E 's/[[:space:]]+/ /g; s/^ +//; s/ +$//' \ + | grep -vE '^(//|#|--)' \ + | grep -vE '^$' +} + +# --- locate the canonical reference --------------------------------------- +REF_FILE="" +for f in "$CORPUS"/*.affine; do + [ -e "$f" ] || continue + [ "$(preview_subcmd "$(face_of "$f")")" = "CANONICAL" ] && REF_FILE="$f" +done +if [ -z "$REF_FILE" ]; then + echo "ERROR: no canonical reference (a file with 'face: canonical') in $CORPUS" >&2 + exit 2 +fi +REF_NORM="$(normalise < "$REF_FILE")" + +program="$(basename "$CORPUS")" +[ -n "$OUT" ] && mkdir -p "$(dirname "$OUT")" +emit() { [ -n "$OUT" ] && printf '%s\n' "$1" >> "$OUT"; } + +echo "same-cube: program '$program' (reference: $(basename "$REF_FILE"))" +echo "compiler: $AS" +echo "──────────────────────────────────────────────" + +fails=0; checks=0 +# canonical must parse +if ! "$AS" parse "$REF_FILE" >/dev/null 2>&1; then + printf " %-12s %s\n" "canonical" "FAIL (reference does not parse)" + fails=$((fails+1)) +fi + +for f in "$CORPUS"/*.affine; do + [ -e "$f" ] || continue + face="$(face_of "$f")" + sub="$(preview_subcmd "$face")" + [ "$sub" = "CANONICAL" ] && continue + if [ -z "$sub" ]; then + printf " %-12s %s\n" "$face" "SKIP (unknown face pragma in $(basename "$f"))" + continue + fi + checks=$((checks+1)) + uri="file://$(cd "$(dirname "$f")" && pwd)/$(basename "$f")" + + # round-trip parse of the face source (auto-detects face via pragma) + parse_ok=1 + "$AS" parse "$f" >/dev/null 2>&1 || parse_ok=0 + + # lower via preview-* and compare to the canonical cube + actual_norm="$("$AS" "$sub" "$f" 2>/dev/null | normalise)" + if [ "$parse_ok" -eq 0 ]; then + printf " %-12s %s\n" "$face" "FAIL (face source does not round-trip parse)" + emit "{\"profile\":\"faces\",\"claim\":\"different faces, same cube\",\"program\":\"$program\",\"face\":\"$face\",\"artifact_uri\":\"$uri\",\"transition\":\"$sub\",\"target\":\"$(basename "$REF_FILE")\",\"result\":\"Ungrounded\",\"evidence\":\"round-trip parse failed\"}" + fails=$((fails+1)) + elif [ "$actual_norm" = "$REF_NORM" ]; then + printf " %-12s %s\n" "$face" "OK (lowers to the same cube)" + emit "{\"profile\":\"faces\",\"claim\":\"different faces, same cube\",\"program\":\"$program\",\"face\":\"$face\",\"artifact_uri\":\"$uri\",\"transition\":\"$sub\",\"target\":\"$(basename "$REF_FILE")\",\"result\":\"Grounded\",\"evidence\":\"normalised canonical equals reference\"}" + else + printf " %-12s %s\n" "$face" "DIFF (lowers to a DIFFERENT cube)" + echo " ── canonical lowering diff ($face vs canonical) ──" + diff <(printf '%s\n' "$REF_NORM") <(printf '%s\n' "$actual_norm") | sed 's/^/ /' || true + emit "{\"profile\":\"faces\",\"claim\":\"different faces, same cube\",\"program\":\"$program\",\"face\":\"$face\",\"artifact_uri\":\"$uri\",\"transition\":\"$sub\",\"target\":\"$(basename "$REF_FILE")\",\"result\":\"Ungrounded\",\"evidence\":\"normalised canonical differs from reference\"}" + fails=$((fails+1)) + fi +done + +echo "──────────────────────────────────────────────" +if [ "$fails" -gt 0 ]; then + echo "FAIL: $fails of $checks face(s) diverge from the cube." >&2 + exit 1 +fi +echo "OK: all $checks face(s) lower to the same cube. Invariant holds." +[ -n "$OUT" ] && echo "claim records: $OUT" +exit 0