Skip to content
Merged
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
41 changes: 28 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true

# Rust: each `setup-rust-toolchain` step omits `toolchain:` so the action reads
# `rust-toolchain.toml` at the repo root after `actions/checkout`. No job uses a
# non-default `working-directory` for Rust setup; if one did, make `rust-toolchain.toml`
# visible from that directory (e.g. copy it in) — do **not** add a workflow `toolchain:`
# input: `scripts/check-rust-toolchain-single-authority.sh` rejects `toolchain:` in the
# same Actions step as `actions-rust-lang/setup-rust-toolchain` in any
# `.github/workflows/*.{yml,yaml}`, and the action
# ignores the repo file when `toolchain` is set explicitly (upstream semantics).
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
Expand All @@ -49,7 +57,6 @@ jobs:
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.16.0
with:
toolchain: "1.93.0"
components: rustfmt
cache: false
rustflags: ""
Expand Down Expand Up @@ -121,10 +128,14 @@ jobs:
# silently neutering the consumer.
run: bash scripts/test-check-manager-brief-authority.sh

- name: Rust toolchain single-authority check (P2)
# Pinned channel must not be duplicated in dsl/extdeps/rustup.dag;
# authority is rust-toolchain.toml only (PR #1794).
run: bash scripts/check-rust-toolchain-single-authority.sh

- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.16.0
with:
toolchain: "1.93.0"
components: rustfmt
cache: false
rustflags: ""
Expand Down Expand Up @@ -171,7 +182,6 @@ jobs:
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.16.0
with:
toolchain: "1.93.0"
# Integration snapshot tests (`regen_parse` path, lane2d cost lens, …) shell out to `rustfmt`.
components: rustfmt, clippy
cache: false
Expand Down Expand Up @@ -214,13 +224,17 @@ jobs:
# Wall-clock gate on lane2 Stage 2d symbolic-cost tests only (ζ / #537).
# Post-#546 these live in the consolidated `integration` test binary; the
# `lane2_stage_2d_symbolic_cost_test::` filter keeps the ratchet narrow.
# The timer wraps the full `cargo test` process, so **cold** runs (cache
# miss, first compile + link on ubuntu-latest) can spend minutes — keep
# headroom beyond a tight 120s; per-test discipline lives in
# The timer wraps the full `cargo test` process. Prebuild the integration
# harness (`--no-run`) so this gate measures lane2d work — not a cold link
# of the whole `integration` binary (observed ~322s over a 300s budget on
# ubuntu-latest; PR #1794). **Cold** runs (cache miss) can still spend minutes;
# keep headroom beyond a tight 120s; per-test discipline lives in
# `tests/common/budgeted.rs` (DEFAULT_BUDGET_MS).
# Tracked headroom: ratchet this ceiling back toward 300s once cold
# lane2d CI runs regularly complete under 300s after fixture compile
# amortization lands for the remaining Stage 2d cases.
# Tracked headroom: ratchet this ceiling back toward 300s once cold lane2d
# CI runs regularly complete under 300s after fixture compile amortization.
- name: Prebuild v3 integration test binary (lane2d wall-clock denominator)
run: cargo test -p v3-compiler --test integration --no-run

- name: v3 tests (Stage 2d integration module, 360s cold-compile-safe budget)
run: |
start=$(date +%s)
Expand Down Expand Up @@ -351,9 +365,11 @@ jobs:
# PR job still reports as a failed check, which makes this advisory ratchet
# merge-blocking before Lane 1e graduation.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Same sizing rationale as `v3` above: DB-8 release builds run on
# ubicloud-standard-8. Use a 60m wall-clock cap so cold-cache / full release
# `cargo` work is unlikely to hit mid-compile cancellation (ratchet remains non-blocking).
# Same sizing rationale as `v3` above: DB-8 release builds + determinism_test (5× matrix) +
# `self_host_fixed_point` run on `ubicloud-standard-8` (#1814); small/default runners saw
# mid-compile shutdown on #1794. **60m** wall so cold-cache / full release `cargo` is unlikely
# to cancel mid-job; ratchet remains non-blocking. (Runner label is orthogonal to P2:
# `rust-toolchain.toml` + no workflow `toolchain:` input.)
runs-on: ubicloud-standard-8
timeout-minutes: 60
needs: [v3]
Expand All @@ -367,7 +383,6 @@ jobs:
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.16.0
with:
toolchain: "1.93.0"
cache: false
rustflags: ""

Expand Down
44 changes: 14 additions & 30 deletions docs/design-fixed-point-ratchet.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,31 +88,15 @@ Comparison uses raw byte equality — whitespace matters, line endings matter. M

### CI integration

`.github/workflows/ci.yml` — add a new job:

```yaml
self_host:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [ci, v3] # runs after basic build + v3 tests pass
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.93.0"
- name: Cache Cargo (self_host)
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
target/
key: cargo-self-host-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('src/v3/compiler/**') }}
- name: Self-host fixed-point check
run: cargo run --bin self_host_fixed_point --release
```
Canonical wiring is **`.github/workflows/ci.yml`**, job **`self_host_ratchet`** (`needs: [v3]`).

While DB-8 stays **staged** and the job remains **`continue-on-error: true`**, it is scheduled on **`push` to `refs/heads/main` only** (PR runs were producing merge-blocking red checks when Actions cancelled in-flight jobs). The **`v3`** job still runs **`determinism_test`** on pull requests.

- **Runner / wall clock:** `ubicloud-standard-8`, `timeout-minutes: 60` (heavy release work + cold cache).
- **Rust setup:** `actions-rust-lang/setup-rust-toolchain@v1.16.0` with **`toolchain:` omitted** so the action reads `rust-toolchain.toml` (enforced by `scripts/check-rust-toolchain-single-authority.sh`).
- **Steps (release):** `cargo test -p v3-compiler --release --test determinism_test`, then `cargo run -p v3-compiler --release --bin self_host_fixed_point`, plus informational `emit.rs` HashMap/HashSet grep.

Runs after the core `v3` job. If emission changes, this fails; CI blocks merge.
Failures are **advisory** until Lane 1e graduates the ratchet to merge-blocking.

### Sources of non-determinism (to eliminate)

Expand Down Expand Up @@ -222,7 +206,7 @@ Runs per-test, local to each emit call. Catches non-determinism without needing

**Why per-fixture 5x re-run test?** Because most non-determinism manifests within a single process (HashMap seed randomness). 5 runs gives high confidence without absurd test runtime.

**Why separate `self_host_fixed_point` binary, not a test?** Because it's a pipeline (emit → rustc → run → diff) too heavyweight for `cargo test`. CI runs it as its own job; developers can invoke it locally via `cargo run --bin self_host_fixed_point --release`.
**Why separate `self_host_fixed_point` binary, not a test?** Because it's a pipeline (emit → rustc → run → diff) too heavyweight for `cargo test`. CI runs it as its own job; developers can invoke it locally via `cargo run -p v3-compiler --release --bin self_host_fixed_point`.

---

Expand All @@ -232,7 +216,7 @@ Runs per-test, local to each emit call. Catches non-determinism without needing

**Structural diff (parse both, compare ASTs)** — allows whitespace differences. Too lenient. We want bit-identical as the contract. Rejected.

**Only check on push to main** — misses PRs that introduce non-determinism. Run on every PR + main push. Rejected "only on main."
**Only check on push to main** — misses PRs that introduce non-determinism if it were the *sole* long-term policy. **Current compromise (staged):** `self_host_ratchet` runs on **`main` pushes only** while the job is still advisory, because cancelled PR workflow runs were surfacing as merge-blocking failures; PRs still get **`determinism_test`** inside **`v3`**. Re-open PR+`main` coverage when the ratchet graduates merge-blocking.

**Accept "close enough" — diff lines < 5** — opens a hole. Any intentional emission change must be accompanied by snapshot update; any unintentional change is a bug. Rejected.

Expand All @@ -255,7 +239,7 @@ target/self_host/

### Performance

Self-host cycle takes: emit (2s) + rustc (30s) + run (1s) + diff (instant) = ~33s per cycle. Running once per PR is acceptable. If it grows to the v2 "20-minute self-compile" regime (THESIS.md §merge_envs case study), sound alarm — see open question 2 below.
Self-host cycle takes: emit (2s) + rustc (30s) + run (1s) + diff (instant) = ~33s per cycle on a warm machine. CI runs the dedicated job on each **`main` push** while staged (see **CI integration**). If it grows to the v2 "20-minute self-compile" regime (THESIS.md §merge_envs case study), sound alarm — see open question 2 below.

### Bisecting non-determinism

Expand All @@ -276,7 +260,7 @@ Maintain a `docs/self-host-incidents.md` log of found non-determinism sources
- **`src/v3/compiler/compiler.dag`** — the compiler source being cycled (PR #418 and later additions)
- **Create `src/v3/compiler/src/bin/self_host_fixed_point.rs`** — the CI binary
- **Create `src/v3/compiler/tests/determinism_test.rs`** (+ shared matrix in `tests/common/determinism_fixtures.rs`) — per-fixture 5× determinism check
- **Update `.github/workflows/ci.yml`** — `self_host` job
- **Update `.github/workflows/ci.yml`** — `self_host_ratchet` job
- **Update `.gitignore`** — `target/self_host/`
- **Thesis anchor** — SELF_HOSTING.md §14 (fixed-point discipline)

Expand All @@ -285,7 +269,7 @@ Maintain a `docs/self-host-incidents.md` log of found non-determinism sources
## Acceptance (Lane 3 Stage 3c owns)

- [ ] `self_host_fixed_point` binary passes full emit → rustc → run → **byte-identical** diff on `dsl/gunbc/compiler.dag` (staged until v3 parses + emits a CLI-shaped crate)
- [x] CI job `self_host_ratchet` runs after `v3` on every PR + main push; **`continue-on-error: true`** until Lane 1e closes (then graduate to merge-blocking)
- [x] CI job `self_host_ratchet` runs after `v3` on each **`main` branch push** while staged (PRs: `determinism_test` in **`v3`**); **`continue-on-error: true`** until Lane 1e closes (then graduate to merge-blocking)
- [x] `tests/determinism_test.rs` passes per-matrix-row 5× equivalence (Rust full matrix; Go/Python scoped per `determinism_fixtures.rs`)
- [x] Informational grep step in `self_host_ratchet` surfaces `HashMap`/`HashSet::` in `emit.rs` (strict gate deferred until Lane 1e clears iteration debt)
- [x] Invariant D-1 (determinism) added to `INVARIANTS.md`
Expand All @@ -294,7 +278,7 @@ Maintain a `docs/self-host-incidents.md` log of found non-determinism sources

## Open questions

1. **Does the ratchet need to run in `release` mode?** Probably yes — rustc release inlines more, which can stabilize output. Design has `cargo run --bin self_host_fixed_point --release`.
1. **Does the ratchet need to run in `release` mode?** Probably yes — rustc release inlines more, which can stabilize output. Design has `cargo run -p v3-compiler --release --bin self_host_fixed_point`.

2. **What if self-host cycle time approaches v2's 20-min issue?** Alarm. Root-cause the slowdown (usually HashMap-dependent re-derivation, per THESIS.md case study). Don't accept >2min for the cycle.

Expand Down
2 changes: 1 addition & 1 deletion dsl/extdeps/github/actions.dag
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ data checkout_action: ActionRef = ActionRef {
}

data setup_rust_action: ActionRef = ActionRef {
owner: "dtolnay", repo: "rust-toolchain", ref: "stable"
owner: "actions-rust-lang", repo: "setup-rust-toolchain", ref: "v1.16.0"
}

data cache_action: ActionRef = ActionRef {
Expand Down
14 changes: 6 additions & 8 deletions dsl/extdeps/rustup.dag
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@
// What we actually depend on from rustup:
// - cargo binary, installed by default with the stable toolchain
// - rustc, installed by default
// - Pinned toolchain "1.93.0" in CI (.github/workflows/ci.yml)
// - Pinned toolchain channel: **single authority** is `rust-toolchain.toml`
// `[toolchain].channel` only (this stub does not duplicate that string).
//
// What we do NOT currently depend on:
// - Custom components (clippy ships with stable, rustfmt ships with stable)
// - Extra rustup components beyond rust-toolchain.toml (rustfmt + clippy today)
// - Cross-compilation targets (gunbc only builds for the host)
// - Multiple toolchains (no toolchain switching)
//
// The CI workflow uses dtolnay/rust-toolchain@v1 to provision
// rustup. Local dev uses whatever rustup ships as stable.
// CI uses actions-rust-lang/setup-rust-toolchain (see .github/workflows/ci.yml), which
// reads rust-toolchain.toml. Local dev uses rust-toolchain.toml when present, else
// whatever rustup ships as stable.
//
// Future:
// When we model toolchain switching, channels, components, or
Expand All @@ -44,9 +46,5 @@ data rustup_install_url: NonEmptyStr = "https://rustup.rs"
// Default channel we expect when "rustup default" is queried.
data default_channel: RustChannel = Stable

// The pinned toolchain version used in CI. Local dev usually tracks
// stable; CI pins for reproducibility.
data ci_pinned_toolchain: NonEmptyStr = "1.93.0"

// Rustup docs URL for reference.
data rustup_docs: NonEmptyStr = "https://rust-lang.github.io/rustup/"
5 changes: 3 additions & 2 deletions rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Generated by gunbc-codegen. Do not edit manually.
# Source of truth: dsl/config/toolchain.dag
# Sole in-repo rustup channel authority (CI + local). Hand-maintained (no
# regen step overwrites this file). `dsl/extdeps/rustup.dag` documents rustup
# installer behavior only — it must not introduce a second pinned channel literal.
[toolchain]
channel = "1.93.0"
components = ["clippy", "rustfmt"]
154 changes: 154 additions & 0 deletions scripts/check-rust-toolchain-single-authority.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
#
# P2 single-authority: the pinned rustc channel string lives only in
# `rust-toolchain.toml` `[toolchain].channel`. `dsl/extdeps/rustup.dag` must not
# reintroduce that value as a quoted literal, as a bare semver-like token (catches
# unquoted comment drift such as `// pin 1.93.0`), nor via the retired
# `ci_pinned_toolchain` symbol — see PR #1794 / INVARIANTS P2. Word channels (e.g.
# `stable`) are only checked in quoted form to avoid unrelated prose false positives.
#
# Also fail if any `.github/workflows/*.{yml,yaml}` pairs `toolchain:` with
# `actions-rust-lang/setup-rust-toolchain` in the same step (the action ignores
# rust-toolchain.toml when that input is present — same authority drift class).
#
# Dissolution: delete this script and its CI step if extdeps + workflow toolchain
# selection are generated or schema-checked so this shell guard is redundant.

set -euo pipefail

script_dir=$(cd "$(dirname "$0")" && pwd)
repo_root=$(cd "$script_dir/.." && pwd)
rustup_dag="$repo_root/dsl/extdeps/rustup.dag"
toolchain_toml="$repo_root/rust-toolchain.toml"
workflows_dir="$repo_root/.github/workflows"

if [ ! -r "$rustup_dag" ]; then
echo "::error::missing $rustup_dag"
exit 2
fi

if [ ! -r "$toolchain_toml" ]; then
echo "::error::missing $toolchain_toml"
exit 2
fi

if [ ! -d "$workflows_dir" ]; then
echo "::error::missing $workflows_dir"
exit 2
fi

if ! grep -Eq '^[[:space:]]*channel[[:space:]]*=[[:space:]]*"' "$toolchain_toml"; then
echo "::error::rust-toolchain.toml must contain a quoted [toolchain].channel line"
exit 1
fi

channel=$(
sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$toolchain_toml" | head -n1
)
if [ -z "$channel" ]; then
echo "::error::could not parse [toolchain].channel from rust-toolchain.toml"
exit 1
fi

quoted_channel="\"${channel}\""
if grep -Fq "$quoted_channel" "$rustup_dag"; then
echo "::error::dsl/extdeps/rustup.dag contains the pinned channel literal ${quoted_channel} — duplicate authority (keep the channel only in rust-toolchain.toml)."
exit 1
fi

# Semver-like channels: also reject the bare token so unquoted comment/data drift
# cannot reintroduce the pin (e.g. `// use 1.93.0`). Skipped for word channels like
# `stable` where this substring can appear in unrelated prose.
if [[ "$channel" =~ ^[0-9]+\.[0-9]+ ]]; then
if grep -Fq "$channel" "$rustup_dag"; then
echo "::error::dsl/extdeps/rustup.dag contains bare channel token '${channel}' — duplicate authority (keep the channel only in rust-toolchain.toml)."
exit 1
fi
fi

if grep -Eq '^[[:space:]]*data[[:space:]]+ci_pinned_toolchain' "$rustup_dag"; then
echo "::error::dsl/extdeps/rustup.dag declares ci_pinned_toolchain — retired duplicate authority symbol. Use rust-toolchain.toml only."
exit 1
fi

# Indented YAML key `toolchain:` under `with:` for `actions-rust-lang/setup-rust-toolchain`
# ignores rust-toolchain.toml — forbid that pairing only (not unrelated `toolchain:` keys
# in other actions). Python scan walks each `toolchain:` line up to its Actions step head,
# then scans the **full** step (through the next sibling `-` at the same list indent) so a
# pathological `with.toolchain` **before** `uses: …/setup-rust-toolchain` cannot evade the check.
if ! command -v python3 >/dev/null 2>&1; then
echo "::error::python3 is required for workflow toolchain guard (setup-rust-toolchain scope)"
exit 2
fi

python3 - "$workflows_dir" "$repo_root" <<'PY'
import pathlib
import re
import sys

SETUP = "actions-rust-lang/setup-rust-toolchain"


def _step_span(lines, step_start):
"""Return [step_start, end) line indices for one GitHub Actions `steps:` list item."""
m0 = re.match(r"^(\s*)-\s", lines[step_start])
if not m0:
return step_start, min(step_start + 1, len(lines))
base = len(m0.group(1))
k = step_start + 1
while k < len(lines):
m = re.match(r"^(\s*)-\s", lines[k])
if m is not None and len(m.group(1)) == base:
break
k += 1
return step_start, k


def violation_in_file(wf_path):
lines = wf_path.read_text(encoding="utf-8").splitlines()
for i, line in enumerate(lines):
m_tc = re.match(r"^(\s+)toolchain\s*:", line)
if not m_tc:
continue
tc_ws = len(m_tc.group(1))
j = i - 1
while j >= 0:
m_dash = re.match(r"^(\s*)-\s", lines[j])
if m_dash is not None and len(m_dash.group(1)) < tc_ws:
break
j -= 1
if j < 0:
continue
_, k = _step_span(lines, j)
block = "\n".join(lines[j:k])
if SETUP in block:
return i + 1, line.strip()
return None


def main():
workflows_dir = pathlib.Path(sys.argv[1])
repo_root = pathlib.Path(sys.argv[2])
files = sorted(workflows_dir.glob("*.yml")) + sorted(workflows_dir.glob("*.yaml"))
if not files:
print(f"::error::no *.yml or *.yaml under {workflows_dir}")
return 2
for wf in files:
hit = violation_in_file(wf)
if hit is not None:
lineno, preview = hit
rel = wf.resolve().relative_to(repo_root.resolve())
print(
f"::error::file={rel},line={lineno}::explicit `toolchain:` input on "
f"{SETUP} — rust-toolchain.toml would be ignored. Remove it from that step's `with:`."
)
print(f"{rel}:{lineno}: {preview}")
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
PY

echo "Rust toolchain single-authority check OK (channel=${channel}; rustup.dag + workflow guard)."
Loading
Loading