diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md index fd15cd5abe..01dcf7679b 100644 --- a/.claude/skills/pr-review/SKILL.md +++ b/.claude/skills/pr-review/SKILL.md @@ -22,7 +22,7 @@ difference is a defect in this file. **Contract**: `contracts/pr-review-skill-v2.yaml` (§1 grounding, §7 blocking, §8 metrics) **Guard**: `scripts/check_pr_review_receipt.sh` — it validates what you emit here, it has its own positive controls, and its mutation set (`scripts/mutate-guard.sh`) reports -233/233. **Run it on your own receipt before you post anything.** +237/237. **Run it on your own receipt before you post anything.** ## Context @@ -438,7 +438,7 @@ Bash guards are exercised with `bats-core` fixtures. For the receipt guard itsel mutation set already exists and is a derivation, not a list: ```bash -bash scripts/mutate-guard.sh # 233/233 on scripts/check_pr_review_receipt.sh +bash scripts/mutate-guard.sh # 237/237 on scripts/check_pr_review_receipt.sh ``` **`attempted: 0` with `status: consulted` is rejected** (fixture row 2). A mutation set @@ -1192,7 +1192,7 @@ the real merged commits, not from the spec's reasoning about them. ## §13 Autonomous merge on quorum (DESIGNED AND BUILT, **NOT ARMED**) Spec §13. Operator instruction, 2026-08-31: PRs auto-merge once the review quorum passes. -The mechanism exists — `scripts/pr_review_quorum_arm.sh`, a table of 83 rows, a 134-mutant +The mechanism exists — `scripts/pr_review_quorum_arm.sh`, a table of 90 rows, a 134-mutant set at 100% — and **it is reachable from no workflow that can merge anything.** §13.11 is the arming ladder; rung 0 is where this file is written. @@ -1345,7 +1345,7 @@ That is the first falsifiable property of the section, and `q-44` plus the ### §13.9 Verifying the mechanism ```bash -bats tests/pr-review-quorum.bats # 83 rows: one per refusal path, four that PERMIT +bats tests/pr-review-quorum.bats # 90 rows: one per refusal path, four that PERMIT bash scripts/mutate_quorum_arm.sh # 134/134 — §13.10 fixes this at one, no ratchet bash scripts/mutate_quorum_arm.sh --list # the catalogue, no mutants run ``` diff --git a/.claude/skills/pre-release/SKILL.md b/.claude/skills/pre-release/SKILL.md index d9d1cd1d47..cb39fc0254 100644 --- a/.claude/skills/pre-release/SKILL.md +++ b/.claude/skills/pre-release/SKILL.md @@ -326,6 +326,11 @@ regression this repo has on record, and its power *falls* as data accumulates Richer fields (surface counts, findings, notable, verdict) are encouraged — the receipts already under `evidence/dogfood/` are the worked examples. +**The tracked implementation of this step** is `scripts/release/host_receipt.sh`, which +`scripts/release/autopilot.sh` ships to each host after publish (#3731). Never hand-roll +the sweep; `scripts/check_dogfood_shim.sh` row 1b allows those two scripts only while +this paragraph names them. + **The sweep must `cargo install` the PUBLISHED crate**, not build the local tree. Building the tree tests what you have; installing tests what a user gets. On a box with a pre-existing `apr` the install correctly fails closed (rc=101) *before* compiling — use diff --git a/.githooks/pre-push b/.githooks/pre-push index e2686ad76f..d51a06732c 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -19,4 +19,14 @@ cargo doc --no-deps -q 2>/dev/null || echo "WARN: cargo doc failed" echo "── ComputeBrick Compliance ──" pmat comply check --failures-only 2>/dev/null || true +echo "── #4415/#4416: CI guard steps (scripts/ci_guards.sh, the script CI runs) ──" +# The drift check is a YAML parse, so it always runs. The full guard run takes +# many minutes; opt in with APR_PREPUSH_GUARDS=1 (or run `make guards-local`). +rc=0; bash scripts/ci_guards.sh --check-coverage || rc=$? +[ "$rc" -eq 1 ] && exit 1 +[ "$rc" -eq 0 ] || echo "WARN: guard drift check could not run (rc=$rc)" +if [ "${APR_PREPUSH_GUARDS:-0}" = 1 ]; then + bash scripts/ci_guards.sh || exit 1 +fi + echo "✅ Pre-push passed" diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index f1162eaef4..074889486a 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -24,3 +24,5 @@ self-hosted-runner: - apple-silicon - m4 - mini + # #4292 / infra#1042: the mini's dedicated repo-level release runner + - mini-builder diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml index 3f461c4f5d..e10f3d7d6e 100644 --- a/.github/workflows/binary-release.yml +++ b/.github/workflows/binary-release.yml @@ -79,6 +79,34 @@ concurrency: jobs: + # A release that already carries every asset is NOT rebuilt (#4286). A promoted final + # is its rc's exact bytes (scripts/release/promote_rc.sh); publishing it with a + # personal token fires `release: published` here, and the upload steps below delete + # and replace same-named assets, so an ungated rebuild would silently swap the bytes + # that were dogfooded for new ones. With all eighteen present the build jobs skip and + # verify + smoke still run, on the promoted bytes. A partial release (a lane died) + # still rebuilds. An unreadable release (exit 2) fails this job, so the builds skip: + # when in doubt, never overwrite. + assets: + name: Does the release already carry every asset? + runs-on: [self-hosted, Linux, X64, clean-room] + timeout-minutes: 5 + outputs: + present: ${{ steps.check.outputs.present }} + steps: + - uses: actions/checkout@v7 + - id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: | + setsid --wait bash scripts/check_release_assets.sh "$TAG"; rc=$? + case "$rc" in + 0) echo "present=true" >> "$GITHUB_OUTPUT"; echo "$TAG already carries every asset: nothing is rebuilt" >> "$GITHUB_STEP_SUMMARY" ;; + 1) echo "present=false" >> "$GITHUB_OUTPUT" ;; + *) echo "::error::could not read release $TAG (rc=$rc); refusing to build over assets that may exist"; exit 1 ;; + esac + # The pv lane on the fleet (#3073; operator rule 2026-09-10: no GitHub-hosted runners). It ran on # ubuntu-latest, with cross for aarch64. Each target now builds natively on the box of its architecture # (x86_64 on yoga, aarch64 on gx10) in a sibling container, the pattern of build-apr-cpu: gnu targets @@ -86,6 +114,8 @@ jobs: # well, so no floor rises), musl targets inside rust:1.93.0-bookworm (static). Asset names unchanged. build: name: pv ${{ matrix.target }} on ${{ matrix.host }} + needs: assets + if: needs.assets.outputs.present == 'false' runs-on: ${{ fromJSON(matrix.labels) }} strategy: fail-fast: false @@ -124,6 +154,20 @@ jobs: with: ref: ${{ steps.tag.outputs.tag }} + # #4110/#4290: an rc asset's crate version IS its tag. The rc commit's Cargo.toml says + # X.Y.Z, so without this every vX.Y.Z-rc.N asset printed bare `X.Y.Z` (v0.69.3-rc.2: + # `apr 0.69.3 (v0.69.3+no-git)`). The stamper comes from THIS workflow's commit, not the + # tag's tree, which an rc cut from release/X.Y.Z predates. A final tag stamps nothing. + # verify-apr-assets then requires `apr --version` to equal the tag exactly. + - name: Stamp the rc version into the tag tree (#4110) + env: + TAG: ${{ steps.tag.outputs.tag }} + run: | + case "$TAG" in *-rc.*) ;; *) echo "final tag $TAG: nothing to stamp"; exit 0 ;; esac + git fetch --no-tags --depth 1 origin "$GITHUB_WORKFLOW_SHA" + git show "FETCH_HEAD:scripts/release/stamp_rc_version.sh" > "$RUNNER_TEMP/stamp_rc_version.sh" + bash "$RUNNER_TEMP/stamp_rc_version.sh" "$GITHUB_WORKSPACE" "$TAG" + - name: Preflight - this box has the tools this job assumes run: bash scripts/ci_self_hosted_preflight.sh --need docker objdump @@ -220,7 +264,10 @@ jobs: run: | TAG="${{ steps.tag.outputs.tag }}" API="https://api.github.com/repos/${GITHUB_REPOSITORY}" - rel=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/releases/tags/$TAG") + # by listing, not releases/tags/: an rc is a DRAFT until every fleet host runs it (#4327), + # and releases/tags/ never returns a draft + rel=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/releases?per_page=100" \ + | python3 -c 'import json,sys; t=sys.argv[1]; print(json.dumps(next(r for r in json.load(sys.stdin) if r["tag_name"]==t)))' "$TAG") rid=$(printf '%s' "$rel" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') for f in "${{ steps.package.outputs.archive }}" "${{ steps.package.outputs.sha }}"; do # --clobber semantics: delete an asset of the same name first @@ -233,6 +280,8 @@ jobs: build-apr-cuda: name: apr (cuda) ${{ matrix.target }} on ${{ matrix.host }} + needs: assets + if: needs.assets.outputs.present == 'false' runs-on: ${{ fromJSON(matrix.labels) }} strategy: fail-fast: false @@ -261,6 +310,20 @@ jobs: with: ref: ${{ steps.tag.outputs.tag }} + # #4110/#4290: an rc asset's crate version IS its tag. The rc commit's Cargo.toml says + # X.Y.Z, so without this every vX.Y.Z-rc.N asset printed bare `X.Y.Z` (v0.69.3-rc.2: + # `apr 0.69.3 (v0.69.3+no-git)`). The stamper comes from THIS workflow's commit, not the + # tag's tree, which an rc cut from release/X.Y.Z predates. A final tag stamps nothing. + # verify-apr-assets then requires `apr --version` to equal the tag exactly. + - name: Stamp the rc version into the tag tree (#4110) + env: + TAG: ${{ steps.tag.outputs.tag }} + run: | + case "$TAG" in *-rc.*) ;; *) echo "final tag $TAG: nothing to stamp"; exit 0 ;; esac + git fetch --no-tags --depth 1 origin "$GITHUB_WORKFLOW_SHA" + git show "FETCH_HEAD:scripts/release/stamp_rc_version.sh" > "$RUNNER_TEMP/stamp_rc_version.sh" + bash "$RUNNER_TEMP/stamp_rc_version.sh" "$GITHUB_WORKSPACE" "$TAG" + # FIRST thing this box does after it has a working tree. Run 34448908554 # built a 21 MB CUDA asset here and then died on `gh: command not found` # at the upload — forty minutes of GPU spent on a shell error a one-second @@ -291,7 +354,7 @@ jobs: # build.rs runs `git rev-parse` INSIDE this container, where the checkout is owned by # the runner uid and git refuses it — so every release asset printed `(vX+no-git)` and # scripts/apr_bin.sh (STRICT) could not attribute it to its tag. Ask the host checkout. - APR_SHA=$(git rev-parse --short HEAD) + APR_SHA=$(git rev-parse --short=9 HEAD) mkdir -p "$GITHUB_WORKSPACE/target" $DOCKER run --rm \ -v "$GITHUB_WORKSPACE:/workspace" \ @@ -354,7 +417,10 @@ jobs: run: | TAG="${{ steps.tag.outputs.tag }}" API="https://api.github.com/repos/${GITHUB_REPOSITORY}" - rel=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/releases/tags/$TAG") + # by listing, not releases/tags/: an rc is a DRAFT until every fleet host runs it (#4327), + # and releases/tags/ never returns a draft + rel=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/releases?per_page=100" \ + | python3 -c 'import json,sys; t=sys.argv[1]; print(json.dumps(next(r for r in json.load(sys.stdin) if r["tag_name"]==t)))' "$TAG") rid=$(printf '%s' "$rel" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') for f in "${{ steps.package.outputs.archive }}" "${{ steps.package.outputs.sha }}"; do # --clobber semantics: delete an asset of the same name first @@ -385,6 +451,8 @@ jobs: # same step, so the merge moves both or neither. build-apr-cpu: name: apr (cpu) ${{ matrix.target }} on ${{ matrix.host }} + needs: assets + if: needs.assets.outputs.present == 'false' runs-on: ${{ fromJSON(matrix.labels) }} strategy: fail-fast: false @@ -413,6 +481,20 @@ jobs: with: ref: ${{ steps.tag.outputs.tag }} + # #4110/#4290: an rc asset's crate version IS its tag. The rc commit's Cargo.toml says + # X.Y.Z, so without this every vX.Y.Z-rc.N asset printed bare `X.Y.Z` (v0.69.3-rc.2: + # `apr 0.69.3 (v0.69.3+no-git)`). The stamper comes from THIS workflow's commit, not the + # tag's tree, which an rc cut from release/X.Y.Z predates. A final tag stamps nothing. + # verify-apr-assets then requires `apr --version` to equal the tag exactly. + - name: Stamp the rc version into the tag tree (#4110) + env: + TAG: ${{ steps.tag.outputs.tag }} + run: | + case "$TAG" in *-rc.*) ;; *) echo "final tag $TAG: nothing to stamp"; exit 0 ;; esac + git fetch --no-tags --depth 1 origin "$GITHUB_WORKFLOW_SHA" + git show "FETCH_HEAD:scripts/release/stamp_rc_version.sh" > "$RUNNER_TEMP/stamp_rc_version.sh" + bash "$RUNNER_TEMP/stamp_rc_version.sh" "$GITHUB_WORKSPACE" "$TAG" + # 67-C2: the first thing this box does after it has a working tree. No # `--cuda` here — a CPU asset must build on a box with no visible device, # and asserting one would make this lane lie about what it needs. `docker` @@ -442,7 +524,7 @@ jobs: # build.rs runs `git rev-parse` INSIDE this container, where the checkout is owned by # the runner uid and git refuses it — so every release asset printed `(vX+no-git)` and # scripts/apr_bin.sh (STRICT) could not attribute it to its tag. Ask the host checkout. - APR_SHA=$(git rev-parse --short HEAD) + APR_SHA=$(git rev-parse --short=9 HEAD) mkdir -p "$GITHUB_WORKSPACE/target" $DOCKER run --rm \ -v "$GITHUB_WORKSPACE:/workspace" \ @@ -527,7 +609,10 @@ jobs: run: | TAG="${{ steps.tag.outputs.tag }}" API="https://api.github.com/repos/${GITHUB_REPOSITORY}" - rel=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/releases/tags/$TAG") + # by listing, not releases/tags/: an rc is a DRAFT until every fleet host runs it (#4327), + # and releases/tags/ never returns a draft + rel=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/releases?per_page=100" \ + | python3 -c 'import json,sys; t=sys.argv[1]; print(json.dumps(next(r for r in json.load(sys.stdin) if r["tag_name"]==t)))' "$TAG") rid=$(printf '%s' "$rel" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') for f in "${{ steps.package.outputs.archive }}" "${{ steps.package.outputs.sha }}"; do # --clobber semantics: delete an asset of the same name first @@ -538,6 +623,101 @@ jobs: | python3 -c 'import json,sys; a=json.load(sys.stdin); print("uploaded", a["name"], a["size"], "bytes")' done + # mini (Darwin arm64) is fleet hardware and could not take v0.69.3-rc.2: the release shipped + # no darwin asset, so mini was RED on every rc (#4327, infra-64 evidence/fleet-versions.md). + # Native build on the self-hosted mini-m4 runner (no hosted runners, #3073). No container: + # macOS has no glibc floor to pin, and the stamped version + the sha are checked below + # before anything is uploaded. + build-apr-darwin: + name: apr (cpu) aarch64-apple-darwin on mini + needs: assets + if: needs.assets.outputs.present == 'false' + # mini-builder (#4292, infra#1042): the mini's dedicated repo-level release runner, used + # only through this label so it never joins the general pool. + runs-on: [self-hosted, macOS, ARM64, mini-builder] + env: + # The macOS floor, recorded in the Mach-O load command and read back below (#4292). + MACOSX_DEPLOYMENT_TARGET: "11.0" + steps: + - name: Resolve release tag + id: tag + run: | + TAG="${{ github.event.release.tag_name || inputs.tag }}" + [ -n "$TAG" ] || { echo "::error::No tag resolved"; exit 1; } + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Checkout at tag + uses: actions/checkout@v7 + with: + ref: ${{ steps.tag.outputs.tag }} + + # The release scripts come from THIS workflow's commit: the tag tree predates them. + - name: Stamp the rc version into the tag tree (#4110) + env: + TAG: ${{ steps.tag.outputs.tag }} + run: | + git fetch --no-tags --depth 1 origin "$GITHUB_WORKFLOW_SHA" + for f in stamp_rc_version.sh asset_version_check.sh; do + git show "FETCH_HEAD:scripts/release/$f" > "$RUNNER_TEMP/$f" + done + case "$TAG" in *-rc.*) ;; *) echo "final tag $TAG: nothing to stamp"; exit 0 ;; esac + bash "$RUNNER_TEMP/stamp_rc_version.sh" "$GITHUB_WORKSPACE" "$TAG" + + - name: Build apr on the default features (no cuda) + run: | + APR_GIT_SHA_OVERRIDE="$(git rev-parse --short=9 HEAD)" \ + cargo build --release -p apr-cli --bin apr --target aarch64-apple-darwin --locked + strip target/aarch64-apple-darwin/release/apr + + - name: The binary names its tag and commit (#4110) + run: | + line=$(target/aarch64-apple-darwin/release/apr --version) + bash "$RUNNER_TEMP/asset_version_check.sh" "${{ steps.tag.outputs.tag }}" "$(git rev-parse HEAD)" "$line" + + # The same discriminator as the linux cpu lane, plus the arch and the minimum + # macOS actually recorded in the binary (#4292). + - name: Prove the asset is an arm64 CPU build + run: | + BIN=target/aarch64-apple-darwin/release/apr + n=$(grep -ac 'libcuda.so' "$BIN" || true) + if [ "$n" -ne 0 ]; then + echo "::error::$BIN carries $n libcuda.so loader string(s) — this is a CUDA build wearing a -cpu name" + exit 1 + fi + file "$BIN" | grep -q 'arm64' || { echo "::error::$BIN is not arm64: $(file "$BIN")"; exit 1; } + MINOS=$(otool -l "$BIN" | awk '/LC_BUILD_VERSION/{f=1} f&&$1=="minos"{print $2; exit}') + echo "- minimum macOS (LC_BUILD_VERSION minos): ${MINOS:-unknown}" >> "$GITHUB_STEP_SUMMARY" + + - name: Package archive + id: package + run: | + ARCHIVE="apr-${{ steps.tag.outputs.tag }}-aarch64-apple-darwin-cpu" + mkdir -p "$ARCHIVE" + cp target/aarch64-apple-darwin/release/apr "$ARCHIVE/" + for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE; do [ -f "$f" ] && cp "$f" "$ARCHIVE/"; done + tar czf "${ARCHIVE}.tar.gz" "$ARCHIVE" + shasum -a 256 "${ARCHIVE}.tar.gz" > "${ARCHIVE}.tar.gz.sha256" + echo "archive=${ARCHIVE}.tar.gz" >> "$GITHUB_OUTPUT" + echo "sha=${ARCHIVE}.tar.gz.sha256" >> "$GITHUB_OUTPUT" + + - name: Upload assets to release (REST, no gh) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.tag.outputs.tag }}" + API="https://api.github.com/repos/${GITHUB_REPOSITORY}" + # by listing, not releases/tags/: an rc is a DRAFT until every fleet host runs it (#4327) + rel=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/releases?per_page=100" \ + | python3 -c 'import json,sys; t=sys.argv[1]; print(json.dumps(next(r for r in json.load(sys.stdin) if r["tag_name"]==t)))' "$TAG") + rid=$(printf '%s' "$rel" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') + for f in "${{ steps.package.outputs.archive }}" "${{ steps.package.outputs.sha }}"; do + aid=$(printf '%s' "$rel" | python3 -c 'import json,sys; n=sys.argv[1]; print(next((a["id"] for a in json.load(sys.stdin)["assets"] if a["name"]==n), ""))' "$f") + [ -n "$aid" ] && curl -sSf -X DELETE -H "Authorization: Bearer $GH_TOKEN" "$API/releases/assets/$aid" > /dev/null + curl -sSf -X POST -H "Authorization: Bearer $GH_TOKEN" -H "Content-Type: application/octet-stream" \ + --data-binary @"$f" "https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/$rid/assets?name=$f" \ + | python3 -c 'import json,sys; a=json.load(sys.stdin); print("uploaded", a["name"], a["size"], "bytes")' + done + # The hard requirement, as a check that can fail: all FOUR apr assets (tarball + # sha256) and the eight pv assets must be on the release. A missing one is a red # workflow, never a partial summary. @@ -550,7 +730,7 @@ jobs: # mutation (RED) that this job cannot demonstrate on a real release. verify-apr-assets: name: All four apr assets are on the release - needs: [build, build-apr-cuda, build-apr-cpu] + needs: [build, build-apr-cuda, build-apr-cpu, build-apr-darwin] runs-on: [self-hosted, Linux, X64, clean-room] if: always() steps: @@ -583,6 +763,8 @@ jobs: smoke-cuda: name: smoke apr (cuda) on ${{ matrix.host }} needs: verify-apr-assets + # verify runs under always(); a skipped build two levels up must not skip this + if: ${{ !cancelled() && needs.verify-apr-assets.result == 'success' }} strategy: fail-fast: false matrix: @@ -617,16 +799,25 @@ jobs: T="${{ matrix.target }}" D="$RUNNER_TEMP/apr-cuda-smoke"; rm -rf "$D"; mkdir -p "$D"; cd "$D" API="https://api.github.com/repos/${GITHUB_REPOSITORY}" - rel=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/releases/tags/$TAG") + # by listing, not releases/tags/: an rc is a DRAFT until every fleet host runs it (#4327), + # and releases/tags/ never returns a draft + rel=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/releases?per_page=100" \ + | python3 -c 'import json,sys; t=sys.argv[1]; print(json.dumps(next(r for r in json.load(sys.stdin) if r["tag_name"]==t)))' "$TAG") for f in "apr-${TAG}-${T}-cuda.tar.gz" "apr-${TAG}-${T}-cuda.tar.gz.sha256"; do aid=$(printf '%s' "$rel" | python3 -c 'import json,sys; n=sys.argv[1]; print(next(a["id"] for a in json.load(sys.stdin)["assets"] if a["name"]==n))' "$f") curl -sSfL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$f" "$API/releases/assets/$aid" done sha256sum -c "apr-${TAG}-${T}-cuda.tar.gz.sha256" tar xzf "apr-${TAG}-${T}-cuda.tar.gz" - BIN="$D/apr-${TAG}-${T}-cuda/apr" - v=$("$BIN" --version); echo "$v" - case "$v" in *"${TAG#v}"*) ;; *) echo "::error::asset reports '$v', not ${TAG#v}"; exit 1 ;; esac + # A promoted final (#4286) is its rc's exact bytes, so the archive's top directory + # may carry the rc label (apr-vX.Y.Z-rc.N-...). Take the one apr directory it holds. + set -- "$D"/apr-v*-"${T}"-cuda/apr + if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then echo "::error::expected exactly one apr binary in the archive, found: $*"; exit 1; fi + BIN=$1 + v=$("$BIN" --version | head -1); echo "$v" + # #4275: X.Y.Z whole (an rc tag's -rc.N stripped) AND the asset's sha is the tag's commit + COMMIT=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/commits/$TAG" | python3 -c 'import json,sys; print(json.load(sys.stdin)["sha"])') + bash "$GITHUB_WORKSPACE/scripts/release/asset_version_check.sh" "$TAG" "$COMMIT" "$v" || { echo "::error::asset reports '$v', which does not belong to $TAG ($COMMIT)"; exit 1; } [ "$(grep -ac 'libcuda.so' "$BIN")" -ge 1 ] || { echo "::error::downloaded asset is not a CUDA build"; exit 1; } floor=$(objdump -T "$BIN" 2>/dev/null | grep -oE 'GLIBC_[0-9.]+' | sort -Vu | tail -1) echo "highest GLIBC symbol version required: $floor" @@ -645,6 +836,8 @@ jobs: smoke-cpu: name: smoke apr (cpu) on ${{ matrix.host }} needs: verify-apr-assets + # verify runs under always(); a skipped build two levels up must not skip this + if: ${{ !cancelled() && needs.verify-apr-assets.result == 'success' }} env: # The CPU assets build inside rust:1.93.0-bullseye (glibc 2.31) like the cuda lane, so the floor is # declared and GATED: an asset whose bytes import a newer GLIBC_* symbol fails here. 0.66.0 shipped a @@ -684,16 +877,25 @@ jobs: T="${{ matrix.target }}" D="$RUNNER_TEMP/apr-cpu-smoke"; rm -rf "$D"; mkdir -p "$D"; cd "$D" API="https://api.github.com/repos/${GITHUB_REPOSITORY}" - rel=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/releases/tags/$TAG") + # by listing, not releases/tags/: an rc is a DRAFT until every fleet host runs it (#4327), + # and releases/tags/ never returns a draft + rel=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/releases?per_page=100" \ + | python3 -c 'import json,sys; t=sys.argv[1]; print(json.dumps(next(r for r in json.load(sys.stdin) if r["tag_name"]==t)))' "$TAG") for f in "apr-${TAG}-${T}-cpu.tar.gz" "apr-${TAG}-${T}-cpu.tar.gz.sha256"; do aid=$(printf '%s' "$rel" | python3 -c 'import json,sys; n=sys.argv[1]; print(next(a["id"] for a in json.load(sys.stdin)["assets"] if a["name"]==n))' "$f") curl -sSfL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/octet-stream" -o "$f" "$API/releases/assets/$aid" done sha256sum -c "apr-${TAG}-${T}-cpu.tar.gz.sha256" tar xzf "apr-${TAG}-${T}-cpu.tar.gz" - BIN="$D/apr-${TAG}-${T}-cpu/apr" - v=$("$BIN" --version); echo "$v" - case "$v" in *"${TAG#v}"*) ;; *) echo "::error::asset reports '$v', not ${TAG#v}"; exit 1 ;; esac + # A promoted final (#4286) is its rc's exact bytes, so the archive's top directory + # may carry the rc label (apr-vX.Y.Z-rc.N-...). Take the one apr directory it holds. + set -- "$D"/apr-v*-"${T}"-cpu/apr + if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then echo "::error::expected exactly one apr binary in the archive, found: $*"; exit 1; fi + BIN=$1 + v=$("$BIN" --version | head -1); echo "$v" + # #4275: X.Y.Z whole (an rc tag's -rc.N stripped) AND the asset's sha is the tag's commit + COMMIT=$(curl -sSf -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$API/commits/$TAG" | python3 -c 'import json,sys; print(json.load(sys.stdin)["sha"])') + bash "$GITHUB_WORKSPACE/scripts/release/asset_version_check.sh" "$TAG" "$COMMIT" "$v" || { echo "::error::asset reports '$v', which does not belong to $TAG ($COMMIT)"; exit 1; } n=$(grep -ac 'libcuda.so' "$BIN" || true) [ "$n" -eq 0 ] || { echo "::error::the downloaded -cpu asset carries $n libcuda.so string(s) — it is a CUDA build"; exit 1; } # The floor is READ on every row and ASSERTED only where the build was @@ -716,7 +918,7 @@ jobs: summary: name: Summary - needs: [build, build-apr-cuda, build-apr-cpu, verify-apr-assets, smoke-cuda, smoke-cpu] + needs: [assets, build, build-apr-cuda, build-apr-cpu, build-apr-darwin, verify-apr-assets, smoke-cuda, smoke-cpu] runs-on: [self-hosted, Linux, X64, clean-room] if: always() steps: @@ -736,6 +938,8 @@ jobs: echo "" if [ "$STATUS" = "success" ]; then echo "- Build matrix: **4/4 targets succeeded**" + elif [ "$STATUS" = "skipped" ] && [ "${{ needs.assets.outputs.present }}" = "true" ]; then + echo "- Build matrix: **not rebuilt** — every asset was already on the release (a promoted rc, #4286)" else echo "- Build matrix: **partial** (status=$STATUS)" fi @@ -746,10 +950,11 @@ jobs: echo " - aarch64-unknown-linux-gnu" echo "" echo "### apr Binary Release: $TAG - four assets, {cuda,cpu} x {x86_64,aarch64}" - echo "- cuda build: ${{ needs.build-apr-cuda.result }} · cpu build: ${{ needs.build-apr-cpu.result }}" + echo "- cuda build: ${{ needs.build-apr-cuda.result }} · cpu build: ${{ needs.build-apr-cpu.result }} · darwin build (mini): ${{ needs.build-apr-darwin.result }}" echo "- all four on the release: ${{ needs.verify-apr-assets.result }} · smoke cuda (yoga+gx10): ${{ needs.smoke-cuda.result }} · smoke cpu (clean-room+yoga+gx10): ${{ needs.smoke-cpu.result }}" echo " - apr-$TAG-x86_64-unknown-linux-gnu-cuda.tar.gz (+ .sha256)" echo " - apr-$TAG-aarch64-unknown-linux-gnu-cuda.tar.gz (+ .sha256)" echo " - apr-$TAG-x86_64-unknown-linux-gnu-cpu.tar.gz (+ .sha256)" echo " - apr-$TAG-aarch64-unknown-linux-gnu-cpu.tar.gz (+ .sha256)" + echo " - apr-$TAG-aarch64-apple-darwin-cpu.tar.gz (+ .sha256)" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/book-contracts.yml b/.github/workflows/book-contracts.yml index e1ca6e9c65..b28d4404fe 100644 --- a/.github/workflows/book-contracts.yml +++ b/.github/workflows/book-contracts.yml @@ -39,6 +39,15 @@ on: schedule: - cron: '45 22 * * *' + +# #3676: no concurrency group meant every superseded PR push ran the ~18 min +# chapter suite to completion. Sampled 24 h to 2026-09-24 19:15Z: ~1650 of ~3700 +# wall-minutes of this workflow's pull_request runs came after a newer push +# to the same PR had already started. Same shape as ci.yml: one group per PR, a +# new push cancels the old run, and push/schedule runs are never cancelled. +concurrency: + group: book-contracts-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml index 6dd9126865..f695c6ed4e 100644 --- a/.github/workflows/book.yml +++ b/.github/workflows/book.yml @@ -60,9 +60,14 @@ permissions: pages: write id-token: write +# #3676: one group per PR, and a newer push cancels the superseded run. The old +# workflow-level group "pages" (cancel false) held EVERY run -- all PRs, main and +# the nightly -- in one global queue, so each PR's mdBook check waited behind +# every other PR's. Only the Pages deploy needs that serialisation, so the +# "pages" group now sits on the deploy job alone. Non-PR events never cancel. concurrency: - group: "pages" - cancel-in-progress: false + group: book-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build: @@ -203,6 +208,10 @@ jobs: runs-on: [self-hosted, Linux, X64, clean-room] needs: build if: github.event_name == 'push' && github.ref == 'refs/heads/main' + # GitHub Pages deploys never overlap and are never cancelled (was workflow-level). + concurrency: + group: "pages" + cancel-in-progress: false permissions: pages: write id-token: write diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb0f36f3da..a918c18348 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,8 +73,8 @@ jobs: # with the same rule the old job of that name applied. # ========================================================================== x86-main: - # Any free X64 runner in the pool takes it: intel, framework16 or yoga. - runs-on: [self-hosted, Linux, X64, clean-room] + # Car #4429 keeps its pre-merge runner pin (cop 2026-09-26: no runner-host change mid-release). + runs-on: [self-hosted, Linux, X64, clean-room, intel] timeout-minutes: 240 # The sections carry their own permissions blocks (the sov provenance section # needs id-token + attestations); one job token must cover all of them. @@ -85,13 +85,32 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} FAT_VARS_JSON: ${{ toJSON(vars) }} - FAT_SECRET_PR_REVIEW_SIGNING_KEY_B: ${{ secrets.PR_REVIEW_SIGNING_KEY_B }} + FAT_SECRET_PR_REVIEW_SIGNING_KEY_B64: ${{ secrets.PR_REVIEW_SIGNING_KEY_B64 }} steps: # Full history: a section whose own checkout asked for depth 1 (the default) # is cut back to exactly that by the driver; one asking for 0 needs it all. - uses: actions/checkout@v7 with: fetch-depth: 0 + # Fail fast: the reds car#4429 took four fat runs to see, in ~2 min, before + # any section starts. rustfmt runs in the image sov.lint uses (same digest, + # so the same verdict); the ID-order rule is contract_data_integrity's, + # without cargo; the case tables are guard-tree's cheapest. + - name: Cheap checks (fmt, falsification-ID order, guard case tables) + run: | + set -euo pipefail + python3 scripts/ci/fail_fast.py self-test + python3 scripts/ci/fail_fast.py ids --base origin/main + docker run --rm -v "$GITHUB_WORKSPACE:/w:ro" -w /w \ + localhost:5000/sovereign-ci:stable@sha256:70f2cdc9e537fe4447306d33c317fea33017f78f1ffa3b7af99095391419ef36 \ + cargo fmt --all -- --check + bash scripts/tests/guard_tree_test.sh + bash scripts/bump-version.sh --self-test + setsid --wait bash scripts/check_no_claim_literals.sh --selftest + setsid --wait bash scripts/check_hardcoded_paths.sh --self-test + setsid --wait bash scripts/check_pr_review_wiring.sh --self-test + setsid --wait bash scripts/check_ci_gate_mutants_rule.sh --self-test + setsid --wait bash scripts/check_ci_gate_docs_only_rule.sh --self-test # The driver decides what every section's if:/matrix/needs means and whether # the vendored workflow is the upstream one; its case table must go RED on # each planted defect before it is trusted to report a section green. @@ -100,7 +119,7 @@ jobs: - name: Sections (the determinism raster returns first; the rest keep running) run: >- python3 scripts/ci/fat_driver.py run - --sections 'sov.*,guard-tree,guard-cargo,vendored-schemas,pr-review-shadow,pr-review-sign,mutants,determinism[X64]' + --sections 'sov.*,changes,guard-tree,guard-cargo,vendored-schemas,pr-review-shadow,pr-review-sign,mutants,determinism[X64],provable-ladder' --external-job workspace-test --results "$RUNNER_TEMP/fat/results.json" --background-until 'determinism[X64]' @@ -436,7 +455,8 @@ jobs: # cuda-unit were never in the gate; they still are not. # workspace-test is its own job now (three shard jobs + the Σ fan-in). [ "$WT" = success ] || { echo "workspace-test failed: $WT"; exit 1; } - for pair in X86:guard-tree X86:guard-cargo X86:sov.gate DET:determinism-compare; do + # guard-cargo and determinism-compare are read below under the docs-only rule (#3668). + for pair in X86:guard-tree X86:sov.gate; do job="${pair%%:*}" sec="${pair#*:}" r=$(res "${!job}" "$sec") if [ "$r" != success ]; then @@ -444,6 +464,29 @@ jobs: exit 1 fi done + # guard-cargo is the cargo half of the same guard universe (BSE-01), and + # determinism-compare is APEX-001 EV-2a's two-architecture comparison. A + # required check that `gate` does not read is not a required check, so both + # are read here -- and each may be `skipped` ONLY on a docs-only PR (#3668). + DOCS=$(printf '%s' "${X86:-null}" | jq -r '(. // {}).changes.outputs.docs_only // ""') + CHG=$(res "$X86" changes) + GC=$(res "$X86" guard-cargo) + DC=$(res "$DET" determinism-compare) + # --- GATE-DOCS-ONLY-RULE-BEGIN (#3668; scripts/check_ci_gate_docs_only_rule.sh runs this block) --- + # A skip is the docs-only skip only when the `changes` section SUCCEEDED and + # said docs_only=true. Any other skip -- changes skipped, failed, missing or + # said false -- means a section that should have run did not: a failure. + skip_ok=0 + if [ "$CHG" = success ] && [ "$DOCS" = true ]; then skip_ok=1; fi + for pair in "guard-cargo=$GC" "determinism-compare=$DC"; do + job=${pair%%=*}; res=${pair#*=} + case "$res:$skip_ok" in + success:*) echo "$job: success" ;; + skipped:1) echo "$job: skipped -- docs-only PR (changes: docs_only=true, #3668)" ;; + *) echo "$job failed: $res (changes=$CHG docs_only='$DOCS'; a skip passes only on a docs-only PR)"; exit 1 ;; + esac + done + # --- GATE-DOCS-ONLY-RULE-END --- MUT=$(res "$X86" mutants) # --- GATE-MUTANTS-RULE-BEGIN (#3676; scripts/check_ci_gate_mutants_rule.sh runs this block) --- # `skipped` is a pass ONLY when the skip is the job's own filter: diff --git a/.github/workflows/cuda-nightly.yml b/.github/workflows/cuda-nightly.yml index d4c033e735..53dafd909d 100644 --- a/.github/workflows/cuda-nightly.yml +++ b/.github/workflows/cuda-nightly.yml @@ -327,6 +327,43 @@ jobs: nice -n 19 cargo test -p aprender-serve --lib --release cb008_checker_controls APR_FALSIFY_MODEL="$model" nice -n 19 cargo test -p aprender-serve --features cuda \ --release --test falsify_cb008_no_frozen_slots_2753 -- --nocapture + + # aprender#4251: #4250's serve batched-prefill test. ci.yml's cuda-only step selects it, but + # there a missing Qwen3.5 file is a legitimate skip, and the runners do not carry one. So it + # passed without running. Here the model is resolved first, and a `SKIP:` line or a + # run that is not exactly `1 passed` is RED. `!cancelled()` so an earlier red falsifier + # cannot hide this one. + - name: aprender#4251 - every serve route prefills through the batched prefill (Qwen3.5) + if: ${{ !cancelled() && steps.decide.outputs.proceed == 'true' }} + env: + APR_QWEN35_MODEL_VAR: ${{ vars.APR_QWEN35_MODEL }} + run: | + set -euo pipefail + export APR_QWEN35_MODEL="${APR_QWEN35_MODEL_VAR:-$HOME/models/Qwen3.5-0.8B-Q4_K_M.gguf}" + if [ ! -f "$APR_QWEN35_MODEL" ]; then + echo "::error::#4251 UNMEASURABLE on ${{ matrix.name }} (provisioning, not a code defect): $APR_QWEN35_MODEL is absent" + exit 1 + fi + echo "#4251 model: $APR_QWEN35_MODEL" + log="$RUNNER_TEMP/serve-batched-prefill.log" + rc=0 + nice -n 19 cargo test -p aprender-serve --features cuda --lib --release \ + api::openai_handlers::qwen35_serve_tests::gpu_every_serve_route_prefills_through_the_batched_prefill \ + -- --exact --nocapture \ + >"$log" 2>&1 || rc=$? + cat "$log" + if [ "$rc" -ne 0 ]; then + echo "::error::#4251 serve batched-prefill test FAILED (exit $rc)" + exit "$rc" + fi + if grep -q '^SKIP:' "$log"; then + echo "::error::#4251 the test SKIPPED on a CUDA runner with the model named: $(grep -m1 '^SKIP:' "$log")" + exit 1 + fi + if ! grep -q 'test result: ok\. 1 passed' "$log"; then + echo "::error::#4251 the filter did not run exactly one test" + exit 1 + fi # PP-26 / L0 CORRECTNESS: the batch-invariance witness (FALSIFY-CB-006, # #2753, §9 #1a on Blackwell). PP-LLAMA-001 §7.0 puts correctness at layer # L0 and says perf041 runs "nightly + release, missing marker = RED"; the diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml index 9fd9092a59..f8daec95c5 100644 --- a/.github/workflows/install-script.yml +++ b/.github/workflows/install-script.yml @@ -37,6 +37,12 @@ on: schedule: - cron: '0 23 * * *' + +# #3676: a newer push to the same PR cancels the superseded run +# (scripts/check_pr_workflows_cancel_superseded.sh). +concurrency: + group: install-script-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 071a4d8b1e..c12ba6fbb5 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -29,9 +29,9 @@ # job runs on the fleet. x86_64 builds on yoga and aarch64 natively on gx10, both # inside rust:1.93.0-bullseye (binary-release.yml's CPU-lane pattern), so the # nightly's glibc floor is 2.31; the ubuntu-latest build needed GLIBC_2.39 and did -# not run on lambda's 22.04. The macOS and Windows targets are gone: no self-hosted -# macOS or Windows runner is registered in the org, and there is no hosted -# fallback. They come back when a box for them does. +# not run on lambda's 22.04. Windows is gone: no self-hosted Windows runner is +# registered and there is no hosted fallback. macOS came back with a box for it +# (#3204): aarch64-apple-darwin builds host-native on mini-m4 (build-darwin). name: Nightly @@ -162,7 +162,7 @@ jobs: # The driver is dlopen'd at run time (aprender-gpu: dep:libloading), so # the bullseye image needs no toolkit -- binary-release.yml's cuda lane. # Record proves the feature took (libcuda.so in the executable). - APR_SHA=$(git rev-parse --short HEAD) + APR_SHA=$(git rev-parse --short=9 HEAD) mkdir -p "$CACHE/target-cuda" $DOCKER run --rm \ -v "$GITHUB_WORKSPACE:/workspace" \ @@ -214,6 +214,60 @@ jobs: path: dist/* if-no-files-found: warn + # ── Build apr: macOS aarch64 natively on mini-m4 (#3204) ── + # Host-native: there is no docker on macOS and no glibc floor to pin, so the + # toolchain is rust-toolchain.toml via the runner's rustup, as in ci.yml's + # mac-check. A LITERAL runs-on list, not fromJSON, so check_runner_labels.sh + # sees the apple-silicon label. Gated on the same `gate` decision as the Linux + # matrix (car's `check-activity` job does not exist on this branch -- folded + # into `gate`'s reused/build/red-ci/ci-pending verdict, #4189). One box with + # 16 GB: this leg must not hold the Linux nightly hostage -- `publish-darwin` + # below only uploads when this job is green, and the darwin tarball is + # simply absent that night otherwise; it never blocks or is blocked by + # `publish` (that job's per-arch manifest tracks Linux targets only). + build-darwin: + needs: gate + if: needs.gate.outputs.decision == 'build' + name: apr aarch64-apple-darwin on mini + runs-on: [self-hosted, macOS, ARM64, apple-silicon, m4, mini] + timeout-minutes: 90 + env: + CARGO_INCREMENTAL: 0 + CARGO_TERM_COLOR: never + T: aarch64-apple-darwin + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Build apr (host-native, pinned toolchain) + run: | + set -euo pipefail + rustup show active-toolchain + cargo build --release --locked -p apr-cli --target "$T" + "target/$T/release/apr" --version + + - name: Package + run: | + set -euo pipefail + ARCHIVE="apr-$T" + rm -rf "$ARCHIVE" "$ARCHIVE.tar.gz" "$ARCHIVE.tar.gz.sha256" + mkdir -p "$ARCHIVE" + cp "target/$T/release/apr" "$ARCHIVE/" + for f in README.md LICENSE LICENSE-MIT COPYING UNLICENSE; do + if [ -f "$f" ]; then cp "$f" "$ARCHIVE/"; fi + done + tar czf "${ARCHIVE}.tar.gz" "$ARCHIVE" + shasum -a 256 "${ARCHIVE}.tar.gz" > "${ARCHIVE}.tar.gz.sha256" + echo "Packaged: ${ARCHIVE}.tar.gz ($(du -h "${ARCHIVE}.tar.gz" | cut -f1))" + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: apr-aarch64-apple-darwin + path: | + apr-aarch64-apple-darwin.tar.gz + apr-aarch64-apple-darwin.tar.gz.sha256 + # ── Publish per arch, then the manifest ────────────────── publish: needs: [gate, build] @@ -262,3 +316,31 @@ jobs: ci-pending) echo "::warning::required checks not finished on ${GITHUB_SHA:0:9}; last green kept"; exit 0 ;; *) echo "::error::nightly $DECISION at ${GITHUB_SHA:0:9}; see nightly-manifest.json"; exit 1 ;; esac + + # ── Attach the darwin asset to the nightly release ──────── + # `publish`'s manifest system (scripts/nightly_manifest.py) tracks the Linux + # targets only; it neither knows about nor deletes unlisted assets, so this + # upload is additive and independent -- a red/skipped darwin leg leaves the + # Linux assets alone, and a red Linux leg (this job needs `publish` only so + # the "nightly" release already exists) does not block the mac asset. + publish-darwin: + needs: [gate, build-darwin, publish] + if: always() && needs.gate.outputs.decision == 'build' && needs.build-darwin.result == 'success' + runs-on: [self-hosted, Linux, X64, clean-room] + steps: + - name: Download darwin artifact + uses: actions/download-artifact@v4 + with: + name: apr-aarch64-apple-darwin + path: dist-darwin + + - name: Upload to the nightly release + uses: softprops/action-gh-release@v3 + with: + tag_name: nightly + name: Nightly Build + prerelease: true + make_latest: false + files: | + dist-darwin/apr-aarch64-apple-darwin.tar.gz + dist-darwin/apr-aarch64-apple-darwin.tar.gz.sha256 diff --git a/.github/workflows/rc-cut.yml b/.github/workflows/rc-cut.yml new file mode 100644 index 0000000000..ea86e867c4 --- /dev/null +++ b/.github/workflows/rc-cut.yml @@ -0,0 +1,89 @@ +name: RC Cut + +# Cut vX.Y.Z-rc.N as a GitHub PRERELEASE when CI goes green on the tip of +# release/X.Y.Z (#4285, RC-DOGFOOD-001 §4.1). The decision and every write live in +# scripts/release/rc_cut.sh; this file only wires the trigger and the token. +# +# TRIGGER. ci.yml does not run on a push to release/**. A release branch is gated by +# CI on its release PR (release/X.Y.Z -> main), which re-runs on every push with +# head_sha = the branch tip. `workflow_run` fires when any CI run completes. The +# `branches` filter matches the run's HEAD branch, so a release PR's runs arrive +# here and a feature PR's do not. rc_cut.sh then requires, on that run: +# `ci / gate` and `workspace-test` success, head repository = this repository +# (fork PRs can name a branch release/X.Y.Z), head_sha still the tip, and no rc +# tag already on that commit. Anything else is a logged skip, not a failure. +# +# `workflow_run` always runs this file as it is on the DEFAULT branch, with this +# repository's token, so a release branch cannot change what the cut does. +# +# ASSETS. A release created with GITHUB_TOKEN does not fire `release: published` +# in binary-release.yml (GitHub suppresses recursive triggers), so rc_cut.sh +# dispatches binary-release.yml with inputs.tag. That needs `actions: write`. +# +# PUBLISH GATE (#4287). Before anything is written, the job decides with --dry-run, +# checks the rc commit out beside the default-branch checkout (in RUNNER_TEMP, so no +# cargo config is inherited from this tree) and runs scripts/release/rc_publish_gate.sh +# on it: the publish graph (R6), the path-only dev-dep rule that #3425 broke v0.68.0 +# on, and a `cargo package --no-verify` walk of every publishable crate. Red means the +# job fails and no rc exists; the defect is found on the rc, never at the final tag. +# The real cut then re-decides from the same run, so it tags the commit that was gated. +# +# CI cuts the tag and the prerelease. Publishing a crate stays out of CI (RP-001). + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: ['release/**'] + +permissions: + contents: write # create the tag ref and the prerelease + actions: write # dispatch binary-release.yml for the assets + +# One cut at a time per release branch, never cancelled: N is read then written, +# and two green runs racing would both read the same N. The tag ref is also the +# lock (a duplicate gets HTTP 422 and writes nothing else). +concurrency: + group: rc-cut-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +jobs: + cut: + # Operator rule (2026-09-10): self-hosted fleet only. The job needs bash, curl + # and python3, which clean-room carries (binary-release.yml's verify job). + runs-on: [self-hosted, Linux, clean-room] + # the package walk is ~65 s warm on lambda; a cold registry index is the rest + timeout-minutes: 30 + # Cheap pre-filter only; rc_cut.sh re-judges every condition itself. + if: github.event.workflow_run.conclusion != 'cancelled' + steps: + - uses: actions/checkout@v7 + with: + # the default branch's script, never the release branch's + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 1 + - name: Case tables (the decision and the gate must still be able to refuse) + run: | + bash scripts/release/rc_cut.sh --self-test + bash scripts/release/rc_publish_gate.sh --self-test + - name: Decide (writes nothing) + id: decide + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: bash scripts/release/rc_cut.sh --run-id "$RUN_ID" --dry-run + - name: Publish gate on the rc commit (#4287) + if: steps.decide.outputs.tag != '' + env: + SHA: ${{ steps.decide.outputs.sha }} + run: | + git fetch --no-tags --depth 1 origin "$SHA" + git worktree add --detach "$RUNNER_TEMP/rc-tree" "$SHA" + bash scripts/release/rc_publish_gate.sh "$RUNNER_TEMP/rc-tree" + - name: Cut the rc + if: success() && steps.decide.outputs.tag != '' # explicit: a red publish gate never reaches the cut + id: cut + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: bash scripts/release/rc_cut.sh --run-id "$RUN_ID" diff --git a/.gitignore b/.gitignore index fe9cf7f63e..fb5cc588cd 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ lcov.info # mdBook generated output book/book/ *.log +# Kernel A/B receipt logs are tracked evidence (ont-kernel-receipts-v1), not build noise. +!/evidence/kernels/*/*.ab.log # Shell artifacts (generated by aprender-shell) crates/aprender-shell/corpus/ diff --git a/.pmat-gates.toml b/.pmat-gates.toml index 5334a80911..21aff037a1 100644 --- a/.pmat-gates.toml +++ b/.pmat-gates.toml @@ -100,7 +100,16 @@ exclude = [ # the count did not. Between those readings #3004 added one B- definition # (registry/wgpu_probe.rs entry, 603) and merged, because no required job runs # `pmat comply`; it was split into four graded tables in the pin-bump PR, back to 602. -baseline = 599 +baseline = 617 +# 617 = the ONE-TIME re-baseline of car #4429 (cop ruling B, 2026-09-26), receipt +# scripts/cb200_baseline.rebaseline. main recorded 599 and MEASURED 620 at merge-base 00058476c323 +# (pmat 3.41.1, cold index): check_cb200_tdg_grade.sh never ran on main, so the number was never +# held against the tree. The car measures 624 before its fixes and 617 after (7 of its own +# regressions fixed, 0 new; net -3 vs main). 617 <= 620, and from the next PR it only falls. +# The 19 main definitions over 598 are docs/findings/cb200-main-below-b-0701.jsonl (0.70.1). +# 598 re-measured 2026-09-25 under pmat 3.41.1 from a COLD index (XDG_CACHE_HOME in a fresh temp dir) +# on batch/0.70.0 + #4101: 603 there before #4101 split token_usage_report.main, shapes.parse_property, +# shapes.well_formed, crux_inference_judge.collect and model_ladder_cells.judge. Lowered, never raised. # 599 re-measured 2026-09-18 under pmat 3.41.1 (the fleet pin, infra#692) from a cold index at # 3409b29d2. Instrument vs tree, separated: 3.41.1 reads 602 on 5d95ed54e, the tree that banked # 601 under 3.40.2 (+1 from the instrument on that tree, definition not identified), and 599 diff --git a/.quorum/0694-batch1/receipt.json b/.quorum/0694-batch1/receipt.json new file mode 100644 index 0000000000..69ec5c734a --- /dev/null +++ b/.quorum/0694-batch1/receipt.json @@ -0,0 +1,23 @@ +{ + "pr": 4318, + "batch": "batch/0.69.4-epic-4249", + "reviewed_head": "01c71e648", + "fix_head": "3c4e19242", + "base": "a5efa1443", + "author_model": "claude-opus-5-5", + "degraded": "same-family", + "mandatory_scope": ".github/workflows/binary-release.yml reviewed explicitly by every lane (cop GO condition)", + "round_1": [ + {"lane": 1, "model": "claude-sonnet-5", "scope": "workflows + scripts/release + install", "verdict": "FAIL", "finding": "build-apr-darwin did not stamp the rc version; the rc darwin asset printed bare X.Y.Z"}, + {"lane": 2, "model": "claude-haiku-4-5-20251001", "scope": "binary-release.yml line-by-line + ci/ + contracts/", "verdict": "PASS"}, + {"lane": 3, "model": "claude-sonnet-5", "scope": "crates/** + binary-release.yml", "verdict": "PASS"} + ], + "fix": "3c4e19242: darwin lane stamps the rc version (stamper from the workflow commit) and runs asset_version_check.sh against the tag and commit", + "round_2_on_fix": [ + {"lane": "r1", "model": "claude-sonnet-5", "verdict": "FAIL", "reason": "darwin is not a required asset and has no post-upload smoke; this is aprender#4353 scope (milestone 0.70.1), and the operator ruled no scope change for 0.69.4"}, + {"lane": "r2", "model": "claude-haiku-4-5-20251001", "verdict": "PASS"}, + {"lane": "r3", "model": "claude-sonnet-5", "verdict": "PASS", "note": "#4353 gap is non-blocking and out of scope; smoke the stamper on the mini"} + ], + "extra_evidence": "stamp_rc_version.sh --self-test on mini (GNU bash 5.3.20, aarch64-apple-darwin): PASS", + "result": "3/3 PASS (lanes 2 and 3 on the whole batch; r2 and r3 on the fix to lane 1's finding), degraded: same-family" +} diff --git a/CLAUDE.md b/CLAUDE.md index 6cc9c0e5a3..00c06c31b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,7 @@ +--- +tools: [pmat, pv, cargo, gh, bashrs] +--- + # CLAUDE.md ## Project Overview @@ -83,7 +87,7 @@ When using `/loop`, treat fallback wakeups as cheap and merge events as primary. ## Build Commands -```bash +```text cargo build --release # Optimized build (every workspace member; no default-members) cargo test -p aprender-core --lib # Core ML library only cargo test -p apr-cli --lib # CLI tests only @@ -108,7 +112,7 @@ make tier1 # Fast feedback (<1s): fmt, clippy, check make tier2 # Pre-commit (<5s): tests + strict clippy make tier3 # Pre-push (1-5min): full validation + coverage make tier4 # CI/CD: includes pmat analysis -make coverage # Coverage report (enforced floor 88%, target ≥95%) +make coverage # Coverage report (enforced floor: COV_FLOOR in Makefile, target ≥95%) ``` ## Debugging: Use apr Tools First (MANDATORY) @@ -121,17 +125,17 @@ GH-202 lesson: we read code instead of running `apr qa` which would have instant an absolute path to one. Four `apr` binaries were found coexisting on the dev box (0.60.0 ×2, 0.61.0, 0.62.0); a bare `apr` resolved to a **26-day-old** copy, and the path this file used to call "canonical" was two minor versions stale. There is -no correct path to hardcode — `.cargo/config.toml` [gitignored] redirects cargo's +no correct path to hardcode — the gitignored `config.toml` under `.cargo/` redirects cargo's target-dir, so the main checkout and a fresh worktree build to different places. -```bash +```text . scripts/apr_bin.sh || exit 1 # exports $APR, proves it was built from HEAD ``` Everything below uses `"$APR"`. A diagnostic run against the wrong binary is worse than no diagnostic: it produces a confident answer about code you are not running. -```bash +```text # Step 1: ALWAYS start here (catches 80% of issues) "$APR" qa model.apr @@ -168,7 +172,7 @@ All tools support GGUF, APR, and SafeTensors formats. If a tool says "format not `aprender-serve` package (`[lib] name = "realizar"`, `crates/aprender-serve/Cargo.toml`); that package ships no `[[bin]]`. Tracing is driven through `apr run`: -```bash +```text "$APR" run model.safetensors --prompt "2+2?" --trace "$APR" run model.gguf --prompt "Hi" --trace --trace-steps tokenize,sample,decode "$APR" run model.gguf --prompt "Hi" --trace --trace-level payload # or --trace-payload @@ -215,17 +219,17 @@ the crate count run the command in the Project Overview table, don't trust a num | CUDA/GPU Inference | Never | Primary | Kernels | ```rust -// WRONG - bypasses realizar, 0.3 tok/s +// WRONG - bypasses realizar use aprender::models::Qwen2Model; let output = model.generate(&input_ids, 32, 0.7, 0.9); -// CORRECT - uses realizar, 225+ tok/s +// CORRECT - uses realizar use realizar::Model; let model = Model::load_safetensors(&path)?; let output = model.generate(&input_ids, config)?; ``` -```bash +```text # BEST - apr CLI uses realizar automatically cargo run --bin apr --features inference -- run model.safetensors \ --prompt "What is 2+2?" --max-tokens 32 @@ -235,7 +239,7 @@ Feature flag (`crates/apr-cli/Cargo.toml`): `inference = ["realizar", "trueno", "axum", "futures-util"]`, and `default = ["hf-hub", "safetensors-compare", "inference", "training", "visualization", "zram"]` — so `inference` is on unless you pass `--no-default-features`. GPU work needs `--features cuda`, which pulls in `inference`, -`realizar/cuda` and `entrenar/cuda`. +the `cuda` features of `realizar` and `entrenar`. Always profile with `apr profile`/`apr trace`/`apr bench` before optimizing. ### Performance Targets (Ollama Parity) @@ -307,28 +311,34 @@ CONTRACT.validate_apr_shape("lm_head.weight", &[vocab, hidden], vocab, hidden)?; ### Code Scheduled for Deletion - ~~`src/models/qwen2/mod.rs::generate()` / `forward()`~~ - DELETED (Refs #224, #1977). `Qwen2Model` has no inference path; all inference (incl. KV caching) goes through `realizar`. Only construction, weight loading, and introspection remain. -- ~~`examples/qwen_inference.rs`~~ - DELETED (Refs #224). Use the `apr` CLI / `realizar` for inference. +- ~~examples/qwen_inference.rs~~ - DELETED (Refs #224). Use the `apr` CLI / `realizar` for inference. ## Publishing Safety (CB-510 Lesson) **CRITICAL: `.gitignore` and `Cargo.toml` exclude patterns must use root-anchored paths.** -The `models/` pattern silently matches `src/models/` — hiding source code from git and crates.io. Always use `/models/` (root-anchored). +The unanchored pattern **models/** silently matches **src/models/** — hiding source code from git and crates.io. Always use **/models/** (root-anchored). -```bash +```text # Pre-publish checks (also in make tier3). Both print the count they checked — # read it off the output, don't quote a number from this file. bash scripts/check_include_files.sh # scans src/ AND crates/; printed 1771 on 2026-08-13 -bash scripts/check_package_includes.sh # scans src/ ONLY, against `cargo package -p aprender --list` # After creating new include!() files, verify they're not gitignored: git ls-files --others --exclude-standard crates/ git check-ignore -v crates//src/path/to/new_file.rs # exit 1 == not ignored (good) ``` +The one of the two that a merge-path CI step runs verbatim — so it stays a `bash` claim fence (ONT-4c, +`contracts/claude-md.yaml`); the fence above is `text` because no workflow runs those lines: + +```bash +setsid --wait bash scripts/check_package_includes.sh # as CI runs it; scans src/ ONLY, against `cargo package -p aprender --list` +``` + **`check_package_includes.sh` is currently vacuous — know this before trusting it.** It greps `include!(` in the root `src/` only, and the root `src/` is a two-file facade -(`lib.rs`, `bin/`) with **zero** `include!()` directives. So it reports +(`src/lib.rs`, `src/bin/`) with **zero** `include!()` directives. So it reports `OK: All 0 include!() files are included in cargo package` and can never fail. The real coverage against CB-510 today comes from `check_include_files.sh` (1771 files, `src/` + `crates/`). Extending the package check to the 70+ publishable member crates @@ -338,7 +348,7 @@ is open work — do not treat its green as evidence. ## Shell Scripts: Use bashrs (NOT shellcheck) -```bash +```text bashrs lint scripts/*.sh # Lint bashrs purify scripts/ci.sh # Determinism + idempotency bashrs make lint Makefile # Makefile linting @@ -360,7 +370,7 @@ These are not style notes. Each cost real time, and in every case the general principle was known and the specific instance still went wrong. **1. Never read `$?` through a pipe.** It is the LAST command's status. -```bash +```text cmd > /tmp/out.log 2>&1; rc=$? # correct cmd | tee /tmp/out.log; rc=${PIPESTATUS[0]} # correct (bash) cmd | grep -E "^error"; echo "exit=$?" # WRONG — that is grep's status @@ -404,9 +414,9 @@ ran (#2361). When a fix seems to have no effect, ask what else claims that name ## Testing -Target: 60% unit, 30% property, 10% integration. Coverage: **88.78% line** (786448/885829, measured 2026-07-29 by coverage-nightly on 95145584f; target ≥95%, enforced floor 88% via COV_FLOOR). The long-quoted "96.35%" predates the measurement ever working - the pipeline reported 0/0 until #2333. +Target: 60% unit, 30% property, 10% integration. Coverage: **90.84% line** (849706/935347, measured 2026-09-24 by a manual `workflow_dispatch` of coverage-nightly (run 36065904363) on branch `PMAT-3839-cov-regex-scope` @ 237f10f8b, folded into batch/0.70.0 and NOT on `main` until that batch merges; `main`'s own nightly (49fe19c28, 2026-09-24) still measured 826969/941649 = 87.82% under the old regex (run 35937679357); target ≥95%, enforced floor 89% via COV_FLOOR). Before #3839 the exclude regex still described the pre-monorepo tree, so the 88.78% of 2026-07-29 measured a different set of files. The long-quoted "96.35%" predates the measurement ever working - the pipeline reported 0/0 until #2333. -```bash +```text cargo test -p --lib # Unit tests for one crate (what you run while working) cargo test -p --test # ONE integration target. There is NO root # `tests/` dir, so `cargo test --test integration` @@ -491,7 +501,7 @@ the `commands:` list, never `grep -c '^ - name:'` (117; other same-indent `name:` keys exist in the file). Key commands: `run`, `chat`, `serve`, `pull`, `finetune`, `prune`, `distill`, `merge`, `quantize`, `inspect`, `debug`, `validate`, `diff`, `tensors`, `trace`, `lint`, `explain`, `export`, `import`, `convert`, `compile`, `train`, `tune`, `eval`, `bench`, `profile`, `qa`, `mcp`, `probar`, `cbtop`, `tui`, `hex`, `tree`, `flow`, `qualify` -```bash +```text apr validate model.apr --quality apr convert model.safetensors --quantize int8 -o model-int8.apr apr export model.apr --format gguf -o model.gguf @@ -508,7 +518,7 @@ still carries a header comment claiming it was "Updated for PMAT v2.215.0". | Score | Value | Provenance | |-------|-------|------------| -| Line coverage | **88.78%** (786448/885829) | coverage-nightly, 2026-07-29, commit `95145584f`. The long-quoted "96.35%"/"96.94%" predates the pipeline ever working — it reported 0/0 until #2333 | +| Line coverage | **90.84%** (849706/935347) | coverage-nightly `workflow_dispatch` run 36065904363, 2026-09-24, on branch commit `237f10f8b` (monorepo scope, #3839; in batch/0.70.0, not yet `main`, whose scheduled nightly 35937679357 read 87.82%). The previous 88.78% (2026-07-29, `95145584f`) used the pre-monorepo exclude regex. The long-quoted "96.35%"/"96.94%" predates the pipeline ever working — it reported 0/0 until #2333 | | Project score / TDG / mutation % | **re-derive** — `pmat rust-project-score`, `pmat tdg . --include-components`, `cargo mutants` | The previously published "124/134", "TDG 95.2/100" and "Mutation 85.3%" carried no date or commit and could not be reproduced from the tree | **Thresholds — read from the config, which does not say what this file used to say:** @@ -516,7 +526,7 @@ still carries a header comment claiming it was "Updated for PMAT v2.215.0". | Gate | Configured as | Where | |------|---------------|-------| | Coverage (aspirational) | `min_coverage = 95.0` | `.pmat-gates.toml` | -| Coverage (**enforced**) | `COV_FLOOR := 88` — the last *measured* value, and the one that actually fails a build | `Makefile:287`, `.github/workflows/coverage-nightly.yml` | +| Coverage (**enforced**) | `COV_FLOOR := 89` — a ratchet under the last *measured* value, and the one that actually fails a build | `Makefile` (`COV_FLOOR`), `.github/workflows/coverage-nightly.yml` | | Cyclomatic complexity | `max_complexity = 10` per fn | `.pmat-gates.toml` | | TDG | `min_grade = "B"` — **not** "≥95" | `.pmat-gates.toml` `[tdg]` | | Mutation | `MUTANTS_MAX_MISSED` (default **0**) surviving mutants **on the PR diff** — not a global 85% score | `.github/workflows/ci.yml:517` | @@ -526,7 +536,7 @@ still carries a header comment claiming it was "Updated for PMAT v2.215.0". SATD is checked by `pmat analyze satd`, but no SATD threshold is configured in `.pmat-gates.toml`; the pre-commit hook there is `pmat comply check --failures-only`. -```bash +```text pmat quality-gates # Run all gates (config: .pmat-gates.toml) pmat rust-project-score # Project analysis pmat analyze complexity # Cyclomatic/cognitive complexity @@ -547,7 +557,7 @@ unwrap() banned via `.clippy.toml` disallowed-methods. Use `expect()` or `ok_or_ **`pv` is THE dogfooded contract CLI.** When you need to validate, lint, score, scaffold, diff, audit, generate proofs, or run falsification tests on a YAML contract in `contracts/`, use `pv`. Writing a bash/yq/python script that re-implements what `pv` already does is **muda** (waste) and will be rejected. -```bash +```text pv validate contracts/apr-code-parity-v1.yaml # schema + falsification gates pv lint contracts/ # validate + audit + score on all pv status contracts/tensor-layout-v1.yaml # equations, obligations, coverage @@ -565,10 +575,10 @@ verify-bindings, migrate`. **If `pv validate` rejects a contract** (wrong kind, missing required fields), the fix is one of: 1. Restructure the contract to fit the existing schema (usually `KernelContract` shape with `equations`, `proof_obligations`, `falsification_tests`). -2. Extend `aprender-contracts/src/schema/` to add the new `kind` + validator rule (real engineering task, own PMAT ticket). +2. Extend `crates/aprender-contracts/src/schema/` to add the new `kind` + validator rule (real engineering task, own PMAT ticket). 3. If it genuinely isn't a provable contract, use a different YAML schema under a different directory and a purpose-built `apr` subcommand — not `contracts/`. -**Never** work around `pv` with a shell script. The in-tree tool is the source of truth. +**Never:** work around `pv` with a shell script. The in-tree tool is the source of truth. ## CRITICAL: Code Search Policy @@ -586,7 +596,7 @@ verify-bindings, migrate`. ### Examples -```bash +```text # BAD - Raw text search returns 500+ noisy matches with no context # GOOD - Semantic search returns 10 ranked functions with quality metrics pmat query "error handling" --limit 10 @@ -597,7 +607,7 @@ pmat query "error handling" --limit 10 The index automatically includes sibling projects (aprender, trueno, realizar). Query from any project to search 60k+ functions across all three codebases. -```bash +```text # Build index in each project first (one-time setup) cd ~/src/aprender && pmat query "init" --rebuild-index --limit 1 cd ~/src/trueno && pmat query "init" --rebuild-index --limit 1 @@ -616,7 +626,7 @@ pmat query "matrix multiplication" --limit 5 ### Quick Reference -```bash +```text pmat query "" # Basic search pmat query "" --rank-by pagerank # Most important functions pmat query "" --format json # Machine-readable @@ -638,7 +648,7 @@ pmat query "model training" --churn --duplicates --entropy --faults -G # full a **Use `pmat query --coverage` to find untested code. NEVER parse coverage JSON manually.** -```bash +```text # Find top uncovered functions (no query needed) pmat query --coverage-gaps @@ -668,7 +678,7 @@ See monorepo spec Rule 7: Coverage + Contracts Co-Evolution. ## Stack Documentation Search -```bash +```text batuta oracle --rag "your question here" # Search entire Sovereign AI Stack batuta oracle --rag-index # Reindex (the command prints the doc count) ``` diff --git a/Cargo.lock b/Cargo.lock index 5194bb3ee5..fce0546401 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,7 +248,7 @@ dependencies = [ [[package]] name = "apr-cli" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "aprender-build-sha", @@ -266,6 +266,7 @@ dependencies = [ "aprender-present-core", "aprender-present-terminal", "aprender-profile", + "aprender-ptx-debug", "aprender-quant", "aprender-rag-cli", "aprender-registry", @@ -324,7 +325,7 @@ dependencies = [ [[package]] name = "apr-format" -version = "0.69.3" +version = "0.70.0" dependencies = [ "bincode", "cargo_metadata", @@ -342,7 +343,7 @@ dependencies = [ [[package]] name = "aprender" -version = "0.69.3" +version = "0.70.0" dependencies = [ "apr-cli", "aprender-core", @@ -350,7 +351,7 @@ dependencies = [ [[package]] name = "aprender-bench-compute" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-core", "criterion 0.7.0", @@ -359,7 +360,7 @@ dependencies = [ [[package]] name = "aprender-bench-tokenizer" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-core", "criterion 0.7.0", @@ -368,11 +369,14 @@ dependencies = [ [[package]] name = "aprender-build-sha" -version = "0.69.3" +version = "0.70.0" +dependencies = [ + "tempfile", +] [[package]] name = "aprender-cbtop" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "aprender-build-sha", @@ -398,7 +402,7 @@ dependencies = [ [[package]] name = "aprender-cgp" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "aprender-build-sha", @@ -419,17 +423,19 @@ dependencies = [ [[package]] name = "aprender-common" -version = "0.69.3" +version = "0.70.0" dependencies = [ + "clap", "lz4_flex 0.11.6", "zstd", ] [[package]] name = "aprender-compute" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", + "aprender-contracts", "aprender-contracts-macros", "aprender-core", "aprender-cuda-edge", @@ -476,7 +482,7 @@ dependencies = [ [[package]] name = "aprender-compute-xtask" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "aprender-build-sha", @@ -491,7 +497,7 @@ dependencies = [ [[package]] name = "aprender-contracts" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-contracts-macros", "criterion 0.5.1", @@ -509,9 +515,10 @@ dependencies = [ [[package]] name = "aprender-contracts-cli" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", + "aprender-common", "aprender-contracts", "aprender-update", "clap", @@ -524,7 +531,7 @@ dependencies = [ [[package]] name = "aprender-contracts-macros" -version = "0.69.3" +version = "0.70.0" dependencies = [ "proc-macro2", "quote", @@ -534,7 +541,7 @@ dependencies = [ [[package]] name = "aprender-contrastive-data" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-contracts-macros", "aprender-rand", @@ -549,7 +556,7 @@ dependencies = [ [[package]] name = "aprender-core" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aes-gcm", "alsa", @@ -603,7 +610,7 @@ dependencies = [ [[package]] name = "aprender-cuda-edge" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-gpu", "proptest", @@ -614,7 +621,7 @@ dependencies = [ [[package]] name = "aprender-cupti" -version = "0.69.3" +version = "0.70.0" dependencies = [ "bindgen", "bitflags 2.13.0", @@ -624,7 +631,7 @@ dependencies = [ [[package]] name = "aprender-data" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aes-gcm", "aprender-build-sha", @@ -677,12 +684,13 @@ dependencies = [ [[package]] name = "aprender-db" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "aprender-build-sha", "aprender-common", "aprender-compute", + "aprender-contracts", "arrow 59.2.0", "axum 0.7.9", "bytemuck", @@ -723,7 +731,7 @@ dependencies = [ [[package]] name = "aprender-distribute" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-compute", "aprender-db", @@ -753,7 +761,7 @@ dependencies = [ [[package]] name = "aprender-explain" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "aprender-gpu", @@ -772,7 +780,7 @@ dependencies = [ [[package]] name = "aprender-fft" -version = "0.69.3" +version = "0.70.0" dependencies = [ "criterion 0.7.0", "proptest", @@ -781,7 +789,7 @@ dependencies = [ [[package]] name = "aprender-gemm-codegen" -version = "0.69.3" +version = "0.70.0" dependencies = [ "proc-macro2", "quote", @@ -790,7 +798,7 @@ dependencies = [ [[package]] name = "aprender-gpu" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-common", "aprender-simulate", @@ -808,7 +816,7 @@ dependencies = [ [[package]] name = "aprender-graph" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "aprender-compute", @@ -829,7 +837,7 @@ dependencies = [ [[package]] name = "aprender-image" -version = "0.69.3" +version = "0.70.0" dependencies = [ "criterion 0.7.0", "proptest", @@ -838,7 +846,7 @@ dependencies = [ [[package]] name = "aprender-mcp" -version = "0.69.3" +version = "0.70.0" dependencies = [ "inventory", "jsonschema", @@ -851,7 +859,7 @@ dependencies = [ [[package]] name = "aprender-monte-carlo" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender", "assert_cmd", @@ -870,7 +878,7 @@ dependencies = [ [[package]] name = "aprender-orchestrate" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "aprender-build-sha", @@ -941,9 +949,10 @@ dependencies = [ [[package]] name = "aprender-present-cli" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", + "aprender-contracts", "aprender-present-yaml", "clap", "notify", @@ -955,8 +964,9 @@ dependencies = [ [[package]] name = "aprender-present-core" -version = "0.69.3" +version = "0.70.0" dependencies = [ + "aprender-contracts", "aprender-contracts-macros", "criterion 0.7.0", "proptest", @@ -967,7 +977,7 @@ dependencies = [ [[package]] name = "aprender-present-layout" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-present-core", "criterion 0.7.0", @@ -978,7 +988,7 @@ dependencies = [ [[package]] name = "aprender-present-lib" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-contracts", "aprender-present-core", @@ -1007,9 +1017,10 @@ dependencies = [ [[package]] name = "aprender-present-terminal" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", + "aprender-contracts", "aprender-present-core", "bitvec", "clap", @@ -1028,7 +1039,7 @@ dependencies = [ [[package]] name = "aprender-present-test" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-present-core", "aprender-present-test-macros", @@ -1039,7 +1050,7 @@ dependencies = [ [[package]] name = "aprender-present-test-macros" -version = "0.69.3" +version = "0.70.0" dependencies = [ "proc-macro2", "quote", @@ -1048,7 +1059,7 @@ dependencies = [ [[package]] name = "aprender-present-widgets" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-present-core", "aprender-present-yaml", @@ -1060,7 +1071,7 @@ dependencies = [ [[package]] name = "aprender-present-yaml" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-present-core", "proptest", @@ -1070,7 +1081,7 @@ dependencies = [ [[package]] name = "aprender-profile" -version = "0.69.3" +version = "0.70.0" dependencies = [ "addr2line 0.25.1", "anyhow", @@ -1123,7 +1134,7 @@ dependencies = [ [[package]] name = "aprender-profile-core" -version = "0.69.3" +version = "0.70.0" dependencies = [ "hex", "serde", @@ -1133,7 +1144,7 @@ dependencies = [ [[package]] name = "aprender-ptx-debug" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "clap", @@ -1143,7 +1154,7 @@ dependencies = [ [[package]] name = "aprender-qa-certify" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "chrono", @@ -1155,7 +1166,7 @@ dependencies = [ [[package]] name = "aprender-qa-cli" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "aprender-qa-certify", @@ -1175,7 +1186,7 @@ dependencies = [ [[package]] name = "aprender-qa-gen" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "chrono", @@ -1192,7 +1203,7 @@ dependencies = [ [[package]] name = "aprender-qa-report" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "aprender-qa-gen", @@ -1210,7 +1221,7 @@ dependencies = [ [[package]] name = "aprender-qa-runner" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "aprender-qa-gen", @@ -1235,7 +1246,7 @@ dependencies = [ [[package]] name = "aprender-quant" -version = "0.69.3" +version = "0.70.0" dependencies = [ "half", "serde_json", @@ -1243,10 +1254,11 @@ dependencies = [ [[package]] name = "aprender-rag" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-common", "aprender-compute", + "aprender-contracts", "aprender-serve", "async-trait", "bincode", @@ -1271,10 +1283,11 @@ dependencies = [ [[package]] name = "aprender-rag-cli" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "aprender-build-sha", + "aprender-common", "aprender-rag", "assert_cmd", "blake3", @@ -1290,7 +1303,7 @@ dependencies = [ [[package]] name = "aprender-rand" -version = "0.69.3" +version = "0.70.0" dependencies = [ "criterion 0.7.0", "proptest", @@ -1299,7 +1312,7 @@ dependencies = [ [[package]] name = "aprender-registry" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "aprender-core", @@ -1329,7 +1342,7 @@ dependencies = [ [[package]] name = "aprender-serve" -version = "0.69.3" +version = "0.70.0" dependencies = [ "anyhow", "approx", @@ -1400,7 +1413,7 @@ dependencies = [ [[package]] name = "aprender-shell" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-core", "clap", @@ -1415,7 +1428,7 @@ dependencies = [ [[package]] name = "aprender-simulate" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "aprender-contracts", @@ -1456,7 +1469,7 @@ dependencies = [ [[package]] name = "aprender-solve" -version = "0.69.3" +version = "0.70.0" dependencies = [ "criterion 0.7.0", "half", @@ -1466,7 +1479,7 @@ dependencies = [ [[package]] name = "aprender-sparse" -version = "0.69.3" +version = "0.70.0" dependencies = [ "criterion 0.7.0", "proptest", @@ -1475,7 +1488,7 @@ dependencies = [ [[package]] name = "aprender-tensor" -version = "0.69.3" +version = "0.70.0" dependencies = [ "criterion 0.7.0", "proptest", @@ -1484,7 +1497,7 @@ dependencies = [ [[package]] name = "aprender-test-cli" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "aprender-test-lib", @@ -1513,7 +1526,7 @@ dependencies = [ [[package]] name = "aprender-test-derive" -version = "0.69.3" +version = "0.70.0" dependencies = [ "proc-macro2", "quote", @@ -1523,7 +1536,7 @@ dependencies = [ [[package]] name = "aprender-test-js-gen" -version = "0.69.3" +version = "0.70.0" dependencies = [ "blake3", "chrono", @@ -1539,7 +1552,7 @@ dependencies = [ [[package]] name = "aprender-test-lib" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-compute", "aprender-present-core", @@ -1585,7 +1598,7 @@ dependencies = [ [[package]] name = "aprender-test-showcase" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-present-terminal", "aprender-test-lib", @@ -1602,7 +1615,7 @@ dependencies = [ [[package]] name = "aprender-train" -version = "0.69.3" +version = "0.70.0" dependencies = [ "approx", "aprender-common", @@ -1671,7 +1684,7 @@ dependencies = [ [[package]] name = "aprender-train-bench" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "aprender-train", @@ -1689,7 +1702,7 @@ dependencies = [ [[package]] name = "aprender-train-common" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-viz", "clap", @@ -1701,7 +1714,7 @@ dependencies = [ [[package]] name = "aprender-train-distill" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "aprender-train", @@ -1723,7 +1736,7 @@ dependencies = [ [[package]] name = "aprender-train-inspect" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "aprender-train", @@ -1740,7 +1753,7 @@ dependencies = [ [[package]] name = "aprender-train-lora" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "aprender-train", @@ -1757,7 +1770,7 @@ dependencies = [ [[package]] name = "aprender-train-shell" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "aprender-train", @@ -1774,7 +1787,7 @@ dependencies = [ [[package]] name = "aprender-train-wasm" -version = "0.69.3" +version = "0.70.0" dependencies = [ "proptest", "serde", @@ -1785,7 +1798,7 @@ dependencies = [ [[package]] name = "aprender-tsp" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender", "assert_cmd", @@ -1800,7 +1813,7 @@ dependencies = [ [[package]] name = "aprender-update" -version = "0.69.3" +version = "0.70.0" dependencies = [ "flate2", "semver", @@ -1814,7 +1827,7 @@ dependencies = [ [[package]] name = "aprender-verify" -version = "0.69.3" +version = "0.70.0" dependencies = [ "chrono", "criterion 0.5.1", @@ -1826,7 +1839,7 @@ dependencies = [ [[package]] name = "aprender-verify-ml" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "aprender-compute", @@ -1857,7 +1870,7 @@ dependencies = [ [[package]] name = "aprender-viz" -version = "0.69.3" +version = "0.70.0" dependencies = [ "approx", "aprender-common", @@ -1886,7 +1899,7 @@ dependencies = [ [[package]] name = "aprender-zram" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-zram-adaptive", "aprender-zram-core", @@ -1896,7 +1909,7 @@ dependencies = [ [[package]] name = "aprender-zram-adaptive" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-zram-core", "proptest", @@ -1905,7 +1918,7 @@ dependencies = [ [[package]] name = "aprender-zram-cli" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "aprender-zram-adaptive", @@ -1920,7 +1933,7 @@ dependencies = [ [[package]] name = "aprender-zram-core" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-gpu", "criterion 0.7.0", @@ -1932,7 +1945,7 @@ dependencies = [ [[package]] name = "aprender-zram-generator" -version = "0.69.3" +version = "0.70.0" dependencies = [ "aprender-build-sha", "aprender-zram-core", @@ -3833,9 +3846,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.3.0", @@ -10455,7 +10468,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "chacha20 0.10.0", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] diff --git a/Cargo.toml b/Cargo.toml index a3039a07f9..6aa530603a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -153,7 +153,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "0.69.3" +version = "0.70.0" edition = "2021" license = "MIT" repository = "https://github.com/paiml/aprender" @@ -234,7 +234,7 @@ tokenizers = { version = "0.23.1", default-features = false, features = ["fancy- # aprender-rand (lib name `trueno_rand`) is a workspace MEMBER but had no # [workspace.dependencies] entry, so every consumer had to re-declare the path. # It is the counter-based Philox RNG behind the SetFit encoder's dropout masks. -aprender-rand = { path = "crates/aprender-rand", version = "0.69.3" } +aprender-rand = { path = "crates/aprender-rand", version = "0.70.0" } # Chat templates minijinja = { version = "2.14", features = ["loader", "serde"] } @@ -244,9 +244,9 @@ minijinja = { version = "2.14", features = ["loader", "serde"] } # `crates/aprender-contracts` (lib name `provable_contracts`). Declaring it from # crates.io compiled a SECOND copy of the contract engine alongside the in-tree # one — exactly the duplicate-crate + publish-cycle the consolidation removed. -provable-contracts = { path = "crates/aprender-contracts", version = "0.69.3", package = "aprender-contracts" } -provable-contracts-macros = { path = "crates/aprender-contracts-macros", version = "0.69.3", package = "aprender-contracts-macros" } -aprender-contracts-macros = { path = "crates/aprender-contracts-macros", version = "0.69.3" } +provable-contracts = { path = "crates/aprender-contracts", version = "0.70.0", package = "aprender-contracts" } +provable-contracts-macros = { path = "crates/aprender-contracts-macros", version = "0.70.0", package = "aprender-contracts-macros" } +aprender-contracts-macros = { path = "crates/aprender-contracts-macros", version = "0.70.0" } # YAML serde_yaml = "0.9" @@ -258,68 +258,69 @@ hf-hub = { version = "0.4", default-features = false, features = ["ureq"] } hf-xet = "1.5.1" # Workspace-internal crates (paths within the monorepo) -aprender-compute = { path = "crates/aprender-compute", version = "0.69.3" } -aprender-gpu = { path = "crates/aprender-gpu", version = "0.69.3" } -aprender-quant = { path = "crates/aprender-quant", version = "0.69.3" } -aprender-serve = { path = "crates/aprender-serve", version = "0.69.3" } -realizar = { path = "crates/aprender-serve", version = "0.69.3", package = "aprender-serve" } -alimentar = { path = "crates/aprender-data", version = "0.69.3", package = "aprender-data" } -pacha = { path = "crates/aprender-registry", version = "0.69.3", package = "aprender-registry" } +aprender-compute = { path = "crates/aprender-compute", version = "0.70.0" } +aprender-gpu = { path = "crates/aprender-gpu", version = "0.70.0" } +aprender-quant = { path = "crates/aprender-quant", version = "0.70.0" } +aprender-serve = { path = "crates/aprender-serve", version = "0.70.0" } +realizar = { path = "crates/aprender-serve", version = "0.70.0", package = "aprender-serve" } +alimentar = { path = "crates/aprender-data", version = "0.70.0", package = "aprender-data" } +pacha = { path = "crates/aprender-registry", version = "0.70.0", package = "aprender-registry" } # APR-MONO self-containment: legacy sibling lib-name aliases -> in-monorepo crates (2026-06-12) -trueno-quant = { path = "crates/aprender-quant", version = "0.69.3", package = "aprender-quant" } -trueno-db = { path = "crates/aprender-db", version = "0.69.3", package = "aprender-db" } -trueno-viz = { path = "crates/aprender-viz", version = "0.69.3", package = "aprender-viz" } -trueno-rag = { path = "crates/aprender-rag", version = "0.69.3", package = "aprender-rag" } -trueno-cuda-edge = { path = "crates/aprender-cuda-edge", version = "0.69.3", package = "aprender-cuda-edge" } -trueno-sparse = { path = "crates/aprender-sparse", version = "0.69.3", package = "aprender-sparse" } -trueno-solve = { path = "crates/aprender-solve", version = "0.69.3", package = "aprender-solve" } -renacer-core = { path = "crates/aprender-profile-core", version = "0.69.3", package = "aprender-profile-core" } -repartir = { path = "crates/aprender-distribute", version = "0.69.3", package = "aprender-distribute" } -simular = { path = "crates/aprender-simulate", version = "0.69.3", package = "aprender-simulate" } -probar = { path = "crates/aprender-test-lib", version = "0.69.3", package = "aprender-test-lib" } +trueno-quant = { path = "crates/aprender-quant", version = "0.70.0", package = "aprender-quant" } +trueno-db = { path = "crates/aprender-db", version = "0.70.0", package = "aprender-db" } +trueno-viz = { path = "crates/aprender-viz", version = "0.70.0", package = "aprender-viz" } +trueno-rag = { path = "crates/aprender-rag", version = "0.70.0", package = "aprender-rag" } +trueno-cuda-edge = { path = "crates/aprender-cuda-edge", version = "0.70.0", package = "aprender-cuda-edge" } +trueno-sparse = { path = "crates/aprender-sparse", version = "0.70.0", package = "aprender-sparse" } +trueno-solve = { path = "crates/aprender-solve", version = "0.70.0", package = "aprender-solve" } +renacer-core = { path = "crates/aprender-profile-core", version = "0.70.0", package = "aprender-profile-core" } +repartir = { path = "crates/aprender-distribute", version = "0.70.0", package = "aprender-distribute" } +simular = { path = "crates/aprender-simulate", version = "0.70.0", package = "aprender-simulate" } +probar = { path = "crates/aprender-test-lib", version = "0.70.0", package = "aprender-test-lib" } # English key for the same crate, named after the command it serves (`apr test`). # The dep KEY, not the package name, is what becomes the extern crate name when # the two differ — the lib target is still `jugar_probar`, so keying it by its # package name would have imported Spanish anyway. Renaming that lib target is # 196 call sites in 17 crates and gets its own PR; this key is how new code # reads English today. -apr-test = { path = "crates/aprender-test-lib", version = "0.69.3", package = "aprender-test-lib", default-features = false, features = ["llm"] } +apr-test = { path = "crates/aprender-test-lib", version = "0.70.0", package = "aprender-test-lib", default-features = false, features = ["llm"] } # `jugar-probar` is the crates.io/lib name of the same in-tree crate as `probar` # above. Both aliases resolve to ONE package; consumers that spelled it # `jugar-probar = "0.4"|"0.5"|"1.0"` were building three extra copies. -jugar-probar = { path = "crates/aprender-test-lib", version = "0.69.3", package = "aprender-test-lib" } +jugar-probar = { path = "crates/aprender-test-lib", version = "0.70.0", package = "aprender-test-lib" } # NOTE: the matching `jugar-probar-derive` alias already exists further down this # same table (it points at crates/aprender-test-derive). crates/aprender-test-lib # was pinning `jugar-probar-derive = "=1.0.3"` from crates.io regardless — the # alias was sitting there unused. -aprender-train = { path = "crates/aprender-train", version = "0.69.3" } -entrenar = { path = "crates/aprender-train", version = "0.69.3", package = "aprender-train" } -aprender-train-common = { path = "crates/aprender-train-common", version = "0.69.3" } -aprender-train-distill = { path = "crates/aprender-train-distill", version = "0.69.3" } -aprender-orchestrate = { path = "crates/aprender-orchestrate", version = "0.69.3" } -aprender-contracts = { path = "crates/aprender-contracts", version = "0.69.3" } -aprender-build-sha = { path = "crates/aprender-build-sha", version = "0.69.3" } -aprender-profile = { path = "crates/aprender-profile", version = "0.69.3" } -aprender-profile-core = { path = "crates/aprender-profile-core", version = "0.69.3" } -aprender-zram-core = { path = "crates/aprender-zram-core", version = "0.69.3" } -aprender-zram-adaptive = { path = "crates/aprender-zram-adaptive", version = "0.69.3" } -aprender-zram-cli = { path = "crates/aprender-zram-cli", version = "0.69.3" } -aprender-present-core = { path = "crates/aprender-present-core", version = "0.69.3" } -aprender-present-terminal = { path = "crates/aprender-present-terminal", version = "0.69.3" } -aprender-present-widgets = { path = "crates/aprender-present-widgets", version = "0.69.3" } -aprender-present-layout = { path = "crates/aprender-present-layout", version = "0.69.3" } -aprender-present-yaml = { path = "crates/aprender-present-yaml", version = "0.69.3" } -aprender-present-test = { path = "crates/aprender-present-test", version = "0.69.3" } -aprender-db = { path = "crates/aprender-db", version = "0.69.3" } -aprender-graph = { path = "crates/aprender-graph", version = "0.69.3" } -aprender-rag = { path = "crates/aprender-rag", version = "0.69.3" } -aprender-rag-cli = { path = "crates/aprender-rag-cli", version = "0.69.3" } -aprender-qa-cli = { path = "crates/aprender-qa-cli", version = "0.69.3" } -aprender-cgp = { path = "crates/aprender-cgp", version = "0.69.3" } -aprender-contrastive-data = { path = "crates/aprender-contrastive-data", version = "0.69.3" } -aprender-contracts-cli = { path = "crates/aprender-contracts-cli", version = "0.69.3" } -aprender-update = { path = "crates/aprender-update", version = "0.69.3" } -aprender-data = { path = "crates/aprender-data", version = "0.69.3" } +aprender-train = { path = "crates/aprender-train", version = "0.70.0" } +entrenar = { path = "crates/aprender-train", version = "0.70.0", package = "aprender-train" } +aprender-train-common = { path = "crates/aprender-train-common", version = "0.70.0" } +aprender-train-distill = { path = "crates/aprender-train-distill", version = "0.70.0" } +aprender-orchestrate = { path = "crates/aprender-orchestrate", version = "0.70.0" } +aprender-contracts = { path = "crates/aprender-contracts", version = "0.70.0" } +aprender-build-sha = { path = "crates/aprender-build-sha", version = "0.70.0" } +aprender-profile = { path = "crates/aprender-profile", version = "0.70.0" } +aprender-profile-core = { path = "crates/aprender-profile-core", version = "0.70.0" } +aprender-zram-core = { path = "crates/aprender-zram-core", version = "0.70.0" } +aprender-zram-adaptive = { path = "crates/aprender-zram-adaptive", version = "0.70.0" } +aprender-zram-cli = { path = "crates/aprender-zram-cli", version = "0.70.0" } +aprender-present-core = { path = "crates/aprender-present-core", version = "0.70.0" } +aprender-present-terminal = { path = "crates/aprender-present-terminal", version = "0.70.0" } +aprender-present-widgets = { path = "crates/aprender-present-widgets", version = "0.70.0" } +aprender-present-layout = { path = "crates/aprender-present-layout", version = "0.70.0" } +aprender-present-yaml = { path = "crates/aprender-present-yaml", version = "0.70.0" } +aprender-present-test = { path = "crates/aprender-present-test", version = "0.70.0" } +aprender-db = { path = "crates/aprender-db", version = "0.70.0" } +aprender-graph = { path = "crates/aprender-graph", version = "0.70.0" } +aprender-rag = { path = "crates/aprender-rag", version = "0.70.0" } +aprender-rag-cli = { path = "crates/aprender-rag-cli", version = "0.70.0" } +aprender-qa-cli = { path = "crates/aprender-qa-cli", version = "0.70.0" } +aprender-cgp = { path = "crates/aprender-cgp", version = "0.70.0" } +aprender-contrastive-data = { path = "crates/aprender-contrastive-data", version = "0.70.0" } +aprender-contracts-cli = { path = "crates/aprender-contracts-cli", version = "0.70.0" } +aprender-update = { path = "crates/aprender-update", version = "0.70.0" } +aprender-data = { path = "crates/aprender-data", version = "0.70.0" } +aprender-ptx-debug = { path = "crates/aprender-ptx-debug", version = "0.70.0" } # Additional external deps needed by sub-crates serde_yaml_ng = "0.10" @@ -377,25 +378,25 @@ wasmtime = "47.0.4" bollard = "0.17" # Old crate name aliases (workspace = true refs in migrated sub-crates) -trueno-zram-core = { path = "crates/aprender-zram-core", version = "0.69.3", package = "aprender-zram-core" } -trueno-zram-adaptive = { path = "crates/aprender-zram-adaptive", version = "0.69.3", package = "aprender-zram-adaptive" } -trueno = { path = "crates/aprender-compute", version = "0.69.3", package = "aprender-compute" } -presentar-core = { path = "crates/aprender-present-core", version = "0.69.3", package = "aprender-present-core" } -presentar-terminal = { path = "crates/aprender-present-terminal", version = "0.69.3", package = "aprender-present-terminal" } -presentar-widgets = { path = "crates/aprender-present-widgets", version = "0.69.3", package = "aprender-present-widgets" } -presentar-layout = { path = "crates/aprender-present-layout", version = "0.69.3", package = "aprender-present-layout" } -presentar-yaml = { path = "crates/aprender-present-yaml", version = "0.69.3", package = "aprender-present-yaml" } -presentar-test = { path = "crates/aprender-present-test", version = "0.69.3", package = "aprender-present-test" } -presentar-test-macros = { path = "crates/aprender-present-test-macros", version = "0.69.3", package = "aprender-present-test-macros" } -jugar-probar-derive = { path = "crates/aprender-test-derive", version = "0.69.3", package = "aprender-test-derive" } -batuta-common = { path = "crates/aprender-common", version = "0.69.3", package = "aprender-common" } +trueno-zram-core = { path = "crates/aprender-zram-core", version = "0.70.0", package = "aprender-zram-core" } +trueno-zram-adaptive = { path = "crates/aprender-zram-adaptive", version = "0.70.0", package = "aprender-zram-adaptive" } +trueno = { path = "crates/aprender-compute", version = "0.70.0", package = "aprender-compute" } +presentar-core = { path = "crates/aprender-present-core", version = "0.70.0", package = "aprender-present-core" } +presentar-terminal = { path = "crates/aprender-present-terminal", version = "0.70.0", package = "aprender-present-terminal" } +presentar-widgets = { path = "crates/aprender-present-widgets", version = "0.70.0", package = "aprender-present-widgets" } +presentar-layout = { path = "crates/aprender-present-layout", version = "0.70.0", package = "aprender-present-layout" } +presentar-yaml = { path = "crates/aprender-present-yaml", version = "0.70.0", package = "aprender-present-yaml" } +presentar-test = { path = "crates/aprender-present-test", version = "0.70.0", package = "aprender-present-test" } +presentar-test-macros = { path = "crates/aprender-present-test-macros", version = "0.70.0", package = "aprender-present-test-macros" } +jugar-probar-derive = { path = "crates/aprender-test-derive", version = "0.70.0", package = "aprender-test-derive" } +batuta-common = { path = "crates/aprender-common", version = "0.70.0", package = "aprender-common" } # APR-MONO sibling aliases — MUST use workspace refs, NEVER crates.io versions. # Hard-pinning these to crates.io creates a diamond: sub-crates pull older # aprender versions → multiple `aprender` crates in Cargo.lock = MUDA. -renacer = { path = "crates/aprender-profile", version = "0.69.3", package = "aprender-profile" } -entrenar-lora = { path = "crates/aprender-train-lora", version = "0.69.3", package = "aprender-train-lora" } -batuta = { path = "crates/aprender-orchestrate", version = "0.69.3", package = "aprender-orchestrate" } -trueno-graph = { path = "crates/aprender-graph", version = "0.69.3", package = "aprender-graph" } +renacer = { path = "crates/aprender-profile", version = "0.70.0", package = "aprender-profile" } +entrenar-lora = { path = "crates/aprender-train-lora", version = "0.70.0", package = "aprender-train-lora" } +batuta = { path = "crates/aprender-orchestrate", version = "0.70.0", package = "aprender-orchestrate" } +trueno-graph = { path = "crates/aprender-graph", version = "0.70.0", package = "aprender-graph" } [workspace.lints.rust] # Safety @@ -577,7 +578,7 @@ semicolon_if_nothing_returned = "allow" # Style preference [package] name = "aprender" -version = "0.69.3" +version = "0.70.0" edition = "2021" rust-version = "1.91" authors = ["Noah Gift "] @@ -669,6 +670,16 @@ gates = [ # Release time only, like the entry above; the PR-time half (the helpers' case table) is # scripts/check_tokenizer_parity.sh, which guard_tree.sh dispatches. "scripts/tokenizer_parity.sh", + # #3740 — in-tree tests that compare against llama.cpp read ONLY the pinned build that + # scripts/llama_bin.sh proves and exports; without it they skip. The release runs them WITH it, + # and a skip here is a FAIL, so a skip is never the release path. + "scripts/dogfood_comparator_env_tests.sh", + # #4114: every publishable crate's tests compile FROM ITS .crate (packaged, unpacked outside + # the tree, siblings patched to their tarballs). The source reader check_package_includes.sh + # skipped #[cfg(test)] modules and passed aprender-serve 0.69.1, whose tarball cannot compile + # its tests. Release time only: one workspace test build. The PR-time half is its case table, + # rows 14-16 of check_package_includes.sh --self-test (ci.yml guard-cargo). + "scripts/package_tarball_build.sh", ] # The interfaces this release SHIPS, each paired with the e2e target that proves @@ -725,9 +736,9 @@ required-features = ["cli"] # (e.g. apr-cookbook, downstream apps) don't pull apr-cli's heavy transitive # dep graph (renacer/profile/inference/zram/visualization). `cargo install # aprender` still works because `cli` is in default features. See GH-1599. -apr-cli = { path = "crates/apr-cli", version = "0.69.3", default-features = true, optional = true } +apr-cli = { path = "crates/apr-cli", version = "0.70.0", default-features = true, optional = true } # Core ML library (lib name = "aprender") -aprender_ml = { path = "crates/aprender-core", version = "0.69.3", package = "aprender-core" } +aprender_ml = { path = "crates/aprender-core", version = "0.70.0", package = "aprender-core" } [features] default = ["cli"] @@ -887,4 +898,15 @@ debug = "line-tables-only" [profile.dev.package.proptest] debug-assertions = false +# ---------------------------------------------------------------------------- +# x86-main SLOW tests: debug builds do not inline trueno's SIMD intrinsics +# ---------------------------------------------------------------------------- +# gdb samples of the LoRA falsify tests sat ~all in gemm_blis -> +# gemm_small_avx512_16x8 / gemm_direct_rowmajor, each `_mm256_fmadd_ps` an +# out-of-line call at opt-level 0. Optimizing only the compute crate keeps every +# other crate debuggable. Interleaved A/B on lambda (aprender-e6, 2026-09-26): +# f_conv_008 67.5s -> 32.2s, f_conv_003 92.7s -> 47.5s, ship_021 63.3s -> 23.1s. +[profile.dev.package.aprender-compute] +opt-level = 3 + diff --git a/Makefile b/Makefile index b0ba092e05..1f98b9b93c 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ SHELL := /bin/bash # Multi-line recipes execute in same shell .ONESHELL: -.PHONY: all build test test-smoke test-fast test-quick test-full test-heavy lint lint-current fmt clean doc book book-build book-serve book-test tier1 tier2 tier3 tier4 coverage coverage-fast profile hooks-install hooks-verify lint-scripts bashrs-score bashrs-lint-makefile chaos-test chaos-test-full chaos-test-lite fuzz bench dev pre-push ci gate check run-ci run-bench audit deps-validate deny pmat-score pmat-gates quality-report semantic-search examples mutants mutants-fast property-test install-alsa test-alsa test-audio-full contract-validate contract-test contract-audit contract-regen contract-check dev-setup check-siblings check-wasm32 contrastive-data-boundary contrastive-data-boundary-cases +.PHONY: all build guards-local test test-smoke test-fast test-quick test-full test-heavy lint lint-current fmt clean doc book book-build book-serve book-test tier1 tier2 tier3 tier4 coverage coverage-fast profile hooks-install hooks-verify lint-scripts bashrs-score bashrs-lint-makefile chaos-test chaos-test-full chaos-test-lite fuzz bench dev pre-push ci gate check run-ci run-bench audit deps-validate deny pmat-score pmat-gates quality-report semantic-search examples mutants mutants-fast property-test install-alsa test-alsa test-audio-full contract-validate contract-test contract-audit contract-regen contract-check dev-setup check-siblings check-wasm32 contrastive-data-boundary contrastive-data-boundary-cases # Default target all: tier2 @@ -493,10 +493,14 @@ COV_REFUSE_GLOBAL_MOLD = @if [ -f "$${CARGO_HOME:-$$HOME/.cargo}/config.toml" ] # Test infrastructure: # - test_factory : Test code, not production # - demo/ : Demo/example code -# NOTE: Coverage tracks the main aprender library only. -# Subcrate tests still RUN (--workspace), exercising main lib code paths, -# but subcrate source files are excluded from the coverage REPORT. -# External deps (trueno, realizar, .cargo) also excluded. +# NOTE (#3839): coverage measures the MONOREPO. #4023 scopes every report by a derived +# `-p` list, so aprender-serve/-train/-compute (formerly realizar/entrenar/trueno) are +# measured. The pre-monorepo `trueno|realizar/|entrenar/` alternatives were removed: in-tree +# `entrenar/` matched 0 files, `realizar/` 7 unrelated aprender-train files, and `trueno` +# 54/56 of aprender-zram while missing aprender-compute entirely. +# aprender-compute/src/backends/gpu/ : no coverage runner executes it (no GPU lane), so it +# is kept out of the denominator until one exists - excluded AND unrun, never "measured 0%". +# Named subcrates below (apr-cli, aprender-shell, ...) and .cargo stay excluded. # Subcrate code, external deps, and modules requiring external model files for coverage. # models/ = dead code per UCBD §9.1 (scheduled for deletion). # serialization/ = SafeTensors IO (needs actual .safetensors files). @@ -506,7 +510,7 @@ COV_REFUSE_GLOBAL_MOLD = @if [ -f "$${CARGO_HOME:-$$HOME/.cargo}/config.toml" ] # format/rosetta = cross-format parity (needs model files). # transfer/ = transfer learning (needs pretrained models). # bench/ = benchmark visualization (non-core). -COVERAGE_EXCLUDE_REGEX := \.cargo/|trueno|realizar/|entrenar/|fuzz/|golden_traces/|hf_hub/|demo/|test_factory|pacha/|showcase/|apr-cli/|aprender-shell/|aprender-tsp/|aprender-monte-carlo/|chaos\.rs|audio/|format/quantize\.rs|format/signing\.rs|voice/|playback\.rs|rustlib/src/rust|models/|serialization/|speech/|format/onnx|format/converter|format/rosetta|transfer/|bench_viz/ +COVERAGE_EXCLUDE_REGEX := \.cargo/|aprender-compute/src/backends/gpu/|fuzz/|golden_traces/|hf_hub/|demo/|test_factory|pacha/|showcase/|apr-cli/|aprender-shell/|aprender-tsp/|aprender-monte-carlo/|chaos\.rs|audio/|format/quantize\.rs|format/signing\.rs|voice/|playback\.rs|rustlib/src/rust|models/|serialization/|speech/|format/onnx|format/converter|format/rosetta|transfer/|bench_viz/ # Coverage threshold (enforced: fail if below) COV_THRESHOLD := 95 @@ -564,7 +568,7 @@ COV_CARGO_ENV := $(if $(COV_TARGET_DIR),CARGO_TARGET_DIR=$(COV_TARGET_DIR)) # DERIVED from `cargo metadata` (scripts/coverage_report_scope.py), the verified alternative # above, and scripts/check_coverage_report_scoped.sh refuses any unscoped `llvm-cov report`. profraw # survive it (31 present afterwards), so coverage-html still has data to work from. -.PHONY: coverage-check contracts +.PHONY: coverage-check contracts census # BSE-03 phase A (Pmat-Ticket: PMAT-1068). The README's contract count is # DERIVED: scripts/readme_sync.sh rewrites the text between the @@ -609,41 +613,32 @@ coverage-check: coverage # CLAUDE.md, and the dogfood protocol looked for a target that did not exist, so # it WARNed instead of checking. `pv lint` runs validate + audit + score across # contracts/ and is the documented entry point (never hand-rolled bash). +# The release train's one census writer (#3569). A PR that runs this and commits +# the result is refused by scripts/check_census_derived.sh; the train runs it on +# release/X.Y.Z, where the guard exempts the edit. +census: + @. scripts/pv_bin.sh && t=$$(mktemp contracts/census.json.XXXXXX) && "$$PV" census contracts --format json > "$$t" && bash scripts/check_census_derived.sh --census "$$t" && mv "$$t" contracts/census.json || { rm -f "$$t"; exit 1; } + @bash scripts/readme_sync.sh --write + # EXIT PROPAGATION (PVL-001 EV-4, aprender#4168). Under .ONESHELL this whole # recipe is ONE shell script, so without errexit its status is the LAST line's # and every earlier step -- `pv lint` included -- was advisory: a failing lint -# printed its tail and the gate exited 0. `set -e` stops at a failing step. -# It is NOT enough on the pv lines: errexit ignores a failure on the LEFT of -# `&&`, so a pv_bin.sh that REFUSES the binary (stale, wrong identity) would -# fall through to the next step. Hence `|| exit` there as well, which exits -# with that list's own status (pv's rc through the pipe, via -o pipefail). -# Case table + mutants: scripts/tests/make_contracts_propagates.sh. +# printed its tail and the gate exited 0. So every line that can fail ends in +# `|| exit`, which ends the recipe with that line's own status. +# Case table + mutants: scripts/tests/make_contracts_propagates.sh (this recipe) +# and `scripts/contracts_gate.sh --self-test` (the gate's steps). contracts: - @set -e - @echo "== provable contracts: pv lint contracts/ ==" -# `| tail -5` DISCARDED THE VERDICT: the pipeline's status is tail's, so the armed-meet -# result was PRINTED and NOT ENFORCED (found by aprender-d8, 0.69.1 tail rehearsal). That -# is Verification Discipline #1 in the release's own contract gate, and -# contracts-exit-integrity does not catch it -- it looks for `|| true` and bare for-loops, -# not for a pipe. The output is kept to a tail for readability by writing it to a file and -# tailing THAT, so the exit status belongs to pv and nothing else. -# .ONESHELL: the whole recipe is ONE shell, so an unconditional `exit $$rc` here ended the -# recipe green after lint -- census, graph, README, provenance and the engine tests never ran. -# Exit only on failure (#4315, caught by scripts/tests/make_contracts_propagates.sh). - @. scripts/pv_bin.sh && { "$$PV" lint contracts/ > /tmp/pv-lint-contracts.$$$$.log 2>&1; rc=$$?; tail -5 /tmp/pv-lint-contracts.$$$$.log; rm -f /tmp/pv-lint-contracts.$$$$.log; [ $$rc -eq 0 ] || exit $$rc; } || exit - @echo "== census: tracked contracts/census.json == a fresh one (ONT-001 ONT-1, F-1) ==" - @git ls-files --error-unmatch contracts/census.json >/dev/null || { echo "FAIL: contracts/census.json is not tracked, so diffing it proves nothing"; exit 1; } - @. scripts/pv_bin.sh && "$$PV" census contracts --format json > contracts/census.json || exit - @git diff --exit-code contracts/census.json || { echo "FAIL: the tracked census differs from a fresh one — commit the regenerated contracts/census.json"; exit 1; } - @echo "== graph: tracked contracts/contracts.nt + shapes.ttl == a fresh extraction (ONT-001 ONT-4b, R-18) ==" - @. scripts/pv_bin.sh && "$$PV" extract contracts --check >/dev/null || exit - @echo "== README states the censused count ==" - @bash scripts/readme_sync.sh --check - @echo "== provenance marks, interim (ONT-001 R-10) ==" - @bash scripts/lint-provenance.sh --self-test - @bash scripts/lint-provenance.sh contracts/external-corpora.yaml +# #4475: the steps live in scripts/contracts_gate.sh. The recipe used to hold them as lines, and under +# `.ONESHELL` + `.SHELLFLAGS := -o pipefail -c` (no -e) the whole recipe is ONE bash script: the lint line's +# unconditional `exit $$rc` ended it on a GREEN lint, so the census diff, `extract --check`, the README sync +# and provenance never ran — and had they run, a failing middle line would not have failed the recipe. The +# gate runs EVERY step, prints `N of 6 step(s) RAN, M FAILED`, fails closed on a shapes verdict it cannot +# measure, and regenerates census.json / contracts.nt / shapes.ttl then asks `git diff --exit-code`. + @bash scripts/contracts_gate.sh || exit 1 @echo "== contract engine tests ==" - @cargo test -p aprender-contracts --lib 2>&1 | grep -E "test result" | tail -1 + @# The old `| grep | tail -1` printed the verdict and + @# discarded it (the exit status was tail's), so a failing engine test passed the gate. + @t=$$(mktemp) && ( cargo test -p aprender-contracts --lib > "$$t" 2>&1; rc=$$?; grep -E "test result" "$$t" | tail -1; [ $$rc -eq 0 ] || tail -30 "$$t"; rm -f "$${t:?}"; exit $$rc ) || exit # #3839: skips are EXACT full test paths from scripts/coverage-skips.txt, one reason # per entry. They used to be 19 --skip substrings that removed 2,713 tests (2,702 of @@ -682,7 +677,7 @@ coverage: ## Coverage summary + threshold check (warm: ~3min) @$(COV_CARGO_ENV) cargo llvm-cov test --no-report -p aprender-serve --lib -- --list \ > target/coverage/serve-list.txt 2>> target/coverage/test.log || \ { echo "❌ coverage DID NOT MEASURE: could not list aprender-serve's lib tests. No coverage verdict."; exit 1; } - @python3 scripts/coverage_serve_shards.py target/coverage/serve-list.txt scripts/coverage-skips.txt \ + @bash scripts/coverage_serve_shards.sh target/coverage/serve-list.txt scripts/coverage-skips.txt \ target/coverage/serve-shards scripts/coverage-solo.txt @# scripts/coverage-solo.txt: run FIRST, each in its OWN process, and print its test binary's peak RSS @# (RUSAGE_CHILDREN.ru_maxrss), so a later skip carries a measured per-test reason. @@ -860,6 +855,11 @@ dev: tier1 # Pre-push checks pre-push: tier3 +# Run CI's guard steps locally, every step, with the SAME script CI runs (#4415, #4416) +guards-local: ## Run every guard-cargo/guard-tree step CI runs, all of them, streaming + @bash scripts/ci_guards.sh --check-coverage + @bash scripts/ci_guards.sh + # CI/CD checks ci: tier4 @@ -1488,9 +1488,11 @@ check-siblings: ## Verify sibling repos exist and versions are compatible echo " Remove [patch.crates-io] from .cargo/config.toml"; \ fi -# APR-RELEASE-001 §11.2 (ONT R-6): the five ontology counters move ONLY through -# this target. `--check` is what guard_tree.sh runs on every PR; `--write` is the -# deliberate restamp, and it is the only way a counter is allowed to change. +# APR-RELEASE-001 §11.2 (ONT R-6). Since #3569 the ontology counters are MEASURED, +# never committed: `--check` (guard_tree.sh, every PR) measures them at the +# comparand tree and at the working tree and refuses any move the wrong way. +# `--write` stores DECISIONS only (armed_gates, armed_shapes + the Rust gates' +# foreign keys); it can no longer restamp a counter to hide a regression. .PHONY: ont-ratchet ont-ratchet-check ont-ratchet: @bash scripts/check_ont_ratchet.sh --write @@ -1498,6 +1500,16 @@ ont-ratchet: ont-ratchet-check: @bash scripts/check_ont_ratchet.sh --check +# PVL-001 EV-11 (PMAT-4166): the two `pv lint` ratchets (theorem-pairing, depends-on-present) move ONLY +# through this target, and only DOWN. The gates read contracts/lint-baseline.json and never write it. +# NEVER in CI: a CI job that could rewrite the baseline is a ratchet that turns both ways. +.PHONY: lint-ratchet lint-ratchet-self-test +lint-ratchet: + @bash scripts/lint_ratchet.sh + +lint-ratchet-self-test: + @bash scripts/lint_ratchet.sh --self-test + # ONT-001 §5 ONT-4b2 / R-13 — the out-of-gate SHACL differential oracle. # # NOT a PR check, by the rule that puts it here: `shacl` is 316 crates and pinned at ONE version (ONT-0's diff --git a/README.md b/README.md index 7a7920919d..a5912d3b9f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ +--- +schema_version: "1.0" +kind: library +entrypoints: [crates/aprender-contracts-cli] +contract_count: 1837 +--- +

aprender — Next-generation ML framework in pure Rust

@@ -22,7 +29,7 @@ Pre-built and ready to use (Linux x86_64/aarch64 only today — other platforms fall through to `cargo install` below): -```bash +```text curl -LsSf https://paiml.com/apr/install.sh | sh ``` @@ -30,7 +37,7 @@ To read the script before it runs, download it with `curl -LsSf https://paiml.co Compile and build: -```bash +```text cargo install aprender # CPU ONLY - no GPU backend is compiled in cargo install aprender --features cuda # NVIDIA GPU acceleration cargo install aprender --features full # everything (training, visualization, zram) @@ -52,10 +59,10 @@ publishing — all backed by YAML provable contracts that fail CI on drift. | Metric | Count | Source of truth | |-------:|------:|---| | Workspace crates | **81** workspace crates | `cargo metadata --no-deps` (NOT `ls crates/` — 4 are `exclude`d, 1 has no Cargo.toml) | -| Provable contracts | **1837** provable contracts | `contracts/census.json` `.n_files` — the set `pv lint` walks (`pv census`, ONT-001 ONT-1; regenerated by `make contracts`, written by `make readme-sync`, guarded by `scripts/check_readme_claims.sh`) | -| CLI commands | **111** CLI commands | `contracts/apr-cli-commands-v1.yaml` §`commands` (parse the list; `apr --help` prints 112 because it lists `help` itself, and `grep -c '^ - name:'` gives 117 — other same-indent `name:` keys exist in the file) | -| Book CLI chapters | **113** chapters | `ls book/src/cli/*.md` | -| Book lib chapters | **72** chapters | `ls book/src/lib/*.md` (parity with `pub mod`) | +| Provable contracts | **1844** provable contracts | `contracts/census.json` `.n_files` — the set `pv lint` walks (`pv census`, ONT-001 ONT-1; regenerated by `make contracts`, written by `make readme-sync`, guarded by `scripts/check_readme_claims.sh`) | +| CLI commands | **113** CLI commands | `contracts/apr-cli-commands-v1.yaml` §`commands` (parse the list; `apr --help` also lists `help` itself, and `grep -c '^ - name:'` gives 119 — other same-indent `name:` keys exist in the file) | +| Book CLI chapters | **114** chapters | `ls book/src/cli/*.md` | +| Book lib chapters | **73** chapters | `ls book/src/lib/*.md` (parity with `pub mod`) | These numbers are enforced by [`contracts/readme-claims-v1.yaml`](contracts/readme-claims-v1.yaml). Drift between this table and live repo state fails `bash scripts/check_readme_claims.sh` @@ -130,7 +137,7 @@ with local `book/src/` walkthroughs. The count is the [`contracts/readme-claims-v1.yaml`](contracts/readme-claims-v1.yaml) — it lives in another repository, so it carries the date it was measured rather than pretending to be live. -```bash +```text git clone https://github.com/paiml/apr-cookbook cd apr-cookbook cargo run --example bundle_static_model # any example @@ -139,7 +146,7 @@ mdbook serve book # walkthrough docs ## Install -```bash +```text cargo install aprender # installs the `apr` binary apr --version ``` @@ -154,21 +161,21 @@ form is [`scripts/qwen-story.sh`](scripts/qwen-story.sh); nightly cron is [`.github/workflows/qwen-story-daily.yml`](.github/workflows/qwen-story-daily.yml); the dogfood gate is `/apr-dogfood` Gate 18. -```bash +```text # Reproduce locally (uses ~/models cache; ~3-5 min on RTX 4090): bash scripts/qwen-story.sh ``` ### Beat 1 — Discover (Registry) -```bash +```text apr pull hf://Qwen/Qwen2.5-Coder-0.5B-Instruct # 494 MB safetensors apr list # confirm cached ``` ### Beat 2 — Trust (QA gates) -```bash +```text apr qa qwen2.5-coder-1.5b-instruct-q4k # 12 falsifiable gates apr validate qwen2.5-coder-1.5b-instruct-q4k --quality # scores the checks that ran apr lint qwen2.5-coder-1.5b-instruct-q4k # best-practice signals @@ -176,7 +183,7 @@ apr lint qwen2.5-coder-1.5b-instruct-q4k # best-practice signals ### Beat 3 — Explore (Inspection) -```bash +```text apr inspect --json qwen2.5-coder-1.5b-instruct-q4k # arch, params, tensors apr tensors --json qwen2.5-coder-1.5b-instruct-q4k # 339 tensors with shapes apr tree qwen2.5-coder-1.5b-instruct-q4k # layer architecture @@ -184,7 +191,7 @@ apr tree qwen2.5-coder-1.5b-instruct-q4k # layer architecture ### Beat 4 — Adapt (Model ops) -```bash +```text apr export qwen2.5-coder-1.5b-instruct-q4k --format gguf -o roundtrip.gguf apr diff qwen2.5-coder-1.5b-instruct-q4k roundtrip.gguf # tensor-by-tensor delta apr convert model.safetensors --quantize q4_k -o quantized.apr @@ -192,7 +199,7 @@ apr convert model.safetensors --quantize q4_k -o quantized.apr ### Beat 5 — Use (Inference) -```bash +```text apr run qwen2.5-coder-1.5b-instruct-q4k "fn sum(a: i32, b: i32) -> i32 {" --max-tokens 16 apr chat qwen2.5-coder-1.5b-instruct-q4k # interactive REPL apr code -p "review this Python function" --max-turns 1 # agent mode (PMAT-182) @@ -200,7 +207,7 @@ apr code -p "review this Python function" --max-turns 1 # agent mode (PMAT-182 ### Beat 6 — Serve (REST API) -```bash +```text apr serve run qwen2.5-coder-1.5b-instruct-q4k --port 8080 curl -s localhost:8080/v1/chat/completions \ -H 'Content-Type: application/json' \ @@ -210,7 +217,7 @@ curl -s localhost:8080/v1/chat/completions \ ### Beat 7 — Operate (Profiling) -```bash +```text apr profile qwen2.5-coder-7b-instruct-q4_k_m # Roofline analysis apr gpu --json # VRAM, sm_*, cuda version apr serve plan qwen2.5-coder-7b-instruct-q4_k_m # capacity plan before run @@ -218,7 +225,7 @@ apr serve plan qwen2.5-coder-7b-instruct-q4_k_m # capacity plan before run ### Beat 8 — Scale (MoE introspection) -```bash +```text apr inspect --json Qwen3-Coder-30B-A3B-Instruct # arch=qwen3moe, 30 B params apr tensors --json Qwen3-Coder-30B-A3B-Instruct # 579 tensors (MoE expert layout) ``` @@ -234,7 +241,7 @@ apr tensors --json Qwen3-Coder-30B-A3B-Instruct # 579 tensors (MoE expert la ### Publish (separate flow) -```bash +```text # Publish a derived model to HuggingFace Hub (see SPEC-HF-PUBLISH-001 for the 12-file pipeline) apr stamp ckpt.apr --tokenizer /path/to/qwen-tokenizer --license Apache-2.0 -o staging/model.apr apr export staging/model.apr --format gguf --quantize int4 -o staging/model-q4k.gguf @@ -332,7 +339,7 @@ falsification_tests: prediction: apr validate bad-model.apr exits non-zero ``` -The tree carries 1837 contracts across inference, training, quantization, attention, FFN, +The tree carries 1844 contracts across inference, training, quantization, attention, FFN, tokenization, model formats, CLI safety — and this README itself. ## Migration from old crates @@ -348,13 +355,18 @@ Old repositories are archived. All development happens here. ## Contributing -```bash +```text git clone https://github.com/paiml/aprender cd aprender cargo test --workspace --lib cargo check --workspace apr --help -bash scripts/check_readme_claims.sh # README contract gate +``` + +The README's own gate — a merge-path CI step runs exactly this line, which is what lets this fence stay `bash`: + +```bash +setsid --wait bash scripts/check_readme_claims.sh # README contract gate (as CI runs it) ``` ## License diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 539233f765..5fd811dc90 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -353,6 +353,7 @@ - [apr prune](./cli/prune.md) - [apr ptx-map](./cli/ptx-map.md) - [apr ptx](./cli/ptx.md) +- [apr ptx-debug](./cli/ptx-debug.md) - [apr publish](./cli/publish.md) - [apr pull](./cli/pull.md) - [apr pv](./cli/pv.md) diff --git a/book/src/cli/ptx-debug.md b/book/src/cli/ptx-debug.md new file mode 100644 index 0000000000..e3fbf09261 --- /dev/null +++ b/book/src/cli/ptx-debug.md @@ -0,0 +1,34 @@ +# apr ptx-debug + +PTX falsification analysis and FKR test generation (was the `aprender-ptx-debug` binary) + +**Category**: Hardware + +Pure Rust: unlike `apr ptx`, it needs no build feature. + +## Synopsis + +```text +apr ptx-debug analyze [--falsify] [--min-score N] [--html FILE] [--json] +apr ptx-debug gen-fkr [-o FILE] +apr ptx-debug version +``` + +- `analyze` scores a PTX file against the falsification framework and fails when the score is below `--min-score` (default 70). +- `gen-fkr` generates FKR tests for jugar-probar from a PTX file (stdout unless `-o`). + +## Example + + +```bash +apr ptx-debug version +``` + +## Full help + +Run `apr ptx-debug --help` for the complete option list. + +## See also + +- Source: [`crates/aprender-ptx-debug/src/cli.rs`](https://github.com/paiml/aprender/blob/main/crates/aprender-ptx-debug/src/cli.rs) +- [apr ptx](./ptx.md) diff --git a/ci/explicit-test-commands.d/447-apr-cli-surface-binary-pin.cmd b/ci/explicit-test-commands.d/447-apr-cli-surface-binary-pin.cmd new file mode 100644 index 0000000000..08a5c395a5 --- /dev/null +++ b/ci/explicit-test-commands.d/447-apr-cli-surface-binary-pin.cmd @@ -0,0 +1 @@ +cargo test -p apr-cli --test surface_binary_pin diff --git a/ci/explicit-test-commands.d/456-aprender-contracts-cli-ev11-lint-ratchets.cmd b/ci/explicit-test-commands.d/456-aprender-contracts-cli-ev11-lint-ratchets.cmd new file mode 100644 index 0000000000..7f427a94da --- /dev/null +++ b/ci/explicit-test-commands.d/456-aprender-contracts-cli-ev11-lint-ratchets.cmd @@ -0,0 +1 @@ +cargo test -p aprender-contracts-cli --test ev11_lint_ratchets diff --git a/ci/explicit-test-commands.d/457-aprender-contracts-cli-shapes-vacuity.cmd b/ci/explicit-test-commands.d/457-aprender-contracts-cli-shapes-vacuity.cmd new file mode 100644 index 0000000000..75beb0e63c --- /dev/null +++ b/ci/explicit-test-commands.d/457-aprender-contracts-cli-shapes-vacuity.cmd @@ -0,0 +1 @@ +cargo test -p aprender-contracts-cli --test shapes_vacuity diff --git a/ci/explicit-test-commands.d/457-apr-format-golden-fixtures.cmd b/ci/explicit-test-commands.d/458-apr-format-golden-fixtures.cmd similarity index 100% rename from ci/explicit-test-commands.d/457-apr-format-golden-fixtures.cmd rename to ci/explicit-test-commands.d/458-apr-format-golden-fixtures.cmd diff --git a/ci/explicit-test-commands.d/480-aprender-contracts-cli-ont4c5-capability-cells.cmd b/ci/explicit-test-commands.d/480-aprender-contracts-cli-ont4c5-capability-cells.cmd new file mode 100644 index 0000000000..633f6310ab --- /dev/null +++ b/ci/explicit-test-commands.d/480-aprender-contracts-cli-ont4c5-capability-cells.cmd @@ -0,0 +1 @@ +cargo test -p aprender-contracts-cli --test ont4c5_capability_cells diff --git a/ci/explicit-test-commands.d/480-aprender-facade-e2e.cmd b/ci/explicit-test-commands.d/481-aprender-facade-e2e.cmd similarity index 100% rename from ci/explicit-test-commands.d/480-aprender-facade-e2e.cmd rename to ci/explicit-test-commands.d/481-aprender-facade-e2e.cmd diff --git a/ci/explicit-test-commands.d/530-aprender-contracts-cli-ont4c-doc-contracts.cmd b/ci/explicit-test-commands.d/530-aprender-contracts-cli-ont4c-doc-contracts.cmd new file mode 100644 index 0000000000..360976bafb --- /dev/null +++ b/ci/explicit-test-commands.d/530-aprender-contracts-cli-ont4c-doc-contracts.cmd @@ -0,0 +1 @@ +cargo test -p aprender-contracts-cli --test ont4c_doc_contracts diff --git a/ci/sections.yml b/ci/sections.yml index d4b98423eb..840f412781 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -298,6 +298,16 @@ jobs: } parent="/mnt/nvme-raid0/targets/aprender-ci/${PR_OR_REF}" reg="/mnt/nvme-raid0/cargo-ci/registry/${PR_OR_REF}" + # infra#1059: admit this target unit against gx10's disk budget BEFORE creating it + # (free >= 50G + 25G x (live+1), live < 2; waits <=20m, then exit 1). The admit + # record is what the runner's JOB_COMPLETED hook reaps, and nothing else. Hosts + # without the gate skip; gx10 exports CI_TARGET_ADMIT_REQUIRED=1, so a missing + # gate there is red, never a silent skip. + if command -v ci-target-admit >/dev/null 2>&1; then + ci-target-admit "${parent}/run-${GITHUB_RUN_ID}" || exit 1 + elif [ "${CI_TARGET_ADMIT_REQUIRED:-0}" = 1 ]; then + echo "::error::infra#1059: ci-target-admit is required on ${RUNNER_NAME:-this runner} and is missing"; exit 1 + fi heal_path /mnt/nvme-raid0/targets/aprender-ci "${parent}/run-${GITHUB_RUN_ID}" heal_path /mnt/nvme-raid0/cargo-ci/registry "$reg" ls -ld "$parent" "${parent}/run-${GITHUB_RUN_ID}" "$reg" @@ -955,11 +965,17 @@ jobs: # workspace. Runner (noah:1000) can't git-clean them on next run # → checkout fails → CI breaks. This runs inside the container # (as root) restoring host ownership for subsequent bare-metal jobs. + # infra#1059: mount the target unit only if it exists. When the disk + # admit gate refused it, a bind mount here would make docker create the + # refused unit as root, unrecorded, so the reap hook would never free it. + unit="/mnt/nvme-raid0/targets/aprender-ci/${PR_OR_REF}/run-${GITHUB_RUN_ID}" + target_mount=() + if [ -d "$unit" ]; then target_mount=(-v "${unit}:/workspace/target"); fi docker run --rm \ -e CI -e GITHUB_ACTIONS -e GITHUB_REF -e GITHUB_SHA -e GITHUB_REPOSITORY -e GITHUB_RUN_ID -e GITHUB_EVENT_NAME -e GITHUB_WORKFLOW \ -v "${GITHUB_WORKSPACE}:/workspace" \ -v "/mnt/nvme-raid0/cargo-ci/registry/${PR_OR_REF}:/usr/local/cargo/registry" \ - -v "/mnt/nvme-raid0/targets/aprender-ci/${PR_OR_REF}/run-${GITHUB_RUN_ID}:/workspace/target" \ + "${target_mount[@]}" \ "$IMAGE" \ bash -c 'chown -R 1000:1000 /workspace || true; chown -R 1000:1000 /usr/local/cargo/registry || true; chown -R 1000:1000 /workspace/target || true' @@ -990,6 +1006,43 @@ jobs: # discriminating runner label, so a bare [self-hosted, X64, Linux] selector # can never again silently land a job on a GPU/dev runner without the # sovereign-ci registry. Pure text check; runs on the clean-room pool. + # #3668: ONE docs-only decision for the run. A roadmap-only PR paid ~26 min of + # guard-cargo and ~6 of determinism for a diff neither reads. This job asks + # scripts/ci_docs_only.sh (which asks ci_test_tier.sh's #3658 definition: + # every path under docs/roadmaps/ or docs/audits/ and present at HEAD) and + # publishes docs_only. The consumers are written FAIL-CLOSED: they read + # `!cancelled() && needs.changes.outputs.docs_only != 'true'`, so this job + # being skipped (every non-PR event), failing, or printing anything but + # `true` runs everything. `gate` accepts their `skipped` only when this job + # SUCCEEDED and said true (GATE-DOCS-ONLY-RULE, + # scripts/check_ci_gate_docs_only_rule.sh). + changes: + runs-on: [self-hosted, Linux, clean-room] # arch-neutral: intel, yoga or gx10 (#3100) + timeout-minutes: 10 # shallow checkout + one git diff + one script + if: github.event_name == 'pull_request' + outputs: + docs_only: ${{ steps.decide.outputs.docs_only }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + - name: Is this PR docs-only? (#3668) + id: decide + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin \ + "+refs/heads/${GITHUB_BASE_REF}:refs/remotes/origin/${GITHUB_BASE_REF}" + # --no-renames (#3664): a source moved into docs/ must list BOTH paths. + git diff --no-renames --name-only "origin/${GITHUB_BASE_REF}" HEAD > "$RUNNER_TEMP/touched.txt" + printf 'touched (tip vs tip): %s path(s)\n' "$(wc -l < "$RUNNER_TEMP/touched.txt" | tr -d ' ')" + bash scripts/ci_docs_only.sh --event "${GITHUB_EVENT_NAME}" --diff-from "$RUNNER_TEMP/touched.txt" \ + > "$RUNNER_TEMP/docs_only.txt" + cat "$RUNNER_TEMP/docs_only.txt" + grep -E '^docs_only=(true|false)$' "$RUNNER_TEMP/docs_only.txt" >> "$GITHUB_OUTPUT" + - name: The docs-only decision can still go RED (case table) + run: bash scripts/ci_docs_only.sh --self-test + guard-tree: runs-on: [self-hosted, Linux, clean-room] # arch-neutral: intel, yoga or gx10 (#3100) # 20: the operator's hard PR budget (rule E, 2026-09-10), and it OVERRIDES @@ -1073,13 +1126,146 @@ jobs: # ratchets fall back to the origin/main TIP. That is the STRICTER # comparand, and it means the CI path is the tip path: a local run that # passes on the merge-base can still red here, and CI is authoritative. - - name: Fetch origin/main as the comparand for every ratchet in this job + - id: guard-setup + name: Fetch origin/main as the comparand for every ratchet in this job run: | git fetch --no-tags --depth=1 origin +refs/heads/main:refs/remotes/origin/main # push shape: HEAD IS the origin/main tip, so every differential ratchet judges it against # its FIRST PARENT (scripts/lib/resolve_base.sh: HEAD vs HEAD would pass vacuously, the # G-10 quorum's finding), and a depth-1 checkout has not fetched that parent. Deepen by one. if [ "${GITHUB_EVENT_NAME:-}" = push ]; then git fetch --no-tags --deepen=1 origin +refs/heads/main:refs/remotes/origin/main; fi + # #4415 (operator A4, 2026-09-25): ONE step runs every guard step of guard-tree-steps + # below, keeps going past a red one, and reports every failure. GitHub would + # stop this job at its first red step, and the others would never be seen. + # `make guards-local` and the pre-push hook run this same script and print + # the same `ci_guards: sha256` line. + - name: Every guard step runs, and every failure is reported (scripts/ci_guards.sh, #4415) + if: ${{ !cancelled() && steps.guard-setup.outcome == 'success' }} + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + run: bash scripts/ci_guards.sh guard-tree + # APR-RELEASE-001 §6 R-2, WIRED (#3200 shipped the guard; this is its + # caller). This step is its ONLY runner: guard_tree.sh skips it as + # wired-with-args (it needs a body), so its case table ran in NO job until + # this step called `--self-test` beside `--body` (found by aprender-f0, + # 2026-09-21; the comment here used to claim guard_tree ran it bare). The + # 0.67.0 reconcile found 48 merged PRs since the previous tag of which only + # 9 closed anything; that is what this guard exists to stop. + # + # THE BODY IS NEVER INTERPOLATED INTO THE SCRIPT. `${{ }}` is substituted + # textually BEFORE bash sees the line, so a body containing a backtick or + # $(...) would execute on the runner -- and this body is attacker-controlled + # by construction. Since #3298 it is fetched live by `gh api` straight into + # a file (it used to arrive through `env:` from the stale event payload); + # either way it is data. scripts/check_workflow_env_defined.sh enforces + # the env: side of that rule. + # + # WHY THIS STEP AND ITS TWO NEIGHBOURS ARE NOT IN guard-tree-steps (#4415): + # they read the pull_request payload (PR_NUMBER), which ci_guards.sh cannot + # supply to a local run, so they stay here. They still run all: both carry + # !cancelled(), the report is always(), and check_guard_steps_run_all.sh + # refuses any other fail-fast step in a guard section over the baseline. + # + # pull_request only: on merge_group there is no pull_request payload, and + # a body is fixed before a PR is ever enqueued. + - name: A PR body must close every issue it cites, or say why not (§6 R-2) + # `!cancelled()` and NOT a bare event check. Actions steps are fail-fast: + # measured on run 34875945193, step 8 went red and 48 of this job's 59 + # steps were SKIPPED -- including this one and its §11.1 neighbour. This + # guard exists because the 0.67.0 reconcile found 48 merged PRs of which + # only 9 closed anything; masked by an unrelated guard, it is dark exactly + # as it was before it had a caller. It reads a PR body and nothing else, + # so no guard result can be its precondition. (#3282 classifies the other + # 46, which is a judgment call per step rather than one blanket edit.) + if: "!cancelled() && github.event_name == 'pull_request'" + # #3298: the body is read LIVE from the API, not from the event payload. + # `github.event.pull_request.body` is frozen when the event is minted and + # `gh run rerun` replays that payload, so fixing the body per the guard's + # own FAIL message could never be re-graded by a rerun -- only by a push. + # Still data, never interpolated: gh writes it straight to the file. + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + setsid --wait bash scripts/check_pr_closes_issue.sh --self-test + gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + --jq '.body // ""' > "$RUNNER_TEMP/pr-body.txt" + setsid --wait bash scripts/check_pr_closes_issue.sh --body "$RUNNER_TEMP/pr-body.txt" + # APR-RELEASE-001 §11.1, WIRED. The section shipped as prose in #3268 and + # nothing read it: `grep -rl ont-delta scripts/ .github/ Makefile` returned + # NOTHING, while §11.1's own text says "Absent is a PR-body lint failure, + # not a review comment". A rule whose enforcement is "not a review comment" + # and whose only mechanism IS a review comment is this repo's anti-theater + # class, one level up from the guard it sits beside. + # + # Same body-handling rule as the step above: the body is fetched live by + # `gh api` into a file as DATA and is never interpolated into the script. + # It is attacker-controlled by construction. + # + # The changed-file list is the other input, and it is what makes the guard + # non-trivial: half the sweep surface ("a known-red list anywhere") is + # DERIVED from the tree, not typed. Measured on #3277, which touches + # scripts/cb200_baseline.txt and no prose sink at all -- a hand-typed sink + # list would have passed it. + - name: A sweep PR closes with an ontology delta, not a paragraph (§11.1) + # `!cancelled()` and NOT a bare event check. Actions steps are fail-fast: + # measured on run 34875945193, step 8 went red and 48 of this job's 59 + # steps were SKIPPED -- including this one and its §11.1 neighbour. This + # guard exists because the 0.67.0 reconcile found 48 merged PRs of which + # only 9 closed anything; masked by an unrelated guard, it is dark exactly + # as it was before it had a caller. It reads a PR body and nothing else, + # so no guard result can be its precondition. (#3282 classifies the other + # 46, which is a judgment call per step rather than one blanket edit.) + if: "!cancelled() && github.event_name == 'pull_request'" + # #3298: body and file list now come from the same place (the live API) + # at the same time. The body used to be the event SNAPSHOT while the file + # list was live, so a rerun after a body edit re-graded the OLD body. + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + --jq '.body // ""' > "$RUNNER_TEMP/ont-body.txt" + gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate \ + --jq '.[].filename' > "$RUNNER_TEMP/ont-files.txt" + setsid --wait bash scripts/check_pr_ont_delta.sh \ + --body "$RUNNER_TEMP/ont-body.txt" --changed "$RUNNER_TEMP/ont-files.txt" + # aprender#2822: THE REPORT. Runs even when the job dies mid-cargo, + # and takes its own final probe first -- without that, a failure at + # step K leaves the last recorded boundary BEFORE K and the table + # reads clean, which is the whole failure mode being investigated. + # + # It also pulls the host journal for the job's window into the job + # log. That is the decisive evidence and the only evidence that + # expires: `_work/*/target` is deleted by HOST automation (a sibling + # runner's job-started hook, a systemd timer) which logs under its own + # tag and appears in NO GitHub log. See + # scripts/runner-infra/runner-disk-guard.sh, whose own header records + # this exact signature. + # + # continue-on-error is deliberate and is NOT a disarmed gate: nothing + # here asserts anything. The gate that can fail is the case table + # wired above. + - name: "target-watch: report (aprender#2822)" + if: always() + continue-on-error: true + run: | + bash scripts/ci_target_watch.sh probe post-job + bash scripts/ci_target_watch.sh report + + guard-tree-steps: + # MANIFEST, never run by GitHub (`if: false`). These are guard-tree's guard steps, + # executed by the single `scripts/ci_guards.sh guard-tree` step of guard-tree above, and by + # `make guards-local` (#4415). They live here, verbatim, so every guard that + # reads this file for an invocation (check_guards_are_wired.sh, guard_tree.sh, + # the contracts' `test:` greps) still finds it. A step here is name/run/env + # only; scripts/check_guard_steps_run_all.sh refuses anything else. + if: false + runs-on: [self-hosted, Linux, clean-room] # arch-neutral: intel, yoga or gx10 (#3100) + steps: # aprender#2822. Case table FIRST, per the convention above: the probe # below is only worth its 0.15s if the classifier it feeds can still turn # RED. Seven rows over a throwaway tree - no cargo, no network, no host. @@ -1126,6 +1312,70 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: bash scripts/guard_tree.sh --no-cargo + # #3668: these three ran in guard-cargo, but none of them invokes cargo -- + # guard_tree.sh --no-cargo files them as cargo guards only because their + # SOURCE says `cargo doc` / `cargo install` in a comment or a pattern. They + # are the guard-cargo steps whose reads include docs/roadmaps/ or docs/audits/ + # (docs/**/*.md, a `git ls-files | grep dogfood.sh$`), so they live here: + # guard-cargo is skipped on a docs-only PR (the `changes` job) and guard-tree + # never is, so a docs-only diff is still judged by every guard that reads it. + # One runner, one prose, and the user-scope copy stays a <=70-line shim + # that fails CLOSED. The cap lives here, in the repo on protected main; + # the file it caps lives at user scope, where it could otherwise raise its + # own ceiling. + - name: One dogfood runner, and the user-scope copy is a gated shim + run: setsid --wait bash scripts/check_dogfood_shim.sh + # §4.1 names BOTH of these in the merge-phase blocking set, alongside the + # fabricated-baseline guard above. They shipped unwired, which is why the + # five `2.93x Ollama` claims in book/ and the four deleted harnesses were + # each caught by a person rather than by CI. + # + # Neither is baselined as an exemption, deliberately. The tip commit of + # this epic says PERF-009 is "enforced by a shrink-only guard"; a guard + # named by no workflow enforces nothing, and recording that in a baseline + # file would make the claim true only in the sense that the gap is + # written down. + # + # This step is ALSO the I-12 `[X]`-figure gate, which v2.2 §5 listed as + # "not written" against this file. It was right: the comparator pattern + # required the competitor's name to sit immediately after the ratio, in + # ASCII, so of the six shapes §0.1 names it caught one. PERF-010 admits + # the connector ("36.9x over FasterTransformer") and U+00D7, and adds + # placeholders bound to a unit ("XX tok/s"). Twelve pre-existing sites + # surfaced and are baselined shrink-only; one was a LIVE comparator claim + # in book/ and is deleted. + - name: No claim literals on user-facing surfaces + run: setsid --wait bash scripts/check_no_claim_literals.sh + - name: No claim-literal guard regression (case table) + run: setsid --wait bash scripts/check_no_claim_literals.sh --selftest + # PERF-010, the receipt rule's namesake check. The guard above is the + # NEGATIVE half -- a comparator ratio may not appear at all. This is the + # POSITIVE half over the class that is allowed to stay: a speed comparison + # naming no competitor is legal iff it cites the evidence/ receipt that + # produced it (v2.2 section 3.2). Text-only and deterministic, so it + # belongs in the merge phase (section 4.1) rather than at release. + # + # It found 146 uncited speed comparisons across 30 files and ZERO citing a + # receipt -- a 0% citation rate on the very number class this epic is + # named for. Baselined shrink-only; the count is the finding. + - name: Performance claims cite their receipts (PERF-010) + run: setsid --wait bash scripts/check_perf_claims_cite_receipts.sh + - name: Claims-cite-receipts guard can still go RED (case table) + run: setsid --wait bash scripts/check_perf_claims_cite_receipts.sh --selftest + # The gate's two skip rules, executed from ci.yml's own marker blocks. Neither + # was run by any step: guard_tree.sh files both as cargo guards (their text + # names guard-cargo) and no workflow dispatches --cargo-only, so the mutants + # rule (#3676) passed check_guards_are_wired.sh on a comment mention alone. + # They read only ci.yml and invoke no build tool, so they run here -- the job + # that runs on every PR, including the one that edits the gate (#3668). + - name: gate accepts a skipped mutants job only when that is its filter (#3676) + run: setsid --wait bash scripts/check_ci_gate_mutants_rule.sh + - name: gate mutants rule can still go RED (#3676) + run: setsid --wait bash scripts/check_ci_gate_mutants_rule.sh --self-test + - name: gate accepts a skipped guard-cargo/determinism only on a docs-only PR (#3668) + run: setsid --wait bash scripts/check_ci_gate_docs_only_rule.sh + - name: gate docs-only rule can still go RED (#3668) + run: setsid --wait bash scripts/check_ci_gate_docs_only_rule.sh --self-test # scripts/tests/guard_tree_test.sh is guard_tree.sh's OWN case table # (BSE-01, PMAT-1062) -- it proves guard_tree.sh's no-fail-fast # behaviour against a fixture, proves a fail-fast MUTANT of it goes RED @@ -1411,6 +1661,13 @@ jobs: run: setsid --wait bash scripts/check_pr_review_wiring.sh --self-test - name: The PR-review receipt guard is wired job-level, not path-filtered run: setsid --wait bash scripts/check_pr_review_wiring.sh + # #4441 passed FAT_SECRET_PR_REVIEW_SIGNING_KEY_B (no "64"), so pr-review-sign + # read an empty key and Arm 4 went RED on every new PR. A section's + # secrets.NAME reaches it only through ci.yml's FAT_SECRET_NAME. Case table first. + - name: "Fat-job secret pass-through case table (6 rows)" + run: setsid --wait bash scripts/check_fat_secrets_passed.sh --self-test + - name: Every secret a section reads is passed to the fat job under its own name + run: setsid --wait bash scripts/check_fat_secrets_passed.sh # PRQ-013 (PP-066 C0-5, #2982): the job that judges a PR's OWN receipt must be # defined by the base — a workflow on pull_request_target + merge_group, never # pull_request — and `gate` must not wait on a head-defined receipt job. Case @@ -1429,98 +1686,10 @@ jobs: # HARNESS-BROKEN rather than as a dead mutant. Text-only, no build. - name: "Receipt-wiring guard: every rule it states, mutated (15/15 kill)" run: bash scripts/mutate_pr_review_wiring_guard.sh - # APR-RELEASE-001 §6 R-2, WIRED (#3200 shipped the guard; this is its - # caller). This step is its ONLY runner: guard_tree.sh skips it as - # wired-with-args (it needs a body), so its case table ran in NO job until - # this step called `--self-test` beside `--body` (found by aprender-f0, - # 2026-09-21; the comment here used to claim guard_tree ran it bare). The - # 0.67.0 reconcile found 48 merged PRs since the previous tag of which only - # 9 closed anything; that is what this guard exists to stop. - # - # THE BODY ARRIVES THROUGH `env:`, NEVER INTERPOLATED INTO THE SCRIPT. - # `${{ }}` is substituted textually BEFORE bash sees the line, so a body - # containing a backtick or $(...) would execute on the runner -- and this - # body is attacker-controlled by construction. `env:` passes it as data. - # scripts/check_workflow_env_defined.sh enforces the same rule. - # - # pull_request only: on merge_group there is no pull_request payload, and - # a body is fixed before a PR is ever enqueued. - - name: A PR body must close every issue it cites, or say why not (§6 R-2) - # `!cancelled()` and NOT a bare event check. Actions steps are fail-fast: - # measured on run 34875945193, step 8 went red and 48 of this job's 59 - # steps were SKIPPED -- including this one and its §11.1 neighbour. This - # guard exists because the 0.67.0 reconcile found 48 merged PRs of which - # only 9 closed anything; masked by an unrelated guard, it is dark exactly - # as it was before it had a caller. It reads a PR body and nothing else, - # so no guard result can be its precondition. (#3282 classifies the other - # 46, which is a judgment call per step rather than one blanket edit.) - if: "!cancelled() && github.event_name == 'pull_request'" - env: - PR_BODY: ${{ github.event.pull_request.body }} + - name: "Guard jobs run all: no fail-fast step over baseline, every guard script reachable (#4415)" run: | - set -euo pipefail - setsid --wait bash scripts/check_pr_closes_issue.sh --self-test - printf '%s' "$PR_BODY" > "$RUNNER_TEMP/pr-body.txt" - setsid --wait bash scripts/check_pr_closes_issue.sh --body "$RUNNER_TEMP/pr-body.txt" - # APR-RELEASE-001 §11.1, WIRED. The section shipped as prose in #3268 and - # nothing read it: `grep -rl ont-delta scripts/ .github/ Makefile` returned - # NOTHING, while §11.1's own text says "Absent is a PR-body lint failure, - # not a review comment". A rule whose enforcement is "not a review comment" - # and whose only mechanism IS a review comment is this repo's anti-theater - # class, one level up from the guard it sits beside. - # - # Same body-handling rule as the step above: the body arrives through - # `env:` as DATA and is never interpolated into the script. It is - # attacker-controlled by construction. - # - # The changed-file list is the other input, and it is what makes the guard - # non-trivial: half the sweep surface ("a known-red list anywhere") is - # DERIVED from the tree, not typed. Measured on #3277, which touches - # scripts/cb200_baseline.txt and no prose sink at all -- a hand-typed sink - # list would have passed it. - - name: A sweep PR closes with an ontology delta, not a paragraph (§11.1) - # `!cancelled()` and NOT a bare event check. Actions steps are fail-fast: - # measured on run 34875945193, step 8 went red and 48 of this job's 59 - # steps were SKIPPED -- including this one and its §11.1 neighbour. This - # guard exists because the 0.67.0 reconcile found 48 merged PRs of which - # only 9 closed anything; masked by an unrelated guard, it is dark exactly - # as it was before it had a caller. It reads a PR body and nothing else, - # so no guard result can be its precondition. (#3282 classifies the other - # 46, which is a judgment call per step rather than one blanket edit.) - if: "!cancelled() && github.event_name == 'pull_request'" - env: - PR_BODY: ${{ github.event.pull_request.body }} - PR_NUMBER: ${{ github.event.pull_request.number }} - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - printf '%s' "$PR_BODY" > "$RUNNER_TEMP/ont-body.txt" - gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate \ - --jq '.[].filename' > "$RUNNER_TEMP/ont-files.txt" - setsid --wait bash scripts/check_pr_ont_delta.sh \ - --body "$RUNNER_TEMP/ont-body.txt" --changed "$RUNNER_TEMP/ont-files.txt" - # aprender#2822: THE REPORT. Runs even when the job dies mid-cargo, - # and takes its own final probe first -- without that, a failure at - # step K leaves the last recorded boundary BEFORE K and the table - # reads clean, which is the whole failure mode being investigated. - # - # It also pulls the host journal for the job's window into the job - # log. That is the decisive evidence and the only evidence that - # expires: `_work/*/target` is deleted by HOST automation (a sibling - # runner's job-started hook, a systemd timer) which logs under its own - # tag and appears in NO GitHub log. See - # scripts/runner-infra/runner-disk-guard.sh, whose own header records - # this exact signature. - # - # continue-on-error is deliberate and is NOT a disarmed gate: nothing - # here asserts anything. The gate that can fail is the case table - # wired above. - - name: "target-watch: report (aprender#2822)" - if: always() - continue-on-error: true - run: | - bash scripts/ci_target_watch.sh probe post-job - bash scripts/ci_target_watch.sh report + setsid --wait bash scripts/check_guard_steps_run_all.sh --self-test + setsid --wait bash scripts/check_guard_steps_run_all.sh # ── THE CARGO-USING GUARDS, PARALLEL TO workspace-test ──────────────────── # @@ -1542,6 +1711,11 @@ jobs: # minutes onto every green run for no ordering that anything requires. guard-cargo: runs-on: [self-hosted, Linux, clean-room] # arch-neutral: intel, yoga or gx10 (#3100) + # #3668: skipped on a docs-only PR -- no step here reads docs/roadmaps/ or + # docs/audits/ (the three that did moved to guard-tree). Fail-closed: a + # skipped/failed `changes` job leaves docs_only empty, and this runs. + needs: [changes] + if: ${{ !cancelled() && needs.changes.outputs.docs_only != 'true' }} # 20: the operator's hard PR budget (rule E, 2026-09-10), and it OVERRIDES # the BSE-05 formula rather than being derived from it. # @@ -1637,7 +1811,8 @@ jobs: # .package-cache-mutate) are created by cargo itself INSIDE this same # directory — which is the entire reason the mount is $CARGO_HOME and # not $CARGO_HOME/registry. - - name: Create this job's own target dir and CARGO_HOME (before any bind mount) + - id: guard-setup + name: Create this job's own target dir and CARGO_HOME (before any bind mount) run: | # A root-owned parent (created by the docker daemon for another job's # bind mount, #3039) is chowned through the image before the mkdir; @@ -1661,10 +1836,42 @@ jobs: done mkdir -p "$path" } + # infra#1059: admit this target unit against gx10's disk budget BEFORE creating it + # (free >= 50G + 25G x (live+1), live < 2; waits <=20m, then exit 1). The admit + # record is what the runner's JOB_COMPLETED hook reaps, and nothing else. Hosts + # without the gate skip; gx10 exports CI_TARGET_ADMIT_REQUIRED=1, so a missing + # gate there is red, never a silent skip. + if command -v ci-target-admit >/dev/null 2>&1; then + CI_TARGET_ADMIT_WAIT_S=300 ci-target-admit "$GUARD_TARGET_DIR" || exit 1 # 5 min, not 20: the 30 min job budget is rule E and is never widened; gx10 guard-cargo max 23 min (46 runs, 2026-09-24) + elif [ "${CI_TARGET_ADMIT_REQUIRED:-0}" = 1 ]; then + echo "::error::infra#1059: ci-target-admit is required on ${RUNNER_NAME:-this runner} and is missing"; exit 1 + fi heal_path /mnt/nvme-raid0/targets/aprender-ci "$GUARD_TARGET_DIR" heal_path /mnt/nvme-raid0/cargo-ci/home "$GUARD_CARGO_HOME/registry" printf 'target dir: %s\n' "$GUARD_TARGET_DIR" printf 'cargo home: %s (registry/ .package-cache .package-cache-mutate travel together)\n' "$GUARD_CARGO_HOME" + # #4415 (operator A4, 2026-09-25): ONE step runs every guard step of guard-cargo-steps + # below, keeps going past a red one, and reports every failure. GitHub would + # stop this job at its first red step, and the others would never be seen. + # `make guards-local` and the pre-push hook run this same script and print + # the same `ci_guards: sha256` line. + - name: Every guard step runs, and every failure is reported (scripts/ci_guards.sh, #4415) + if: ${{ !cancelled() && steps.guard-setup.outcome == 'success' }} + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + run: bash scripts/ci_guards.sh guard-cargo + + guard-cargo-steps: + # MANIFEST, never run by GitHub (`if: false`). These are guard-cargo's guard steps, + # executed by the single `scripts/ci_guards.sh guard-cargo` step of guard-cargo above, and by + # `make guards-local` (#4415). They live here, verbatim, so every guard that + # reads this file for an invocation (check_guards_are_wired.sh, guard_tree.sh, + # the contracts' `test:` greps) still finds it. A step here is name/run/env + # only; scripts/check_guard_steps_run_all.sh refuses anything else. + if: false + runs-on: [self-hosted, Linux, clean-room] # arch-neutral: intel, yoga or gx10 (#3100) + steps: # Poka-yoke: a beat that no workflow executes reads as enforcement, is # counted as enforcement, and proves nothing. The Pillar-4 marquee decode # beat sat in ZERO workflows while being quoted as an enforced win (#2319). @@ -1712,12 +1919,6 @@ jobs: # It found `pmat analyze reachability` bare in BOTH copies on its first run. - name: The release runner must not resolve a verifier through PATH run: setsid --wait bash scripts/check_verifier_pinning.sh - # One runner, one prose, and the user-scope copy stays a <=70-line shim - # that fails CLOSED. The cap lives here, in the repo on protected main; - # the file it caps lives at user scope, where it could otherwise raise its - # own ceiling. - - name: One dogfood runner, and the user-scope copy is a gated shim - run: setsid --wait bash scripts/check_dogfood_shim.sh # deny.toml is 7.2 KB of licence / banned-crate / source-allowlist policy # that ran in NO workflow -- only `make deny`, which is not a prerequisite # of any tier, so nothing but a human typing it ever invoked it. Meanwhile @@ -1974,6 +2175,17 @@ jobs: # same binary. Nothing extra is compiled by placing it here. - name: pv_bin.sh must resolve the HEAD-built pv and refuse a stale one run: setsid --wait bash scripts/check_pv_bin_resolution.sh + # #4475: the contract gate, blocking. `make contracts` stopped after its first step (an + # unconditional `exit` under .ONESHELL) and nothing on a PR ran `extract --check`, so a contract + # could land without its census / contracts.nt / shapes.ttl regenerated. The shapes step fails + # CLOSED here with the HEAD pv: a decline (pv exit 2: NoShapes / NoFocus), an Unknown verdict, + # shapes_n == 0, focus_nodes_n == 0 or a silent planted control is RED, unlike the fleet-pin + # andon (check_fleet_pv_shapes_advisory.sh), which reports fleet state and is not this gate. + # The self-test runs first so a gate that can no longer refuse is named as such. + - name: contracts gate refuses what it cannot measure (self-test + mutant) + run: setsid --wait bash scripts/contracts_gate.sh --self-test + - name: contracts gate — lint, fail-closed shapes, regenerate + git diff, README, provenance + run: setsid --wait bash scripts/contracts_gate.sh - name: Every contract cites a test that exists (strict-test-binding) run: setsid --wait bash scripts/check_contract_test_binding.sh # Poka-yoke (#2551): `cargo test -p aprender-contracts --test @@ -2048,43 +2260,6 @@ jobs: # ollama never invoked (#2679). - name: No fabricated comparator baselines run: setsid --wait bash scripts/check_no_fabricated_baselines.sh - # §4.1 names BOTH of these in the merge-phase blocking set, alongside the - # fabricated-baseline guard above. They shipped unwired, which is why the - # five `2.93x Ollama` claims in book/ and the four deleted harnesses were - # each caught by a person rather than by CI. - # - # Neither is baselined as an exemption, deliberately. The tip commit of - # this epic says PERF-009 is "enforced by a shrink-only guard"; a guard - # named by no workflow enforces nothing, and recording that in a baseline - # file would make the claim true only in the sense that the gap is - # written down. - # - # This step is ALSO the I-12 `[X]`-figure gate, which v2.2 §5 listed as - # "not written" against this file. It was right: the comparator pattern - # required the competitor's name to sit immediately after the ratio, in - # ASCII, so of the six shapes §0.1 names it caught one. PERF-010 admits - # the connector ("36.9x over FasterTransformer") and U+00D7, and adds - # placeholders bound to a unit ("XX tok/s"). Twelve pre-existing sites - # surfaced and are baselined shrink-only; one was a LIVE comparator claim - # in book/ and is deleted. - - name: No claim literals on user-facing surfaces - run: setsid --wait bash scripts/check_no_claim_literals.sh - - name: No claim-literal guard regression (case table) - run: setsid --wait bash scripts/check_no_claim_literals.sh --selftest - # PERF-010, the receipt rule's namesake check. The guard above is the - # NEGATIVE half -- a comparator ratio may not appear at all. This is the - # POSITIVE half over the class that is allowed to stay: a speed comparison - # naming no competitor is legal iff it cites the evidence/ receipt that - # produced it (v2.2 section 3.2). Text-only and deterministic, so it - # belongs in the merge phase (section 4.1) rather than at release. - # - # It found 146 uncited speed comparisons across 30 files and ZERO citing a - # receipt -- a 0% citation rate on the very number class this epic is - # named for. Baselined shrink-only; the count is the finding. - - name: Performance claims cite their receipts (PERF-010) - run: setsid --wait bash scripts/check_perf_claims_cite_receipts.sh - - name: Claims-cite-receipts guard can still go RED (case table) - run: setsid --wait bash scripts/check_perf_claims_cite_receipts.sh --selftest # The single source of truth needs a truth check. An authorless policy # number, a `inherited_from` pointing at a document with no history in any # ref, a compute_class no build in this tree reaches, and a cell exception @@ -2863,6 +3038,7 @@ jobs: outputs: gpu_touched: ${{ steps.decide.outputs.gpu_touched }} gpu_crates: ${{ steps.decide.outputs.gpu_crates }} + cuda_lint: ${{ steps.decide.outputs.cuda_lint }} steps: - uses: actions/checkout@v7 with: @@ -2893,7 +3069,7 @@ jobs: bash scripts/ci_gpu_touched.sh --diff-from "$RUNNER_TEMP/touched.txt" \ > "$RUNNER_TEMP/gpu.txt" cat "$RUNNER_TEMP/gpu.txt" - grep -E '^(gpu_touched|gpu_crates|reason)=' "$RUNNER_TEMP/gpu.txt" >> "$GITHUB_OUTPUT" + grep -E '^(gpu_touched|gpu_crates|cuda_lint|reason)=' "$RUNNER_TEMP/gpu.txt" >> "$GITHUB_OUTPUT" # ROW 67-C1 — `gpu-quick` on gx10 (GB10, sm_121, aarch64). # Promotion to required: row 68-C3, after 5 consecutive green gx10 nightlies. @@ -2989,14 +3165,20 @@ jobs: # (the yoga nightly axis is #3060 / YOGA-NIGHTLY-001). cuda-unit: needs: [gpu-touched] - if: github.event_name == 'pull_request' && needs.gpu-touched.outputs.gpu_touched == '1' + # gpu_touched=1 runs every step. cuda_lint=1 alone (an apr-cli-only diff, + # #4336) runs ONLY the clippy step: the test steps carry their own + # gpu_touched `if:`, so yoga is held for a check build, not the test builds. + if: github.event_name == 'pull_request' && (needs.gpu-touched.outputs.gpu_touched == '1' || needs.gpu-touched.outputs.cuda_lint == '1') runs-on: [self-hosted, Linux, X64, cuda, yoga, ephemeral, docker] # 15 -> 35 (#3810): the derived cuda-only set needs a second, default-feature # test build of aprender-serve to list against. MEASURED on this PR's own run # (job 107295428985, yoga-eph, sharing yoga with a coverage job): the step # took 18 m 20 s and the job 21 m 18 s (tests alone 51 s). 25 left 3.7 min # of margin on a shared runner, a future flaky timeout, so 35. - timeout-minutes: 35 + # 35 -> 50 (#3636): the clippy step is a check-profile build of the cuda tree, + # separate from the release test builds. Not yet measured on yoga; 15 min is a + # ceiling, not an estimate. Re-derive from the first run and tighten. + timeout-minutes: 50 continue-on-error: false concurrency: group: perf-yoga @@ -3035,6 +3217,7 @@ jobs: # (operator, 2026-09-09), not a training host. Nothing to yield to. The # `perf-yoga` concurrency group above is what keeps two runs off it. - name: aprender-gpu cuda unit tests (--features cuda --lib --release) + if: needs.gpu-touched.outputs.gpu_touched == '1' run: | nice -n 19 cargo test -p aprender-gpu --features cuda --lib --release # THE SERVE CUDA SET IS DERIVED, NOT FILTERED (#3810). The step used to filter @@ -3070,6 +3253,7 @@ jobs: # counts as a pass. The grep is UNANCHORED, because under --nocapture the # message lands on the `test X ... ` line. - name: "aprender-serve cuda unit tests (the derived cuda-only set, by name)" + if: needs.gpu-touched.outputs.gpu_touched == '1' run: | set -euo pipefail export LC_ALL=C @@ -3119,6 +3303,19 @@ jobs: echo "$summary" printf 'cuda-only: %s passed + %s ignored = %s derived, 0 device skips, %s model-absent skip line(s)\n' \ "$passed" "$ignored" "$n_only" "${model_skips:-0}" + # #3636: `clippy --features cuda` was never run by any gate, and 62 findings + # piled up in cuda-gated code (compute, train, serve) before anyone looked. + # This is the only job with nvcc, so the lint lives here. The script runs its + # own planted-mutant self-test first: an unused import under + # #[cfg(feature = "cuda")] must turn it RED, or the gate is refused as vacuous. + # No step `if:`: this step runs whenever the job does, including on an + # apr-cli-only diff (cuda_lint=1, gpu_touched=0) — apr-cli has 30 files of + # #[cfg(feature = "cuda")] code that only this lint builds (#4336). + - name: "clippy --features cuda is clean (#3636)" + run: | + set -euo pipefail + setsid --wait bash scripts/check_clippy_cuda.sh --self-test + nice -n 19 setsid --wait bash scripts/check_clippy_cuda.sh # APEX-001 EV-2a: the same figure, rendered on two architectures, must be the same bytes. # @@ -3132,6 +3329,10 @@ jobs: # font, a locale or a cached artefact from a previous job and quietly agree for the wrong reason. determinism: name: determinism (${{ matrix.arch }}) + # #3668 does NOT skip this section under #4433's fat jobs: its raster is uploaded + # by the fat job's own step with if-no-files-found: error (x86-main for X64, the + # determinism job for ARM64), so an unrendered raster would fail the fat job on + # every docs-only PR. It always runs; gate's docs-only rule then reads success. strategy: fail-fast: false # if one arch fails we still want the other's receipt to compare against matrix: @@ -3269,6 +3470,135 @@ jobs: path: target/aprender-viz/raster-receipt.json if-no-files-found: error + # ── PVL-001 EV-9 (#4083): the provable ladder, ADVISORY ──────────────────── + # + # `pv discharge run` over the Lean tree: build.sh (Mathlib cache get, and a cache + # MISS declines instead of silently building Mathlib), then `check` with every arm + # (--strict, the comparator, --leanchecker), then the tracked discharge-summary.json. + # The committed summary must be what the run writes (summary-fresh: the tree carries + # the summary and CI regenerates it and diffs it; a gate never rewrites its own + # baseline, PVL-001 §5.3). + # + # ADVISORY: NOT in `gate`'s `needs:` (cop ruling, 2026-09-24). Measured at 4a870daf5 on + # lambda (4m21s, 1.4 GB RSS), the ladder is RED: 7 escape-allowlist.yaml entries are still + # `confirmed_by: pending`, and only a person may confirm them (§5.3). Flip to required -- + # add it to `gate.needs` and read its result there -- only when that is clear. + # + # The clean-room X64 pool is intel-clean-room-* plus yoga-build, yoga-build2 and yoga-build3, + # and infra-8d verified elan and a writable /mnt/nvme-raid0/lean-cache on every one of them + # (2026-09-25). A host added to the pool without them DECLINES here, never builds cold. + # X64 is mandatory: `ci / lint` once landed on aarch64 gx10 and failed on x86-only code. + # 90 min: under the merge queue's 180 min. + # build.sh is ~6 min on a warm cache, and the leanchecker arm's own cap is 3600 s. + provable-ladder: + runs-on: [self-hosted, Linux, clean-room, X64] + timeout-minutes: 90 + # #4433: a section in x86-main. ADVISORY there too: fat_driver's verdict skips a + # continue-on-error section, so a RED ladder does not turn the x86-main job red. + continue-on-error: true + env: + LEAN: crates/aprender-contracts-staging/lean + SUMMARY: crates/aprender-contracts-staging/discharge-summary.json + LEAN_CACHE_ROOT: /mnt/nvme-raid0/lean-cache + CARGO_TARGET_DIR: /mnt/nvme-raid0/targets/aprender-ci/${{ github.event.pull_request.number || github.ref_name }}/provable-ladder + PV: /mnt/nvme-raid0/targets/aprender-ci/${{ github.event.pull_request.number || github.ref_name }}/provable-ladder/debug/pv + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: Preconditions -- elan, the lean cache, and the files the ladder judges + id: pre + # A missing precondition DECLINES (rc 2); it is never a silent cold build. The toolchain + # is read from lean-toolchain, so this workflow never names a Lean version. The .lake dir + # lives in the cache, keyed on lean-toolchain + lake-manifest.json. + shell: bash + run: | + set -euo pipefail + decline() { echo "decline: $1" | tee -a "$GITHUB_STEP_SUMMARY"; exit 2; } + # A file the ladder judges that this TREE does not carry is a SKIP, not a host defect: + # no ladder to run, so no verdict either way (#4429: build.sh and discharge-summary.json + # have never been committed). skip=true turns build + discharge off; summary-fresh and + # pv lint key on their outcomes and skip with them. Host defects below still DECLINE. + skip() { echo "SKIP (no verdict, not a pass): $1" | tee -a "$GITHUB_STEP_SUMMARY"; echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; } + for f in "$LEAN/lean-toolchain" "$LEAN/lake-manifest.json" "$LEAN/build.sh"; do + [ -f "$f" ] || skip "$f is not in this tree" + done + # summary-fresh diffs a TRACKED file: `git diff` exits 0 on an untracked path. + git ls-files --error-unmatch -- "$SUMMARY" >/dev/null 2>&1 || skip "$SUMMARY is not tracked" + # elan's own default, from the runner user's $HOME: no machine-specific literal. + ELAN_HOME="${ELAN_HOME:-$HOME/.elan}" + echo "ELAN_HOME=$ELAN_HOME" >> "$GITHUB_ENV" + export PATH="$ELAN_HOME/bin:$PATH" + command -v elan >/dev/null || decline "no elan in $ELAN_HOME on $(hostname) (proof-toolchain, infra)" + [ -d "$LEAN_CACHE_ROOT" ] || decline "no $LEAN_CACHE_ROOT on $(hostname) (proof-toolchain, infra)" + key=$(cat "$LEAN/lean-toolchain" "$LEAN/lake-manifest.json" | sha256sum | cut -c1-16) + cache=$LEAN_CACHE_ROOT/$key + mkdir -p "$cache" + flock "$cache.lock" elan toolchain install "$(cat "$LEAN/lean-toolchain")" + # A real .lake dir would make `ln` write the link INSIDE it instead of replacing it. + [ ! -e "$LEAN/.lake" ] || [ -L "$LEAN/.lake" ] || decline "$LEAN/.lake is a real directory, not the cache link" + ln -sfn "$cache" "$LEAN/.lake" + echo "$ELAN_HOME/bin" >> "$GITHUB_PATH" + echo "cache=$cache" >> "$GITHUB_OUTPUT" + + - name: Build the in-tree pv + id: build + if: ${{ steps.pre.outputs.skip != 'true' }} + shell: bash + run: | + set -euo pipefail + cargo build --locked -p aprender-contracts-cli --bin pv + test -x "$PV" + + - name: pv discharge run (build.sh, then check --strict + comparator + leanchecker) + id: discharge + if: ${{ steps.pre.outputs.skip != 'true' }} + shell: bash + # One run at a time per cache: two runs unpacking into the same .lake would race. + # set +e: Actions runs `bash -e`, which would abort at a failing run before its rc and + # its log tail are recorded. + run: | + set +e + flock "${{ steps.pre.outputs.cache }}.lock" "$PV" discharge run "$LEAN" > "$RUNNER_TEMP/discharge-run.log" 2>&1 + rc=$? + tail -n 40 "$RUNNER_TEMP/discharge-run.log" + echo "pv discharge run: rc=$rc" >> "$GITHUB_STEP_SUMMARY" + exit "$rc" + + - name: summary-fresh -- the committed discharge-summary.json is what the run wrote + # Only when the run RAN (it writes the summary on failure too). Skipped means nothing + # was regenerated, and a diff of an unregenerated file would pass vacuously. + if: ${{ !cancelled() && (steps.discharge.outcome == 'success' || steps.discharge.outcome == 'failure') }} + shell: bash + run: git diff --exit-code -- "$SUMMARY" + + - name: pv lint contracts/ + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + shell: bash + run: '"$PV" lint contracts/' + + - name: Upload the discharge logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: provable-ladder + path: | + ${{ runner.temp }}/discharge-run.log + crates/aprender-contracts-staging/lean/discharge.json + crates/aprender-contracts-staging/discharge-summary.json + if-no-files-found: warn + + - name: Advisory verdict + if: always() + shell: bash + run: | + { + echo "### provable-ladder (PVL-001 EV-9, #4083): ADVISORY, not in gate.needs" + echo "Job status: ${{ job.status }}. It becomes required only when this blocker is clear:" + echo "1. escape-allowlist.yaml: 7 entries are \`confirmed_by: pending\`, and a person must confirm them or delete the axioms (PVL-001 §5.3)" + } >> "$GITHUB_STEP_SUMMARY" + # Mutation testing — DIFF-SCOPED + BLOCKING on PRs (PMAT build-system audit gap #1). # # BEFORE: full-tree `cargo mutants -- --lib`, push-to-main only, and @@ -3328,8 +3658,20 @@ jobs: # cargo-mutants --in-diff takes a unified diff and mutates ONLY the # lines it adds/changes. We diff the PR head against the merge-base with # the target branch so the scope is exactly "what this PR introduces". + env: + HEAD_REF: ${{ github.head_ref }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + BASE_REPO: ${{ github.repository }} run: | set -euo pipefail + # Rule 0 (operator 2026-09-27, verbatim "exempt the car"): a car/* PR of this repo is judged by + # mutants-nightly (uncapped) after it lands; every other PR keeps MUTANTS_CAP. Case table: + # scripts/mutants_diff_gate.sh --self-test (car, non-car, nested, fork rows). + if bash scripts/mutants_diff_gate.sh --exempt-ref "$HEAD_REF" "$HEAD_REPO" "$BASE_REPO"; then + echo "has_rs=false" >> "$GITHUB_OUTPUT" + : > pr.diff + exit 0 + fi BASE_REF="${{ github.event.pull_request.base.ref }}" # NOT --depth=1: a shallow base commit has no shared ancestor with the # fetch-depth:0 PR head, so `git merge-base` finds none and (under diff --git a/contracts/alibi-slopes-v1.yaml b/contracts/alibi-slopes-v1.yaml index 688538bf36..816a2e47e6 100644 --- a/contracts/alibi-slopes-v1.yaml +++ b/contracts/alibi-slopes-v1.yaml @@ -55,7 +55,7 @@ proof_obligations: mathlib_imports: - Mathlib.Analysis.SpecialFunctions.Pow.Real notes: 'Exponent -8(h+1)/n < 0 for h >= 0, n > 0; base 2 > 1 so 2^(neg) < 1. - Companion alibi_slope_pos proves 0 < m[h] (strictly positive geometric sequence).' + Companion alibi_slope_pos_real proves 0 < m[h] (strictly positive geometric sequence).' - id: AS-EQ-003 type: equivalence property: Matches ggml reference exponent @@ -89,7 +89,7 @@ verification_summary: theorem: Alibi.alibi_slope_lt_one file: ProvableContracts/Theorems/Alibi/Slopes.lean note: 'exponent -8(h+1)/n < 0 for h>=0, n>0, and base 2>1, so 2^(neg) < 1 - (Real.rpow_lt_one_of_one_lt_of_neg). alibi_slope_pos additionally proves 0 < m[h].' + (Real.rpow_lt_one_of_one_lt_of_neg). alibi_slope_pos_real additionally proves 0 < m[h].' - obligation: Matches ggml reference exponent theorem: Alibi.alibi_slope_ggml file: ProvableContracts/Theorems/Alibi/Slopes.lean @@ -97,7 +97,7 @@ verification_summary: matching llama.cpp soft_max_ext.' notes: 'All 3 obligations proved sorry-free in Lean 4 over the reals (Mathlib Real.rpow). Zero N/A — the contract is fully analytic. Slopes are a positive, - strictly-decreasing geometric sequence: alibi_slope_pos (m[h]>0) and + strictly-decreasing geometric sequence: alibi_slope_pos_real (m[h]>0) and alibi_slope_strict_anti (m[0]>m[1]>...>m[n-1]) additionally proved. Verified via lake env lean ProvableContracts/Theorems/Alibi/Slopes.lean (exit 0, 0 sorry).' kernel_structure: diff --git a/contracts/apr-cli-commands-v1.yaml b/contracts/apr-cli-commands-v1.yaml index 8e94cce4f5..82dde985a1 100644 --- a/contracts/apr-cli-commands-v1.yaml +++ b/contracts/apr-cli-commands-v1.yaml @@ -387,6 +387,13 @@ commands: requires_model: false side_effects: [] + - name: ptx-debug + category: hardware + description: "PTX falsification analysis and FKR test generation (was the aprender-ptx-debug binary)" + requires_model: false + side_effects: [filesystem] + subcommands: [analyze, gen-fkr, version] + - name: ptx-map category: hardware description: "Model-to-PTX source mapping" @@ -788,6 +795,14 @@ commands: side_effects: [filesystem] subcommands: [audit, book, census, certify, check-parity, codegen, coq, coverage, diff, equations, explain, extract, extract-pytorch, flux, fuzz, generate, graph, infer, invariants, kaizen, kani, lean, lean-status, lint, migrate, mirai, pipeline, probar, proof-status, query, roofline, scaffold, score, status, tla, unlock, validate, verify-bindings, verify-pipeline, verify-structure] + # Hidden (#[command(hide = true)]): a gate input, not a user command, so it is + # absent from `apr --help` and from cli_commands.rs registered_commands(). + - name: surface + category: tools + description: "Print apr's own command surface as JSON (apr-cli-surface/v1, #3745); hidden gate input" + requires_model: false + side_effects: [stdio] + # ── Falsification Conditions ── falsification: diff --git a/contracts/apr-cli-publish-v1.yaml b/contracts/apr-cli-publish-v1.yaml index 286aeb97d4..b0275886db 100644 --- a/contracts/apr-cli-publish-v1.yaml +++ b/contracts/apr-cli-publish-v1.yaml @@ -74,7 +74,7 @@ falsification_tests: rule: "publishing goes only through scripts/check_publish_preflight.sh, and the cascade carries no --allow-dirty (F-9)" prediction: "check_publish_preflight.sh --selftest drives R1, R3, R4 and R5 to both verdicts (16 rows); cascade-publish.sh calls the gate before its first upload and its cargo publish line has no --allow-dirty" test: "bash scripts/check_publish_preflight.sh --selftest && grep -q 'scripts/check_publish_preflight.sh' scripts/cascade-publish.sh && ! grep -q -- '--allow-dirty' scripts/cascade-publish.sh" - if_fails: "a dirty, untagged, off-main or NO-GO tree can be uploaded to an immutable registry" + if_fails: "a dirty, untagged, off-release-branch or NO-GO tree can be uploaded to an immutable registry" proof_obligations: - id: ACP-INV-001 diff --git a/contracts/apr-dogfood-coverage-v1.yaml b/contracts/apr-dogfood-coverage-v1.yaml index 075c5bfa95..3797bd7ed8 100644 --- a/contracts/apr-dogfood-coverage-v1.yaml +++ b/contracts/apr-dogfood-coverage-v1.yaml @@ -87,29 +87,29 @@ metadata: aprender-orchestrate: {total: 185, covered: 0} aprender-test-cli: {total: 40, covered: 0} pv: {total: 38, covered: 0} - alimentar: {total: 36, covered: 0} + aprender-data: {total: 36, covered: 0} aprender-cgp: {total: 26, covered: 0} aprender-profile: {total: 17, covered: 0} apr-qa: {total: 16, covered: 0} + aprender-rag: {total: 14, covered: 0} aprender-train-shell: {total: 14, covered: 0} - trueno-rag: {total: 14, covered: 0} - simular: {total: 13, covered: 0} - verificar: {total: 12, covered: 0} + aprender-simulate: {total: 13, covered: 0} + aprender-verify: {total: 12, covered: 0} aprender-explain: {total: 8, covered: 0} - presentar: {total: 8, covered: 0} + aprender-present: {total: 8, covered: 0} aprender-train-bench: {total: 7, covered: 0} aprender-train-inspect: {total: 7, covered: 0} aprender-cbtop: {total: 5, covered: 0} aprender-db: {total: 5, covered: 0} + aprender-ptop: {total: 5, covered: 0} aprender-train-distill: {total: 5, covered: 0} aprender-train-lora: {total: 5, covered: 0} - ptop: {total: 5, covered: 0} - trueno-zram: {total: 5, covered: 0} + aprender-zram: {total: 5, covered: 0} aprender-compute-xtask: {total: 4, covered: 0} aprender-ptx-debug: {total: 4, covered: 0} - apr-corpus-ingest: {total: 3, covered: 0} - score: {total: 3, covered: 0} - apr-qa-readme-sync: {total: 2, covered: 0} + aprender-corpus-ingest: {total: 3, covered: 0} + aprender-score: {total: 3, covered: 0} + aprender-qa-readme-sync: {total: 2, covered: 0} aprender-zram-generator: {total: 2, covered: 0} per_cluster: diff --git a/contracts/apr-mono-binary-rule-v1.yaml b/contracts/apr-mono-binary-rule-v1.yaml index 0c25bbac95..26a900d8ae 100644 --- a/contracts/apr-mono-binary-rule-v1.yaml +++ b/contracts/apr-mono-binary-rule-v1.yaml @@ -1,7 +1,7 @@ metadata: - version: "2.0.0" + version: "2.1.0" created: "2026-04-08" - updated: "2026-04-10" + updated: "2026-09-25" author: PAIML Engineering registry: true references: @@ -41,8 +41,8 @@ equations: INTERNAL-HELPER (8 crates, 9 binaries): aprender-cbtop -> aprender-cbtop # GPU monitor, called by `apr monitor` - aprender-present-cli -> presentar # TUI server, called by `apr present` - aprender-present-terminal -> score, ptop # TUI widgets, used by presentar + aprender-present-cli -> aprender-present # TUI server, called by `apr present` + aprender-present-terminal -> aprender-score, aprender-ptop # TUI widgets, used by presentar aprender-ptx-debug -> aprender-ptx-debug # CUDA PTX debugger (dev-only) aprender-test-cli -> aprender-test-cli # WASM test runner (dev-only) aprender-train-bench -> aprender-train-bench # Training benchmark harness @@ -51,7 +51,7 @@ equations: QA-TOOL (2 crates, 2 binaries — from Phase 2g port): aprender-qa-cli -> apr-qa # QA playbook runner (to wire into `apr qa`) - aprender-qa-certify -> apr-qa-readme-sync # README badge sync (CI tool) + aprender-qa-certify -> aprender-qa-readme-sync # README badge sync (CI tool) MIGRATED (2 crates — [[bin]] → [[example]]): aprender-serve -> [[example]] aprender-serve # Was: inference server. Use `apr serve`. @@ -59,13 +59,13 @@ equations: LEGACY-TO-MIGRATE (8 crates, 9 binaries): aprender-cgp -> aprender-cgp # Contract graph processor → `apr contracts graph` - aprender-data -> alimentar # Data loading → `apr data` + aprender-data -> aprender-data # Data loading → `apr data` aprender-db -> aprender-db # Embedded DB → `apr db` - aprender-simulate -> simular # Simulation → `apr simulate` + aprender-simulate -> aprender-simulate # Simulation → `apr simulate` aprender-train-distill -> aprender-train-distill # Distillation → `apr distill` aprender-train-inspect -> aprender-train-inspect # Weight inspector → `apr train inspect` aprender-train-lora -> aprender-train-lora # LoRA training → `apr finetune` - aprender-zram-cli -> trueno-zram # ZRAM manager → `apr zram` + aprender-zram-cli -> aprender-zram # ZRAM manager → `apr zram` domain: 20 crates (22 → 20 after 2 migrated to [[example]]) codomain: classification @@ -103,6 +103,14 @@ falsification_tests: done if_fails: "legacy binary has no apr subcommand migration path" +- id: FALSIFY-BINRULE-004 + rule: every shipped bin is aprender-* (#4430) + prediction: "every [[bin]] in the root and facade workspaces (from cargo metadata) is apr, pv, + aprender-[-...], or a pending-fold row in scripts/bin_names_pending_fold.txt" + test: | + bash scripts/check_bin_names_aprender.sh --self-test && bash scripts/check_bin_names_aprender.sh + if_fails: "a bin ships under a non-aprender name (rename it) or a pending-fold row is stale (delete it)" + proof_obligations: - id: AMBR-INV-001 type: invariant @@ -114,5 +122,5 @@ proof_obligations: verification_summary: total_obligations: 2 proven: 0 - tested: 3 + tested: 4 status: audited diff --git a/contracts/apr-serve-cancellation-v1.yaml b/contracts/apr-serve-cancellation-v1.yaml index 081b8dc777..005c2b9a21 100644 --- a/contracts/apr-serve-cancellation-v1.yaml +++ b/contracts/apr-serve-cancellation-v1.yaml @@ -204,18 +204,21 @@ falsification_tests: - id: FALSIFY-SERVE-CANCEL-009 name: q4k_scheduler_decode_stops_at_the_cancel_point_not_max_tokens prediction: 'A Q4K request (Session over AprQ4kForward, q4k_generate_config) with - max_tokens=64 and CancelToken::with_budget(8) emits 8 tokens, performs exactly 8 - decode forward passes and polls 9 times; the same call with CancelToken::never() - emits 64 tokens over 63 decode forward passes at contiguous positions, and the - cancelled output is a prefix of it. (Pre-#4269-M2b q4k_decode emitted 9: it - returned the prefill token unpolled.)' + max_tokens=64 and CancelToken::with_budget(8) emits 8 tokens, performs exactly 7 + decode forward passes (emitted - 1: the first token is the prefill''s, and no + forward runs after the poll that cancelled) and polls 9 times; the same call with + CancelToken::never() emits 64 tokens over 63 decode forward passes at contiguous + positions, and the cancelled output is a prefix of it. (Pre-#4269-M2b q4k_decode + emitted 9: it returned the prefill token unpolled. #4269 M2b to #4325 performed 8: + the forward after the 8th token was computed and thrown away.)' test_harness: cargo test -p aprender-serve --lib q4k_scheduler_decode_stops_at_the_cancel_point_not_max_tokens expected_output: 'test result: ok' if_fails: 'PRE-FIX (aprender#2465(1)): AprQ4kRequest had no cancel field and the Q4K decode loop''s only exit was EOS, so /v1/chat/completions, /v1/completions, /generate, /api/chat and /api/generate all ran the GPU to max_tokens for a client that had hung up. Mutation-verified (#4269 M2b) by deleting the session''s poll: - emitted 64 tokens where 8 were required.' + emitted 64 tokens where 8 were required. #4325: moving the session''s decode + forward back after the emit (before the next poll) performs 8 where 7 are required.' - id: FALSIFY-SERVE-CANCEL-010 name: q4k_scheduler_decode_cancelled_before_start_does_no_forward_passes prediction: 'A Q4K request (Session over AprQ4kForward) with an already-cancelled diff --git a/contracts/apr-version-traceability-v1.yaml b/contracts/apr-version-traceability-v1.yaml index 8c5bf391cd..e1eac5897c 100644 --- a/contracts/apr-version-traceability-v1.yaml +++ b/contracts/apr-version-traceability-v1.yaml @@ -1,11 +1,12 @@ metadata: - version: 1.1.0 + version: 1.2.0 created: '2026-04-05' author: PAIML Engineering description: "apr --version traceability contract — the version output must always contain an informative build identifier; never the bare '(unknown)' sentinel when a CARGO_PKG_VERSION or any alternative identifier is available; AND the embedded git SHA must match the actual HEAD of the source tree at build time (including in git worktrees)" references: - "paiml/aprender#597 (Version string shows (unknown) instead of git hash)" - "paiml/aprender#1862 (build.rs misses HEAD changes in worktrees because ../../.git is a file pointer, not a directory)" + - "paiml/aprender#4110 (crates.io installs print +no-git although the tarball's .cargo_vcs_info.json carries the commit)" registry: true tags: - cli @@ -36,11 +37,13 @@ equations: - "Common sentinel forms are forbidden: (unknown), 0000000, , null" fallback_hierarchy: - formula: "APR_GIT_SHA = override ∨ git_hash ∨ committed_sha ∨ 'v{version}+no-git'" + formula: "APR_GIT_SHA = override ∨ vcs_info_sha ∨ git_hash ∨ committed_sha ∨ 'v{version}+no-git'" domain: "build-time environment" codomain: "APR_GIT_SHA environment variable" invariants: - "Env var APR_GIT_SHA_OVERRIDE, if set, wins (for CI/release)" + - "Otherwise: read git.sha1 from the package's .cargo_vcs_info.json (written by cargo package into every published crate), short 9-hex form, -dirty suffix when cargo recorded a dirty tree; a value that is not 40 hex is ignored (#4110)" + - "The vcs rung precedes git rev-parse: an unpacked crate may sit inside an UNRELATED repository whose HEAD is not the crate's commit" - "Otherwise: try git rev-parse --short HEAD" - "Otherwise: try reading crates/apr-cli/.git-sha committed file" - "Otherwise: emit 'v{CARGO_PKG_VERSION}+no-git' as informative fallback" @@ -92,8 +95,13 @@ falsification_tests: - id: FALSIFY-VERSION-004 rule: "build.rs registers worktree-aware rerun-if-changed triggers" prediction: "build.rs invokes `git rev-parse --git-dir` and `--git-common-dir` (not a hardcoded `../../.git/HEAD` path)" - test: "grep -qE 'rev-parse.*--git-(dir|common-dir)' crates/apr-cli/build.rs && ! grep -qE '\\.\\./\\.\\./\\.git/HEAD' crates/apr-cli/build.rs" + test: "grep -qE 'rev-parse.*--git-(dir|common-dir)' crates/aprender-build-sha/src/lib.rs && ! grep -qE '\\.\\./\\.\\./\\.git/HEAD' crates/aprender-build-sha/src/lib.rs crates/apr-cli/build.rs" if_fails: "build.rs still relies on a hardcoded relative .git/HEAD path; worktree HEAD changes won't trigger rebuild" + - id: FALSIFY-VERSION-005 + rule: "a packaged apr-cli (no .git of its own) names the commit in its .cargo_vcs_info.json, even inside a foreign repository" + prediction: "The shipped build.rs, compiled and run as cargo runs it on a package dir holding the byte-exact apr-cli-0.69.1 vcs file, emits APR_GIT_SHA=d8a6df53a; inside an unrelated repo it still does; with the vcs rung removed or placed behind git rev-parse the table goes RED" + test: "cargo test -p aprender-build-sha --lib emit_tests" + if_fails: "crates.io installs regress to '+no-git' (0.68.1/0.69.1 shape), or report an enclosing repository's HEAD" kani_harnesses: - id: KANI-VERSION-001 diff --git a/contracts/aprender/binding.yaml b/contracts/aprender/binding.yaml index 18d9f39a96..1cf8c142fd 100644 --- a/contracts/aprender/binding.yaml +++ b/contracts/aprender/binding.yaml @@ -1,6 +1,174 @@ version: 1.0.0 target_crate: aprender bindings: +- contract: adamw-kernel-v1.yaml + equation: weight_update + module_path: aprender::nn::optim::rm_sprop + function: step + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/nn/optim/rm_sprop.rs:97 had no binding row; row added when the binding moved in-tree' +- contract: batchnorm-kernel-v1.yaml + equation: batchnorm_train + module_path: aprender::nn::normalization + function: forward + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/nn/normalization/mod.rs:391 had no binding row; row added when the binding moved in-tree' +- contract: cma-es-kernel-v1.yaml + equation: mean_update + module_path: aprender::metaheuristics::cmaes_include_01 + function: optimize + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/metaheuristics/cmaes_include_01.rs:5 had no binding row; row added when the binding moved in-tree' +- contract: cma-es-kernel-v1.yaml + equation: sample + module_path: aprender::metaheuristics::cmaes + function: sample + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/metaheuristics/cmaes.rs:274 had no binding row; row added when the binding moved in-tree' +- contract: conv1d-kernel-v1.yaml + equation: conv1d + module_path: aprender::nn::conv + function: forward + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/nn/conv/mod.rs:292 had no binding row; row added when the binding moved in-tree' +- contract: cross-entropy-kernel-v1.yaml + equation: cross_entropy + module_path: aprender::nn::loss + function: forward + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/nn/loss.rs:257 had no binding row; row added when the binding moved in-tree' +- contract: cross-entropy-kernel-v1.yaml + equation: log_softmax + module_path: aprender::nn::functional + function: log_softmax + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/nn/functional.rs:307 had no binding row; row added when the binding moved in-tree' +- contract: embedding-algebra-v1.yaml + equation: embedding_lookup + module_path: aprender::models::qwen2 + function: forward + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/models/qwen2/mod.rs:121 had no binding row; row added when the binding moved in-tree' +- contract: f16-conversion-v1.yaml + equation: f16_to_f32_bias + module_path: aprender::format::gguf::dequant + function: f16_to_f32 + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/format/gguf/dequant.rs:18 had no binding row; row added when the binding moved in-tree' +- contract: gqa-kernel-v1.yaml + equation: gqa + module_path: aprender::nn::transformer + function: forward + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/nn/transformer/mod.rs:409 had no binding row; row added when the binding moved in-tree' +- contract: kmeans-kernel-v1.yaml + equation: assignment + module_path: aprender::cluster::kmeans_impl + function: predict + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/cluster/kmeans_impl.rs:406 had no binding row; row added when the binding moved in-tree' +- contract: kmeans-kernel-v1.yaml + equation: update + module_path: aprender::cluster::kmeans_impl + function: fit + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/cluster/kmeans_impl.rs:362 had no binding row; row added when the binding moved in-tree' +- contract: layernorm-kernel-v1.yaml + equation: layernorm + module_path: aprender::nn::normalization + function: forward + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/nn/normalization/mod.rs:131 had no binding row; row added when the binding moved in-tree' +- contract: matmul-kernel-v1.yaml + equation: matmul + module_path: aprender::autograd::ops::activation + function: matmul + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/autograd/ops/activation.rs:272 had no binding row; row added when the binding moved in-tree' +- contract: rmsnorm-kernel-v1.yaml + equation: rmsnorm + module_path: aprender::nn::normalization::group_norm + function: forward + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/nn/normalization/group_norm.rs:348 had no binding row; row added when the binding moved in-tree' +- contract: rope-kernel-v1.yaml + equation: rope + module_path: aprender::nn::transformer::attention_helpers + function: apply + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/nn/transformer/attention_helpers.rs:268 had no binding row; row added when the binding moved in-tree' +- contract: setfit-encoder-conformance-v1.yaml + equation: additive_attention_mask + module_path: aprender::autograd::ops::masking + function: additive_attention_mask + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/autograd/ops/masking.rs:43 had no binding row; row added when the binding moved in-tree' +- contract: setfit-encoder-conformance-v1.yaml + equation: apply_additive_mask + module_path: aprender::nn::transformer::positional_encoding + function: add_mask + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/nn/transformer/positional_encoding.rs:476 had no binding row; row added when the binding moved in-tree' +- contract: setfit-encoder-conformance-v1.yaml + equation: cosine_similarity_rows + module_path: aprender::autograd::ops::similarity + function: cosine_similarity_rows + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/autograd/ops/similarity.rs:55 had no binding row; row added when the binding moved in-tree' +- contract: setfit-encoder-conformance-v1.yaml + equation: embedding_gather + module_path: aprender::autograd::ops::embedding + function: embedding_gather + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/autograd/ops/embedding.rs:38 had no binding row; row added when the binding moved in-tree' +- contract: setfit-encoder-conformance-v1.yaml + equation: gelu_exact + module_path: aprender::autograd::ops::activation + function: gelu_exact + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/autograd/ops/activation.rs:181 had no binding row; row added when the binding moved in-tree' +- contract: setfit-encoder-conformance-v1.yaml + equation: l2_normalize_rows + module_path: aprender::autograd::ops::normalize + function: l2_normalize_rows + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/autograd/ops/normalize.rs:56 had no binding row; row added when the binding moved in-tree' +- contract: setfit-encoder-conformance-v1.yaml + equation: masked_mean_pool + module_path: aprender::autograd::ops::pooling + function: masked_mean_pool + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/autograd/ops/pooling.rs:50 had no binding row; row added when the binding moved in-tree' +- contract: setfit-encoder-conformance-v1.yaml + equation: mse_loss + module_path: aprender::autograd::ops::similarity + function: mse_loss + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/autograd/ops/similarity.rs:196 had no binding row; row added when the binding moved in-tree' +- contract: setfit-encoder-conformance-v1.yaml + equation: pair_cosine_mse + module_path: aprender::setfit::loss + function: pair_cosine_mse + status: implemented + notes: '#4429: #[contract] site at crates/aprender-core/src/setfit/loss.rs is behind feature setfit, so the #4369 sweep missed it' +- contract: setfit-encoder-conformance-v1.yaml + equation: setfit_encoder_forward + module_path: aprender::setfit::encoder + function: BertSentenceEncoder::forward_tokens + status: implemented + notes: '#4429: #[contract] site at crates/aprender-core/src/setfit/encoder.rs:845 is behind feature setfit, so the #4369 sweep missed it' +- contract: silu-kernel-v1.yaml + equation: sigmoid + module_path: aprender::nn::functional + function: sigmoid + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/nn/functional.rs:58 had no binding row; row added when the binding moved in-tree' +- contract: silu-kernel-v1.yaml + equation: silu + module_path: aprender::nn::functional + function: silu + status: implemented + notes: '#4369: #[contract] site at crates/aprender-core/src/nn/functional.rs:90 had no binding row; row added when the binding moved in-tree' - contract: softmax-kernel-v1.yaml equation: softmax module_path: aprender::nn::functional::softmax diff --git a/contracts/batuta/binding.yaml b/contracts/batuta/binding.yaml new file mode 100644 index 0000000000..6d3c423643 --- /dev/null +++ b/contracts/batuta/binding.yaml @@ -0,0 +1,316 @@ +version: 1.0.0 +target_crate: batuta +critical_path: +- transpile +- analyze +- build_transpiler +- recommend_transpiler +- cmd_transpile +bindings: +- contract: builder-pattern-v1.yaml + equation: transpile + function: build_transpiler_args + module_path: batuta::src::cli::mod::build_transpiler_args + status: implemented +- contract: configuration-v1.yaml + equation: transpile + function: cmd_transpile + module_path: batuta::src::cli::pipeline_cmds_transpile::cmd_transpile + status: implemented +- contract: configuration-v1.yaml + equation: transpile + function: recommend_transpiler + module_path: batuta::src::types::recommend_transpiler + status: implemented +- contract: configuration-v1.yaml + equation: transpile + function: has_transpiler + module_path: batuta::src::tools::has_transpiler + status: implemented +- contract: configuration-v1.yaml + equation: transpile + function: get_transpiler_for_language + module_path: batuta::src::tools::get_transpiler_for_language + status: implemented +- contract: configuration-v1.yaml + equation: transpile + function: transpile_python + module_path: batuta::src::tools::transpile_python + status: implemented +- contract: configuration-v1.yaml + equation: transpile + function: transpile_shell + module_path: batuta::src::tools::transpile_shell + status: implemented +- contract: configuration-v1.yaml + equation: transpile + function: transpile_c_cpp + module_path: batuta::src::tools::transpile_c_cpp + status: implemented +- contract: configuration-v1.yaml + equation: analyze + function: analyze_numpy_usage + module_path: batuta::src::pipeline_analysis::analyze_numpy_usage + status: implemented +- contract: configuration-v1.yaml + equation: analyze + function: analyze_sklearn_usage + module_path: batuta::src::pipeline_analysis::analyze_sklearn_usage + status: implemented +- contract: configuration-v1.yaml + equation: analyze + function: analyze_pytorch_usage + module_path: batuta::src::pipeline_analysis::analyze_pytorch_usage + status: implemented +- contract: configuration-v1.yaml + equation: analyze + function: analyze_contract_gaps + module_path: batuta::src::bug_hunter::contracts::analyze_contract_gaps + status: implemented +- contract: linear-models-v1.yaml + equation: analyze + function: analyze_model_parity_gaps + module_path: batuta::src::bug_hunter::model_parity::analyze_model_parity_gaps + status: implemented +- contract: configuration-v1.yaml + equation: analyze + function: analyze + module_path: batuta::src::recipes::benchmark::analyze + status: implemented +- contract: configuration-v1.yaml + equation: analyze + function: analyze_optimizations + module_path: batuta::src::pipeline::stages::optimization::analyze_optimizations + status: implemented +- contract: configuration-v1.yaml + equation: analyze + function: cmd_analyze + module_path: batuta::src::cli::pipeline_cmds::cmd_analyze + status: implemented +- contract: gpu-decode-profiling-v1.yaml + equation: analyze + function: analyze_code + module_path: batuta::src::wasm::analyze_code + status: implemented +- contract: configuration-v1.yaml + equation: analyze + function: analyze_quality + module_path: batuta::src::tools::analyze_quality + status: implemented +- contract: configuration-v1.yaml + equation: analyze + function: analyze_dependencies + module_path: batuta::src::parf::analyze_dependencies + status: implemented +- contract: fused-qkv-projection-v1.yaml + equation: analyze + function: analyze_project + module_path: batuta::src::analyzer::analyze_project + status: implemented +# --- CCX-RS-inspired features (2026-04-02) --- +- contract: provider-routing-v1.yaml + equation: privacy_enforcement + function: route_to_provider + module_path: batuta::src::agent::driver::routing::route_to_provider + status: pending +- contract: provider-routing-v1.yaml + equation: failover_cascade + function: failover_next + module_path: batuta::src::agent::driver::routing::failover_next + status: pending +- contract: provider-routing-v1.yaml + equation: cost_budget + function: estimate_cost + module_path: batuta::src::agent::driver::remote::estimate_cost + status: pending +- contract: provider-routing-v1.yaml + equation: backoff_jitter + function: delay_for + module_path: batuta::src::agent::driver::remote::backoff::delay_for + status: pending +- contract: provider-routing-v1.yaml + equation: format_translation + function: to_openai + module_path: batuta::src::agent::driver::remote::translator::to_openai + status: pending +- contract: agent-loop-v1.yaml + equation: loop_termination + function: run_loop + module_path: batuta::src::agent::run_loop + status: pending +- contract: agent-loop-v1.yaml + equation: state_machine + function: transition + module_path: batuta::src::agent::state::transition + status: pending +- contract: agent-loop-v1.yaml + equation: context_compaction + function: compact + module_path: batuta::src::agent::context::compact + status: pending +- contract: agent-loop-v1.yaml + equation: sandbox_enforcement + function: check_sandbox + module_path: batuta::src::agent::sandbox::check_sandbox + status: pending +- contract: agent-loop-v1.yaml + equation: parallel_tool_safety + function: execute_all + module_path: batuta::src::agent::tool::execute_all + status: pending +- contract: agent-loop-v1.yaml + equation: session_crash_recovery + function: resume + module_path: batuta::src::agent::session::resume + status: pending +- contract: agent-loop-v1.yaml + equation: hook_ordering + function: execute_with_hooks + module_path: batuta::src::agent::hook::execute_with_hooks + status: pending +# --- Presentar/Probar UX contracts (2026-04-02) --- +- contract: agent-ux-v1.yaml + equation: streaming_responsiveness + function: push_token + module_path: batuta::src::agent::tui::streaming::push_token + status: pending +- contract: agent-ux-v1.yaml + equation: frame_budget + function: render_frame + module_path: batuta::src::agent::tui::layout::render_frame + status: pending +- contract: agent-ux-v1.yaml + equation: brick_verification + function: can_render + module_path: batuta::src::agent::brick::can_render + status: pending +- contract: agent-ux-v1.yaml + equation: cost_display_accuracy + function: update_cost + module_path: batuta::src::agent::tui::cost::update_cost + status: pending +- contract: agent-ux-v1.yaml + equation: layout_correctness + function: compute_layout + module_path: batuta::src::agent::tui::layout::compute_layout + status: pending +- contract: agent-ux-v1.yaml + equation: state_machine_validity + function: transition + module_path: batuta::src::agent::state::transition + status: pending +# --- apr code contracts (2026-04-02) --- +- contract: apr-code-v1.yaml + equation: sovereignty_guarantee + function: enforce_offline + module_path: batuta::src::agent::sandbox::enforce_offline + status: pending +- contract: apr-code-v1.yaml + equation: tool_safety + function: execute_tool_checked + module_path: batuta::src::agent::tool::execute_tool_checked + status: pending +- contract: apr-code-v1.yaml + equation: session_integrity + function: resume_session + module_path: batuta::src::agent::session::resume_session + status: pending +- contract: apr-code-v1.yaml + equation: apr_md_compliance + function: load_apr_md + module_path: batuta::src::agent::config::load_apr_md + status: pending +- contract: apr-code-v1.yaml + equation: startup_latency + function: run_apr_code + module_path: batuta::src::agent::run_apr_code + status: pending +# --- Oracle CLI contracts (2026-04-03) --- +- contract: cli-oracle-v1.yaml + equation: dispatch_correctness + function: dispatch_oracle + module_path: batuta::src::cli::oracle::mod::dispatch_oracle + status: pending + notes: Every oracle subcommand variant has a dispatch handler +- contract: cli-oracle-v1.yaml + equation: rag_query_correctness + function: rag_search + module_path: batuta::src::cli::oracle::rag::rag_search + status: pending + notes: RAG results sorted by BM25 score descending, empty query returns empty +- contract: cli-oracle-v1.yaml + equation: index_freshness + function: check_freshness + module_path: batuta::src::cli::oracle::rag_index::check_freshness + status: pending + notes: Stale index warns but proceeds, missing index triggers auto-build +# --- HTTP API contracts (2026-04-04, PMAT-189) --- +- contract: http-api-v1.yaml + equation: body_schema_compliance + function: build_openai_body + module_path: batuta::src::agent::driver::apr_serve::build_openai_body + status: implemented + notes: AprServeDriver builds OpenAI-compatible JSON request bodies +- contract: http-api-v1.yaml + equation: max_tokens_cap + function: build_openai_body + module_path: batuta::src::agent::driver::apr_serve::build_openai_body + status: implemented + notes: max_tokens capped at 1024 (PMAT-170) +- contract: http-api-v1.yaml + equation: thinking_block_strip + function: strip_thinking_blocks + module_path: batuta::src::agent::driver::apr_serve::strip_thinking_blocks + status: implemented + notes: Removes ... from Qwen3 responses (PMAT-180) +# --- Session contracts (2026-04-04, PMAT-189) --- +- contract: session-v1.yaml + equation: jsonl_roundtrip + function: append_messages + module_path: batuta::src::agent::session::append_messages + status: implemented + notes: JSONL append-only persistence +- contract: session-v1.yaml + equation: manifest_serde + function: new + module_path: batuta::src::agent::session::Session::new + status: implemented + notes: Session manifest serialization +- contract: session-v1.yaml + equation: age_filter + function: find_recent_for_cwd + module_path: batuta::src::agent::session::find_recent_for_cwd + status: implemented + notes: 24h age filter (PMAT-165) +# --- Tokenizer contracts (2026-04-04, PMAT-189) --- +- contract: tokenizer-v1.yaml + equation: deterministic_encode + function: encode + module_path: batuta::src::agent::driver::validate::tokenizer_encode + status: implemented + notes: Tokenizer used for context window management +- contract: tokenizer-v1.yaml + equation: vocab_size_bound + function: encode + module_path: batuta::src::agent::driver::validate::tokenizer_encode + status: implemented + notes: Token IDs must be within vocabulary bounds +# --- Model discovery contracts (2026-04-04, PMAT-188) --- +- contract: apr-model-discovery-v1.yaml + equation: search_order + function: discover_model + module_path: batuta::src::agent::manifest::discover_model + status: implemented + notes: ~/.apr/models/ > ~/.cache/huggingface/ > ./models/ +- contract: apr-model-discovery-v1.yaml + equation: sort_priority + function: discover_model + module_path: batuta::src::agent::manifest::discover_model + status: implemented + notes: valid > mtime > APR (PMAT-185) +- contract: apr-model-discovery-v1.yaml + equation: jidoka_validation + function: is_valid_model_file + module_path: batuta::src::agent::driver::validate::is_valid_model_file + status: implemented + notes: Invalid APR deprioritized behind valid GGUF (PMAT-150) diff --git a/contracts/binding.yaml b/contracts/binding.yaml index 1391a732f4..3e053f1a22 100644 --- a/contracts/binding.yaml +++ b/contracts/binding.yaml @@ -476,10 +476,11 @@ bindings: # --- apr-model-graph-v1 (full LLM forward pass) --- - contract: apr-model-graph-v1.yaml equation: forward_pass_completeness - module_path: aprender::inference::forward - function: forward_pass + module_path: realizar::gguf + function: OwnedQuantizedModel::forward + signature: 'fn forward(&self, token_ids: &[u32]) -> Result>' status: implemented - notes: Full forward pass from tokens to logits + notes: Full forward pass from tokens to logits (crates/aprender-serve/src/gguf/inference/forward/forward_fused_q4k.rs); returns last-position logits [V], not [B,S,V]. Was ghost aprender::inference::forward::forward_pass (#4094) - contract: apr-model-graph-v1.yaml equation: attention_mechanism module_path: aprender::nn::transformer @@ -666,7 +667,7 @@ bindings: status: implemented notes: >- S8, the four jidoka parameters. guard_mutation_score is REPRODUCIBLE: - scripts/mutate-guard.sh is committed and its DERIVED set is 233/233 over + scripts/mutate-guard.sh is committed and its DERIVED set is 237/237 over scripts/check_pr_review_receipt.sh. THE SET SIZE IS DERIVED AND CERTAIN; THE KILL COUNT ON THIS MERGED TREE IS PENDING, and is the `pr-review-receipt` job's Arm 3. 215/215 was measured on PRREV-015's tree and 185/185 on PRREV-013's, and neither @@ -804,6 +805,21 @@ bindings: notes: 'compose(embedding, L x block, final_norm, unembed) as a shape pipeline: [S] -> [S,d] -> [S,d] (xL) -> [S,d] -> [S,V]. composition_is_well_formed() checks it chains; every block stage satisfies QE2E-INV-006 and the last stage is QE2E-CON-007''s [seq_len, V].' +- contract: gated-delta-net-v1.yaml + equation: gated_rmsnorm + module_path: realizar::gguf::forward_qwen35 + function: gated_rmsnorm + signature: 'fn gated_rmsnorm(input: &[f32], gate: &[f32], weight: &[f32], eps: f32, head_v_dim: usize, output: &mut [f32])' + status: implemented + notes: 'CPU reference of the GDN output norm; the kernel-parity shape joins the #[kernel] below to this symbol by the shared equation.' +- contract: gated-delta-net-v1.yaml + equation: gated_rmsnorm + module_path: gated_rmsnorm_oxide::kernels + function: gated_rmsnorm + signature: 'fn gated_rmsnorm(input: &[f32], gate: &[f32], weight: &[f32], out: DisjointSlice, head_dim: u32, eps: f32)' + status: implemented + kernel: true + notes: '#3522 O-1: the cuda-oxide #[kernel] (experiments/cuda-oxide/gated-rmsnorm), beside the hand PTX gdn_gated_rmsnorm; graded by evidence/kernels/gated_rmsnorm/.json.' critical_path: - softmax - rmsnorm diff --git a/contracts/claude-md.yaml b/contracts/claude-md.yaml new file mode 100644 index 0000000000..cc524aeb43 --- /dev/null +++ b/contracts/claude-md.yaml @@ -0,0 +1,85 @@ +# ───────────────────────────────────────────────────────────────────────────── +# claude-md — the repository's LLM context file as a graded document (ONT-001 §5 ONT-4c, Appendix B.5; #3847) +# +# WHAT IS GRADED. `extract:llm-context` reads CLAUDE.md: frontmatter `tools:` → llm:declaredTool (graded `in:` +# Σ's tool names); every `## ` heading → llm:section; a heading listed under a role in Σ +# `llm_context_role_synonyms` → that role's own predicate (v4.16 D5b), each a plain minCount 1; every inline code +# span that is a relative path → llm:referencedPath, refused by name when it does not exist; every claim-fence +# command → llm:command, refused `llm:command unresolved: ` when no merge-path workflow runs it; every +# `Never:` line → llm:neverRule. The claim set is measured into lint-baseline.json `claude_md` (F-33, F-34). +# +# KIND: pattern. The edit that makes it fail: delete `## Project Overview` (Purpose's only synonym). +# ───────────────────────────────────────────────────────────────────────────── +name: claude-md +version: "1.0.0" +scope: > + CLAUDE.md at the repository root: its role sections, the paths and commands it tells an agent about, its + declared tools and its Never rules. Out of scope: whether the advice is good. +status: active + +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-24' + last_modified: '2026-09-24' + author: PAIML Engineering + description: > + CLAUDE.md becomes an llm:LlmContext node; every path it names exists, every claim-fence command is one CI + runs, its tools are Σ's, and the four role sections plus a Never rule are present. + references: + - 'paiml/infra docs/specifications/paiml-ontology.md v4.16 §5 ONT-4c, Appendix B.5 (D5b)' + - 'crates/aprender-contracts/src/ontology/extract/{llm_context,claims}.rs' + - 'contracts/ontology.yaml llm_context_role_synonyms' + +entity: + type: llm-context + ref: CLAUDE.md + +relations: + depends_on: [ont-sigma-v1, ont-shapes-v1] + +shape: + targetClass: llm:LlmContext + closed: true + properties: + - {path: llm:section, minCount: 0} + - {path: llm:purposeSection, minCount: 1} + - {path: llm:rulesSection, minCount: 1} + - {path: llm:commandsSection, minCount: 1} + - {path: llm:layoutSection, minCount: 1} + - {path: llm:referencedPath, resolves: path} + - {path: llm:command, resolves: ci-step} + - {path: llm:declaredTool, in: [pmat, pv, forjar, bashrs, cargo, gh]} + - {path: llm:neverRule, minCount: 1} + +equations: + context_resolves: + formula: "∀ p ∈ paths(CLAUDE.md): exists(p) ∧ ∀ c ∈ claims(CLAUDE.md): normalise(c) ∈ run_lines(W_merge)" + domain: CLAUDE.md's relative-path code spans and claim-fence commands + codomain: llm:referencedPath / llm:command values, or PV-ONT-012 refusals naming each + invariants: + - a path or command an agent is told about and cannot use is refused by name + preconditions: + - Σ llm_context_role_synonyms is declared + postconditions: + - the committed claude_md.verified_commands[] equals the live set (F-33) + lean_theorem: "none — L4 not declared" + +invariants: + - id: LLM-INV-001 + property: every relative path CLAUDE.md names exists + formal: '∀ p ∈ paths(CLAUDE.md): exists(root/p)' + prose: false + - id: LLM-INV-002 + property: every role has a section + formal: '∀ r ∈ {Purpose, Rules, Commands, Layout}: |{h ∈ headings₂ : h ∈ synonyms(r)}| ≥ 1' + prose: false + +falsification_tests: + - id: FALSIFY-LLM-001 + rule: a stale path is refused + prediction: > + naming a path that does not exist in a code span makes the shapes gate exit 1 naming + `llm:referencedPath unresolved:

`; renaming `## Project Overview` fails llm:purposeSection minCount + test: cargo test -p aprender-contracts --lib ontology::extract::llm_context + if_fails: an agent is steered at a file or command that does not exist diff --git a/contracts/contracts.nt b/contracts/contracts.nt index dd58e94bcf..cea335e079 100644 --- a/contracts/contracts.nt +++ b/contracts/contracts.nt @@ -1,3 +1,4 @@ + . . . "contracts/work/APR-ANTIGRAVITY-INTEGRATION-001.yaml"^^ . @@ -6043,6 +6044,19 @@ "claude-code-parity-apr"^^ . "ACTIVE_RUNTIME"^^ . "1.32.0"^^ . + . + . + . + . + . + "CLAUDE.md"^^ . + "llm-context"^^ . + "contracts/claude-md.yaml"^^ . + "claude-md"^^ . + "pattern"^^ . + "claude-md"^^ . + "active"^^ . + "1.0.0"^^ . . . "contracts/clean-chat-output-v1.yaml"^^ . @@ -7519,6 +7533,20 @@ "contracts/crux-intake-metadata-domains-v1.yaml"^^ . "crux-intake-metadata-domains-v1"^^ . "pattern"^^ . + . + . + . + "crates/aprender-train/prepare_data.sh"^^ . + . + . + "crates/aprender-train/data/train.csv"^^ . + "csv"^^ . + "contracts/csv-train.yaml"^^ . + "csv-train"^^ . + "pattern"^^ . + "csv-train"^^ . + "active"^^ . + "1.0.0"^^ . . . "contracts/cublas-fp8-7b-determinism-v1.yaml"^^ . @@ -8290,6 +8318,20 @@ . "contracts/apr-model-qa-playbook/model-qa-v1.yaml"^^ . "model-qa-v1"^^ . + . + . + . + . + . + . + "crates/aprender-core/tests/fixtures/setfit/slice_model.apr"^^ . + "apr-model"^^ . + "contracts/model-setfit-slice.yaml"^^ . + "model-setfit-slice"^^ . + "pattern"^^ . + "model-setfit-slice"^^ . + "active"^^ . + "1.0.0"^^ . . . "contracts/moe-expert-dispatch-v1.yaml"^^ . @@ -8399,6 +8441,19 @@ . "contracts/online-softmax-v1.yaml"^^ . "online-softmax-v1"^^ . + . + . + . + . + . + . + "gguf"^^ . + "contracts/ont-capability-cells-v1.yaml"^^ . + "ont-capability-cells-v1"^^ . + "pattern"^^ . + "ont-capability-cells"^^ . + "active"^^ . + "1.0.0"^^ . . . . @@ -8419,6 +8474,20 @@ "ont-extract-json"^^ . "active"^^ . "1.0.0"^^ . + . + . + . + . + . + . + . + "kernel"^^ . + "contracts/ont-kernel-receipts-v1.yaml"^^ . + "ont-kernel-receipts-v1"^^ . + "pattern"^^ . + "ont-kernel-receipts"^^ . + "active"^^ . + "1.0.0"^^ . . . . @@ -8720,6 +8789,15 @@ "contracts/pv-version-identity-v1.yaml"^^ . "pv-version-identity-v1"^^ . "pattern"^^ . + . + . + . + "contracts/pvl-lint-ratchets-v1.yaml"^^ . + "pvl-lint-ratchets-v1"^^ . + "pattern"^^ . + "pvl-lint-ratchets"^^ . + "active"^^ . + "1.0.0"^^ . . . "contracts/q2k-dequant-parity-v1.yaml"^^ . @@ -8944,6 +9022,19 @@ "contracts/readme-claims-v1.yaml"^^ . "readme-claims-v1"^^ . "pattern"^^ . + . + . + . + . + . + "README.md"^^ . + "readme"^^ . + "contracts/readme-root.yaml"^^ . + "readme-root"^^ . + "pattern"^^ . + "readme-root"^^ . + "active"^^ . + "1.0.0"^^ . . . "contracts/forjar/recipe-determinism-v1.yaml"^^ . @@ -9479,6 +9570,155 @@ "yarn-rope-original-base-v1"^^ . "yarn-rope-original-base"^^ . "1.0.0"^^ . + . + "feature1"^^ . + "feature2"^^ . + "label"^^ . + "int"^^ . + "feature1,feature2,label"^^ . + "crates/aprender-train/prepare_data.sh"^^ . + "100"^^ . + "e92a2460d310818411a48fdb440336024818d1d8fac5f2da853427ba9888bd52"^^ . + . + "oxide"^^ . + "8.9"^^ . + "1"^^ . + "lambda"^^ . + "0.0000009537"^^ . + "2.458"^^ . + "2.734"^^ . + "Cuda compilation tools, release 12.8, V12.8.93"^^ . + "21"^^ . + "91969973aac74cf031d14010d0f4a2938c2b1161"^^ . + . + "oxide"^^ . + "8.9"^^ . + "1"^^ . + "lambda"^^ . + "0.0000009537"^^ . + "2.456"^^ . + "2.764"^^ . + "Cuda compilation tools, release 12.8, V12.8.93"^^ . + "21"^^ . + "91969973aac74cf031d14010d0f4a2938c2b1161"^^ . + . + "oxide"^^ . + "8.9"^^ . + "1"^^ . + "lambda"^^ . + "0.0000009537"^^ . + "2.478"^^ . + "2.765"^^ . + "Cuda compilation tools, release 12.8, V12.8.93"^^ . + "21"^^ . + "91969973aac74cf031d14010d0f4a2938c2b1161"^^ . + . + "oxide"^^ . + "8.9"^^ . + "1"^^ . + "lambda"^^ . + "0.0000009537"^^ . + "2.447"^^ . + "2.754"^^ . + "Cuda compilation tools, release 12.8, V12.8.93"^^ . + "21"^^ . + "91969973aac74cf031d14010d0f4a2938c2b1161"^^ . + . + "setsid --wait bash scripts/check_package_includes.sh"^^ . + "APR CLI (`cargo install aprender`)"^^ . + "Build Commands"^^ . + "Debugging: Use apr Tools First (MANDATORY)"^^ . + "bashrs"^^ . + "cargo"^^ . + "gh"^^ . + "pmat"^^ . + "pv"^^ . + "Architecture"^^ . + "CI/CD (`.github/workflows/`)"^^ . + "Key Files"^^ . + "Modules"^^ . + "**Never:** work around `pv` with a shell script. The in-tree tool is the source of truth."^^ . + "Project Overview"^^ . + ".cargo/"^^ . + ".github/workflows/"^^ . + ".github/workflows/ci.yml"^^ . + ".github/workflows/coverage-nightly.yml"^^ . + "ci/explicit-test-commands.d/"^^ . + "contracts/"^^ . + "contracts/apr-cli-commands-v1.yaml"^^ . + "contracts/beat-ollama-decode-throughput-speed-v1.yaml"^^ . + "contracts/claude-md.yaml"^^ . + "contracts/tensor-layout-v1.yaml"^^ . + "crates/"^^ . + "crates/apr-cli/"^^ . + "crates/apr-cli/Cargo.toml"^^ . + "crates/aprender-compute"^^ . + "crates/aprender-compute/"^^ . + "crates/aprender-contracts-cli/"^^ . + "crates/aprender-contracts-macros/"^^ . + "crates/aprender-contracts/"^^ . + "crates/aprender-contracts/src/schema/"^^ . + "crates/aprender-core/"^^ . + "crates/aprender-core/src/format/"^^ . + "crates/aprender-core/src/format/converter/mod.rs"^^ . + "crates/aprender-core/src/format/converter/write.rs"^^ . + "crates/aprender-core/src/format/layout_contract.rs"^^ . + "crates/aprender-core/src/lib.rs"^^ . + "crates/aprender-core/src/primitives/"^^ . + "crates/aprender-core/src/text/chat_template/"^^ . + "crates/aprender-core/src/traits.rs"^^ . + "crates/aprender-core/tests/readme_contract.rs"^^ . + "crates/aprender-serve/"^^ . + "crates/aprender-serve/Cargo.toml"^^ . + "crates/aprender-serve/src/gguf/inference/fused_matmul_into.rs"^^ . + "crates/aprender-serve/src/inference_trace/"^^ . + "crates/aprender-serve/src/inference_trace/mod.rs"^^ . + "crates/aprender-serve/src/quantize/fused_gate_up.rs"^^ . + "crates/aprender-train/"^^ . + "docs/BEATS.md"^^ . + "docs/specifications/aprender-monorepo-consolidation.md"^^ . + "scripts/apr_bin.sh"^^ . + "scripts/bump-version.sh"^^ . + "scripts/check_clippy_current_stable.sh"^^ . + "scripts/check_msrv.sh"^^ . + "scripts/check_sourced_libs_option_neutral.sh"^^ . + "src/"^^ . + "src/bin/"^^ . + "src/bin/apr.rs"^^ . + "src/lib.rs"^^ . + "Autonomous Operation Mode (DEFAULT)"^^ . + "CRITICAL: Code Search Policy"^^ . + "CRITICAL: Realizar-First Architecture"^^ . + "Contract Validation: DOGFOOD `pv`, NEVER bash"^^ . + "Git Workflow (Branch Protection)"^^ . + "LAYOUT-001/002: Tensor Layout Safety"^^ . + "Linting"^^ . + "Publishing Safety (CB-510 Lesson)"^^ . + "Shell Scripts: Use bashrs (NOT shellcheck)"^^ . + "Verification Discipline (MANDATORY — read before reporting any result)"^^ . + "APR CLI (`cargo install aprender`)"^^ . + "Architecture"^^ . + "Autonomous Operation Mode (DEFAULT)"^^ . + "Build Commands"^^ . + "CI/CD (`.github/workflows/`)"^^ . + "CRITICAL: Code Search Policy"^^ . + "CRITICAL: Realizar-First Architecture"^^ . + "Contract Validation: DOGFOOD `pv`, NEVER bash"^^ . + "Debugging: Use apr Tools First (MANDATORY)"^^ . + "Git Workflow (Branch Protection)"^^ . + "Key Files"^^ . + "LAYOUT-001/002: Tensor Layout Safety"^^ . + "Linting"^^ . + "Modules"^^ . + "PMAT Quality Analysis"^^ . + "Project Overview"^^ . + "Publishing Safety (CB-510 Lesson)"^^ . + "SSC Training Infrastructure Status (snapshot 2026-03-22 — STALE, re-verify before acting)"^^ . + "Shell Scripts: Use bashrs (NOT shellcheck)"^^ . + "Stack Documentation Search"^^ . + "Testing"^^ . + "Verification Discipline (MANDATORY — read before reporting any result)"^^ . + . . . "qwen35"^^ . @@ -9498,6 +9738,7 @@ . "true"^^ . "00fe7986ff5f6b463e62455821146049db6f9313603938a70800d1fb69ef11a4"^^ . + . . . "qwen35"^^ . @@ -9517,6 +9758,15 @@ . "true"^^ . "03b74727a860a56338e042c4420bb3f04b2fec5734175f4cb9fa853daf52b7e8"^^ . + . + . + "2.0"^^ . + . + "crates/aprender-core/tests/fixtures/setfit/slice_model.apr"^^ . + "apr"^^ . + "4a75ee8031b3fbe0b17e1706a248aabbf158420c06f082bc29a89e2103d4057e"^^ . + "37"^^ . + . . . "qwen2"^^ . @@ -9536,6 +9786,7 @@ . "true"^^ . "6a1a2eb6d15622bf3c96857206351ba97e1af16c30d7a74ee38970e434e9407e"^^ . + . . . "qwen35"^^ . @@ -9555,6 +9806,7 @@ . "true"^^ . "84b5f7f112156d63836a01a69dc3f11a6ba63b10a23b8ca7a7efaf52d5a2d806"^^ . + . . . "qwen35"^^ . @@ -9574,6 +9826,7 @@ . "true"^^ . "aaf42c8b7c3cab2bf3d69c355048d4a0ee9973d48f16c731c0520ee914699223"^^ . + . . . "qwen3"^^ . @@ -9593,6 +9846,7 @@ . "true"^^ . "b139949c5bd74937ad8ed8c8cf3d9ffb1e99c866c823204dc42c0d91fa181897"^^ . + . . . "qwen35"^^ . @@ -9612,6 +9866,7 @@ . "true"^^ . "bd258782e35f7f458f8aced1adc053e6e92e89bc735ba3be89d38a06121dc517"^^ . + . . . "qwen3"^^ . @@ -9738,6 +9993,36 @@ "model_sha256: NOT RECORDED at measurement time and deliberately not back-filled. Hashing the file on the host today would attach a claim about a different world to a receipt about 2026-09-09. Absent means no measurement, never a match (ONT-4c1)."^^ . . . + . + "1837"^^ . + "crates/aprender-contracts-cli"^^ . + "library"^^ . + "1.0"^^ . + "A Qwen story"^^ . + "Architecture"^^ . + "At HEAD"^^ . + "Beat 1 — Discover (Registry)"^^ . + "Beat 2 — Trust (QA gates)"^^ . + "Beat 3 — Explore (Inspection)"^^ . + "Beat 4 — Adapt (Model ops)"^^ . + "Beat 5 — Use (Inference)"^^ . + "Beat 6 — Serve (REST API)"^^ . + "Beat 7 — Operate (Profiling)"^^ . + "Beat 8 — Scale (MoE introspection)"^^ . + "Command surface"^^ . + "Contributing"^^ . + "Cookbook"^^ . + "Install"^^ . + "Library usage"^^ . + "License"^^ . + "Migration from old crates"^^ . + "Performance"^^ . + "Provable contracts"^^ . + "Publish (separate flow)"^^ . + "Quick Start"^^ . + "Verified this release"^^ . + "What is Aprender?"^^ . + "setsid --wait bash scripts/check_readme_claims.sh"^^ . . "cpu=ok"^^ . "cuda=ok"^^ . @@ -10975,6 +11260,86 @@ "with_stdin_support"^^ . "true"^^ . "pub"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "gelu_exact"^^ . + . + "aprender::autograd::ops::activation"^^ . + "gelu_exact"^^ . + "false"^^ . + "no `mod activation` or `use … activation` in `crates/aprender-core/src/autograd/ops/mod.rs`"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "matmul"^^ . + . + "aprender::autograd::ops::activation"^^ . + "matmul"^^ . + "false"^^ . + "no `mod activation` or `use … activation` in `crates/aprender-core/src/autograd/ops/mod.rs`"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "embedding_gather"^^ . + . + "aprender::autograd::ops::embedding"^^ . + "embedding_gather"^^ . + "false"^^ . + "no `mod embedding` or `use … embedding` in `crates/aprender-core/src/autograd/ops/mod.rs`"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "additive_attention_mask"^^ . + . + "aprender::autograd::ops::masking"^^ . + "additive_attention_mask"^^ . + "false"^^ . + "no `mod masking` or `use … masking` in `crates/aprender-core/src/autograd/ops/mod.rs`"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "l2_normalize_rows"^^ . + . + "aprender::autograd::ops::normalize"^^ . + "l2_normalize_rows"^^ . + "false"^^ . + "no `mod normalize` or `use … normalize` in `crates/aprender-core/src/autograd/ops/mod.rs`"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "masked_mean_pool"^^ . + . + "aprender::autograd::ops::pooling"^^ . + "masked_mean_pool"^^ . + "false"^^ . + "no `mod pooling` or `use … pooling` in `crates/aprender-core/src/autograd/ops/mod.rs`"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "cosine_similarity_rows"^^ . + . + "aprender::autograd::ops::similarity"^^ . + "cosine_similarity_rows"^^ . + "false"^^ . + "no `mod similarity` or `use … similarity` in `crates/aprender-core/src/autograd/ops/mod.rs`"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "mse_loss"^^ . + . + "aprender::autograd::ops::similarity"^^ . + "mse_loss"^^ . + "false"^^ . + "no `mod similarity` or `use … similarity` in `crates/aprender-core/src/autograd/ops/mod.rs`"^^ . . . "doc"^^ . @@ -11100,6 +11465,26 @@ "predict_proba"^^ . "true"^^ . "pub"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "update"^^ . + . + "aprender::cluster::kmeans_impl"^^ . + "fit"^^ . + "false"^^ . + "no `mod kmeans_impl` or `use … kmeans_impl` in `crates/aprender-core/src/cluster/mod.rs`"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "assignment"^^ . + . + "aprender::cluster::kmeans_impl"^^ . + "predict"^^ . + "false"^^ . + "no `mod kmeans_impl` or `use … kmeans_impl` in `crates/aprender-core/src/cluster/mod.rs`"^^ . . . "doc"^^ . @@ -11199,6 +11584,20 @@ "validate"^^ . "false"^^ . "no `mod GgufModelConfig` or `use … GgufModelConfig` in `crates/aprender-core/src/format/gguf/api.rs`"^^ . + . + . + "doc"^^ . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "f16_to_f32_bias"^^ . + "crates/aprender-core/src/format/gguf/dequant.rs"^^ . + . + "fn"^^ . + "aprender::format::gguf::dequant"^^ . + "f16_to_f32"^^ . + "true"^^ . + "pub(crate)"^^ . . . "implemented"^^ . @@ -11429,16 +11828,6 @@ "run"^^ . "false"^^ . "no `mod import` or `use … import` in `crates/aprender-core/src/lib.rs`"^^ . - . - . - "implemented"^^ . - "aprender"^^ . - "forward_pass_completeness"^^ . - . - "aprender::inference::forward"^^ . - "forward_pass"^^ . - "false"^^ . - "no `mod inference` or `use … inference` in `crates/aprender-core/src/lib.rs`"^^ . . . "implemented"^^ . @@ -11451,6 +11840,30 @@ "generate"^^ . "false"^^ . "no `mod inference` or `use … inference` in `crates/aprender-core/src/lib.rs`"^^ . + . + . + "doc"^^ . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "sample"^^ . + "crates/aprender-core/src/metaheuristics/cmaes.rs"^^ . + . + "method"^^ . + "aprender::metaheuristics::cmaes"^^ . + "sample"^^ . + "true"^^ . + "private"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "mean_update"^^ . + . + "aprender::metaheuristics::cmaes_include_01"^^ . + "optimize"^^ . + "false"^^ . + "no `mod cmaes_include_01` or `use … cmaes_include_01` in `crates/aprender-core/src/metaheuristics/mod.rs`"^^ . . . "doc"^^ . @@ -11720,6 +12133,22 @@ "weight_names"^^ . "false"^^ . "no `mod constructors` or `use … constructors` in `crates/aprender-core/src/models/qwen2/mod.rs`"^^ . + . + . + "allow"^^ . + "doc"^^ . + "must_use"^^ . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "embedding_lookup"^^ . + "crates/aprender-core/src/models/qwen2/mod.rs"^^ . + . + "method"^^ . + "aprender::models::qwen2"^^ . + "forward"^^ . + "true"^^ . + "pub"^^ . . . "doc"^^ . @@ -11735,6 +12164,19 @@ "weight"^^ . "true"^^ . "pub"^^ . + . + . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "conv1d"^^ . + "crates/aprender-core/src/nn/conv/mod.rs"^^ . + . + "method"^^ . + "aprender::nn::conv"^^ . + "forward"^^ . + "true"^^ . + "private"^^ . . . "doc"^^ . @@ -11750,6 +12192,21 @@ "gelu"^^ . "true"^^ . "pub"^^ . + . + . + "doc"^^ . + "must_use"^^ . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "log_softmax"^^ . + "crates/aprender-core/src/nn/functional.rs"^^ . + . + "fn"^^ . + "aprender::nn::functional"^^ . + "log_softmax"^^ . + "true"^^ . + "pub"^^ . . . "doc"^^ . @@ -11765,6 +12222,36 @@ "relu"^^ . "true"^^ . "pub"^^ . + . + . + "doc"^^ . + "must_use"^^ . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "sigmoid"^^ . + "crates/aprender-core/src/nn/functional.rs"^^ . + . + "fn"^^ . + "aprender::nn::functional"^^ . + "sigmoid"^^ . + "true"^^ . + "pub"^^ . + . + . + "doc"^^ . + "must_use"^^ . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "silu"^^ . + "crates/aprender-core/src/nn/functional.rs"^^ . + . + "fn"^^ . + "aprender::nn::functional"^^ . + "silu"^^ . + "true"^^ . + "pub"^^ . . . "doc"^^ . @@ -11811,6 +12298,61 @@ "with_top_p"^^ . "true"^^ . "pub"^^ . + . + . + "doc"^^ . + "must_use"^^ . + "implemented"^^ . + "aprender"^^ . + "cross_entropy"^^ . + "crates/aprender-core/src/nn/loss.rs"^^ . + . + "method"^^ . + "aprender::nn::loss"^^ . + "forward"^^ . + "true"^^ . + "pub"^^ . + . + . + "doc"^^ . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "batchnorm_train"^^ . + "layernorm"^^ . + "crates/aprender-core/src/nn/normalization/mod.rs"^^ . + . + . + "method"^^ . + "aprender::nn::normalization"^^ . + "forward"^^ . + "true"^^ . + "private"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "rmsnorm"^^ . + "crates/aprender-core/src/nn/normalization/group_norm.rs"^^ . + . + "method"^^ . + "aprender::nn::normalization::group_norm"^^ . + "forward"^^ . + "true"^^ . + "private"^^ . + . + . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "weight_update"^^ . + "crates/aprender-core/src/nn/optim/rm_sprop.rs"^^ . + . + "method"^^ . + "aprender::nn::optim::rm_sprop"^^ . + "step"^^ . + "true"^^ . + "private"^^ . . . "implemented"^^ . @@ -11821,6 +12363,21 @@ "validate_config"^^ . "false"^^ . "no `mod rope` or `use … rope` in `crates/aprender-core/src/nn/mod.rs`"^^ . + . + . + "doc"^^ . + "must_use"^^ . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "rope"^^ . + "crates/aprender-core/src/nn/transformer/attention_helpers.rs"^^ . + . + "method"^^ . + "aprender::nn::transformer::attention_helpers"^^ . + "apply"^^ . + "true"^^ . + "pub"^^ . . . "implemented"^^ . @@ -11831,6 +12388,33 @@ "bidirectional_attention"^^ . "false"^^ . "no `fn bidirectional_attention` (free or in an impl) in `crates/aprender-core/src/nn/transformer/mod.rs`"^^ . + . + . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "gqa"^^ . + "crates/aprender-core/src/nn/transformer/mod.rs"^^ . + . + "method"^^ . + "aprender::nn::transformer"^^ . + "forward"^^ . + "true"^^ . + "private"^^ . + . + . + "doc"^^ . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "apply_additive_mask"^^ . + "crates/aprender-core/src/nn/transformer/positional_encoding.rs"^^ . + . + "fn"^^ . + "aprender::nn::transformer::positional_encoding"^^ . + "add_mask"^^ . + "true"^^ . + "pub(super)"^^ . . . "doc"^^ . @@ -11897,6 +12481,26 @@ "validate_header"^^ . "false"^^ . "no `mod safetensors` or `use … safetensors` in `crates/aprender-core/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "apr_reader"^^ . + . + "aprender::serialization::apr::AprReader"^^ . + "read"^^ . + "false"^^ . + "no `mod AprReader` or `use … AprReader` in `crates/aprender-core/src/serialization/apr/mod.rs`"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "apr_writer"^^ . + . + "aprender::serialization::apr::AprWriter"^^ . + "write"^^ . + "false"^^ . + "no `mod AprWriter` or `use … AprWriter` in `crates/aprender-core/src/serialization/apr/mod.rs`"^^ . . . "implemented"^^ . @@ -12035,6 +12639,16 @@ "ClassifyResponse"^^ . "false"^^ . "no `fn ClassifyResponse` (free or in an impl) in `crates/aprender-core/src/setfit/classify.rs`"^^ . + . + . + "implemented"^^ . + "aprender"^^ . + "setfit_encoder_forward"^^ . + . + "aprender::setfit::encoder::BertSentenceEncoder"^^ . + "forward_tokens"^^ . + "false"^^ . + "no `mod BertSentenceEncoder` or `use … BertSentenceEncoder` in `crates/aprender-core/src/setfit/encoder.rs`"^^ . . . "implemented"^^ . @@ -12045,6 +12659,20 @@ "identity"^^ . "false"^^ . "no `mod ExecutionBackend` or `use … ExecutionBackend` in `crates/aprender-core/src/setfit/encoder.rs`"^^ . + . + . + "doc"^^ . + "provable_contracts_macros::contract"^^ . + "implemented"^^ . + "aprender"^^ . + "pair_cosine_mse"^^ . + "crates/aprender-core/src/setfit/loss.rs"^^ . + . + "fn"^^ . + "aprender::setfit::loss"^^ . + "pair_cosine_mse"^^ . + "true"^^ . + "pub"^^ . . . "implemented"^^ . @@ -12386,6 +13014,637 @@ "validate_ingest_ladder"^^ . "true"^^ . "private"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "brick_verification"^^ . + . + "batuta::src::agent::brick"^^ . + "can_render"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "brick_budget_enforcement"^^ . + . + "batuta::src::agent::brick"^^ . + "verify_all"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "apr_md_compliance"^^ . + . + "batuta::src::agent::config"^^ . + "load_apr_md"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "context_compaction"^^ . + . + "batuta::src::agent::context"^^ . + "compact"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "body_schema_compliance"^^ . + "max_tokens_cap"^^ . + . + "batuta::src::agent::driver::apr_serve"^^ . + "build_openai_body"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "thinking_block_strip"^^ . + . + "batuta::src::agent::driver::apr_serve"^^ . + "strip_thinking_blocks"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "backoff_jitter"^^ . + . + "batuta::src::agent::driver::remote::backoff"^^ . + "delay_for"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "cost_budget"^^ . + . + "batuta::src::agent::driver::remote"^^ . + "estimate_cost"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "format_translation"^^ . + . + "batuta::src::agent::driver::remote::translator"^^ . + "to_openai"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "failover_cascade"^^ . + . + "batuta::src::agent::driver::routing"^^ . + "failover_next"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "privacy_enforcement"^^ . + . + "batuta::src::agent::driver::routing"^^ . + "route_to_provider"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "jidoka_validation"^^ . + . + "batuta::src::agent::driver::validate"^^ . + "is_valid_model_file"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "deterministic_encode"^^ . + "vocab_size_bound"^^ . + . + "batuta::src::agent::driver::validate::tokenizer_encode"^^ . + "encode"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "hook_ordering"^^ . + . + "batuta::src::agent::hook"^^ . + "execute_with_hooks"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "search_order"^^ . + "sort_priority"^^ . + . + "batuta::src::agent::manifest"^^ . + "discover_model"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "startup_latency"^^ . + . + "batuta::src::agent"^^ . + "run_apr_code"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "loop_termination"^^ . + . + "batuta::src::agent"^^ . + "run_loop"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "sandbox_enforcement"^^ . + . + "batuta::src::agent::sandbox"^^ . + "check_sandbox"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "sovereignty_guarantee"^^ . + . + "batuta::src::agent::sandbox"^^ . + "enforce_offline"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "manifest_serde"^^ . + . + "batuta::src::agent::session::Session"^^ . + "new"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "jsonl_roundtrip"^^ . + . + "batuta::src::agent::session"^^ . + "append_messages"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "age_filter"^^ . + . + "batuta::src::agent::session"^^ . + "find_recent_for_cwd"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "session_crash_recovery"^^ . + . + "batuta::src::agent::session"^^ . + "resume"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "session_integrity"^^ . + . + "batuta::src::agent::session"^^ . + "resume_session"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "state_machine"^^ . + "state_machine_validity"^^ . + . + . + "batuta::src::agent::state"^^ . + "transition"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "parallel_tool_safety"^^ . + . + "batuta::src::agent::tool"^^ . + "execute_all"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "tool_safety"^^ . + . + "batuta::src::agent::tool"^^ . + "execute_tool_checked"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "cost_display_accuracy"^^ . + "cost_display_invariants"^^ . + . + . + "batuta::src::agent::tui::cost"^^ . + "update_cost"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "layout_correctness"^^ . + "panel_layout_nonoverlap"^^ . + . + . + "batuta::src::agent::tui::layout"^^ . + "compute_layout"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "adaptive_degradation"^^ . + . + "batuta::src::agent::tui::layout"^^ . + "detail_level"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "frame_budget"^^ . + . + "batuta::src::agent::tui::layout"^^ . + "render_frame"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "sandbox_violation_visibility"^^ . + . + "batuta::src::agent::tui::sandbox"^^ . + "add_violation"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "statusbar_state_display"^^ . + . + "batuta::src::agent::tui::statusbar"^^ . + "update_statusbar"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "streaming_responsiveness"^^ . + "streaming_token_ordering"^^ . + . + . + "batuta::src::agent::tui::streaming"^^ . + "push_token"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "tool_progress_monotonic"^^ . + . + "batuta::src::agent::tui::tools"^^ . + "update_tool"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "analyze"^^ . + . + "batuta::src::analyzer"^^ . + "analyze_project"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "analyze"^^ . + . + "batuta::src::bug_hunter::contracts"^^ . + "analyze_contract_gaps"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "analyze"^^ . + . + "batuta::src::bug_hunter::model_parity"^^ . + "analyze_model_parity_gaps"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "transpile"^^ . + . + "batuta::src::cli::mod"^^ . + "build_transpiler_args"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "dispatch_correctness"^^ . + . + "batuta::src::cli::oracle::mod"^^ . + "dispatch_oracle"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "rag_query_correctness"^^ . + . + "batuta::src::cli::oracle::rag"^^ . + "rag_search"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "pending"^^ . + "batuta"^^ . + "index_freshness"^^ . + . + "batuta::src::cli::oracle::rag_index"^^ . + "check_freshness"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "analyze"^^ . + . + "batuta::src::cli::pipeline_cmds"^^ . + "cmd_analyze"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "transpile"^^ . + . + "batuta::src::cli::pipeline_cmds_transpile"^^ . + "cmd_transpile"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "analyze"^^ . + . + "batuta::src::parf"^^ . + "analyze_dependencies"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "analyze"^^ . + . + "batuta::src::pipeline::stages::optimization"^^ . + "analyze_optimizations"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "analyze"^^ . + . + "batuta::src::pipeline_analysis"^^ . + "analyze_numpy_usage"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "analyze"^^ . + . + "batuta::src::pipeline_analysis"^^ . + "analyze_pytorch_usage"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "analyze"^^ . + . + "batuta::src::pipeline_analysis"^^ . + "analyze_sklearn_usage"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "analyze"^^ . + . + "batuta::src::recipes::benchmark"^^ . + "analyze"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "analyze"^^ . + . + "batuta::src::tools"^^ . + "analyze_quality"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "transpile"^^ . + . + "batuta::src::tools"^^ . + "get_transpiler_for_language"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "transpile"^^ . + . + "batuta::src::tools"^^ . + "has_transpiler"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "transpile"^^ . + . + "batuta::src::tools"^^ . + "transpile_c_cpp"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "transpile"^^ . + . + "batuta::src::tools"^^ . + "transpile_python"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "transpile"^^ . + . + "batuta::src::tools"^^ . + "transpile_shell"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "transpile"^^ . + . + "batuta::src::types"^^ . + "recommend_transpiler"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "batuta"^^ . + "analyze"^^ . + . + "batuta::src::wasm"^^ . + "analyze_code"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-orchestrate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "accuracy"^^ . + . + "entrenar"^^ . + "accuracy"^^ . + "false"^^ . + "no `fn accuracy` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "attention"^^ . + . + "entrenar"^^ . + "attention"^^ . + "false"^^ . + "no `fn attention` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "device_dispatch"^^ . + . + "entrenar::autograd::cuda_training::ComputeDevice"^^ . + "auto_detect"^^ . + "false"^^ . + "no `mod ComputeDevice` or `use … ComputeDevice` in `crates/aprender-train/src/autograd/cuda_training.rs`"^^ . + . + . + "pending"^^ . + "entrenar"^^ . + "weight_roundtrip"^^ . + . + "entrenar::autograd::cuda_training"^^ . + "weight_roundtrip"^^ . + "false"^^ . + "no `fn weight_roundtrip` (free or in an impl) in `crates/aprender-train/src/autograd/cuda_training.rs`"^^ . . . "implemented"^^ . @@ -12396,6 +13655,232 @@ "matmul"^^ . "false"^^ . "no `fn matmul` (free or in an impl) in `crates/aprender-train/src/autograd/mod.rs`"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "entrenar"^^ . + "transpose_tracked"^^ . + "crates/aprender-train/src/autograd/ops/matmul.rs"^^ . + . + "fn"^^ . + "entrenar::autograd::ops::matmul"^^ . + "transpose_tracked"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "tensor_clone"^^ . + . + "entrenar::autograd::tensor::Tensor"^^ . + "clone"^^ . + "false"^^ . + "no `mod Tensor` or `use … Tensor` in `crates/aprender-train/src/autograd/tensor.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "data_read"^^ . + . + "entrenar::autograd::tensor::Tensor"^^ . + "data"^^ . + "false"^^ . + "no `mod Tensor` or `use … Tensor` in `crates/aprender-train/src/autograd/tensor.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "data_mut"^^ . + . + "entrenar::autograd::tensor::Tensor"^^ . + "data_mut"^^ . + "false"^^ . + "no `mod Tensor` or `use … Tensor` in `crates/aprender-train/src/autograd/tensor.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "bounds"^^ . + . + "entrenar"^^ . + "bounds"^^ . + "false"^^ . + "no `fn bounds` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "confusion_matrix"^^ . + . + "entrenar"^^ . + "confusion_matrix"^^ . + "false"^^ . + "no `fn confusion_matrix` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "vram_budget"^^ . + . + "entrenar::finetune::classify_pipeline"^^ . + "estimate_wgpu_vram"^^ . + "false"^^ . + "no `fn estimate_wgpu_vram` (free or in an impl) in `crates/aprender-train/src/finetune/classify_pipeline/mod.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "load_checkpoint"^^ . + . + "entrenar::finetune::classify_trainer"^^ . + "load_checkpoint"^^ . + "false"^^ . + "no `fn load_checkpoint` (free or in an impl) in `crates/aprender-train/src/finetune/classify_trainer.rs`"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "entrenar"^^ . + "save_checkpoint"^^ . + "crates/aprender-train/src/finetune/classify_trainer.rs"^^ . + . + "method"^^ . + "entrenar::finetune::classify_trainer"^^ . + "save_checkpoint"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "gradient_allreduce"^^ . + . + "entrenar::finetune::data_parallel::DataParallelCoordinator"^^ . + "allreduce"^^ . + "false"^^ . + "no `mod DataParallelCoordinator` or `use … DataParallelCoordinator` in `crates/aprender-train/src/finetune/data_parallel.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "sharding"^^ . + . + "entrenar::finetune::data_parallel::DataParallelCoordinator"^^ . + "shard_data"^^ . + "false"^^ . + "no `mod DataParallelCoordinator` or `use … DataParallelCoordinator` in `crates/aprender-train/src/finetune/data_parallel.rs`"^^ . + . + . + "pending"^^ . + "entrenar"^^ . + "lora_gradient_size"^^ . + . + "entrenar::finetune::data_parallel"^^ . + "lora_gradient_byte_size"^^ . + "false"^^ . + "no `fn lora_gradient_byte_size` (free or in an impl) in `crates/aprender-train/src/finetune/data_parallel.rs`"^^ . + . + . + "pending"^^ . + "entrenar"^^ . + "weighted_loss"^^ . + . + "entrenar::finetune::data_parallel"^^ . + "weighted_loss_aggregate"^^ . + "false"^^ . + "no `fn weighted_loss_aggregate` (free or in an impl) in `crates/aprender-train/src/finetune/data_parallel.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "effective_batch_size"^^ . + . + "entrenar::finetune::qlora_config::QLoraConfig"^^ . + "effective_batch_size"^^ . + "false"^^ . + "no `mod qlora_config` or `use … qlora_config` in `crates/aprender-train/src/finetune/mod.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "epoch_count_imbalanced"^^ . + . + "entrenar::finetune::qlora_config::QLoraConfig"^^ . + "epochs"^^ . + "false"^^ . + "no `mod qlora_config` or `use … qlora_config` in `crates/aprender-train/src/finetune/mod.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "gradient_clip_bound"^^ . + . + "entrenar::finetune::qlora_config::QLoraConfig"^^ . + "gradient_clip_norm"^^ . + "false"^^ . + "no `mod qlora_config` or `use … qlora_config` in `crates/aprender-train/src/finetune/mod.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "learning_rate_scaling"^^ . + . + "entrenar::finetune::qlora_config::QLoraConfig"^^ . + "learning_rate"^^ . + "false"^^ . + "no `mod qlora_config` or `use … qlora_config` in `crates/aprender-train/src/finetune/mod.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "lora_alpha_ratio"^^ . + . + "entrenar::finetune::qlora_config::QLoraConfig"^^ . + "lora_alpha"^^ . + "false"^^ . + "no `mod qlora_config` or `use … qlora_config` in `crates/aprender-train/src/finetune/mod.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "seq_len_from_data"^^ . + . + "entrenar::finetune::qlora_config::QLoraConfig"^^ . + "max_seq_len"^^ . + "false"^^ . + "no `mod qlora_config` or `use … qlora_config` in `crates/aprender-train/src/finetune/mod.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "warmup_fraction"^^ . + . + "entrenar::finetune::qlora_config::QLoraConfig"^^ . + "warmup_steps"^^ . + "false"^^ . + "no `mod qlora_config` or `use … qlora_config` in `crates/aprender-train/src/finetune/mod.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "gelu"^^ . + . + "entrenar"^^ . + "gelu"^^ . + "false"^^ . + "no `fn gelu` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "greedy"^^ . + . + "entrenar"^^ . + "greedy"^^ . + "false"^^ . + "no `fn greedy` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . . . "doc"^^ . @@ -12422,6 +13907,136 @@ "merge"^^ . "true"^^ . "pub"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "matmul"^^ . + . + "entrenar"^^ . + "matmul"^^ . + "false"^^ . + "no `fn matmul` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "ndcg_at_k"^^ . + . + "entrenar"^^ . + "ndcg_at_k"^^ . + "false"^^ . + "no `fn ndcg_at_k` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "output"^^ . + . + "entrenar"^^ . + "output"^^ . + "false"^^ . + "no `fn output` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "predict"^^ . + . + "entrenar"^^ . + "predict"^^ . + "false"^^ . + "no `fn predict` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "quantize"^^ . + . + "entrenar"^^ . + "quantize"^^ . + "false"^^ . + "no `fn quantize` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "relu"^^ . + . + "entrenar"^^ . + "relu"^^ . + "false"^^ . + "no `fn relu` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "rmse"^^ . + . + "entrenar"^^ . + "rmse"^^ . + "false"^^ . + "no `fn rmse` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "sample"^^ . + . + "entrenar"^^ . + "sample"^^ . + "false"^^ . + "no `fn sample` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "sigmoid"^^ . + . + "entrenar"^^ . + "sigmoid"^^ . + "false"^^ . + "no `fn sigmoid` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "statistics"^^ . + . + "entrenar"^^ . + "statistics"^^ . + "false"^^ . + "no `fn statistics` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "temperature"^^ . + . + "entrenar"^^ . + "temperature"^^ . + "false"^^ . + "no `fn temperature` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "top_k"^^ . + . + "entrenar"^^ . + "top_k"^^ . + "false"^^ . + "no `fn top_k` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "total_bytes"^^ . + . + "entrenar"^^ . + "total_bytes"^^ . + "false"^^ . + "no `fn total_bytes` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . . . "implemented"^^ . @@ -12452,6 +14067,1091 @@ "mint_test_token"^^ . "false"^^ . "no `mod setfit` or `use … setfit` in `crates/aprender-train/src/train/mod.rs`"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "entrenar"^^ . + "C-WGPU-TRAIN-001"^^ . + "crates/aprender-train/src/train/transformer_trainer/wgpu_trainer.rs"^^ . + . + "method"^^ . + "entrenar::train::transformer_trainer::wgpu_trainer"^^ . + "full_train_step"^^ . + "true"^^ . + "pub"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "entrenar"^^ . + "C-WGPU-CKPT-002"^^ . + "crates/aprender-train/src/train/transformer_trainer/wgpu_trainer.rs"^^ . + . + "method"^^ . + "entrenar::train::transformer_trainer::wgpu_trainer"^^ . + "load_checkpoint"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "C-WGPU-CKPT-001"^^ . + "crates/aprender-train/src/train/transformer_trainer/wgpu_trainer.rs"^^ . + . + "method"^^ . + "entrenar::train::transformer_trainer::wgpu_trainer"^^ . + "save_checkpoint"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "head_extraction"^^ . + . + "entrenar::transformer::attention::MultiHeadAttention"^^ . + "forward"^^ . + "false"^^ . + "no `mod MultiHeadAttention` or `use … MultiHeadAttention` in `crates/aprender-train/src/transformer/attention.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "forward_with_lora"^^ . + "lora_forward"^^ . + . + . + "entrenar::transformer::attention::MultiHeadAttention"^^ . + "forward_with_lora"^^ . + "false"^^ . + "no `mod MultiHeadAttention` or `use … MultiHeadAttention` in `crates/aprender-train/src/transformer/attention.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "gpu_forward"^^ . + . + "entrenar::transformer::cuda_block::CudaTransformerBlock"^^ . + "gpu_forward"^^ . + "false"^^ . + "no `mod CudaTransformerBlock` or `use … CudaTransformerBlock` in `crates/aprender-train/src/transformer/cuda_block.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "swiglu_ffn"^^ . + . + "entrenar::transformer::cuda_block::CudaTransformerBlock"^^ . + "swiglu_ffn"^^ . + "false"^^ . + "no `mod CudaTransformerBlock` or `use … CudaTransformerBlock` in `crates/aprender-train/src/transformer/cuda_block.rs`"^^ . + . + . + "doc"^^ . + "provable_contracts_macros::contract"^^ . + "partial"^^ . + "entrenar"^^ . + "backward"^^ . + "crates/aprender-train/src/transformer/cuda_block.rs"^^ . + . + "method"^^ . + "entrenar::transformer::cuda_block"^^ . + "backward"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "with_resident_weights"^^ . + . + "entrenar::transformer::wgpu_block::WgpuForwardPass"^^ . + "with_resident_weights"^^ . + "false"^^ . + "no `mod WgpuForwardPass` or `use … WgpuForwardPass` in `crates/aprender-train/src/transformer/wgpu_block.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "transpose"^^ . + . + "entrenar"^^ . + "transpose"^^ . + "false"^^ . + "no `fn transpose` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "entrenar"^^ . + "update"^^ . + . + "entrenar"^^ . + "update"^^ . + "false"^^ . + "no `fn update` (free or in an impl) in `crates/aprender-train/src/lib.rs`"^^ . + . + . + . + "oxide"^^ . + "lambda"^^ . + . + . + . + . + . + "21"^^ . + "true"^^ . + "lambda"^^ . + "doc"^^ . + "kernel"^^ . + "implemented"^^ . + "true"^^ . + "gated_rmsnorm_oxide"^^ . + "gated_rmsnorm"^^ . + "experiments/cuda-oxide/gated-rmsnorm/src/lib.rs"^^ . + . + "fn"^^ . + "gated_rmsnorm_oxide::kernels"^^ . + "gated_rmsnorm"^^ . + "true"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-core::src::virtualization"^^ . + "cells_to_render"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-core::src::virtualization"^^ . + "render_count"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-core::src::virtualization"^^ . + "render_range"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-core::src::virtualization"^^ . + "should_render"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-core::src::virtualization"^^ . + "should_render_cell"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-layout::src::compute_block"^^ . + "render_order"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "pending"^^ . + "presentar"^^ . + "terminal_restore"^^ . + . + "presentar::crates::presentar-terminal::src::app::TuiApp"^^ . + "drop"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "pending"^^ . + "presentar"^^ . + "widget_lifecycle"^^ . + . + "presentar::crates::presentar-terminal::src::app::TuiApp"^^ . + "mount"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "pending"^^ . + "presentar"^^ . + "widget_lifecycle"^^ . + . + "presentar::crates::presentar-terminal::src::app::TuiApp"^^ . + "unmount"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "pending"^^ . + "presentar"^^ . + "event_dispatch"^^ . + . + "presentar::crates::presentar-terminal::src::app"^^ . + "dispatch_event"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-terminal::src::app"^^ . + "record_render"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "pending"^^ . + "presentar"^^ . + "color_mode_fallback"^^ . + . + "presentar::crates::presentar-terminal::src::color"^^ . + "nearest_256"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-terminal::src::compute_block"^^ . + "render"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "pending"^^ . + "presentar"^^ . + "unicode_width"^^ . + . + "presentar::crates::presentar-terminal::src::direct::cell_buffer"^^ . + "display_width"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "pending"^^ . + "presentar"^^ . + "cellbuffer_bounds"^^ . + . + "presentar::crates::presentar-terminal::src::direct::cell_buffer"^^ . + "get"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "pending"^^ . + "presentar"^^ . + "dirty_tracking"^^ . + . + "presentar::crates::presentar-terminal::src::direct::cell_buffer"^^ . + "mark_dirty"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "pending"^^ . + "presentar"^^ . + "resize_safety"^^ . + . + "presentar::crates::presentar-terminal::src::direct::cell_buffer"^^ . + "resize"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "pending"^^ . + "presentar"^^ . + "cellbuffer_bounds"^^ . + . + "presentar::crates::presentar-terminal::src::direct::cell_buffer"^^ . + "set"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "pending"^^ . + "presentar"^^ . + "diff_renderer_correctness"^^ . + "render_cycle_correctness"^^ . + "zero_alloc_render"^^ . + . + . + "presentar::crates::presentar-terminal::src::direct::diff_renderer"^^ . + "render_diff"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-terminal::src::direct::diff_renderer"^^ . + "render_full"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-terminal::src::perf_trace::trackers"^^ . + "for_render"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-terminal::src::tools::bench"^^ . + "render_frame"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-terminal::src::widgets::micro_heat_bar"^^ . + "render_string"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-terminal::src::widgets::micro_heat_bar"^^ . + "render_text"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-terminal::src::widgets::ux"^^ . + "render_lines"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar-widgets::src::list"^^ . + "render_with"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar::src::browser::app"^^ . + "render_counter"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar::src::browser::app"^^ . + "render_dashboard"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar::src::browser::app"^^ . + "render_json"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar::src::browser::app"^^ . + "render_widget"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "presentar"^^ . + "render"^^ . + . + "presentar::crates::presentar::src::webgpu"^^ . + "render_instances"^^ . + "false"^^ . + "crate `presentar` is not a workspace member"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "token_budget"^^ . + . + "realizar::api::cuda_batch_scheduler::CudaBatchScheduler"^^ . + "schedule_step"^^ . + "false"^^ . + "no `mod CudaBatchScheduler` or `use … CudaBatchScheduler` in `crates/aprender-serve/src/api/cuda_batch_scheduler.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "residual_stream"^^ . + . + "realizar::apply"^^ . + "forward_layer"^^ . + "false"^^ . + "no `mod apply` or `use … apply` in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "apr_loader_validation"^^ . + . + "realizar::apr::cuda_part_02_part_03::AprV2ModelCuda"^^ . + "upload_quantized_weights_to_gpu"^^ . + "false"^^ . + "no `mod cuda_part_02_part_03` or `use … cuda_part_02_part_03` in `crates/aprender-serve/src/apr/mod.rs`"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "realizar"^^ . + "attention"^^ . + "gqa"^^ . + "crates/aprender-serve/src/apr/helpers.rs"^^ . + . + . + "fn"^^ . + "realizar::apr::helpers"^^ . + "simple_attention"^^ . + "true"^^ . + "pub(crate)"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "role_mapping"^^ . + . + "realizar::arch_requirements_fallback::WeightRole"^^ . + "field_name"^^ . + "false"^^ . + "no `mod arch_requirements_fallback` or `use … arch_requirements_fallback` in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "constraint_matrix_exhaustiveness"^^ . + . + "realizar::arch_requirements_fallback"^^ . + "required_roles"^^ . + "false"^^ . + "no `mod arch_requirements_fallback` or `use … arch_requirements_fallback` in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "bounds"^^ . + . + "realizar"^^ . + "bounds"^^ . + "false"^^ . + "no `fn bounds` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "compute_ceiling"^^ . + . + "realizar::brick::fused::CoalescedDp4aBrick"^^ . + "arithmetic_intensity"^^ . + "false"^^ . + "no `mod CoalescedDp4aBrick` or `use … CoalescedDp4aBrick` in `crates/aprender-serve/src/brick/fused.rs`"^^ . + . + . + "doc"^^ . + "must_use"^^ . + "implemented"^^ . + "realizar"^^ . + "transpose_involution"^^ . + "crates/aprender-serve/src/contract_gate.rs"^^ . + . + "fn"^^ . + "realizar::contract_gate"^^ . + "transpose_f32"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "weight_completeness"^^ . + "crates/aprender-serve/src/contract_gate.rs"^^ . + . + "fn"^^ . + "realizar::contract_gate"^^ . + "validate_completeness"^^ . + "true"^^ . + "private"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "paged_contiguous_equivalence"^^ . + . + "realizar::cuda::executor::attention"^^ . + "paged_attention_into"^^ . + "false"^^ . + "no `fn paged_attention_into` (free or in an impl) in `crates/aprender-serve/src/cuda/executor/attention.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "jit_compilation_success"^^ . + . + "realizar::cuda::executor::core::CudaExecutor"^^ . + "compile_ptx"^^ . + "false"^^ . + "no `mod CudaExecutor` or `use … CudaExecutor` in `crates/aprender-serve/src/cuda/executor/core.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "report_denominator"^^ . + "report_fidelity"^^ . + "report_metadata"^^ . + . + "realizar::cuda::executor::executor_api::ExecutorApi"^^ . + "start_brick_id"^^ . + "false"^^ . + "no `mod executor_api` or `use … executor_api` in `crates/aprender-serve/src/cuda/executor/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "report_completeness"^^ . + . + "realizar::cuda::executor::executor_api::ExecutorApi"^^ . + "stop_brick_id"^^ . + "false"^^ . + "no `mod executor_api` or `use … executor_api` in `crates/aprender-serve/src/cuda/executor/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "block_allocation"^^ . + . + "realizar::cuda::executor::kv_cache::BlockAllocator"^^ . + "allocate"^^ . + "false"^^ . + "no `mod BlockAllocator` or `use … BlockAllocator` in `crates/aprender-serve/src/cuda/executor/kv_cache.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "slot_mapping"^^ . + . + "realizar::cuda::executor::kv_cache::PagedKvCache"^^ . + "slot"^^ . + "false"^^ . + "no `mod PagedKvCache` or `use … PagedKvCache` in `crates/aprender-serve/src/cuda/executor/kv_cache.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "fused_kernel"^^ . + . + "realizar::cuda::executor::kv_cache_part_02_part_03"^^ . + "flash_attention_cached"^^ . + "false"^^ . + "no `mod kv_cache_part_02_part_03` or `use … kv_cache_part_02_part_03` in `crates/aprender-serve/src/cuda/executor/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "qk_norm_score_bound"^^ . + . + "realizar::cuda::executor::layers::apply"^^ . + "apply_qk_norm"^^ . + "false"^^ . + "no `mod apply` or `use … apply` in `crates/aprender-serve/src/cuda/executor/layers/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "context_health"^^ . + . + "realizar::cuda::executor::layers::cublas_prefill::attention::CudaExecutor"^^ . + "warmup_fp8_cache"^^ . + "false"^^ . + "no `mod CudaExecutor` or `use … CudaExecutor` in `crates/aprender-serve/src/cuda/executor/layers/cublas_prefill/attention.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "cuda_graph_guard"^^ . + "graph_disable"^^ . + . + . + "realizar::cuda::executor::layers::graphed_capture"^^ . + "try_graph_capture"^^ . + "false"^^ . + "no `mod graphed_capture` or `use … graphed_capture` in `crates/aprender-serve/src/cuda/executor/layers/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "chunked_prefill"^^ . + . + "realizar::cuda::executor::layers::prefill::CudaExecutor"^^ . + "chunked_prefill"^^ . + "false"^^ . + "no `mod CudaExecutor` or `use … CudaExecutor` in `crates/aprender-serve/src/cuda/executor/layers/prefill.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "shared_q8_qkv"^^ . + . + "realizar::cuda::executor::q4k_mwv_gemv::CudaExecutor"^^ . + "batched_qkv_dp4a_q4k_gemv_into"^^ . + "false"^^ . + "no `mod q4k_mwv_gemv` or `use … q4k_mwv_gemv` in `crates/aprender-serve/src/cuda/executor/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "gguf_loader_validation"^^ . + . + "realizar::cuda::executor::weights::CudaExecutor"^^ . + "build_indexed_weights"^^ . + "false"^^ . + "no `mod CudaExecutor` or `use … CudaExecutor` in `crates/aprender-serve/src/cuda/executor/weights.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "throughput_target"^^ . + . + "realizar::cuda::executor::weights::CudaExecutor"^^ . + "load_weights"^^ . + "false"^^ . + "no `mod CudaExecutor` or `use … CudaExecutor` in `crates/aprender-serve/src/cuda/executor/weights.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "target_parity"^^ . + . + "realizar::cuda::gpu_profile::GpuProfile"^^ . + "detect"^^ . + "false"^^ . + "no `mod GpuProfile` or `use … GpuProfile` in `crates/aprender-serve/src/cuda/gpu_profile.rs`"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "realizar"^^ . + "fp8_architecture_guard"^^ . + "crates/aprender-serve/src/cuda/gpu_profile.rs"^^ . + . + "method"^^ . + "realizar::cuda::gpu_profile"^^ . + "detect_fp8_prefill"^^ . + "true"^^ . + "private"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "no_hardcoded_targets"^^ . + . + "realizar::cuda::kernel_generator::CudaKernels"^^ . + "generate_ptx"^^ . + "false"^^ . + "no `mod kernel_generator` or `use … kernel_generator` in `crates/aprender-serve/src/cuda/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "effective_context"^^ . + . + "realizar"^^ . + "effective_context"^^ . + "false"^^ . + "no `fn effective_context` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "element_count"^^ . + . + "realizar"^^ . + "element_count"^^ . + "false"^^ . + "no `fn element_count` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "flash_attention"^^ . + . + "realizar"^^ . + "flash_attention"^^ . + "false"^^ . + "no `fn flash_attention` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "gelu"^^ . + . + "realizar"^^ . + "gelu"^^ . + "false"^^ . + "no `fn gelu` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "forward_pass_completeness"^^ . + . + "realizar::gguf::OwnedQuantizedModel"^^ . + "forward"^^ . + "false"^^ . + "no `mod OwnedQuantizedModel` or `use … OwnedQuantizedModel` in `crates/aprender-serve/src/gguf/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "o_projection_transpose"^^ . + . + "realizar::gguf::config::ArchConstraints"^^ . + "from_gguf_metadata"^^ . + "false"^^ . + "no `mod ArchConstraints` or `use … ArchConstraints` in `crates/aprender-serve/src/gguf/config.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "correctness_under_batching"^^ . + . + "realizar::gguf::cuda"^^ . + "generate_batched_streaming"^^ . + "false"^^ . + "no `fn generate_batched_streaming` (free or in an impl) in `crates/aprender-serve/src/gguf/cuda/mod.rs`"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "realizar"^^ . + "gated_rmsnorm"^^ . + "crates/aprender-serve/src/gguf/inference/forward/forward_qwen35.rs"^^ . + . + "fn"^^ . + "realizar::gguf::forward_qwen35"^^ . + "gated_rmsnorm"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "flash_attention"^^ . + . + "realizar::gguf::inference::attention_part_02"^^ . + "flash_attention_tiled"^^ . + "false"^^ . + "no `mod attention_part_02` or `use … attention_part_02` in `crates/aprender-serve/src/gguf/inference/mod.rs`"^^ . + . + . + "partial"^^ . + "realizar"^^ . + "flattened_batched_gemm"^^ . + . + "realizar::gguf::inference::cached::single_flattened_batched_owned"^^ . + "flattened_batched_gemm"^^ . + "false"^^ . + "no `mod single_flattened_batched_owned` or `use … single_flattened_batched_owned` in `crates/aprender-serve/src/gguf/inference/cached/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "decode_step"^^ . + . + "realizar::gguf::inference::forward::Model"^^ . + "forward"^^ . + "false"^^ . + "no `mod Model` or `use … Model` in `crates/aprender-serve/src/gguf/inference/forward/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "prefill_incremental"^^ . + "prefill_phase"^^ . + . + . + "realizar::gguf::inference::forward::batch_part_02_part_03"^^ . + "prefill_batch"^^ . + "false"^^ . + "no `mod batch_part_02_part_03` or `use … batch_part_02_part_03` in `crates/aprender-serve/src/gguf/inference/forward/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "batched_serial_equivalence"^^ . + . + "realizar::gguf::inference::forward"^^ . + "generate_with_batched_prefill"^^ . + "false"^^ . + "no `fn generate_with_batched_prefill` (free or in an impl) in `crates/aprender-serve/src/gguf/inference/forward/mod.rs`"^^ . + . + . + "contract"^^ . + "doc"^^ . + "partial"^^ . + "realizar"^^ . + "rms_norm"^^ . + "crates/aprender-serve/src/gguf/ops.rs"^^ . + . + "fn"^^ . + "realizar::gguf::ops"^^ . + "rms_norm_unit_offset"^^ . + "true"^^ . + "pub"^^ . + . + . + "contract"^^ . + "doc"^^ . + "partial"^^ . + "realizar"^^ . + "softmax_inplace"^^ . + "crates/aprender-serve/src/gguf/ops.rs"^^ . + . + "fn"^^ . + "realizar::gguf::ops"^^ . + "softmax"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "merge_rule"^^ . + . + "realizar::gguf::token::GgufTokenizer"^^ . + "merge_rules"^^ . + "false"^^ . + "no `mod token` or `use … token` in `crates/aprender-serve/src/gguf/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "layer_composition"^^ . + . + "realizar::gpu::adapters::using"^^ . + "forward_layer"^^ . + "false"^^ . + "no `mod using` or `use … using` in `crates/aprender-serve/src/gpu/adapters/mod.rs`"^^ . + . + . + "doc"^^ . + "provable_contracts_macros::contract"^^ . + "partial"^^ . + "realizar"^^ . + "dequant_correctness"^^ . + "crates/aprender-serve/src/gpu/adapters/wgpu_adapter.rs"^^ . + . + "fn"^^ . + "realizar::gpu::adapters::wgpu_adapter"^^ . + "dequant_model_weights"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "attention_sublayer"^^ . + . + "realizar::gpu::scheduler::forward_from_model"^^ . + "forward_block_traced"^^ . + "false"^^ . + "no `mod forward_from_model` or `use … forward_from_model` in `crates/aprender-serve/src/gpu/scheduler/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "decay"^^ . + . + "realizar::gpu::scheduler::linear_attn"^^ . + "gated_delta_net_decay"^^ . + "false"^^ . + "no `fn gated_delta_net_decay` (free or in an impl) in `crates/aprender-serve/src/gpu/scheduler/linear_attn.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "delta"^^ . + . + "realizar::gpu::scheduler::linear_attn"^^ . + "gated_delta_net_delta"^^ . + "false"^^ . + "no `fn gated_delta_net_delta` (free or in an impl) in `crates/aprender-serve/src/gpu/scheduler/linear_attn.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "gdn_sublayer"^^ . + . + "realizar::gpu::scheduler::linear_attn"^^ . + "gated_delta_net_forward"^^ . + "false"^^ . + "no `fn gated_delta_net_forward` (free or in an impl) in `crates/aprender-serve/src/gpu/scheduler/linear_attn.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "output"^^ . + . + "realizar::gpu::scheduler::linear_attn"^^ . + "gated_delta_net_output"^^ . + "false"^^ . + "no `fn gated_delta_net_output` (free or in an impl) in `crates/aprender-serve/src/gpu/scheduler/linear_attn.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "read"^^ . + . + "realizar::gpu::scheduler::linear_attn"^^ . + "gated_delta_net_read"^^ . + "false"^^ . + "no `fn gated_delta_net_read` (free or in an impl) in `crates/aprender-serve/src/gpu/scheduler/linear_attn.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "write"^^ . + . + "realizar::gpu::scheduler::linear_attn"^^ . + "gated_delta_net_write"^^ . + "false"^^ . + "no `fn gated_delta_net_write` (free or in an impl) in `crates/aprender-serve/src/gpu/scheduler/linear_attn.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "fused_qkv"^^ . + . + "realizar::gpu::scheduler::model_forward_block_gpu"^^ . + "fused_qkv_projection"^^ . + "false"^^ . + "no `mod model_forward_block_gpu` or `use … model_forward_block_gpu` in `crates/aprender-serve/src/gpu/scheduler/mod.rs`"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "realizar"^^ . + "temperature"^^ . + "crates/aprender-serve/src/gpu/scheduler/ops.rs"^^ . + . + "fn"^^ . + "realizar::gpu::scheduler::ops"^^ . + "sample_topk"^^ . + "true"^^ . + "pub(super)"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "realizar"^^ . + "rope_frequency"^^ . + "crates/aprender-serve/src/gpu/simd_ops.rs"^^ . + . + "fn"^^ . + "realizar::gpu::simd_ops"^^ . + "rope_frequency_table"^^ . + "true"^^ . + "private"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "gpu_threshold"^^ . + . + "realizar"^^ . + "gpu_threshold"^^ . + "false"^^ . + "no `fn gpu_threshold` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "gqa"^^ . + . + "realizar"^^ . + "gqa"^^ . + "false"^^ . + "no `fn gqa` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "greedy"^^ . + . + "realizar"^^ . + "greedy"^^ . + "false"^^ . + "no `fn greedy` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . . . "doc"^^ . @@ -12506,6 +15206,1340 @@ "tool_to_anthropic"^^ . "true"^^ . "pub"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "multi_backend_parity"^^ . + . + "realizar::infer::batch_wgpu"^^ . + "generate"^^ . + "false"^^ . + "no `mod batch_wgpu` or `use … batch_wgpu` in `crates/aprender-serve/src/infer/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "pcie_overhead"^^ . + . + "realizar::infer::batch_wgpu"^^ . + "try_init_wgpu_batch"^^ . + "false"^^ . + "no `mod batch_wgpu` or `use … batch_wgpu` in `crates/aprender-serve/src/infer/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "garbage_oracle"^^ . + . + "realizar::inference_trace::mod_get_top_compute"^^ . + "is_garbage_output"^^ . + "false"^^ . + "no `mod mod_get_top_compute` or `use … mod_get_top_compute` in `crates/aprender-serve/src/inference_trace/mod.rs`"^^ . + . + . + "doc"^^ . + "must_use"^^ . + "implemented"^^ . + "realizar"^^ . + "model_bytes"^^ . + "crates/aprender-serve/src/lambda.rs"^^ . + . + "method"^^ . + "realizar::lambda"^^ . + "model_size_bytes"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "base_frequency"^^ . + . + "realizar::layers::position::RoPE"^^ . + "new"^^ . + "false"^^ . + "no `mod RoPE` or `use … RoPE` in `crates/aprender-serve/src/layers/position.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "linear_interpolation"^^ . + "ntk_scaled_base"^^ . + "yarn_mixed_frequency"^^ . + "yarn_ramp"^^ . + . + "realizar::layers::position::ScaledRope"^^ . + "new"^^ . + "false"^^ . + "no `mod ScaledRope` or `use … ScaledRope` in `crates/aprender-serve/src/layers/position.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "matmul"^^ . + . + "realizar"^^ . + "matmul"^^ . + "false"^^ . + "no `fn matmul` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "page_shape"^^ . + . + "realizar::paged_kv::mod_paged::PagedKvCache"^^ . + "new"^^ . + "false"^^ . + "no `mod mod_paged` or `use … mod_paged` in `crates/aprender-serve/src/paged_kv/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "position_embedding"^^ . + . + "realizar"^^ . + "position_embedding"^^ . + "false"^^ . + "no `fn position_embedding` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "predict"^^ . + . + "realizar"^^ . + "predict"^^ . + "false"^^ . + "no `fn predict` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "quantize"^^ . + . + "realizar"^^ . + "quantize"^^ . + "false"^^ . + "no `fn quantize` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "realizar"^^ . + "bsum"^^ . + "crates/aprender-serve/src/quantize/bsum_precompute.rs"^^ . + . + "fn"^^ . + "realizar::quantize::bsum_precompute"^^ . + "precompute_q8k_bsums"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "q4k_superblock"^^ . + . + "realizar::quantize::format_trait"^^ . + "Q4K"^^ . + "false"^^ . + "no `fn Q4K` (free or in an impl) in `crates/aprender-serve/src/quantize/format_trait.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "dequantization"^^ . + . + "realizar::quantize::format_trait::Q4K"^^ . + "dequant_value"^^ . + "false"^^ . + "no `mod Q4K` or `use … Q4K` in `crates/aprender-serve/src/quantize/format_trait.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "q6k_superblock"^^ . + . + "realizar::quantize::format_trait"^^ . + "Q6K"^^ . + "false"^^ . + "no `fn Q6K` (free or in an impl) in `crates/aprender-serve/src/quantize/format_trait.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "size_ordering"^^ . + . + "realizar::quantize::format_trait"^^ . + "QuantBlockFormat"^^ . + "false"^^ . + "no `fn QuantBlockFormat` (free or in an impl) in `crates/aprender-serve/src/quantize/format_trait.rs`"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "realizar"^^ . + "quantized_dot"^^ . + "crates/aprender-serve/src/quantize/fused_k.rs"^^ . + . + "fn"^^ . + "realizar::quantize::fused_k"^^ . + "fused_q4k_dot"^^ . + "true"^^ . + "pub"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "realizar"^^ . + "simd_only_threshold"^^ . + "crates/aprender-serve/src/quantize/fused_k.rs"^^ . + . + "fn"^^ . + "realizar::quantize::fused_k"^^ . + "fused_q4k_dot_simd"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "ffn_sublayer"^^ . + . + "realizar::quantize::quantize_rmsnorm_into"^^ . + "fused_swiglu_simd"^^ . + "false"^^ . + "no `mod quantize_rmsnorm_into` or `use … quantize_rmsnorm_into` in `crates/aprender-serve/src/quantize/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "rotation_matrix"^^ . + . + "realizar::quantize::rope"^^ . + "apply_rope_rotation_simd"^^ . + "false"^^ . + "no `mod rope` or `use … rope` in `crates/aprender-serve/src/quantize/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "relu"^^ . + . + "realizar"^^ . + "relu"^^ . + "false"^^ . + "no `fn relu` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "rmsnorm"^^ . + . + "realizar"^^ . + "rmsnorm"^^ . + "false"^^ . + "no `fn rmsnorm` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "hybrid_block"^^ . + "hybrid_layer_schedule"^^ . + . + . + "realizar::safetensors::safetensors_config::SafeTensorsConfig"^^ . + "is_hybrid_attention"^^ . + "false"^^ . + "no `mod safetensors_config` or `use … safetensors_config` in `crates/aprender-serve/src/safetensors/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "safetensors_loader_validation"^^ . + . + "realizar::safetensors_cuda::SafeTensorsCudaModel"^^ . + "upload_weights"^^ . + "false"^^ . + "no `mod SafeTensorsCudaModel` or `use … SafeTensorsCudaModel` in `crates/aprender-serve/src/safetensors_cuda.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "sample"^^ . + . + "realizar"^^ . + "sample"^^ . + "false"^^ . + "no `fn sample` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "request_state"^^ . + . + "realizar::scheduler::mod_max_scheduler"^^ . + "SlotState"^^ . + "false"^^ . + "no `mod mod_max_scheduler` or `use … mod_max_scheduler` in `crates/aprender-serve/src/scheduler/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "scheduling_fairness"^^ . + . + "realizar::scheduler::mod_max_scheduler"^^ . + "handle_preemption"^^ . + "false"^^ . + "no `mod mod_max_scheduler` or `use … mod_max_scheduler` in `crates/aprender-serve/src/scheduler/mod.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "sigmoid"^^ . + . + "realizar"^^ . + "sigmoid"^^ . + "false"^^ . + "no `fn sigmoid` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "silu"^^ . + . + "realizar"^^ . + "silu"^^ . + "false"^^ . + "no `fn silu` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "softmax"^^ . + . + "realizar"^^ . + "softmax"^^ . + "false"^^ . + "no `fn softmax` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "standard_softmax"^^ . + . + "realizar"^^ . + "standard_softmax"^^ . + "false"^^ . + "no `fn standard_softmax` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "tensor_count"^^ . + . + "realizar"^^ . + "tensor_count"^^ . + "false"^^ . + "no `fn tensor_count` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "realizar"^^ . + "name_bijection"^^ . + "crates/aprender-serve/src/tensor_names.rs"^^ . + . + "fn"^^ . + "realizar::tensor_names"^^ . + "resolve_global"^^ . + "true"^^ . + "pub(crate)"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "decode"^^ . + . + "realizar::tokenizer::Tokenizer"^^ . + "decode"^^ . + "false"^^ . + "no `mod Tokenizer` or `use … Tokenizer` in `crates/aprender-serve/src/tokenizer.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "encode"^^ . + . + "realizar::tokenizer::Tokenizer"^^ . + "encode"^^ . + "false"^^ . + "no `mod Tokenizer` or `use … Tokenizer` in `crates/aprender-serve/src/tokenizer.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "top_k"^^ . + . + "realizar"^^ . + "top_k"^^ . + "false"^^ . + "no `fn top_k` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "top_p"^^ . + . + "realizar"^^ . + "top_p"^^ . + "false"^^ . + "no `fn top_p` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "total_bytes"^^ . + . + "realizar"^^ . + "total_bytes"^^ . + "false"^^ . + "no `fn total_bytes` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "transpose"^^ . + . + "realizar"^^ . + "transpose"^^ . + "false"^^ . + "no `fn transpose` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "update"^^ . + . + "realizar"^^ . + "update"^^ . + "false"^^ . + "no `fn update` (free or in an impl) in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "realizar"^^ . + "kv_cache_growth"^^ . + . + "realizar::verification_specs"^^ . + "verify_kv_cache_size"^^ . + "false"^^ . + "no `mod verification_specs` or `use … verification_specs` in `crates/aprender-serve/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::demos::harmonic_oscillator"^^ . + "run_periods"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::demos::kepler_orbit"^^ . + "run_orbits"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "simulate"^^ . + . + "simular::src::domains::ml::mod"^^ . + "simulate"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::edd::audit"^^ . + "find_by_step_id"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::edd::audit"^^ . + "new"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::edd::audit"^^ . + "seek"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::edd::audit"^^ . + "step_forward"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::edd::gui_coverage"^^ . + "register_journey"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::edd::v2"^^ . + "record_step"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::edd::v2"^^ . + "record_step_with_equations"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::edd::v2"^^ . + "step_count"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::engine::clock"^^ . + "steps_until"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::engine::clock"^^ . + "timestep_secs"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::orbit::heijunka"^^ . + "estimate_substeps"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::orbit::heijunka"^^ . + "substep_multiplier"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::orbit::wasm"^^ . + "run_steps"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::orbit::wasm"^^ . + "step_days"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::orbit::wasm"^^ . + "step_hours"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "simulate"^^ . + . + "simular::src::scenarios::portfolio"^^ . + "simulate_path"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "simular"^^ . + "step"^^ . + . + "simular::src::tui::tsp_app"^^ . + "step"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-simulate/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "import_buffer"^^ . + . + "trueno::backends::gpu::batch::GpuCommandBatch"^^ . + "import_buffer"^^ . + "false"^^ . + "no `mod GpuCommandBatch` or `use … GpuCommandBatch` in `crates/aprender-compute/src/backends/gpu/batch/mod.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "single_encoder"^^ . + . + "trueno::backends::gpu::batch::execute::GpuCommandBatch"^^ . + "execute"^^ . + "false"^^ . + "no `mod GpuCommandBatch` or `use … GpuCommandBatch` in `crates/aprender-compute/src/backends/gpu/batch/mod.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "pipeline_cache"^^ . + . + "trueno::backends::gpu::batch::execute::dispatch"^^ . + "PipelineCache"^^ . + "false"^^ . + "no `fn PipelineCache` (free or in an impl) in `crates/aprender-compute/src/backends/gpu/batch/execute/dispatch.rs`"^^ . + . + . + "allow"^^ . + "doc"^^ . + "implemented"^^ . + "trueno"^^ . + "execute_matmul_op"^^ . + "crates/aprender-compute/src/backends/gpu/batch/execute/dispatch.rs"^^ . + . + "method"^^ . + "trueno::backends::gpu::batch::execute::dispatch"^^ . + "encode_matmul_op"^^ . + "true"^^ . + "pub(crate)"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "trueno"^^ . + "matmul_dispatch"^^ . + "crates/aprender-compute/src/backends/gpu/device/linalg/matmul.rs"^^ . + . + "method"^^ . + "trueno::backends::gpu::device::linalg::matmul"^^ . + "matmul_async"^^ . + "true"^^ . + "pub"^^ . + . + . + "doc"^^ . + "provable_contracts_macros::contract"^^ . + "partial"^^ . + "trueno"^^ . + "rmsnorm_correctness"^^ . + "crates/aprender-compute/src/backends/gpu/device/linalg/wgsl_forward.rs"^^ . + . + "method"^^ . + "trueno::backends::gpu::device::linalg::wgsl_forward"^^ . + "forward_model"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "matmul_shader"^^ . + . + "trueno::backends::gpu::shaders::basic_ops"^^ . + "MATMUL_SHADER"^^ . + "false"^^ . + "no `fn MATMUL_SHADER` (free or in an impl) in `crates/aprender-compute/src/backends/gpu/shaders/basic_ops.rs`"^^ . + . + . + "cfg"^^ . + "doc"^^ . + "target_feature"^^ . + "implemented"^^ . + "trueno"^^ . + "dequant"^^ . + "crates/aprender-compute/src/backends/q4k/gemv/avx512.rs"^^ . + . + "fn"^^ . + "trueno::backends::q4k::gemv::avx512"^^ . + "compute_chunk_q4k_avx512"^^ . + "true"^^ . + "pub(crate)"^^ . + . + . + "cfg"^^ . + "doc"^^ . + "target_feature"^^ . + "implemented"^^ . + "trueno"^^ . + "dequant"^^ . + "crates/aprender-compute/src/backends/q4k/gemv/avx512.rs"^^ . + . + "fn"^^ . + "trueno::backends::q4k::gemv::avx512"^^ . + "matmul_q4k_f32_avx512"^^ . + "true"^^ . + "pub(crate)"^^ . + . + . + "cfg"^^ . + "doc"^^ . + "target_feature"^^ . + "implemented"^^ . + "trueno"^^ . + "peak_throughput"^^ . + "crates/aprender-compute/src/blis/compute.rs"^^ . + . + "fn"^^ . + "trueno::blis::compute"^^ . + "avx512_microkernel_8x16_rowmajor"^^ . + "true"^^ . + "pub(super)"^^ . + . + . + "cfg"^^ . + "doc"^^ . + "target_feature"^^ . + "implemented"^^ . + "trueno"^^ . + "flops_per_tile"^^ . + "crates/aprender-compute/src/blis/compute.rs"^^ . + . + "fn"^^ . + "trueno::blis::compute"^^ . + "gemm_blis_avx512_large"^^ . + "true"^^ . + "private"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "trueno"^^ . + "flops_per_tile"^^ . + "crates/aprender-compute/src/blis/compute.rs"^^ . + . + "fn"^^ . + "trueno::blis::compute"^^ . + "pack_b_block_nr16"^^ . + "true"^^ . + "pub(super)"^^ . + . + . + "cfg"^^ . + "doc"^^ . + "implemented"^^ . + "trueno"^^ . + "amdahl_speedup"^^ . + "crates/aprender-compute/src/blis/parallel.rs"^^ . + . + "fn"^^ . + "trueno::blis::parallel"^^ . + "gemm_blis_parallel"^^ . + "true"^^ . + "pub"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "bounds"^^ . + . + "trueno"^^ . + "bounds"^^ . + "false"^^ . + "no `fn bounds` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "brick_ordering"^^ . + . + "trueno::brick::profiler::BrickProfiler"^^ . + "brick_stats"^^ . + "false"^^ . + "no `mod BrickProfiler` or `use … BrickProfiler` in `crates/aprender-compute/src/brick/profiler/mod.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "sync_verification"^^ . + . + "trueno::brick::profiler::BrickProfiler"^^ . + "set_sync_mode"^^ . + "false"^^ . + "no `mod BrickProfiler` or `use … BrickProfiler` in `crates/aprender-compute/src/brick/profiler/mod.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "wall_coverage"^^ . + . + "trueno::brick::profiler::BrickProfiler"^^ . + "total_ns"^^ . + "false"^^ . + "no `mod BrickProfiler` or `use … BrickProfiler` in `crates/aprender-compute/src/brick/profiler/mod.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "token_accounting"^^ . + . + "trueno::brick::profiler::BrickProfiler"^^ . + "total_tokens"^^ . + "false"^^ . + "no `mod BrickProfiler` or `use … BrickProfiler` in `crates/aprender-compute/src/brick/profiler/mod.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "bytes_per_param"^^ . + . + "trueno"^^ . + "bytes_per_param"^^ . + "false"^^ . + "no `fn bytes_per_param` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "precision"^^ . + . + "trueno::cbtop::adaptive_ml::types::ClassificationMetrics"^^ . + "precision"^^ . + "false"^^ . + "no `mod cbtop` or `use … cbtop` in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "recall"^^ . + . + "trueno::cbtop::adaptive_ml::types::ClassificationMetrics"^^ . + "recall"^^ . + "false"^^ . + "no `mod cbtop` or `use … cbtop` in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "tensor_count"^^ . + . + "trueno::cbtop::quantize::gguf::GgufReader"^^ . + "tensor_count"^^ . + "false"^^ . + "no `mod cbtop` or `use … cbtop` in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "throughput_bound"^^ . + . + "trueno::cbtop::roofline::HardwareProfile"^^ . + "theoretical_peak_at_oi"^^ . + "false"^^ . + "no `mod cbtop` or `use … cbtop` in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "quantize"^^ . + . + "trueno::crates::trueno_quant::quantize"^^ . + "quantize_q4_k"^^ . + "false"^^ . + "no `mod crates` or `use … crates` in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "eigenvector"^^ . + . + "trueno"^^ . + "eigenvector"^^ . + "false"^^ . + "no `fn eigenvector` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "embedding_lookup"^^ . + . + "trueno"^^ . + "embedding_lookup"^^ . + "false"^^ . + "no `fn embedding_lookup` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "gelu"^^ . + . + "trueno"^^ . + "gelu"^^ . + "false"^^ . + "no `fn gelu` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "log_softmax"^^ . + . + "trueno"^^ . + "log_softmax"^^ . + "false"^^ . + "no `fn log_softmax` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "matmul"^^ . + . + "trueno"^^ . + "matmul"^^ . + "false"^^ . + "no `fn matmul` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "bandwidth_ceiling"^^ . + . + "trueno::monitor::compute_device::ComputeDevice"^^ . + "memory_bandwidth_gbps"^^ . + "false"^^ . + "no `mod ComputeDevice` or `use … ComputeDevice` in `crates/aprender-compute/src/monitor/compute_device.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "predict"^^ . + . + "trueno"^^ . + "predict"^^ . + "false"^^ . + "no `fn predict` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "read"^^ . + . + "trueno"^^ . + "read"^^ . + "false"^^ . + "no `fn read` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "relu"^^ . + . + "trueno"^^ . + "relu"^^ . + "false"^^ . + "no `fn relu` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "sigmoid"^^ . + . + "trueno"^^ . + "sigmoid"^^ . + "false"^^ . + "no `fn sigmoid` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "silu"^^ . + . + "trueno"^^ . + "silu"^^ . + "false"^^ . + "no `fn silu` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "softmax"^^ . + . + "trueno"^^ . + "softmax"^^ . + "false"^^ . + "no `fn softmax` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "update"^^ . + . + "trueno"^^ . + "update"^^ . + "false"^^ . + "no `fn update` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno"^^ . + "write"^^ . + . + "trueno"^^ . + "write"^^ . + "false"^^ . + "no `fn write` (free or in an impl) in `crates/aprender-compute/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno_db"^^ . + "insert"^^ . + . + "trueno_db::src::gpu::jit"^^ . + "get_or_insert"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-db/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno_db"^^ . + "query"^^ . + . + "trueno_db::src::wasm"^^ . + "query"^^ . + "false"^^ . + "no `mod src` or `use … src` in `crates/aprender-db/src/lib.rs`"^^ . + . + . + "implemented"^^ . + "trueno_gpu"^^ . + "pcie_overhead"^^ . + . + "trueno_gpu::driver::memory::buffer::GpuBuffer"^^ . + "from_host_registered"^^ . + "false"^^ . + "no `mod GpuBuffer` or `use … GpuBuffer` in `crates/aprender-gpu/src/driver/memory/buffer.rs`"^^ . + . + . + "doc"^^ . + "implemented"^^ . + "trueno_gpu"^^ . + "culink_skip"^^ . + "crates/aprender-gpu/src/driver/module.rs"^^ . + . + "fn"^^ . + "trueno_gpu::driver::module"^^ . + "compile_ptx_to_cubin"^^ . + "true"^^ . + "private"^^ . + . + . + "implemented"^^ . + "trueno_gpu"^^ . + "attention"^^ . + . + "trueno_gpu::kernels::attention"^^ . + "AttentionKernel"^^ . + "false"^^ . + "no `fn AttentionKernel` (free or in an impl) in `crates/aprender-gpu/src/kernels/attention/flash/mod.rs`"^^ . + . + . + "implemented"^^ . + "trueno_gpu"^^ . + "backward_a_gemm"^^ . + "shared_memory_per_tile"^^ . + "tiled_gemm_arithmetic_intensity"^^ . + . + "trueno_gpu::kernels::backward::gemm::GemmBackwardAKernel"^^ . + "tiled"^^ . + "false"^^ . + "no `mod GemmBackwardAKernel` or `use … GemmBackwardAKernel` in `crates/aprender-gpu/src/kernels/backward/gemm.rs`"^^ . + . + . + "implemented"^^ . + "trueno_gpu"^^ . + "unrolled_instruction_ratio"^^ . + . + "trueno_gpu::kernels::backward::gemm::GemmBackwardAKernel"^^ . + "tiled_unrolled"^^ . + "false"^^ . + "no `mod GemmBackwardAKernel` or `use … GemmBackwardAKernel` in `crates/aprender-gpu/src/kernels/backward/gemm.rs`"^^ . + . + . + "implemented"^^ . + "trueno_gpu"^^ . + "backward_b_gemm"^^ . + . + "trueno_gpu::kernels::backward::gemm::GemmBackwardBKernel"^^ . + "tiled"^^ . + "false"^^ . + "no `mod GemmBackwardBKernel` or `use … GemmBackwardBKernel` in `crates/aprender-gpu/src/kernels/backward/gemm.rs`"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "embed"^^ . + . + "trueno_rag::src::chunk::mod"^^ . + "set_embedding"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "retrieve"^^ . + . + "trueno_rag::src::metrics"^^ . + "average_precision"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "retrieve"^^ . + . + "trueno_rag::src::metrics"^^ . + "compute"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "retrieve"^^ . + . + "trueno_rag::src::metrics"^^ . + "f1_at_k"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "retrieve"^^ . + . + "trueno_rag::src::metrics"^^ . + "hit_rate_at_k"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "retrieve"^^ . + . + "trueno_rag::src::metrics"^^ . + "mean_reciprocal_rank"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "retrieve"^^ . + . + "trueno_rag::src::metrics"^^ . + "ndcg_at_k"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "retrieve"^^ . + . + "trueno_rag::src::metrics"^^ . + "precision_at_k"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "retrieve"^^ . + . + "trueno_rag::src::metrics"^^ . + "recall_at_k"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "embed"^^ . + . + "trueno_rag::src::multivector::codec"^^ . + "compress"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "embed"^^ . + . + "trueno_rag::src::multivector::codec"^^ . + "find_nearest_centroid"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "embed"^^ . + . + "trueno_rag::src::multivector::codec"^^ . + "train"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "embed"^^ . + . + "trueno_rag::src::multivector::index"^^ . + "insert"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "embed"^^ . + . + "trueno_rag::src::multivector::types"^^ . + "new"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "embed"^^ . + . + "trueno_rag::src::retrieve"^^ . + "embedder"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "retrieve"^^ . + . + "trueno_rag::src::retrieve"^^ . + "retrieve"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "retrieve"^^ . + . + "trueno_rag::src::retrieve"^^ . + "retrieve_dense"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "retrieve"^^ . + . + "trueno_rag::src::retrieve"^^ . + "retrieve_sparse"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "chunk"^^ . + . + "trueno_rag::src::sqlite::mod"^^ . + "chunk_count"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . + . + . + "implemented"^^ . + "trueno_rag"^^ . + "chunk"^^ . + . + "trueno_rag::src::sqlite::mod"^^ . + "get_chunk"^^ . + "false"^^ . + "crate `trueno_rag` is not a workspace member"^^ . . . "admitted"^^ . @@ -12830,15 +16864,15 @@ "ProvableContracts.Theorems.Alibi.Slopes"^^ . "alibi_slope_lt_one"^^ . "true"^^ . - . - . - "grounded"^^ . - "Alibi"^^ . - "crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Alibi/Slopes.lean"^^ . - . - "ProvableContracts.Theorems.Alibi.Slopes"^^ . - "alibi_slope_pos"^^ . - "true"^^ . + . + . + "grounded"^^ . + "Alibi"^^ . + "crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Alibi/Slopes.lean"^^ . + . + "ProvableContracts.Theorems.Alibi.Slopes"^^ . + "alibi_slope_pos_real"^^ . + "true"^^ . . . "grounded"^^ . diff --git a/contracts/csv-train.yaml b/contracts/csv-train.yaml new file mode 100644 index 0000000000..a31e1637dc --- /dev/null +++ b/contracts/csv-train.yaml @@ -0,0 +1,79 @@ +# ───────────────────────────────────────────────────────────────────────────── +# csv-train — one tracked dataset as a graded entity (ONT-001 §5 ONT-4c, Appendix B.8; #3847, PMAT-3847) +# +# WHAT IS GRADED. `extract:csv` reads crates/aprender-train/data/train.csv: the header row, the column names, the +# data-row count, one inferred dtype per column, the sha256 of the bytes, and the producer this contract names. +# `csv:producer` is `resolves: path` (v4.16: measured 0 of 9 tracked CSVs are named by a workflow run: step) — +# prepare_data.sh writes this file's header. A data row whose column count differs from the header's is refused +# naming the file and the row. +# +# KIND: pattern. The edit that makes it fail: rename a header column, or add a fourth. +# ───────────────────────────────────────────────────────────────────────────── +name: csv-train +version: "1.0.0" +scope: > + crates/aprender-train/data/train.csv: its header, shape and dtypes, and the script that produces it. Out of + scope: the values' statistics. +status: active + +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-24' + last_modified: '2026-09-24' + author: PAIML Engineering + description: > + The aprender-train training CSV becomes a csv:Dataset node with an exact header, three columns, typed + dtypes and a producer that exists. + references: + - 'paiml/infra docs/specifications/paiml-ontology.md v4.16 Appendix B.8' + - 'crates/aprender-contracts/src/ontology/extract/csv.rs' + - 'crates/aprender-train/prepare_data.sh (the producer)' + +entity: + type: csv + ref: crates/aprender-train/data/train.csv + properties: + producer: crates/aprender-train/prepare_data.sh + +relations: + depends_on: [ont-sigma-v1, ont-shapes-v1] + +shape: + targetClass: csv:Dataset + closed: true + properties: + - {path: csv:column, minCount: 3, maxCount: 3} + - {path: csv:header, in: ["feature1,feature2,label"]} + - {path: csv:rows, minCount: 1, datatype: xsd:integer} + - {path: csv:dtype, in: [string, float, int]} + - {path: csv:sha256, minCount: 1, maxCount: 1} + - {path: csv:producer, minCount: 1, maxCount: 1, resolves: path} + +equations: + rectangular: + formula: "∀ row ∈ data(train.csv): |row| = |header(train.csv)| = 3" + domain: every non-empty line after the header + codomain: one csv:Dataset node, or a PV-ONT-012 refusal naming the file and the row + invariants: + - a ragged row is refused by name, never truncated or padded + preconditions: + - the file is UTF-8 + postconditions: + - the header is exactly feature1,feature2,label + lean_theorem: "none — L4 not declared" + +invariants: + - id: CSVT-INV-001 + property: every data row has the header's column count + formal: '∀ r ∈ rows(train.csv): |r| = 3' + prose: false + +falsification_tests: + - id: FALSIFY-CSVT-001 + rule: a ragged row is refused + prediction: > + appending a row with four fields makes extract:csv refuse the file naming the row, and the shapes gate + exits 1 with PV-ONT-012 + test: cargo test -p aprender-contracts --lib ontology::extract::csv + if_fails: a malformed dataset trains a model silently diff --git a/contracts/entrenar/binding.yaml b/contracts/entrenar/binding.yaml new file mode 100644 index 0000000000..7b13fc37d4 --- /dev/null +++ b/contracts/entrenar/binding.yaml @@ -0,0 +1,369 @@ +version: 1.0.0 +target_crate: entrenar +bindings: +- contract: backward-pass-v1.yaml + equation: backward + module_path: entrenar::transformer::cuda_block + function: backward + status: partial + notes: '#4369: #[contract] site at crates/aprender-train/src/transformer/cuda_block.rs:1275 (feature cuda) had no binding row, and contracts/backward-pass-v1.yaml does not exist in-tree: the annotation cites no contract' +- contract: apr-checkpoint-v1.yaml + equation: save_checkpoint + module_path: entrenar::finetune::classify_trainer + function: save_checkpoint + signature: 'fn save_checkpoint(&self, path: &Path) -> Result<()>' + status: implemented + notes: APR v1.2.0 format with adapter + training state +- contract: apr-checkpoint-v1.yaml + equation: load_checkpoint + module_path: entrenar::finetune::classify_trainer + function: load_checkpoint + signature: 'fn load_checkpoint(&mut self, path: &Path) -> Result<()>' + status: implemented + notes: Restores optimizer state, epoch, step counters +- contract: apr-checkpoint-v1.yaml + equation: apr_writer + module_path: aprender::serialization::apr + function: AprWriter::write + signature: 'fn write(&self, path: &Path, tensors: &[(&str, &Array1)]) -> Result<()>' + status: implemented + notes: Cross-crate; aprender provides APR format I/O +- contract: apr-checkpoint-v1.yaml + equation: apr_reader + module_path: aprender::serialization::apr + function: AprReader::read + signature: 'fn read(path: &Path) -> Result)>>' + status: implemented + notes: Cross-crate; aprender provides APR format I/O +- contract: attention-head-extraction-v1.yaml + equation: forward_with_lora + module_path: entrenar::transformer::attention + function: MultiHeadAttention::forward_with_lora + signature: 'fn forward_with_lora(&self, x: &Tensor, mask: Option<&Tensor>) -> Tensor' + status: implemented + notes: 'KAIZEN-016: 99.2% reduction in per-head allocations' +- contract: attention-head-extraction-v1.yaml + equation: head_extraction + module_path: entrenar::transformer::attention + function: MultiHeadAttention::forward + signature: 'fn forward(&self, x: &Tensor, mask: Option<&Tensor>) -> Tensor' + status: implemented + notes: Pre-allocated head buffers; zero per-head heap alloc +- contract: cuda-classify-training-v1.yaml + equation: gpu_forward + module_path: entrenar::transformer::cuda_block + function: CudaTransformerBlock::gpu_forward + signature: 'fn gpu_forward(&self, hidden: &CudaBuffer) -> CudaBuffer' + status: implemented + notes: GPU forward pass; parity within 1e-3 of CPU +- contract: cuda-classify-training-v1.yaml + equation: weight_roundtrip + module_path: entrenar::autograd::cuda_training + function: weight_roundtrip + signature: 'fn weight_roundtrip(cpu: &[f32], device: &CudaDevice) -> Vec' + status: pending + notes: function not found in source +- contract: cuda-classify-training-v1.yaml + equation: device_dispatch + module_path: entrenar::autograd::cuda_training + function: ComputeDevice::auto_detect + signature: fn auto_detect() -> ComputeDevice + status: implemented + notes: Auto-detects CUDA, wgpu, or CPU fallback +- contract: distributed-training-v1.yaml + equation: gradient_allreduce + module_path: entrenar::finetune::data_parallel + function: DataParallelCoordinator::allreduce + signature: 'fn allreduce(&self, grads: &mut [Tensor]) -> Result<()>' + status: implemented + notes: Phase 1 complete (multi-GPU); Phase 2 in progress (multi-node) +- contract: distributed-training-v1.yaml + equation: sharding + module_path: entrenar::finetune::data_parallel + function: DataParallelCoordinator::shard_data + signature: 'fn shard_data(&self, dataset: &[Sample]) -> Vec>' + status: implemented + notes: function not found in source +- contract: distributed-training-v1.yaml + equation: weighted_loss + module_path: entrenar::finetune::data_parallel + function: weighted_loss_aggregate + signature: 'fn weighted_loss_aggregate(losses: &[f32], counts: &[usize]) -> f32' + status: pending + notes: function not found in source +- contract: distributed-training-v1.yaml + equation: swiglu_ffn + module_path: entrenar::transformer::cuda_block + function: CudaTransformerBlock::swiglu_ffn + signature: 'fn swiglu_ffn(&self, hidden: &CudaBuffer) -> CudaBuffer' + status: implemented + notes: function not found in source +- contract: distributed-training-v1.yaml + equation: lora_gradient_size + module_path: entrenar::finetune::data_parallel + function: lora_gradient_byte_size + signature: 'fn lora_gradient_byte_size(rank: usize, hidden: usize, layers: usize) + -> usize' + status: pending + notes: function not found in source +- contract: lora-gradient-flow-v1.yaml + equation: transpose_tracked + module_path: entrenar::autograd::ops::matmul + function: transpose_tracked + signature: 'fn transpose_tracked(x: &Tensor) -> Tensor' + status: implemented + notes: 'KAIZEN-018: tracked transpose for LoRA gradient flow' +- contract: lora-gradient-flow-v1.yaml + equation: lora_forward + module_path: entrenar::transformer::attention + function: MultiHeadAttention::forward_with_lora + signature: 'fn forward_with_lora(&self, x: &Tensor, mask: Option<&Tensor>) -> Tensor' + status: implemented + notes: wgpu LoRA gradients restored; 5K to 5.9M trainable params +- contract: qlora-hyperparameters-v1.yaml + equation: learning_rate_scaling + module_path: entrenar::finetune::qlora_config + function: QLoraConfig::learning_rate + signature: fn learning_rate(&self) -> f32 + status: implemented + notes: HP design pending; contract specifies constraints +- contract: qlora-hyperparameters-v1.yaml + equation: effective_batch_size + module_path: entrenar::finetune::qlora_config + function: QLoraConfig::effective_batch_size + signature: fn effective_batch_size(&self) -> usize + status: implemented + notes: function not found in source +- contract: qlora-hyperparameters-v1.yaml + equation: lora_alpha_ratio + module_path: entrenar::finetune::qlora_config + function: QLoraConfig::lora_alpha + signature: fn lora_alpha(&self) -> f32 + status: implemented + notes: function not found in source +- contract: qlora-hyperparameters-v1.yaml + equation: seq_len_from_data + module_path: entrenar::finetune::qlora_config + function: QLoraConfig::max_seq_len + signature: fn max_seq_len(&self) -> usize + status: implemented + notes: Derived from P99 of training data lengths +- contract: qlora-hyperparameters-v1.yaml + equation: warmup_fraction + module_path: entrenar::finetune::qlora_config + function: QLoraConfig::warmup_steps + signature: fn warmup_steps(&self) -> usize + status: implemented + notes: function not found in source +- contract: qlora-hyperparameters-v1.yaml + equation: gradient_clip_bound + module_path: entrenar::finetune::qlora_config + function: QLoraConfig::gradient_clip_norm + signature: fn gradient_clip_norm(&self) -> f32 + status: implemented + notes: function not found in source +- contract: qlora-hyperparameters-v1.yaml + equation: epoch_count_imbalanced + module_path: entrenar::finetune::qlora_config + function: QLoraConfig::epochs + signature: fn epochs(&self) -> usize + status: implemented + notes: 3-10 epochs; more for imbalanced datasets +- contract: tensor-rc-data-v1.yaml + equation: tensor_clone + module_path: entrenar::autograd::tensor + function: Tensor::clone + signature: fn clone(&self) -> Tensor + status: implemented + notes: 'KAIZEN-019: Rc bump (O(1)) instead of 16GB deep copies' +- contract: tensor-rc-data-v1.yaml + equation: data_read + module_path: entrenar::autograd::tensor + function: Tensor::data + signature: fn data(&self) -> &Array1 + status: implemented + notes: Shared read access via Rc +- contract: tensor-rc-data-v1.yaml + equation: data_mut + module_path: entrenar::autograd::tensor + function: Tensor::data_mut + signature: fn data_mut(&mut self) -> &mut Array1 + status: implemented + notes: Copy-on-write via Rc::make_mut +- contract: wgpu-resident-weights-v1.yaml + equation: with_resident_weights + module_path: entrenar::transformer::wgpu_block + function: WgpuForwardPass::with_resident_weights + signature: 'fn with_resident_weights(weights: &GpuWeights) -> WgpuForwardPass' + status: implemented + notes: 'KAIZEN-015: one-time FFN weight upload; frozen during QLoRA' +- contract: wgpu-resident-weights-v1.yaml + equation: import_buffer + module_path: trueno::backends::gpu::batch + function: GpuCommandBatch::import_buffer + signature: 'fn import_buffer(&mut self, buffer: &GpuBuffer)' + status: implemented + notes: function not found in source +- contract: wgpu-resident-weights-v1.yaml + equation: vram_budget + module_path: entrenar::finetune::classify_pipeline + function: estimate_wgpu_vram + signature: 'fn estimate_wgpu_vram(model_params: usize) -> usize' + status: implemented + notes: function not found in source +- contract: cma-es-kernel-v1.yaml + equation: sample + module_path: entrenar + function: sample + status: implemented + notes: Auto-inferred by pv infer +- contract: sampling-algorithms-v1.yaml + equation: temperature + module_path: entrenar + function: temperature + status: implemented + notes: Auto-inferred by pv infer +- contract: silu-kernel-v1.yaml + equation: sigmoid + module_path: entrenar + function: sigmoid + status: implemented + notes: Auto-inferred by pv infer +- contract: random-forest-v1.yaml + equation: predict + module_path: entrenar + function: predict + status: implemented + notes: Auto-inferred by pv infer +- contract: metrics-classification-v1.yaml + equation: accuracy + module_path: entrenar + function: accuracy + status: implemented + notes: Auto-inferred by pv infer +- contract: sampling-algorithms-v1.yaml + equation: greedy + module_path: entrenar + function: greedy + status: implemented + notes: Auto-inferred by pv infer +- contract: metrics-classification-v1.yaml + equation: confusion_matrix + module_path: entrenar + function: confusion_matrix + status: implemented + notes: Auto-inferred by pv infer +- contract: metrics-ranking-v1.yaml + equation: ndcg_at_k + module_path: entrenar + function: ndcg_at_k + status: implemented + notes: Auto-inferred by pv infer +- contract: layernorm-kernel-v1.yaml + equation: statistics + module_path: entrenar + function: statistics + status: implemented + notes: Auto-inferred by pv infer +- contract: kmeans-kernel-v1.yaml + equation: update + module_path: entrenar + function: update + status: implemented + notes: Auto-inferred by pv infer +- contract: sampling-algorithms-v1.yaml + equation: top_k + module_path: entrenar + function: top_k + status: implemented + notes: Auto-inferred by pv infer +- contract: metrics-regression-v1.yaml + equation: rmse + module_path: entrenar + function: rmse + status: implemented + notes: Auto-inferred by pv infer +- contract: int8-symmetric-quant-v1.yaml + equation: quantize + module_path: entrenar + function: quantize + status: implemented + notes: Auto-inferred by pv infer +- contract: model-config-algebra-v1.yaml + equation: bounds + module_path: entrenar + function: bounds + status: implemented + notes: Auto-inferred by pv infer +- contract: gated-delta-net-v1.yaml + equation: output + module_path: entrenar + function: output + status: implemented + notes: Auto-inferred by pv infer +- contract: q4k-q6k-superblock-v1.yaml + equation: total_bytes + module_path: entrenar + function: total_bytes + status: implemented + notes: Auto-inferred by pv infer +- contract: attention-kernel-v1.yaml + equation: attention + module_path: entrenar + function: attention + status: implemented + notes: Auto-inferred by pv infer +- contract: transpose-kernel-v1.yaml + equation: transpose + module_path: entrenar + function: transpose + status: implemented + notes: Auto-inferred by pv infer +- contract: matmul-kernel-v1.yaml + equation: matmul + module_path: entrenar + function: matmul + status: implemented + notes: Auto-inferred by pv infer +- contract: activation-kernel-v1.yaml + equation: relu + module_path: entrenar + function: relu + status: implemented + notes: Auto-inferred by pv infer +- contract: activation-kernel-v1.yaml + equation: gelu + module_path: entrenar + function: gelu + status: implemented + notes: Auto-inferred by pv infer +- contract: wgpu-transformer-trainer-v1.yaml + equation: C-WGPU-TRAIN-001 + module_path: entrenar::train::transformer_trainer::wgpu_trainer + function: full_train_step + signature: 'fn full_train_step(&mut self, hidden: &[f32], targets: &[u32], model: + &mut WgpuModelState) -> Result<(f32, f32), String>' + status: implemented + notes: 36-layer Qwen3-4B forward + lm_head + loss + backward + AdamW on WGPU +- contract: wgpu-transformer-trainer-v1.yaml + equation: C-WGPU-CKPT-001 + module_path: entrenar::train::transformer_trainer::wgpu_trainer + function: save_checkpoint + signature: 'fn save_checkpoint(&self, dir: &Path, step: u32, loss: f32, rank: u32, + alpha: f32) -> Result' + status: implemented + notes: Serializes LoRA A/B + optimizer state to JSON +- contract: wgpu-transformer-trainer-v1.yaml + equation: C-WGPU-CKPT-002 + module_path: entrenar::train::transformer_trainer::wgpu_trainer + function: load_checkpoint + signature: 'fn load_checkpoint(&mut self, path: &Path) -> Result<(u32, f32), String>' + status: implemented + notes: Restores LoRA adapters with dimension validation +critical_path: +- forward +- checkpoint +- loss +- gradient +- AprWriter +- CudaTransformerBlock diff --git a/contracts/gated-delta-net-v1.yaml b/contracts/gated-delta-net-v1.yaml index 21b743927a..afcc13fcb1 100644 --- a/contracts/gated-delta-net-v1.yaml +++ b/contracts/gated-delta-net-v1.yaml @@ -62,6 +62,17 @@ equations: - input.len() > 0 - input.iter().all(|v| v.is_finite()) lean_theorem: Theorems.Output + gated_rmsnorm: + formula: out_i = x_i / sqrt(Σ_j x_j² / d + ε) × w_i × silu(z_i), per head chunk of d = head_v_dim + domain: x, z ∈ ℝ^{heads × d}, w ∈ ℝ^{d}, ε > 0 + codomain: out ∈ ℝ^{heads × d} + invariants: + - The input is normalised per head; the gate z is not + - w is shared across heads (no head stride); z is indexed per head + preconditions: + - input.len() > 0 + - input.len() % head_v_dim == 0 + - input.iter().all(|v| v.is_finite()) proof_obligations: - id: GDN-BND-001 type: bound diff --git a/contracts/kernel-fusion-v1.yaml b/contracts/kernel-fusion-v1.yaml index a9e87d6f2f..411480f9d4 100644 --- a/contracts/kernel-fusion-v1.yaml +++ b/contracts/kernel-fusion-v1.yaml @@ -130,7 +130,7 @@ fusion_decisions: - Q projection GEMV - K projection GEMV - V projection GEMV - call_site: crates/aprender-serve/src/cuda/kernels_generate_gemm_cuda.rs:371 + call_site: crates/aprender-serve/src/cuda/kernels_generate_gemm_cuda.rs:394 saves_per_layer: 2 falsification: Splitting fused Q/K/V into three separate GEMV launches must increase per-token wall. fused_gate_up: @@ -143,7 +143,7 @@ fusion_decisions: - Gate projection GEMV - Up projection GEMV - SwiGLU activation - call_site: crates/aprender-serve/src/cuda/kernels_generate_gemm_cuda.rs:374 + call_site: crates/aprender-serve/src/cuda/kernels_generate_gemm_cuda.rs:397 saves_per_layer: 2 falsification: Splitting fused Gate+Up+SwiGLU into separate launches must increase per-token wall. fused_gemm_bias_gelu: @@ -183,7 +183,7 @@ fusion_decisions: replaces: - Gate Q4K GEMV (separate input load) - Up Q4K GEMV (separate input load) - call_site: crates/aprender-serve/src/cuda/kernels_generate_gemm_cuda.rs:377 + call_site: crates/aprender-serve/src/cuda/kernels_generate_gemm_cuda.rs:400 saves_per_layer: 1 notes: | Distinct from FUSION-007 (FusedGateUpKernel = FP32 input) — this diff --git a/contracts/lint-baseline.json b/contracts/lint-baseline.json index ab4181e755..534f633309 100644 --- a/contracts/lint-baseline.json +++ b/contracts/lint-baseline.json @@ -1,18 +1,15 @@ { - "_spec": "APR-RELEASE-001 §11.2 — moves only through `make ont-ratchet` (ONT R-6)", + "_spec": "APR-RELEASE-001 §11.2 counters are MEASURED comparand->head by scripts/check_ont_ratchet.sh (#3569); this file holds decisions only", "armed_gates": ["validate", "audit", "score", "verify", "enforce", "enforcement-level", "duplicate-stems", "composition", "sigma", "relations", "shapes"], - "armed_shapes": ["ont-shapes-v1", "ladder-measured", "ladder-green"], + "armed_shapes": ["ont-shapes-v1", "ladder-measured", "ladder-green", "readme-root", "claude-md", "model-setfit-slice", "csv-train", "capability-cells"], + "readme": {"verified_commands":["setsid --wait bash scripts/check_readme_claims.sh"],"withdrawn":[]}, + "claude_md": {"verified_commands":["setsid --wait bash scripts/check_package_includes.sh"],"withdrawn":[]}, "contracts_without_valid_under": 386, + "command": "make lint-ratchet", + "unpaired_theorem_modules": 130, + "contracts_without_depends_on": 278, "ont": { - "consumer_present": true, - "contracts_total": 1889, - "entity_types_registered": 11, - "extractors_implemented": 8, - "contracts_anchored": 8, - "contracts_shaped": 6, - "unanchored_but_bindable": 297, "formal_prose": 1464, - "legacy_unresolved_depends_on": 8, - "shapes_unarmed": 15 + "legacy_unresolved_depends_on": 8 } } diff --git a/contracts/model-capability-ladder-v1.yaml b/contracts/model-capability-ladder-v1.yaml index f3c13618ce..db4d6e0e4e 100644 --- a/contracts/model-capability-ladder-v1.yaml +++ b/contracts/model-capability-ladder-v1.yaml @@ -458,6 +458,11 @@ falsification_tests: prediction: "check_model_ladder.sh --self-test: green-cells passes (20 owed on lambda, 12 pass, 8 honest refusals); a missing cell, a duplicate row, a fallback, a thinking block that never closed, a prompt under its rung, an empty answer, a co-tenant refusal, an OOM instead of a refusal, a rung that passes nowhere, thinking_modes that disagree with the template, a missing context_length (#3733), no cells, a consumer without a number, a missing context-rungs file, a dropped cells block or verb, a rung id or consumer dropped from context-rungs.json vs origin/main, an arch with no held representative, and a pass where the declared arithmetic says the cell cannot fit (the arithmetic decides what is owed, so it must be right) are each RED naming it; 11 module mutants are each killed by their case" test: "bash scripts/check_model_ladder.sh --self-test" if_fails: "a release dogfoods one verb at one short prompt with thinking off and calls the model family working" + - id: FALSIFY-MCL-017 + rule: "A recorded emergency scope applies to the gate called with no --scope, and is SAID; none recorded is the full ladder (#4086)" + prediction: "check_model_ladder.sh --self-test: the select table (no scope / another release / a prefix of the release -> the full ladder; one scope -> it applies; two scopes or a nameless one -> RED) lands on crux_smoke_scope.recorded_scope, and 4 module mutants are each killed; end to end, NO recorded scope judges the full ladder beside CRUX smoke receipts that would satisfy a scope (RED, no SCOPED: line), a recorded scope prints `SCOPED: ` and returns the SCOPE's verdict (green and red), --scope none and a scope for another release judge the full ladder, two scopes are RED judging neither, and the receipts are found through MODEL_LADDER_CRUX_DIR; 4 dispatch mutants are each killed. check_dogfood_no_defer.sh: a declared gate's `SCOPED:` line is carried into its dogfood row, green or red, and a row with none stays plain (3 mutants)" + test: "bash scripts/check_model_ladder.sh --self-test && bash scripts/check_dogfood_no_defer.sh --self-test" + if_fails: "the dogfood's declared ladder gate judges the full ladder while the release gate judges the recorded scope -- two gates, one release, opposite verdicts (0.69.1, #4086) -- or a scoped pass reads as `exit=0`, indistinguishable from every rung green" qa_gate: id: F-MCL-001 diff --git a/contracts/model-setfit-slice.yaml b/contracts/model-setfit-slice.yaml new file mode 100644 index 0000000000..c52357c217 --- /dev/null +++ b/contracts/model-setfit-slice.yaml @@ -0,0 +1,79 @@ +# ───────────────────────────────────────────────────────────────────────────── +# model-setfit-slice — one tracked .apr model file as a graded entity (ONT-001 §5 ONT-4c, Appendix B.6; #3847) +# +# WHY THIS FILE. v4.16's byte-level survey: slice_model.apr is the one tracked v2-magic file that parses under +# extract:apr-model's algorithm (version 2.0, header checksum matches, tensor_count 37), and its sha256 is pinned +# independently by crates/aprender-core/tests/fixtures/setfit/manifest.sha256. golden_v2.apr is the extractor's +# own control and is excluded — it would validate the checker against itself. +# +# TARGET. model:AprModel (v4.16 D-T2, qd4c4): `extract:apr-model` types its node model:AprModel as well as +# model:Model, and the ladder's armed `ladder-measured` shape targets model:LadderRung, so the two do not collide. +# `ignoredProperties` (D1c) declines model:file and model:contract, the extractor's bookkeeping. +# +# KIND: pattern. The edit that makes it fail: change one byte of the file — model:sha256 leaves its `in:`. +# ───────────────────────────────────────────────────────────────────────────── +name: model-setfit-slice +version: "1.0.0" +scope: > + crates/aprender-core/tests/fixtures/setfit/slice_model.apr: format, container version, tensor count and the + exact bytes. Out of scope: what the model computes. +status: active + +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-24' + last_modified: '2026-09-24' + author: PAIML Engineering + description: > + The SetFit slice model becomes a model:AprModel node; its bytes are pinned by sha256 and its header must + parse as an APR v2 container. + references: + - 'paiml/infra docs/specifications/paiml-ontology.md v4.16 Appendix B.6 (D1, D1c, D-T2)' + - 'crates/aprender-contracts/src/ontology/extract/apr_model.rs' + - 'crates/aprender-core/tests/fixtures/setfit/manifest.sha256' + +entity: + type: apr-model + ref: crates/aprender-core/tests/fixtures/setfit/slice_model.apr + +relations: + depends_on: [ont-sigma-v1, ont-shapes-v1, ont-model-receipts-v1] + +shape: + targetClass: model:AprModel + closed: true + ignoredProperties: [model:file, model:contract] + properties: + - {path: model:format, in: ["apr"]} + - {path: model:aprVersion, minCount: 1, maxCount: 1, pattern: "^[0-9]+\\.[0-9]+$"} + - {path: model:tensorCount, minCount: 1, maxCount: 1, datatype: xsd:integer} + - {path: model:sha256, minCount: 1, maxCount: 1, pattern: "^[0-9a-f]{64}$", in: ["4a75ee8031b3fbe0b17e1706a248aabbf158420c06f082bc29a89e2103d4057e"]} + +equations: + bytes_pinned: + formula: "sha256(slice_model.apr) = 4a75ee80…4057e ∧ parse(slice_model.apr) = Ok" + domain: the tracked file's bytes + codomain: one model:AprModel node, or a PV-ONT-012 refusal naming the file + invariants: + - a file that does not parse is refused, never a node + preconditions: + - the file is tracked + postconditions: + - any byte change moves model:sha256 out of its in-list + lean_theorem: "none — L4 not declared" + +invariants: + - id: MSS-INV-001 + property: the model file's bytes are exactly the pinned ones + formal: 'sha256(ref) ≡ "4a75ee8031b3fbe0b17e1706a248aabbf158420c06f082bc29a89e2103d4057e"' + prose: false + +falsification_tests: + - id: FALSIFY-MSS-001 + rule: a changed byte is refused + prediction: > + flipping one byte of slice_model.apr makes the shapes gate exit 1 on model-setfit-slice (model:sha256 + `in`), and a lying header is refused by extract:apr-model naming the file + test: cargo test -p aprender-contracts --lib ontology::extract::apr_model + if_fails: the model file can change with no contract noticing diff --git a/contracts/ont-capability-cells-v1.yaml b/contracts/ont-capability-cells-v1.yaml new file mode 100644 index 0000000000..22cd111ef6 --- /dev/null +++ b/contracts/ont-capability-cells-v1.yaml @@ -0,0 +1,156 @@ +# ───────────────────────────────────────────────────────────────────────────── +# ont-capability-cells-v1 — a required cell of the model-capability ladder is oracle-proven or RED +# (ONT-001 v4.12 §5 ONT-4c5, paiml/infra 948ae923; aprender #3972 + #4047, PMAT-3972) +# +# WHAT IT GOVERNS +# provable_contracts::ontology::capability_cells — D, the cells measured at the current release V*, and +# not_run = D \ {Pass | Fail}; one `apply` for corpus and plant. +# provable_contracts::lint::capability_cells_gate — the validator's half inside `pv lint --gate shapes`: the +# positive control through the ARMED shape, the run-time +# wiring check, a finding naming every NotRun cell, and +# |D| = 0 as a decline. +# +# THE RULE. The domain D is the set of cells ONT-4c1's `ladder-green` quantifies over (receipts::expected_hosts, +# one definition), restricted to `required: true` rungs; a cell id is `@`. A cell is MEASURED only by +# a witness row (sha256 = the rung's) in a receipt of the current release V* (the greatest dotted-numeral +# `version` read) for that host. Its verdict is Pass (ladder-green's row predicate), Fail (its negation), or +# NotRun: a row label that `Verdict::from_label` maps to Unknown(_) — DEFER, MANUAL, NO-VERDICT, … — beats the +# measurement, and a label nobody knows is refused by name. Every D member that is not Pass or Fail is a +# `model:notRunCell`, and the ARMED shape says maxCount 0 on it: Fail, exit 1 — the arming rule. An absent cell is +# NEVER folded into Fail: this row admits Fail (ladder-green refuses it), so a folded absence would pass. +# +# WHY V*, NOT ladder-green's cross-version "∃ witness". Every required rung carries one sha across 0.68.1, +# 0.68.2 and 0.69.1; unioning versions would let a 0.68.2 green row hide a 0.69.1 DEFER or a missing 0.69.1 row +# (plan grill round 1, measured on this tree). ladder-green itself is unchanged. +# +# THE PLANT. A required rung with no receipt (tests/fixtures/ont/capability-cells-plant.yaml, embedded +# byte-identically in the crate) runs every gate run through the same `apply`, then through the ARMED shape; both +# must refuse exactly `capability-cells-plant@plant-host`, or pc_shapes["capability-cells"] is not-fired and the +# gate declines. The corpus wiring is checked at run time too: the rungs the shape flags must be the rungs owning +# a NotRun cell, else the gate declines (Differential). +# +# RESIDUAL, NAMED (the row's own). D takes ONT-4c1's host axis unchanged: `hosts:` absent = every host with a +# receipt read. A host that has never had a receipt is not in D; the probe's literal baseline set B guards the +# 14 named cells. +# +# KIND: pattern. Shapes over a graph; the proof is the gate's own case table. +# ───────────────────────────────────────────────────────────────────────────── +name: ont-capability-cells +version: "1.0.0" +scope: > + Which cells of the model-capability ladder a release owes a verdict on (D), how a cell is measured at the + current release, and the armed shape that turns a required cell without a verdict into RED. Out of scope: + whether a Fail cell may ship (ladder-green, ONT-4c1), per-verb/thinking cells (the v2 cells[] axis, #3712), + the oracle differential (ONT-4b2). +status: active + +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-23' + last_modified: '2026-09-23' + author: PAIML Engineering + description: > + A required ladder cell is Pass or Fail at the current release V*; NotRun (missing, DEFER, MANUAL, + NO-VERDICT, an unknown label) is a violation of the armed capability-cells shape, exit 1; |D| = 0 declines. + references: + - 'paiml/infra docs/specifications/paiml-ontology.md v4.12 §5 ONT-4c5 (948ae923, infra#950); docs/audits/ont-4c5-plan.md there' + - 'aprender #3972 (extractor + arming rule), #4047 (pv lint capability-cells shape); docs/audits/impl-PMAT-3972-plan.md' + - 'crates/aprender-contracts/src/ontology/{capability_cells.rs, receipts.rs, verdict.rs}, lint/{capability_cells_gate.rs, shapes_gate.rs}' + +entity: + type: gguf + +relations: + depends_on: [ont-shapes-v1, ont-model-receipts-v1, model-capability-ladder-v1] + +shapes: + - id: capability-cells + targetClass: model:RequiredModel + properties: + - {path: model:notRunCell, maxCount: 0} + - {path: model:refusedLabel, maxCount: 0} + +equations: + domain: + formula: "D = { rung@host : rung.required ∧ host ∈ expected_hosts(rung, receipt_hosts(R)) }" + domain: "every rung of the ladder contract; R = every receipt read under evidence/dogfood/models/" + codomain: "a set of cell ids @" + invariants: + - "expected_hosts is ONT-4c1's function, called, not copied: D and ladder-green quantify over one host axis" + - "a rung with required: false contributes no cell" + preconditions: + - "every receipt version is dotted numerals, else the gate refuses by name (exit 3)" + postconditions: + - "|D| = 0 ⇒ the gate declines (exit 2), never Pass" + lean_theorem: none — L4 not declared + not_run: + formula: "not_run = D \\ { c ∈ D : cell(c, V*) ∈ {Pass, Fail} }, V* = max(version(R))" + domain: "D and the witness rows of the V* receipts" + codomain: "a model:notRunCell literal per member, on the rung's model:Model node" + invariants: + - "a row label mapping to Unknown(_) beats the measurement; an unknown label is refused by name and NotRun" + - "several V* rows for one cell combine NotRun > Fail > Pass — not the lattice meet" + - "a row of an older version never measures a cell" + preconditions: + - "a witness row is one whose sha256 equals the rung's; no sha256 or another hex is no measurement" + postconditions: + - "|not_run| > 0 ⇒ the armed capability-cells shape fails ⇒ exit 1" + lean_theorem: none — L4 not declared + +invariants: + - id: CAP-INV-001 + property: a required cell without a verdict fails the armed shape + formal: 'c ∈ D ∧ cell(c, V*) ∉ {Pass, Fail} ⇒ Fail(capability-cells)' + prose: false + - id: CAP-INV-002 + property: an absent cell is never folded into Fail + formal: 'c ∈ D ∧ witness(c, V*) = ∅ ⇒ c ∈ not_run' + prose: false + - id: CAP-INV-003 + property: an older version never masks the current release + formal: 'version(r) < V* ⇒ r ∉ witnesses(c) for every c ∈ D' + prose: false + - id: CAP-INV-004 + property: an empty domain declines + formal: '|D| = 0 ⇒ verdict = Unknown' + prose: false + - id: CAP-INV-005 + property: the plant fires through the armed shape or the gate declines + formal: 'pc_shapes(capability-cells) ≠ fired ⇒ verdict = Unknown' + prose: false + +falsification_tests: + - id: FALSIFY-CAP-001 + rule: cells at V*, label precedence, duplicate rows + prediction: "a missing cell and a DEFER/MANUAL/NO-VERDICT row are not_run; an older green row does not mask either; a stray higher version only adds NotRun; a non-numeral version is refused by name; a green duplicate does not hide a DEFER or a Fail; a Fail cell is admitted" + test: cargo test -p aprender-contracts --lib ontology::capability_cells + if_fails: a required cell without a verdict arms, or a Fail is refused by the wrong shape + - id: FALSIFY-CAP-002 + rule: the plant is the tracked fixture and fires through the shape + prediction: the embedded plant equals tests/fixtures/ont/capability-cells-plant.yaml byte for byte, and refuses exactly capability-cells-plant@plant-host through the armed shape + test: cargo test -p aprender-contracts --lib lint::capability_cells_gate + if_fails: the positive control drifts from the tracked one, or stays fired with the shape gone + - id: FALSIFY-CAP-003 + rule: the gate's answers on the CLI + prediction: "Pass on the real corpus with capability-cells armed, pc_shapes fired, not_run empty and B ⊆ domain; exit 1 naming the cell on a missing, DEFER, MANUAL or NO-VERDICT required cell; exit 2 decline when D is empty or capability-cells is disarmed; a Fail cell exits 0 under this shape" + test: cargo test -p aprender-contracts-cli --test ont4c5_capability_cells + if_fails: a decline, a reject and a pass become indistinguishable to the probe + - id: FALSIFY-CAP-004 + rule: the spec row's mutations + prediction: "fold a missing cell into Fail → the plant passes → decline; drop capability-cells from armed_shapes → decline; flip one required rung to required: false → B ⊄ D → the probe is RED; label a required cell DEFER → exit 1 naming it" + test: cargo test -p aprender-contracts-cli --test ont4c5_capability_cells + if_fails: the row's own probe cannot go RED on the mutations it names + +qa_gate: + id: F-CAP-001 + name: capability-cells + description: 'A required cell of the model-capability ladder is Pass or Fail at the current release; NotRun is RED (ONT-4c5)' + checks: + - domain_is_ladder_greens + - cells_measured_at_v_star + - not_run_never_admitted + - empty_domain_declines + - plant_fires_through_armed_shape + pass_criteria: All 4 falsification tests pass; `pv lint contracts/ --gate shapes --format json` is Pass on the real corpus with capability-cells in armed_shapes, pc_shapes["capability-cells"] fired, capability_cells.not_run empty and the 14 ids of B in capability_cells.domain + falsification: 'label one required 0.69.1 row DEFER → exit 1 naming @; delete one required row from a 0.69.1 receipt → exit 1 naming it' diff --git a/contracts/ont-code-symbols-v1.yaml b/contracts/ont-code-symbols-v1.yaml index 2240e234e2..959f83f03b 100644 --- a/contracts/ont-code-symbols-v1.yaml +++ b/contracts/ont-code-symbols-v1.yaml @@ -45,7 +45,7 @@ metadata: extract:code — the bound symbols by a syn module-tree walk (inline mods, file mods, #[path], one re-export hop, impl methods), fail-closed with the reason in the graph; extract:lean — theorems from the in-tree Theorems tree, sorry-free files only grounding, joined to the contracts that cite them by ONT-2a's name - forms; 16 vendored W3C SHACL-Core cases run every gate run; the oracle differential in the release gate. + forms; 19 vendored W3C SHACL-Core cases run every gate run; the oracle differential in the release gate. references: - 'paiml/infra docs/specifications/paiml-ontology.md §3.6 (the subset), §3.7 (extractors), §5 ONT-4b2, R-13, R-15, R-18' - 'paiml/infra docs/specifications/ont-oracle-ledger.md (ONT-0: the 32-case table and the pinned oracle)' diff --git a/contracts/ont-kernel-receipts-v1.yaml b/contracts/ont-kernel-receipts-v1.yaml new file mode 100644 index 0000000000..a65e9f82d1 --- /dev/null +++ b/contracts/ont-kernel-receipts-v1.yaml @@ -0,0 +1,172 @@ +# ───────────────────────────────────────────────────────────────────────────── +# ont-kernel-receipts-v1 — kernel receipts as focus nodes (ONT-001 §5 ONT-4c4, §3.7; issue #4069, PMAT-4069) +# +# WHAT IT GOVERNS +# provable_contracts::ontology::extract::kernel — extract:kernel over extract:code: a bound #[kernel] symbol +# (cuda-oxide, pure Rust → PTX) gains rdf:type ont:KernelSymbol on the +# SAME IRI (ont:KernelSymbol ⊂ ont:Symbol), a kernel:reference to its +# f64 CPU sibling, and its kernel receipts +# (evidence/kernels//.json, apr-kernel-receipt/v1). +# provable_contracts::ontology::extract::code — sym:unsafeFree / sym:boundsChecked, read off the kernel's body. +# +# THE THREE SHAPES, all targetClass ont:KernelSymbol, all REPORTED and NOT ARMED at this row (absent from armed_shapes in +# lint-baseline.json): `kernel-parity` (every required CUDA host at cos ≥ 0.9999 ∧ max|Δ| < 1e-3 on a witness row of +# the receipt's own build sha; a reference that resolves), `kernel-timing` (oxide_us / ptx_us ≤ 1.2 per required host, +# or a tracked exemption receipt), `kernel-safety` (no unsafe, no get_unchecked, a register budget measured). The tree +# holds no #[kernel] fn and no evidence/kernels/ yet — the OXIDE pilot (#3522) lands the first; arming follows its +# first tracked receipt, and the ratchet keeps it armed. +# +# THE PLANT. pc_extract.kernel, drawn every run: a #[kernel] whose reference does not resolve must be named +# kernel:referenceMissing, one whose reference does must not be, and the ladder schema under evidence/kernels/ must +# be refused by name. +# +# KIND: pattern. Vocabulary, extractor and shapes over a graph; the proof is the extractor's own case table. +# ───────────────────────────────────────────────────────────────────────────── +name: ont-kernel-receipts +version: "1.0.0" +scope: > + How a bound #[kernel] symbol becomes an ont:KernelSymbol focus node, how the tracked kernel receipts are joined to it by + kernel name and build sha, and the three shapes over the join. Out of scope: the kernel-kind contracts (the other + sense of Kernel in Σ), the release kernel diffs (apr-kernel-diff-receipt/v1, aprender#3715), hand-PTX strings + (nothing binds them, so nothing extracts them). +status: active + +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-24' + last_modified: '2026-09-24' + author: PAIML Engineering + description: > + Bound #[kernel] symbols are ont:KernelSymbol focus nodes; apr-kernel-receipt/v1 rows witness them when kernel name and + build sha match; kernel-parity, kernel-timing and kernel-safety are reported until the OXIDE pilot's first + tracked receipt arms them. + references: + - 'paiml/infra docs/specifications/paiml-ontology.md §3.7, §5 ONT-4c4' + - 'aprender#4069 (this row), aprender#3522 (the OXIDE pilot: the first #[kernel] and its receipt)' + - 'crates/aprender-contracts/src/ontology/extract/{kernel.rs, code.rs}, lint/shapes_gate.rs' + +entity: + type: kernel + +relations: + depends_on: [ont-shapes-v1, ont-relations-v1, ont-sigma-v1, ont-code-symbols-v1] + +shapes: + - id: kernel-parity + targetClass: ont:KernelSymbol + properties: + - {path: kernel:reference, minCount: 1, nodeKind: IRI} + - {path: kernel:referenceMissing, maxCount: 0} + - {path: kernel:parityReceipt, minCount: 1, nodeKind: IRI, class: kernel:Receipt, resolves: kernel-receipt} + - {path: kernel:missingParityHost, maxCount: 0} + - {path: kernel:receiptShaMismatch, maxCount: 0} + - id: kernel-timing + targetClass: ont:KernelSymbol + properties: + - {path: kernel:missingTimingHost, maxCount: 0} + - id: kernel-safety + targetClass: ont:KernelSymbol + properties: + - {path: kernel:safety, minCount: 1, maxCount: 1, in: ["true"]} + - {path: kernel:registerBudget, minCount: 1} + +equations: + witness: + formula: "witness(k, f, row) ⇔ row.kernel ∈ {name(k), module(k)::name(k)} ∧ row.sha = f.sha ∧ f.sha ≠ ⊥" + domain: "every ont:KernelSymbol and every row of every apr-kernel-receipt/v1 file under evidence/kernels/" + codomain: "a kernel:parityReceipt edge to a kernel:Receipt node, or a kernel:receiptShaMismatch literal naming the file" + invariants: + - "a row whose sha is not its receipt's build sha is a mismatch, never a witness" + - "a row naming no bound kernel is counted (orphan_rows), never graded" + preconditions: + - "every *.json under evidence/kernels/ carries schema apr-kernel-receipt/v1; any other schema is exit 3 naming the file" + postconditions: + - "two extractions are byte-identical (R-15)" + lean_theorem: none — L4 not declared + parity: + formula: "parityOn(k, h) ⇔ ∃ witness row on h: cos ≥ 0.9999 ∧ |maxdiff| < 1e-3" + domain: "every ont:KernelSymbol × every required host (evidence/kernels/REQUIRED_HOSTS; absent → the hosts that measured)" + codomain: "a kernel:parityOn literal per host at parity; a kernel:missingParityHost literal per required host that is not" + invariants: + - "a host below parity is named, never averaged against a host above it" + preconditions: + - "the kernel has a kernel:reference: the contract binds a non-#[kernel] symbol to the same equation" + postconditions: + - "kernel-parity fires iff a required host is missing, the reference is missing, or a sha mismatches" + lean_theorem: none — L4 not declared + timing: + formula: "timingOn(k, h) ⇔ (∃ witness row on h: oxide_us / ptx_us ≤ 1.2) ∨ exemption(k)" + domain: "every ont:KernelSymbol × every required host" + codomain: "a kernel:timingOn literal, or a kernel:missingTimingHost literal" + invariants: + - "an exemption is a tracked receipt (evidence/kernels//EXEMPTION), cited as kernel:exemptionReceipt" + preconditions: + - "ptx_us > 0" + postconditions: + - "kernel-timing fires iff a required host is over budget with no exemption" + lean_theorem: none — L4 not declared + safety: + formula: "safety(k) ⇔ unsafeFree(k) ∧ boundsChecked(k) ∧ ∃ registerBudget(k)" + domain: "every ont:KernelSymbol" + codomain: "kernel:safety \"true\" | \"false\"" + invariants: + - "an unsafe fn, an unsafe block, or a get_unchecked call in the body is not safe" + preconditions: + - "the body was resolved by the syn walk" + postconditions: + - "kernel-safety fires iff safety is false" + lean_theorem: none — L4 not declared + +invariants: + - id: KRC-INV-001 + property: a kernel with no reference fails kernel-parity + formal: '|referenceMissing(k)| ≥ 1 ⇒ Fail(kernel-parity, k)' + prose: false + - id: KRC-INV-002 + property: a required host without parity fails kernel-parity naming the host + formal: 'h ∈ required ∧ ¬parityOn(k, h) ⇒ Fail(kernel-parity, k)' + prose: false + - id: KRC-INV-003 + property: a foreign schema under the kernel root is a declaration fault + formal: 'schema(f) ≠ apr-kernel-receipt/v1 ⇒ exit = 3' + prose: false + - id: KRC-INV-004 + property: an unarmed shape never feeds the meet + formal: 'shape ∉ armed_shapes ⇒ verdict(gate) independent of results(shape)' + prose: false + +falsification_tests: + - id: FALSIFY-KRC-001 + rule: witnesses and parity per host + prediction: a two-host all-green fixture has two witnesses and no missing host; one host at cos 0.9990 is named missingParityHost; a wrong-sha row is no witness and is named receiptShaMismatch + test: cargo test -p aprender-contracts --lib ontology::extract::kernel + if_fails: a kernel is called at parity on a build nobody measured, or a red host is averaged away + - id: FALSIFY-KRC-002 + rule: timing and its exemption + prediction: oxide_us / ptx_us = 1.3 is missingTimingHost without an exemption and timingOn with one, which is cited + test: cargo test -p aprender-contracts --lib ontology::extract::kernel + if_fails: a slow kernel passes silently, or an exemption is honoured without being cited + - id: FALSIFY-KRC-003 + rule: safety read off the body + prediction: an unsafe block or get_unchecked in a #[kernel] body sets sym:unsafeFree / sym:boundsChecked false and kernel:safety "false" + test: cargo test -p aprender-contracts --lib ontology::extract + if_fails: an unchecked kernel is graded safe + - id: FALSIFY-KRC-004 + rule: the extractor's positive control + prediction: pc_extract.kernel fires every run — a kernel with no reference is named, one with a reference is typed clean, the ladder schema is refused + test: cargo test -p aprender-contracts --lib lint::shapes_gate::tests + if_fails: a resolver that typed every kernel clean would make every kernel-parity a Pass + +qa_gate: + id: F-KRC-001 + name: kernel-receipts + description: 'Bound #[kernel] symbols are focus nodes, the tracked kernel receipts are their witnesses (ONT-4c4)' + checks: + - witnesses_by_kernel_and_build_sha + - parity_per_required_host + - timing_or_cited_exemption + - safety_from_the_body + - extractor_control_fires + pass_criteria: All 4 falsification tests pass; `pv lint contracts/ --gate shapes` reports by_entity_type.kernel, pc_extract.kernel fired, and by_shape naming kernel-parity, kernel-timing and kernel-safety + falsification: 'set cos to 0.999 in a tracked kernel receipt row → kernel-parity names the host; change its sha → zero witnesses; add an unsafe block to the #[kernel] body → kernel-safety fires' diff --git a/contracts/ont-model-receipts-v1.yaml b/contracts/ont-model-receipts-v1.yaml index 304db4abd1..f6349b06cb 100644 --- a/contracts/ont-model-receipts-v1.yaml +++ b/contracts/ont-model-receipts-v1.yaml @@ -3,7 +3,8 @@ # # WHAT IT GOVERNS # provable_contracts::ontology::extract::gguf — the model-capability ladder's rungs (and any GGUF file a -# contract names) as `model:Model` nodes keyed by sha256. +# contract names) as `model:Model` nodes keyed by sha256; +# ladder rungs are ALSO typed `model:LadderRung` (v4.16 D-T2, ONT-4c). # provable_contracts::ontology::extract::apr_model — an `.apr` v2 container as a `model:Model` node; a header whose # tensor count disagrees with its index is refused, naming the file. # provable_contracts::ontology::receipts — `resolves: receipt`: the tracked ladder receipts @@ -21,7 +22,7 @@ # (`armed_shapes shrank` → exit 3) keeps it armed. Both quantifications live in receipts.rs, which the fixtures # under tests/fixtures/ont/ladder-* falsify; the shapes stay inside §3.6's subset (minCount, maxCount, in, node). # -# THE PLANT. A bare `model:Model` node drawn every run violates `ladder-measured`'s `model:parityReceipt minCount 1` +# THE PLANT. A bare `model:LadderRung` node (the targetClass since ONT-4c) drawn every run violates `ladder-measured`'s `model:parityReceipt minCount 1` # and `model:sha256 minCount 1`: the positive control, drawn from an ARMED shape. Two more every run: # `pc_extract.gguf` (a corrupt magic is refused) and `pc_extract["apr-model"]` (a lying header is refused). # @@ -42,7 +43,7 @@ metadata: last_modified: '2026-09-19' author: PAIML Engineering description: > - The model-capability ladder's rungs are model:Model focus nodes; resolves: receipt joins the tracked ladder + The model-capability ladder's rungs are model:LadderRung focus nodes (ONT-4c retarget); resolves: receipt joins the tracked ladder receipts to them by measured sha256; ladder-measured is armed, ladder-green is reported until the 0.68.2 bump arms it; arming is per shape and monotone. references: @@ -58,7 +59,7 @@ relations: shapes: - id: ladder-measured - targetClass: model:Model + targetClass: model:LadderRung properties: - {path: model:sha256, minCount: 1, maxCount: 1, pattern: "^[0-9a-f]{64}$"} - {path: model:parityReceipt, minCount: 1, nodeKind: IRI, class: model:Receipt, resolves: receipt} diff --git a/contracts/ontology.yaml b/contracts/ontology.yaml index 8e0fc9d82c..83f8f1145d 100644 --- a/contracts/ontology.yaml +++ b/contracts/ontology.yaml @@ -19,6 +19,10 @@ concepts: Entity: {doc: "anything the ontology can name"} Contract: {doc: "a pv contract file under contracts/"} Kernel: {doc: "a contract whose kind is kernel: it carries equations and proof obligations"} + # ONT-001 v4.15: one IRI, one class. The Rust #[kernel] fn is NOT Kernel (a Contract); it is KernelSymbol, a + # Symbol, on the symbol's own IRI. extract:kernel emits `KernelSymbol rdfs:subClassOf Symbol` and refuses any + # subject typed both Symbol and Contract. + KernelSymbol: {doc: "a Rust #[kernel] fn — cuda-oxide, pure Rust to PTX — graded by its apr-kernel-receipt/v1 receipts; a Symbol, never a Contract"} Code: {doc: "a Rust item — a function, type or module — in this workspace"} Test: {doc: "a test function a contract cites as its falsifier"} Proof: {doc: "a Kani harness or Lean theorem discharging an obligation"} @@ -27,6 +31,10 @@ concepts: Statement: {doc: "a Lean theorem in the in-tree Theorems tree; the focus node of extract:lean (ONT-4b2: ont:Statement with lean:sorryFree, lean:modelOf)"} Model: {doc: "a model file — an .apr container or a GGUF; the ladder's rungs are the ones the release measures (ONT-4c1: model:Model, model:RequiredModel, model:Receipt)"} Dataset: {doc: "a CSV or corpus file"} + AprModel: {doc: "an aprender .apr v2 container, the focus node of extract:apr-model, typed alongside model:Model (v4.16 D-T2; AprModel ⊑ Model is ONT-4d's, not built here)"} + LadderRung: {doc: "a model-capability-ladder rung, typed on extract:gguf's rung path only, disjoint from AprModel (v4.16 D-T2)"} + Readme: {doc: "a README.md under contract, the focus node of extract:readme (ONT-4c)"} + LlmContext: {doc: "an LLM context file (CLAUDE.md) under contract, the focus node of extract:llm-context (ONT-4c)"} roles: binds: @@ -135,11 +143,11 @@ entity_types: - {name: json, extractor: json, implemented: true} - {name: code, extractor: code, implemented: true} - {name: lean, extractor: lean, implemented: true} - - {name: readme, extractor: readme, implemented: false} - - {name: llm-context, extractor: llm_context, implemented: false} + - {name: readme, extractor: readme, implemented: true} + - {name: llm-context, extractor: llm_context, implemented: true} - {name: apr-model, extractor: apr_model, implemented: true} - {name: gguf, extractor: gguf, implemented: true} - - {name: csv, extractor: csv, implemented: false} + - {name: csv, extractor: csv, implemented: true} # ONT-4c3 (aprender#3577): the logit-parity receipts under evidence/parity/**. Subclasses `json` for now; # it moves under a shared `Receipt` class once quorum and dispatch receipts join, so the three families # inherit common shapes. NOT the throughput parity receipts — a different family with a different validator. @@ -147,19 +155,60 @@ entity_types: # aprender#3715: a release's receipts (per-host inventory + cells, kernel diffs, context rungs, the dogfood # receipt R5 judged), extracted ONLY when a release subject is given — an ordinary PR has no release. - {name: release-evidence, extractor: release_evidence, implemented: true} + # aprender#3745 S2 (#3777): the release candidate's own interface (`apr surface --json`, apr-cli-surface/v1), + # extracted ONLY under a release subject with --surface; the release cells are derived from it. + - {name: cli-surface, extractor: cli_surface, implemented: true} + # ONT-4c4 (aprender#4069): the bound #[kernel] symbols over extract:code, joined to evidence/kernels//.json + # (apr-kernel-receipt/v1 — the ladder schema there, or this one under the ladder root, is refused by name). + - {name: kernel, extractor: kernel, implemented: true} extractors: - {name: pv_contract, reader: ontology/extract/pv_contract.rs, implemented: true} - {name: json, reader: ontology/extract/json.rs, implemented: true} - {name: code, reader: ontology/extract/code.rs, implemented: true} - {name: lean, reader: ontology/extract/lean.rs, implemented: true} - - {name: readme, reader: ontology/extract/readme.rs, implemented: false} - - {name: llm_context, reader: ontology/extract/llm_context.rs, implemented: false} + - {name: readme, reader: ontology/extract/readme.rs, implemented: true} + - {name: llm_context, reader: ontology/extract/llm_context.rs, implemented: true} - {name: apr_model, reader: ontology/extract/apr_model.rs, implemented: true} - {name: gguf, reader: ontology/extract/gguf.rs, implemented: true} - - {name: csv, reader: ontology/extract/csv.rs, implemented: false} + - {name: csv, reader: ontology/extract/csv.rs, implemented: true} - {name: parity_receipt, reader: ontology/extract/parity_receipt.rs, implemented: true} - {name: release_evidence, reader: ontology/extract/release_evidence.rs, implemented: true} + - {name: cli_surface, reader: ontology/extract/cli_surface.rs, implemented: true} + - {name: kernel, reader: ontology/extract/kernel.rs, implemented: true} + +# v4.16 D-T1 (qd4c4): the class a shape with no `targetClass` targets, by `entity.type` (shapes.rs::default_target). +# A type absent here has no default: its shape is malformed, exit 3, named. The sigma gate cross-checks the map +# against entity_types both ways: a key no entity_type declares is malformed, and so is an implemented type with +# neither a class nor an explicit `~` (no default target: every shape on it names its own targetClass). +entity_type_target_class: + pv-contract: ont:Contract + readme: readme:Readme + llm-context: llm:LlmContext + csv: csv:Dataset + apr-model: model:AprModel + gguf: model:Model + json: ~ + code: ~ + lean: ~ + parity-receipt: ~ + release-evidence: ~ + cli-surface: ~ + kernel: ~ + +# v4.16 D5b (qd4c3): which `## ` headings of an llm-context file satisfy each role. extract:llm-context emits +# llm:purposeSection / rulesSection / commandsSection / layoutSection per match; an unlisted heading is no error. +llm_context_role_synonyms: + Purpose: ["Project Overview"] + Rules: ["Git Workflow (Branch Protection)", "Autonomous Operation Mode (DEFAULT)", + "LAYOUT-001/002: Tensor Layout Safety", "Publishing Safety (CB-510 Lesson)", + "Shell Scripts: Use bashrs (NOT shellcheck)", + "Verification Discipline (MANDATORY — read before reporting any result)", + "Linting", "Contract Validation: DOGFOOD `pv`, NEVER bash", + "CRITICAL: Code Search Policy", "CRITICAL: Realizar-First Architecture"] + Commands: ["Build Commands", "Debugging: Use apr Tools First (MANDATORY)", + "APR CLI (`cargo install aprender`)"] + Layout: ["Architecture", "Modules", "Key Files", "CI/CD (`.github/workflows/`)"] # What this ontology does NOT express, said out loud so nobody encodes it by accident. Each names the reader that # would have to change first. @@ -177,3 +226,5 @@ readers: entity_types: lint/sigma_gate.rs extractors: ontology/sigma.rs not_expressible: ontology/sigma.rs + entity_type_target_class: ontology/shapes.rs + llm_context_role_synonyms: ontology/extract/llm_context.rs diff --git a/contracts/presentar/binding.yaml b/contracts/presentar/binding.yaml new file mode 100644 index 0000000000..54b976d0cd --- /dev/null +++ b/contracts/presentar/binding.yaml @@ -0,0 +1,221 @@ +version: 1.0.0 +target_crate: presentar +critical_path: +- render +- slide +- export +- compile +bindings: +- contract: dag-ordering-v1.yaml + equation: render + function: render_order + module_path: presentar::crates::presentar-layout::src::compute_block::render_order + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render + module_path: presentar::crates::presentar-terminal::src::compute_block::render + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render_frame + module_path: presentar::crates::presentar-terminal::src::tools::bench::render_frame + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render_full + module_path: presentar::crates::presentar-terminal::src::direct::diff_renderer::render_full + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render_lines + module_path: presentar::crates::presentar-terminal::src::widgets::ux::render_lines + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render_string + module_path: presentar::crates::presentar-terminal::src::widgets::micro_heat_bar::render_string + status: implemented +- contract: gpu-context-health-v1.yaml + equation: render + function: render_text + module_path: presentar::crates::presentar-terminal::src::widgets::micro_heat_bar::render_text + status: implemented +- contract: display-format-v1.yaml + equation: render + function: for_render + module_path: presentar::crates::presentar-terminal::src::perf_trace::trackers::for_render + status: implemented +- contract: display-format-v1.yaml + equation: render + function: record_render + module_path: presentar::crates::presentar-terminal::src::app::record_render + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render_with + module_path: presentar::crates::presentar-widgets::src::list::render_with + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render_range + module_path: presentar::crates::presentar-core::src::virtualization::render_range + status: implemented +- contract: display-format-v1.yaml + equation: render + function: should_render + module_path: presentar::crates::presentar-core::src::virtualization::should_render + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render_count + module_path: presentar::crates::presentar-core::src::virtualization::render_count + status: implemented +- contract: display-format-v1.yaml + equation: render + function: cells_to_render + module_path: presentar::crates::presentar-core::src::virtualization::cells_to_render + status: implemented +- contract: display-format-v1.yaml + equation: render + function: should_render_cell + module_path: presentar::crates::presentar-core::src::virtualization::should_render_cell + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render_instances + module_path: presentar::crates::presentar::src::webgpu::render_instances + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render_json + module_path: presentar::crates::presentar::src::browser::app::render_json + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render_counter + module_path: presentar::crates::presentar::src::browser::app::render_counter + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render_dashboard + module_path: presentar::crates::presentar::src::browser::app::render_dashboard + status: implemented +- contract: display-format-v1.yaml + equation: render + function: render_widget + module_path: presentar::crates::presentar::src::browser::app::render_widget + status: implemented +# --- TUI rendering contracts (2026-04-02) --- +- contract: tui-rendering-v1.yaml + equation: cellbuffer_bounds + function: get + module_path: presentar::crates::presentar-terminal::src::direct::cell_buffer::get + status: pending +- contract: tui-rendering-v1.yaml + equation: cellbuffer_bounds + function: set + module_path: presentar::crates::presentar-terminal::src::direct::cell_buffer::set + status: pending +- contract: tui-rendering-v1.yaml + equation: diff_renderer_correctness + function: render_diff + module_path: presentar::crates::presentar-terminal::src::direct::diff_renderer::render_diff + status: pending +- contract: tui-rendering-v1.yaml + equation: dirty_tracking + function: mark_dirty + module_path: presentar::crates::presentar-terminal::src::direct::cell_buffer::mark_dirty + status: pending +- contract: tui-rendering-v1.yaml + equation: unicode_width + function: display_width + module_path: presentar::crates::presentar-terminal::src::direct::cell_buffer::display_width + status: pending +- contract: tui-rendering-v1.yaml + equation: color_mode_fallback + function: nearest_256 + module_path: presentar::crates::presentar-terminal::src::color::nearest_256 + status: pending +- contract: tui-rendering-v1.yaml + equation: zero_alloc_render + function: render_diff + module_path: presentar::crates::presentar-terminal::src::direct::diff_renderer::render_diff + status: pending +- contract: tui-rendering-v1.yaml + equation: resize_safety + function: resize + module_path: presentar::crates::presentar-terminal::src::direct::cell_buffer::resize + status: pending +# --- Agent TUI panel contracts (2026-04-02) --- +- contract: tui-panels-v1.yaml + equation: panel_layout_nonoverlap + function: compute_layout + module_path: batuta::src::agent::tui::layout::compute_layout + status: pending +- contract: tui-panels-v1.yaml + equation: adaptive_degradation + function: detail_level + module_path: batuta::src::agent::tui::layout::detail_level + status: pending +- contract: tui-panels-v1.yaml + equation: streaming_token_ordering + function: push_token + module_path: batuta::src::agent::tui::streaming::push_token + status: pending +- contract: tui-panels-v1.yaml + equation: tool_progress_monotonic + function: update_tool + module_path: batuta::src::agent::tui::tools::update_tool + status: pending +- contract: tui-panels-v1.yaml + equation: cost_display_invariants + function: update_cost + module_path: batuta::src::agent::tui::cost::update_cost + status: pending +- contract: tui-panels-v1.yaml + equation: sandbox_violation_visibility + function: add_violation + module_path: batuta::src::agent::tui::sandbox::add_violation + status: pending +- contract: tui-panels-v1.yaml + equation: brick_budget_enforcement + function: verify_all + module_path: batuta::src::agent::brick::verify_all + status: pending +- contract: tui-panels-v1.yaml + equation: statusbar_state_display + function: update_statusbar + module_path: batuta::src::agent::tui::statusbar::update_statusbar + status: pending +# --- TUI lifecycle contracts (2026-04-03) --- +- contract: tui-lifecycle-v1.yaml + equation: widget_lifecycle + function: mount + module_path: presentar::crates::presentar-terminal::src::app::TuiApp::mount + status: pending + notes: Widget lifecycle state machine — Created→Mounted→Rendering→Unmounted +- contract: tui-lifecycle-v1.yaml + equation: widget_lifecycle + function: unmount + module_path: presentar::crates::presentar-terminal::src::app::TuiApp::unmount + status: pending + notes: Unmounted is terminal state — no transitions out +- contract: tui-lifecycle-v1.yaml + equation: render_cycle_correctness + function: render_diff + module_path: presentar::crates::presentar-terminal::src::direct::diff_renderer::render_diff + status: pending + notes: Only dirty cells written to terminal, zero writes for unchanged buffer +- contract: tui-lifecycle-v1.yaml + equation: event_dispatch + function: dispatch_event + module_path: presentar::crates::presentar-terminal::src::app::dispatch_event + status: pending + notes: Key→focused widget, Mouse→hit-test, Resize→re-layout, non-blocking +- contract: tui-lifecycle-v1.yaml + equation: terminal_restore + function: drop + module_path: presentar::crates::presentar-terminal::src::app::TuiApp::drop + status: pending + notes: Terminal restored on all exit paths — normal, panic, signal diff --git a/contracts/pretokenize-bin-v1.yaml b/contracts/pretokenize-bin-v1.yaml index 275fdfdad0..5fb63268fd 100644 --- a/contracts/pretokenize-bin-v1.yaml +++ b/contracts/pretokenize-bin-v1.yaml @@ -15,7 +15,7 @@ # apr. The stopgap was a Python shim; that stopgap is MUDA per the # 2026-04-19 user feedback "why not fix root cause vs 'hack'". This # contract specifies the native Rust subcommand `apr tokenize -# encode-corpus` (or equivalent `apr-corpus-ingest run`) that +# encode-corpus` (or equivalent `aprender-corpus-ingest run`) that # consumes a trained BPE tokenizer + JSONL corpus and produces the # exact shard format ShardBatchIter expects. # @@ -113,7 +113,7 @@ format: producer: cli_name: "apr tokenize encode-corpus" - alternative_cli_name: "apr-corpus-ingest run" + alternative_cli_name: "aprender-corpus-ingest run" required_inputs: - "--corpus # JSONL file with `content` field per line, or directory of such" - "--tokenizer

# directory containing vocab.json + merges.txt from `apr tokenize train`" diff --git a/contracts/pvl-lint-ratchets-v1.yaml b/contracts/pvl-lint-ratchets-v1.yaml new file mode 100644 index 0000000000..e8160db4af --- /dev/null +++ b/contracts/pvl-lint-ratchets-v1.yaml @@ -0,0 +1,121 @@ +# ───────────────────────────────────────────────────────────────────────────── +# pvl-lint-ratchets-v1 — theorem pairing + depends_on, shrink-only (PVL-001 EV-11) +# (paiml/infra PVL-001 @00553b0b §EV-11; issue #4166; ticket PMAT-4166) +# +# WHAT IT GOVERNS +# provable_contracts::lint::ratchet_gates — PV-RAT-001 (theorem-pairing) and +# PV-RAT-002 (depends-on-present); `pv lint --gate theorem-pairing --gate +# depends-on-present` and gates 14/15 of the full run (R-8: computed +# everywhere, armed per repo); `make lint-ratchet` (scripts/lint_ratchet.sh), +# the only writer of the two baselines, which only turns them down. +# +# THE PAIRING RULE IS THIS ROW'S DESIGN. The row says "module name appears in +# book/"; the full dotted module name on identifier boundaries is the reading +# that cannot be satisfied by accident (the book names 80 of 165 file STEMS, +# mostly as ordinary words; measured 2026-09-24). +# ───────────────────────────────────────────────────────────────────────────── +name: pvl-lint-ratchets +version: "1.0.0" +scope: > + Two debts that may fall and never rise: Lean theorem modules no book page names, and kernel-kind contracts + with an empty `metadata.depends_on`. Out of scope: whether a book page's mention is a real explanation, and + whether a declared dependency is the right one. +status: active + +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-24' + last_modified: '2026-09-24' + author: PAIML Engineering + description: > + `pv lint --gate theorem-pairing` counts Lean theorem modules whose full module name no book page mentions; + `--gate depends-on-present` counts kernel-kind contracts with no depends_on. Each rejects a count above its + top-level baseline in contracts/lint-baseline.json, reports (never passes) with no baseline, declines when + nothing was measured, and never writes the file. + references: + - 'paiml/infra docs/specifications/pvl-001 @00553b0b §EV-11 (probe + mutation)' + - 'crates/aprender-contracts/src/lint/ratchet_gates.rs — PV-RAT-001, PV-RAT-002' + - 'scripts/lint_ratchet.sh — `make lint-ratchet`, the downward-only writer' + +relations: + depends_on: [ont-verdict-lattice-v1] + +equations: + shrink_only_ratchet: + formula: "admits(corpus) ⟺ baseline ≠ ⊥ ∧ debt(corpus) ≤ baseline" + domain: "a contract corpus, its repo root (lean/, book/) and contracts/lint-baseline.json" + codomain: "Pass | Fail | Unknown" + invariants: + - "no baseline is Unknown(Report) with the count printed, never Pass" + - "nothing measured (no Lean base, no module, no book page, no kernel contract) is a decline" + preconditions: + - "the baseline, when present, is a non-negative integer at the top level" + postconditions: + - "verdict ∈ {Pass, Fail, Unknown}" + - "contracts/lint-baseline.json is byte-identical after the gate" + lean_theorem: none — L4 not declared + +invariants: + - id: RAT-INV-001 + property: the unpaired theorem module debt is shrink-only + formal: 'unpaired(corpus) ≤ baseline(unpaired_theorem_modules)' + prose: false + - id: RAT-INV-002 + property: the kernel contracts without depends_on debt is shrink-only + formal: 'without_depends_on(corpus) ≤ baseline(contracts_without_depends_on)' + prose: false + - id: RAT-INV-003 + property: a module is paired only by its full dotted name on identifier boundaries + formal: 'paired(m) ⟺ ∃ p ∈ book: mentions(p, name(m))' + prose: false + - id: RAT-INV-004 + property: a gate never writes its baseline + formal: '¬(after_gate(baseline) ≠ before_gate(baseline))' + prose: false + +falsification_tests: + - id: FALSIFY-RAT-001 + rule: the spec's mutation + prediction: adding an unpaired Theorem module turns `pv lint --gate theorem-pairing --gate depends-on-present` RED with PV-RAT-001 + test: cargo test -p aprender-contracts-cli --test ev11_lint_ratchets + if_fails: a theorem module can be added that no book page names, unrecorded + - id: FALSIFY-RAT-002 + rule: the depends_on ratchet + prediction: a kernel-kind contract with no depends_on above the baseline is PV-RAT-002; equal or lower passes + test: cargo test -p aprender-contracts --lib lint::ratchet_gates::tests::adding_a_kernel_contract_with_no_depends_on_is_red + if_fails: a kernel contract that names no composition can be added unrecorded + - id: FALSIFY-RAT-003 + rule: the pairing boundary + prediction: a file stem, or a longer module name that starts with it, does not pair a module + test: cargo test -p aprender-contracts --lib lint::ratchet_gates::tests::the_file_stem_or_a_longer_name_does_not_pair_a_module + if_fails: the book's ordinary words pair modules by accident + - id: FALSIFY-RAT-004 + rule: no baseline is reported, never judged + prediction: with no baseline key both gates are Unknown(Report), exit 2, the count printed + test: cargo test -p aprender-contracts --lib lint::ratchet_gates::tests::no_baseline_is_reported_and_never_a_pass + if_fails: deleting the baseline turns the ratchet into a pass + - id: FALSIFY-RAT-005 + rule: the gates never write + prediction: the baseline is byte-identical after both gates, on fixtures and on the real corpus + test: cargo test -p aprender-contracts --lib lint::ratchet_gates::tests::neither_gate_writes_the_baseline + if_fails: a gate can raise its own baseline + - id: FALSIFY-RAT-006 + rule: R-8 + prediction: the full `pv lint` run computes theorem-pairing and depends-on-present, reported and not armed + test: cargo test -p aprender-contracts --lib lint::tests + if_fails: armed_gates could name a gate no run computes + +qa_gate: + id: F-RAT-001 + name: lint ratchets + description: Theorem pairing and depends_on debts may fall, never rise (PVL-001 EV-11) + checks: + - unpaired_theorem_modules_shrink_only + - contracts_without_depends_on_shrink_only + - strict_pairing_boundary + - no_baseline_is_report + - gate_never_writes + - computed_in_every_run + pass_criteria: All 6 falsification tests pass; the EV-11 probe exits 0 on the real corpus and `git diff --exit-code contracts/lint-baseline.json` is clean after it + falsification: 'add lean/ProvableContracts/Theorems/S/B.lean named by no book page → exit 1, PV-RAT-001 (measured on a fixture 2026-09-24)' diff --git a/contracts/readme-root.yaml b/contracts/readme-root.yaml new file mode 100644 index 0000000000..ed729e3259 --- /dev/null +++ b/contracts/readme-root.yaml @@ -0,0 +1,86 @@ +# ───────────────────────────────────────────────────────────────────────────── +# readme-root — the repository README as a graded document (ONT-001 §5 ONT-4c, Appendix B.4; #3847, PMAT-3847) +# +# WHAT IS GRADED. `extract:readme` reads README.md's frontmatter (emitted by `readme_gen` via +# scripts/readme_sync.sh, never hand-written), its headings and its CLAIM fences. A claim fence is a CommonMark +# backtick or tilde fence whose first info token, lower-cased with `{` and `.` stripped, is bash, sh or shell. +# Every command in one must equal a `run:` line of a merge-path workflow step (push, pull_request, +# pull_request_target, merge_group; not `if: false`) — `resolves: ci-step`. One no workflow runs is refused by +# name, `readme:verifiedCommand unresolved: `. A fence that is illustration, not a claim, says so with +# `text`/`console`, which is a visible, reviewable edit — and dropping a VERIFIED claim that way is the F-34 +# withdrawal ratchet's business (lint-baseline.json `readme.withdrawn[]`, `make ont-ratchet`). +# +# KIND: pattern. One shape over one focus node; the proof is the gate's case table and the fixtures under +# tests/fixtures/ont/readme-*. +# ───────────────────────────────────────────────────────────────────────────── +name: readme-root +version: "1.0.0" +scope: > + README.md at the repository root: its frontmatter, its sections, and every command it presents as runnable. + Out of scope: prose accuracy, link targets, anything in a non-claim fence. +status: active + +metadata: + version: "1.0.0" + kind: pattern + created: '2026-09-24' + last_modified: '2026-09-24' + author: PAIML Engineering + description: > + README.md becomes a readme:Readme node; every claim-fence command must be one a merge-path CI step runs, + every entrypoint must exist, and the contract count it prints must equal census.json n_files. + references: + - 'paiml/infra docs/specifications/paiml-ontology.md v4.16 §5 ONT-4c, Appendix B.4' + - 'crates/aprender-contracts/src/ontology/extract/{readme,claims}.rs, ontology/measured_sets.rs' + - 'scripts/readme_sync.sh (the frontmatter), scripts/check_ont_ratchet.sh (the measured sets)' + +entity: + type: readme + ref: README.md + +relations: + depends_on: [ont-sigma-v1, ont-shapes-v1] + +shape: + targetClass: readme:Readme + closed: true + properties: + - {path: readme:schemaVersion, minCount: 1, maxCount: 1, pattern: "^[0-9]+\\.[0-9]+$"} + - {path: readme:kind, in: [library, cli, service, spec]} + - {path: readme:entrypoint, minCount: 1, resolves: path} + - {path: readme:verifiedCommand, minCount: 1, resolves: ci-step} + - {path: readme:section, minCount: 3} + - {path: readme:contractCount, maxCount: 1, resolves: census} + +equations: + claim_resolves: + formula: "∀ c ∈ claims(README.md): normalise(c) ∈ run_lines(merge_path_workflows)" + domain: every command line of every claim fence in README.md + codomain: readme:verifiedCommand values, or a PV-ONT-012 refusal naming the command + invariants: + - a claim is decided by its fence's info token, never by its content + - an unresolved claim is refused by name, never skipped + preconditions: + - .github/workflows/*.yml parse + postconditions: + - the committed readme.verified_commands[] equals the live set (F-33) + lean_theorem: "none — L4 not declared" + +invariants: + - id: RDM-INV-001 + property: every README claim-fence command is one a merge-path workflow runs + formal: '∀ c ∈ claims(README.md): normalise(c) ∈ run_lines(W_merge)' + prose: false + - id: RDM-INV-002 + property: the printed contract count equals the census + formal: 'frontmatter(README.md).contract_count ≡ census.json.n_files' + prose: false + +falsification_tests: + - id: FALSIFY-RDM-001 + rule: an unrun claim is refused + prediction: > + adding a bash fence to README.md whose command no workflow runs makes `pv lint contracts/ --gate shapes` + exit 1 naming `readme:verifiedCommand unresolved: `; relabelling it `text` makes it pass again + test: cargo test -p aprender-contracts --lib ontology::extract::readme + if_fails: the README can advertise a command nothing has ever run diff --git a/contracts/realizar/binding.yaml b/contracts/realizar/binding.yaml new file mode 100644 index 0000000000..7e1ca8a206 --- /dev/null +++ b/contracts/realizar/binding.yaml @@ -0,0 +1,736 @@ +version: 1.0.0 +target_crate: realizar +bindings: +- contract: forward-pass-v1.yaml + equation: rms_norm + module_path: realizar::gguf::ops + function: rms_norm_unit_offset + status: partial + notes: '#4369: #[contract] site at crates/aprender-serve/src/gguf/ops.rs:128 had no binding row, and contracts/forward-pass-v1.yaml does not exist in-tree: the annotation cites no contract' +- contract: gemm-v1.yaml + equation: flattened_batched_gemm + module_path: realizar::gguf::inference::cached::single_flattened_batched_owned + function: flattened_batched_gemm + status: partial + notes: '#4369: #[contract] site at crates/aprender-serve/src/gguf/inference/cached/single_flattened_batched_owned.rs:19 had no binding row, and contracts/gemm-v1.yaml does not exist in-tree: the annotation cites no contract' +- contract: gpu-multi-backend-parity-v1.yaml + equation: multi_backend_parity + module_path: realizar::infer::batch_wgpu + function: generate + status: implemented + notes: '#4369: #[contract] site at crates/aprender-serve/src/infer/batch_wgpu.rs:44 had no binding row; row added when the binding moved in-tree' +- contract: gpu-weight-residency-v1.yaml + equation: pcie_overhead + module_path: realizar::infer::batch_wgpu + function: try_init_wgpu_batch + status: implemented + notes: '#4369: #[contract] site at crates/aprender-serve/src/infer/batch_wgpu.rs:129 had no binding row; row added when the binding moved in-tree' +- contract: sampling-v1.yaml + equation: softmax_inplace + module_path: realizar::gguf::ops + function: softmax + status: partial + notes: '#4369: #[contract] site at crates/aprender-serve/src/gguf/ops.rs:347 had no binding row, and contracts/sampling-v1.yaml does not exist in-tree: the annotation cites no contract' +- contract: wgpu-forward-pass-v1.yaml + equation: dequant_correctness + module_path: realizar::gpu::adapters::wgpu_adapter + function: dequant_model_weights + status: partial + notes: '#4369: #[contract] site at crates/aprender-serve/src/gpu/adapters/wgpu_adapter.rs:52 had no binding row, and contracts/wgpu-forward-pass-v1.yaml does not exist in-tree: the annotation cites no contract' +- contract: matmul-kernel-v1.yaml + equation: quantized_dot + module_path: realizar::quantize::fused_k + function: fused_q4k_dot + signature: 'fn fused_q4k_dot(q4k_data: &[u8], activations: &[f32]) -> Result' + status: implemented + notes: Scalar, AVX2, AVX-512 VNNI variants; also Q6K, Q8_0 +- contract: flash-attention-v1.yaml + equation: flash_attention + module_path: realizar::gguf::inference::attention_part_02 + function: flash_attention_tiled + signature: 'fn flash_attention_tiled(q: &[f32], k: &[f32], v: &[f32], ...) -> Vec' + status: implemented + notes: CPU tiled + CUDA flash attention with causal masking +- contract: attention-kernel-v1.yaml + equation: attention + module_path: realizar::apr::helpers + function: simple_attention + signature: 'fn simple_attention(q: &[f32], k: &[f32], v: &[f32], seq_len: usize, num_heads: usize, num_kv_heads: usize, + head_dim: usize) -> Vec' + status: implemented + notes: Multi-sequence causal attention; GQA-aware with head broadcasting +- contract: gqa-kernel-v1.yaml + equation: gqa + module_path: realizar::apr::helpers + function: simple_attention + signature: 'fn simple_attention(q: &[f32], k: &[f32], v: &[f32], seq_len: usize, num_heads: usize, num_kv_heads: usize, + head_dim: usize) -> Vec' + status: implemented + notes: GQA with head broadcasting (num_heads/num_kv_heads grouping) +- contract: gated-delta-net-v1.yaml + equation: decay + module_path: realizar::gpu::scheduler::linear_attn + function: gated_delta_net_decay + signature: 'fn gated_delta_net_decay(a_log: &[f32], dt: &[f32]) -> Vec' + status: implemented + notes: decay = exp(g_h), S *= decay +- contract: gated-delta-net-v1.yaml + equation: read + module_path: realizar::gpu::scheduler::linear_attn + function: gated_delta_net_read + signature: 'fn gated_delta_net_read(state: &[f32], q: &[f32]) -> Vec' + status: implemented + notes: 'State read: mem = S^T @ k_norm' +- contract: gated-delta-net-v1.yaml + equation: write + module_path: realizar::gpu::scheduler::linear_attn + function: gated_delta_net_write + signature: 'fn gated_delta_net_write(state: &mut [f32], k: &[f32], delta: &[f32], decay: &[f32])' + status: implemented + notes: 'State write: S += k (x) delta' +- contract: gated-delta-net-v1.yaml + equation: delta + module_path: realizar::gpu::scheduler::linear_attn + function: gated_delta_net_delta + signature: 'fn gated_delta_net_delta(v: &[f32], read: &[f32], beta: &[f32]) -> Vec' + status: implemented + notes: 'Delta rule: delta = beta * (v - mem)' +- contract: gated-delta-net-v1.yaml + equation: output + module_path: realizar::gpu::scheduler::linear_attn + function: gated_delta_net_output + signature: 'fn gated_delta_net_output(y: &[f32], z: &[f32]) -> Vec' + status: implemented + notes: 'Output: o = S^T @ q, then rms_norm_gated' +- contract: rope-extrapolation-v1.yaml + equation: ntk_scaled_base + module_path: realizar::layers::position + function: ScaledRope::new + signature: 'fn new(config: RopeConfig) -> Self' + status: implemented + notes: RopeScalingType::Ntk and DynamicNtk +- contract: rope-extrapolation-v1.yaml + equation: linear_interpolation + module_path: realizar::layers::position + function: ScaledRope::new + signature: 'fn new(config: RopeConfig) -> Self' + status: implemented + notes: RopeScalingType::Linear; context_multiplier tested +- contract: rope-extrapolation-v1.yaml + equation: yarn_ramp + module_path: realizar::layers::position + function: ScaledRope::new + signature: 'fn new(config: RopeConfig) -> Self' + status: implemented + notes: RopeScalingType::Yarn with ramp function +- contract: rope-extrapolation-v1.yaml + equation: yarn_mixed_frequency + module_path: realizar::layers::position + function: ScaledRope::new + signature: 'fn new(config: RopeConfig) -> Self' + status: implemented + notes: YaRN mixed-frequency applied via ScaledRope::apply() +- contract: inference-pipeline-v1.yaml + equation: prefill_phase + module_path: realizar::gguf::inference::forward::batch_part_02_part_03 + function: prefill_batch + signature: 'fn prefill_batch(model: &mut Model, tokens: &[u32], ...) -> Result>' + status: implemented + notes: Batched and chunked prefill; 8.2x speedup over serial +- contract: inference-pipeline-v1.yaml + equation: decode_step + module_path: realizar::gguf::inference::forward + function: Model::forward + signature: 'fn forward(&mut self, token: u32, position: usize) -> Result>' + status: implemented + notes: Single-token forward pass; decode is the default path after prefill +- contract: kv-cache-equivalence-v1.yaml + equation: prefill_incremental + module_path: realizar::gguf::inference::forward::batch_part_02_part_03 + function: prefill_batch + signature: 'fn prefill_batch(model: &mut Model, tokens: &[u32], ...) -> Result>' + status: implemented + notes: KV cache populated incrementally during batched prefill +- contract: kv-cache-equivalence-v1.yaml + equation: batched_serial_equivalence + module_path: realizar::gguf::inference::forward + function: generate_with_batched_prefill + signature: fn generate_with_batched_prefill(...) -> Result> + status: implemented + notes: Batched prefill produces same KV cache as serial +- contract: kv-cache-equivalence-v1.yaml + equation: fused_kernel + module_path: realizar::cuda::executor::kv_cache_part_02_part_03 + function: flash_attention_cached + signature: 'fn flash_attention_cached(q: &CudaSlice, k_cache: &CudaSlice, v_cache: &CudaSlice, ...) -> Result<()>' + status: implemented + notes: CUDA flash attention with fused KV cache read +- contract: qwen35-hybrid-forward-v1.yaml + equation: gdn_sublayer + module_path: realizar::gpu::scheduler::linear_attn + function: gated_delta_net_forward + signature: 'fn gated_delta_net_forward(input: &[f32], weights: &GdnWeights, state: &mut GdnState) -> Vec' + status: implemented + notes: pre-norm -> projections -> causal Conv1D -> SiLU -> gated_delta_rule_step -> RMSNorm_gated -> out_proj -> residual +- contract: qwen35-shapes-v1.yaml + equation: o_projection_transpose + module_path: realizar::gguf::config + function: ArchConstraints::from_gguf_metadata + signature: 'fn from_gguf_metadata(metadata: &GgufMetadata) -> ArchConstraints' + status: implemented + notes: O projection shape [hidden, n_h * d_k] validated by ArchConstraints +- contract: architecture-requirements-v1.yaml + equation: gguf_loader_validation + module_path: realizar::cuda::executor::weights + function: CudaExecutor::build_indexed_weights + signature: 'fn build_indexed_weights(&mut self, num_layers: usize, layer_prefix_fn: F, arch: &ArchConstraints) -> Result<(), + GpuError>' + status: implemented + notes: GGUF loader path; validates via ValidatedLayerWeights::validate() +- contract: architecture-requirements-v1.yaml + equation: apr_loader_validation + module_path: realizar::apr::cuda_part_02_part_03 + function: AprV2ModelCuda::upload_quantized_weights_to_gpu + signature: fn upload_quantized_weights_to_gpu(&mut self) -> Result + status: implemented + notes: APR loader path; derives ArchConstraints from metadata +- contract: architecture-requirements-v1.yaml + equation: safetensors_loader_validation + module_path: realizar::safetensors_cuda + function: SafeTensorsCudaModel::upload_weights + signature: fn upload_weights(...) -> Result<(Vec, ...)> + status: implemented + notes: SafeTensors loader; validates completeness + model_load_basic before GPU init +- contract: ptx-target-parity-v1.yaml + equation: target_parity + module_path: realizar::cuda::gpu_profile + function: GpuProfile::detect + signature: 'fn detect(context: &CudaContext) -> GpuProfile' + status: implemented + notes: sm_target set from context.compute_capability() at executor init +- contract: ptx-target-parity-v1.yaml + equation: no_hardcoded_targets + module_path: realizar::cuda::kernel_generator + function: CudaKernels::generate_ptx + signature: 'fn generate_ptx(&self, kernel_type: &KernelType) -> String' + status: implemented + notes: All PTX generation uses self.sm_target — no emit_ptx() calls in executor/ +- contract: ptx-target-parity-v1.yaml + equation: jit_compilation_success + module_path: realizar::cuda::executor::core + function: CudaExecutor::compile_ptx + signature: 'fn compile_ptx(&self, ptx: &str) -> Result' + status: implemented + notes: PTX JIT with blocklist; target parity ensures CUDA_SUCCESS +- contract: fused-qkv-projection-v1.yaml + equation: shared_q8_qkv + module_path: realizar::cuda::executor::q4k_mwv_gemv + function: CudaExecutor::batched_qkv_dp4a_q4k_gemv_into + signature: 'fn batched_qkv_dp4a_q4k_gemv_into(&mut self, q_w: u64, k_w: u64, v_w: u64, input: &GpuBuffer, q_out: &GpuBuffer, + k_out: &GpuBuffer, v_out: &GpuBuffer, m: u32, q_dim: u32, kv_dim: u32, k: u32) -> Result<(), GpuError>' + status: implemented + notes: 'PMAT-054A: Q8_1 quantize once, 3x GEMV reuse. Saves 56 kernel launches per token.' +- contract: paged-kv-cache-v1.yaml + equation: slot_mapping + module_path: realizar::cuda::executor::kv_cache + function: PagedKvCache::slot + signature: 'fn slot(&self, req: usize, pos: usize) -> usize' + status: implemented + notes: 'PMAT-052: Requires block table allocator. Currently stride-based.' +- contract: paged-kv-cache-v1.yaml + equation: block_allocation + module_path: realizar::cuda::executor::kv_cache + function: BlockAllocator::allocate + signature: 'fn allocate(&mut self, num_blocks: usize) -> Result, GpuError>' + status: implemented + notes: 'PMAT-052: Free list block allocator. Currently contiguous allocation.' +- contract: paged-kv-cache-v1.yaml + equation: paged_contiguous_equivalence + module_path: realizar::cuda::executor::attention + function: paged_attention_into + signature: 'fn paged_attention_into(&self, q: &GpuBuffer, block_table: &GpuBuffer, ...) -> Result<(), GpuError>' + status: implemented + notes: 'PMAT-053: Paged attention kernel. Currently incremental_attention_into.' +- contract: continuous-batching-v1.yaml + equation: token_budget + module_path: realizar::api::cuda_batch_scheduler + function: CudaBatchScheduler::schedule_step + signature: fn schedule_step(&mut self) -> SchedulerOutput + status: implemented + notes: 'PMAT-051: Token budget scheduler. Currently 10ms accumulation window.' +- contract: continuous-batching-v1.yaml + equation: chunked_prefill + module_path: realizar::cuda::executor::layers::prefill + function: CudaExecutor::chunked_prefill + signature: 'fn chunked_prefill(&mut self, tokens: &[u32], chunk_size: usize) -> Result<(), GpuError>' + status: implemented + notes: 'PMAT-051: Chunked prefill to avoid blocking decode. Currently full prefill.' +- contract: continuous-batching-v1.yaml + equation: correctness_under_batching + module_path: realizar::gguf::cuda::generate_batched_streaming + function: generate_batched_streaming + signature: 'fn generate_batched_streaming(&mut self, slots: &[BatchSlot]) -> Result<(), GpuError>' + status: implemented + notes: 'CORRECTNESS-013: FALSIFIED. c=4 slots 1-3 produce constant tokens per step. + + Confirmed in BOTH HGEMM (token 585) and DP4A (tokens 198/88648/198) paths. + + Root cause in common path: prefill_multi_prompt → decode_batched transition. + + PMAT-070: Fix batched decode frozen slots. PMAT-071: Wire FALSIFY-CB-006. + + Instrument: probador llm test c=1 vs c=4, X-Trace-Level: brick, PMAT051_TRACE=1. + + ' +- contract: tensor-inventory-v1.yaml + equation: tensor_count + module_path: realizar + function: tensor_count + status: implemented + notes: Auto-inferred by pv infer +- contract: q4k-q6k-superblock-v1.yaml + equation: total_bytes + module_path: realizar + function: total_bytes + status: implemented + notes: Auto-inferred by pv infer +- contract: int8-symmetric-quant-v1.yaml + equation: quantize + module_path: realizar + function: quantize + status: implemented + notes: Auto-inferred by pv infer +- contract: matmul-kernel-v1.yaml + equation: matmul + module_path: realizar + function: matmul + status: implemented + notes: Auto-inferred by pv infer +- contract: activation-kernel-v1.yaml + equation: relu + module_path: realizar + function: relu + status: implemented + notes: Auto-inferred by pv infer +- contract: silu-kernel-v1.yaml + equation: sigmoid + module_path: realizar + function: sigmoid + status: implemented + notes: Auto-inferred by pv infer +- contract: backend-dispatch-v1.yaml + equation: gpu_threshold + module_path: realizar + function: gpu_threshold + status: implemented + notes: Auto-inferred by pv infer +- contract: transpose-kernel-v1.yaml + equation: transpose + module_path: realizar + function: transpose + status: implemented + notes: Auto-inferred by pv infer +- contract: softmax-kernel-v1.yaml + equation: softmax + module_path: realizar + function: softmax + status: implemented + notes: Auto-inferred by pv infer +- contract: activation-kernel-v1.yaml + equation: gelu + module_path: realizar + function: gelu + status: implemented + notes: Auto-inferred by pv infer +- contract: sliding-window-attention-v1.yaml + equation: effective_context + module_path: realizar + function: effective_context + status: implemented + notes: Auto-inferred by pv infer +- contract: kmeans-kernel-v1.yaml + equation: update + module_path: realizar + function: update + status: implemented + notes: Auto-inferred by pv infer +- contract: gqa-kernel-v1.yaml + equation: gqa + module_path: realizar + function: gqa + status: implemented + notes: Auto-inferred by pv infer +- contract: model-config-algebra-v1.yaml + equation: bounds + module_path: realizar + function: bounds + status: implemented + notes: Auto-inferred by pv infer +- contract: activation-kernel-v1.yaml + equation: silu + module_path: realizar + function: silu + status: implemented + notes: Auto-inferred by pv infer +- contract: learned-position-embedding-v1.yaml + equation: position_embedding + module_path: realizar + function: position_embedding + status: implemented + notes: Auto-inferred by pv infer +- contract: online-softmax-v1.yaml + equation: standard_softmax + module_path: realizar + function: standard_softmax + status: implemented + notes: Auto-inferred by pv infer +- contract: random-forest-v1.yaml + equation: predict + module_path: realizar + function: predict + status: implemented + notes: Auto-inferred by pv infer +- contract: flash-attention-v1.yaml + equation: flash_attention + module_path: realizar + function: flash_attention + status: implemented + notes: Auto-inferred by pv infer +- contract: rmsnorm-kernel-v1.yaml + equation: rmsnorm + module_path: realizar + function: rmsnorm + status: implemented + notes: Auto-inferred by pv infer +- contract: format-parity-v1.yaml + equation: element_count + module_path: realizar + function: element_count + status: implemented + notes: Auto-inferred by pv infer +- contract: sampling-algorithms-v1.yaml + equation: greedy + module_path: realizar + function: greedy + status: implemented + notes: Auto-inferred by pv infer +- contract: sampling-algorithms-v1.yaml + equation: top_k + module_path: realizar + function: top_k + status: implemented + notes: Auto-inferred by pv infer +- contract: sampling-algorithms-v1.yaml + equation: top_p + module_path: realizar + function: top_p + status: implemented + notes: Auto-inferred by pv infer +- contract: cma-es-kernel-v1.yaml + equation: sample + module_path: realizar + function: sample + status: implemented + notes: Auto-inferred by pv infer +- contract: rope-extrapolation-v1.yaml + equation: base_frequency + module_path: realizar::layers::position + function: RoPE::new + signature: 'pub fn new(dim: usize, base: f32) -> Result' + status: implemented + notes: 'PMAT-414: freq_i = theta^(-2i/d). Computes inv_freq table at init' +- contract: rope-extrapolation-v1.yaml + equation: rotation_matrix + module_path: realizar::quantize::rope + function: apply_rope_rotation_simd + signature: 'pub fn apply_rope_rotation_simd(x1: &mut [f32], x2: &mut [f32], cos_vals: &[f32], sin_vals: &[f32])' + status: implemented + notes: 'PMAT-414: 2x2 rotation [cos,-sin; sin,cos] on paired dimensions. SIMD path' +- contract: architecture-requirements-v1.yaml + equation: weight_completeness + module_path: realizar::contract_gate + function: validate_completeness + signature: 'fn validate_completeness(arch: &ArchConstraints, present: &[WeightRole], arch_name: &str) -> Result<(), ModelLoadError>' + status: implemented + notes: 'PMAT-414: Checks all required roles present for arch. Returns missing list' +- contract: architecture-requirements-v1.yaml + equation: role_mapping + module_path: realizar::arch_requirements_fallback + function: WeightRole::field_name + signature: 'pub const fn field_name(&self) -> &''static str' + status: implemented + notes: 'PMAT-414: Injective mapping WeightRole → IndexedLayerWeights field name' +- contract: architecture-requirements-v1.yaml + equation: constraint_matrix_exhaustiveness + module_path: realizar::arch_requirements_fallback + function: required_roles + signature: 'pub fn required_roles(arch: &ArchConstraints) -> &''static [WeightRole]' + status: implemented + notes: 'PMAT-414: 4 exhaustive arms for (has_qk_norm, has_bias) boolean pairs' +- contract: qwen35-hybrid-forward-v1.yaml + equation: attention_sublayer + module_path: realizar::gpu::scheduler::forward_from_model + function: forward_block_traced + signature: 'fn forward_block_traced(&mut self, input: &[f32], seq_len: usize, block_idx: usize) -> Result<(Vec, LayerActivation)>' + status: implemented + notes: 'PMAT-414: Full attention sublayer: pre-norm, QKV, RoPE, attention, O proj, residual' +- contract: qwen35-hybrid-forward-v1.yaml + equation: ffn_sublayer + module_path: realizar::quantize::quantize_rmsnorm_into + function: fused_swiglu_simd + signature: 'pub fn fused_swiglu_simd(gate: &mut [f32], up: &[f32])' + status: implemented + notes: 'PMAT-414: SwiGLU FFN kernel. gate *= sigmoid(gate) * up' +- contract: qwen35-hybrid-forward-v1.yaml + equation: hybrid_block + module_path: realizar::safetensors::safetensors_config + function: SafeTensorsConfig::is_hybrid_attention + signature: 'pub fn is_hybrid_attention(&self) -> bool' + status: implemented + notes: 'PMAT-414: Dispatches attention vs GDN per layer_types array' +- contract: qwen35-shapes-v1.yaml + equation: rope_frequency + module_path: realizar::gpu::simd_ops + function: rope_frequency_table + signature: 'fn rope_frequency_table(half_head: usize, head_dim: usize, theta: f32) -> Vec' + status: implemented + notes: 'PMAT-414: freq_i = 1/theta^(2i/head_dim). Used by GPU scheduler' +- contract: quantization-ordering-v1.yaml + equation: size_ordering + module_path: realizar::quantize::format_trait + function: QuantBlockFormat + signature: 'trait QuantBlockFormat { const SUPERBLOCK_BYTES: usize; const ELEMENTS_PER_SUPERBLOCK: usize; }' + status: implemented + notes: 'PMAT-414: Q4K 0.5625 < Q6K 0.8203 < Q8_0 1.0625 B/param' +- contract: fused-qkv-projection-v1.yaml + equation: fused_qkv + module_path: realizar::gpu::scheduler::model_forward_block_gpu + function: fused_qkv_projection + signature: 'pub fn fused_qkv_projection(&mut self, input: &[f32]) -> Result<(Vec, Vec, Vec)>' + status: implemented + notes: 'PMAT-408: Fused QKV projection. CUDA path: fused_qkv_into with DP4A shared Q8' +- contract: sampling-algorithms-v1.yaml + equation: temperature + module_path: realizar::gpu::scheduler::ops + function: sample_topk + signature: 'pub(super) fn sample_topk(logits: &[f32], temperature: f32, top_k: usize) -> usize' + status: implemented + notes: 'PMAT-408: Temperature scaling applied before top-k/top-p sampling' +- contract: bpe-tokenization-v1.yaml + equation: merge_rule + module_path: realizar::gguf::token + function: GgufTokenizer::merge_rules + signature: 'pub fn merge_rules(&self) -> Option>' + status: implemented + notes: 'PMAT-408: BPE merge pairs from GGUF metadata' +- contract: bpe-tokenization-v1.yaml + equation: encode + module_path: realizar::tokenizer + function: Tokenizer::encode + signature: 'pub fn encode(&self, text: &str) -> Vec' + status: implemented + notes: 'PMAT-408: BPE tokenization. Also GGUF path at gguf::token' +- contract: bpe-tokenization-v1.yaml + equation: decode + module_path: realizar::tokenizer + function: Tokenizer::decode + signature: 'pub fn decode(&self, token_ids: &[u32]) -> Result' + status: implemented + notes: 'PMAT-408: BPE detokenization. GPT-2 byte-level BPE for WGPU path' +- contract: continuous-batching-v1.yaml + equation: request_state + module_path: realizar::scheduler::mod_max_scheduler + function: SlotState + signature: 'pub enum SlotState { Idle, Processing, Generating }' + status: implemented + notes: 'PMAT-405: Slot state machine. Also CommandSlotState for GPU path' +- contract: continuous-batching-v1.yaml + equation: scheduling_fairness + module_path: realizar::scheduler::mod_max_scheduler + function: handle_preemption + signature: 'fn handle_preemption(&mut self, output: &mut SchedulerOutput, kv_cache: &mut PagedKvCache)' + status: implemented + notes: 'PMAT-405: Preemption logic for fairness under load' +- contract: inference-pipeline-v1.yaml + equation: residual_stream + module_path: realizar::apply + function: forward_layer + signature: 'fn forward_layer(&mut self, layer_idx: usize, hidden: &[f32]) -> Result>' + status: implemented + notes: 'PMAT-405: hidden += attn_proj, residual += ffn_out. Also GPU PTX residualadd kernel' +- contract: inference-pipeline-v1.yaml + equation: layer_composition + module_path: realizar::gpu::adapters::using + function: forward_layer + signature: 'fn forward_layer(&self, executor: &mut CudaExecutor, input: &GpuBuffer, layer_idx: usize, seq_len: usize) -> Result>' + status: implemented + notes: 'PMAT-405: Chained forward through num_layers. CPU and GPU paths' +- contract: inference-pipeline-v1.yaml + equation: hybrid_layer_schedule + module_path: realizar::safetensors::safetensors_config + function: SafeTensorsConfig::is_hybrid_attention + signature: 'pub fn is_hybrid_attention(&self) -> bool' + status: implemented + notes: 'PMAT-405: GDN/attention dispatch per layer_types array (Qwen3.5 MoE)' +- contract: inference-pipeline-v1.yaml + equation: kv_cache_growth + module_path: realizar::verification_specs + function: verify_kv_cache_size + signature: 'fn verify_kv_cache_size(num_layers: u64, num_heads: u64, head_dim: u64, max_seq_len: u64) -> u64' + status: implemented + notes: 'PMAT-405: KV cache = 2 * layers * seq_len * kv_dim * 4 bytes' +- contract: q4k-q6k-superblock-v1.yaml + equation: q4k_superblock + module_path: realizar::quantize::format_trait + function: Q4K + signature: 'impl QuantBlockFormat for Q4K { ELEMENTS_PER_SUPERBLOCK: 256, SUPERBLOCK_BYTES: 144, QUANT_BITS: 4 }' + status: implemented + notes: 'PMAT-405: 144 bytes/256 elements. 12-byte packed scales+mins' +- contract: q4k-q6k-superblock-v1.yaml + equation: q6k_superblock + module_path: realizar::quantize::format_trait + function: Q6K + signature: 'impl QuantBlockFormat for Q6K { ELEMENTS_PER_SUPERBLOCK: 256, SUPERBLOCK_BYTES: 210, QUANT_BITS: 6 }' + status: implemented + notes: 'PMAT-405: 210 bytes/256 elements. Higher precision for LmHead' +- contract: q4k-q6k-superblock-v1.yaml + equation: dequantization + module_path: realizar::quantize::format_trait + function: Q4K::dequant_value + signature: 'fn dequant_value(superblock: &[u8], i: usize) -> f32' + status: implemented + notes: 'PMAT-405: Per-element Q4K→FP32. Also fused_q4k_dot for dot+dequant' +- contract: q4k-q6k-superblock-v1.yaml + equation: bsum + module_path: realizar::quantize::bsum_precompute + function: precompute_q8k_bsums + signature: 'pub fn precompute_q8k_bsums(q8k_quants: &[i8], num_superblocks: usize) -> Result>' + status: implemented + notes: 'PMAT-405: Block sums for DP4A correction term. 8 sub-blocks per superblock' +- contract: roofline-model-v1.yaml + equation: model_bytes + module_path: realizar::lambda + function: model_size_bytes + signature: 'pub fn model_size_bytes(&self) -> usize' + status: implemented + notes: 'PMAT-405: Total weight bytes for roofline BW calculation' +- contract: roofline-model-v1.yaml + equation: compute_ceiling + module_path: realizar::brick::fused + function: CoalescedDp4aBrick::arithmetic_intensity + signature: 'pub fn arithmetic_intensity(&self) -> f64' + status: implemented + notes: 'PMAT-405: FLOP/byte ratio for roofline compute bound' +- contract: format-parity-v1.yaml + equation: transpose_involution + module_path: realizar::contract_gate + function: transpose_f32 + signature: 'pub fn transpose_f32(data: &[f32], rows: usize, cols: usize) -> Vec' + status: implemented + notes: 'PMAT-405: transpose(transpose(x)) == x. Delegates to trueno::blis::transpose' +- contract: format-parity-v1.yaml + equation: name_bijection + module_path: realizar::tensor_names + function: resolve_global + signature: 'pub(crate) fn resolve_global(source: &S, arch: &str, role: GlobalTensorRole) -> Result>' + status: implemented + notes: 'PMAT-405: Weight name mapping across GGUF/SafeTensors/APR formats' +- contract: backend-dispatch-v1.yaml + equation: simd_only_threshold + module_path: realizar::quantize::fused_k + function: fused_q4k_dot_simd + signature: 'pub fn fused_q4k_dot_simd(q4k_data: &[u8], activations: &[f32]) -> Result' + status: implemented + notes: 'PMAT-405: Runtime AVX2 detection + scalar fallback. 5x PCIe rule for CPU vs GPU' +- contract: backend-dispatch-v1.yaml + equation: garbage_oracle + module_path: realizar::inference_trace::mod_get_top_compute + function: is_garbage_output + signature: 'fn is_garbage_output(text: &str) -> bool' + status: implemented + notes: 'PMAT-405: Detects garbled output from quantization errors' +- contract: backend-dispatch-v1.yaml + equation: qk_norm_score_bound + module_path: realizar::cuda::executor::layers::apply + function: apply_qk_norm + signature: 'fn apply_qk_norm(&mut self, q_buf: &GpuBuffer, k_buf: &GpuBuffer, layer_weights: &ValidatedLayerWeights, epsilon: f32) -> Result<(), GpuError>' + status: implemented + notes: 'PMAT-405: QK normalization bounds attention scores. CPU and GPU paths' +- contract: kv-cache-equivalence-v1.yaml + equation: page_shape + module_path: realizar::paged_kv::mod_paged + function: PagedKvCache::new + signature: 'pub fn new(total_pages: usize, block_size: usize, num_heads: usize, head_dim: usize) -> Self' + status: implemented + notes: 'PMAT-405: KvPage shape [block_size, num_heads, head_dim]. tokens_to_pages rounding' +- contract: gpu-context-health-v1.yaml + equation: fp8_architecture_guard + module_path: realizar::cuda::gpu_profile + function: detect_fp8_prefill + signature: 'fn detect_fp8_prefill(cc: u32) -> bool' + status: implemented + notes: 'PMAT-404: cc >= 89 && cc < 100. Blackwell (cc>=121) excluded.' +- contract: gpu-context-health-v1.yaml + equation: context_health + module_path: realizar::cuda::executor::layers::cublas_prefill::attention + function: CudaExecutor::warmup_fp8_cache + signature: 'pub(crate) fn warmup_fp8_cache(&mut self, num_layers: usize, hidden_dim: u32, ...)' + status: implemented + notes: 'PMAT-404: No-op when cc >= 100. Prevents context poisoning on Blackwell.' +- contract: gpu-context-health-v1.yaml + equation: cuda_graph_guard + module_path: realizar::cuda::executor::layers::graphed_capture + function: try_graph_capture + signature: 'fn try_graph_capture(&mut self) -> bool' + status: implemented + notes: 'PMAT-404: Opt-IN via CUDA_GRAPH_ENABLE=1. ThreadLocal capture mode (PMAT-374).' +- contract: gpu-weight-residency-v1.yaml + equation: throughput_target + module_path: realizar::cuda::executor::weights + function: CudaExecutor::load_weights + signature: 'pub fn load_weights(&mut self, name: &str, weights: &[f32]) -> Result' + status: implemented + notes: 'PMAT-404: Pre-uploads all weights at startup. Uses from_host_registered on cc>=120.' +- contract: gpu-decode-profiling-v1.yaml + equation: graph_disable + module_path: realizar::cuda::executor::layers::graphed_capture + function: try_graph_capture + signature: 'fn try_graph_capture(&mut self) -> bool' + status: implemented + notes: 'PMAT-403: CUDA_GRAPH_ENABLE=1 required. Profiling requires graphs OFF (bricks only measured during capture, not replay)' +- contract: gpu-decode-profiling-v1.yaml + equation: report_fidelity + module_path: realizar::cuda::executor::executor_api + function: ExecutorApi::start_brick_id + signature: 'pub(crate) fn start_brick_id(&mut self, brick_id: BrickId) -> Option' + status: implemented + notes: 'PMAT-403: JSON actual_us must equal profiler per-call avg. start/stop_brick_id provide ground truth' +- contract: gpu-decode-profiling-v1.yaml + equation: report_completeness + module_path: realizar::cuda::executor::executor_api + function: ExecutorApi::stop_brick_id + signature: 'pub(crate) fn stop_brick_id(&mut self, timer: Option, elements: u64)' + status: implemented + notes: 'PMAT-403: Every profiler brick must appear in output. No silent truncation' +- contract: gpu-decode-profiling-v1.yaml + equation: report_denominator + module_path: realizar::cuda::executor::executor_api + function: ExecutorApi::start_brick_id + signature: 'pub(crate) fn start_brick_id(&mut self, brick_id: BrickId) -> Option' + status: implemented + notes: 'PMAT-403: decoded_tokens = LmHead.count (exactly 1 LmHead per decoded token). Never use profiler.total_tokens' +- contract: gpu-decode-profiling-v1.yaml + equation: report_metadata + module_path: realizar::cuda::executor::executor_api + function: ExecutorApi::start_brick_id + signature: 'pub(crate) fn start_brick_id(&mut self, brick_id: BrickId) -> Option' + status: implemented + notes: 'PMAT-403: metadata fields must be measured or zero. No hardcoded nonzero constants (137, 173.9, etc.)' +critical_path: +- forward +- flash_attention +- fused_q4k_dot +- sample +- kv_cache +- quantize +- dequantize diff --git a/contracts/release-readiness-v1.yaml b/contracts/release-readiness-v1.yaml index bf8bc8d8db..01831fed41 100644 --- a/contracts/release-readiness-v1.yaml +++ b/contracts/release-readiness-v1.yaml @@ -25,7 +25,14 @@ # model's measured length. Kernels = every (kernel, quant) on ANY required host's dispatch path, owed on EVERY # required host (each GPU arch). # -# THE FAMILY. `--shape release-readiness-v1` grades these nine shapes, armed whatever `armed_shapes` says, and +# DERIVED CELLS (#3745 S2, #3777). The verb axis is no longer a list: the cells are derived from the release +# candidate's own `apr surface --json` (--surface): per (model command, model, host) a deterministic strength-2 +# covering array over its mode args ∪ {input shape} ∪ (when the surface's typed `generates` says so) {thinking, +# rung}; a model command that does not generate owes `release:ModelCell`s; a command with no model owes one +# `release:ProbeCell` per host; S2.5 adds one-factor-at-a-time `release:EffectCell`s judged by `release:ModeEffect`. +# Receipt rows key onto their cell by `cell_id` (`pv extract --cells-out` lists them). +# +# THE FAMILY. `--shape release-readiness-v1` grades these shapes, armed whatever `armed_shapes` says, and # reports them in this declaration order: release → host → context → model → coverage → tokenizer → kernel → # refusal → cell, so a tokenizer that disagrees with llama.cpp (#3726) or a kernel that diverges across arches is # named before any model cell it breaks (#3715 addenda). @@ -84,6 +91,12 @@ shapes: - {path: release:dogfoodReceipt, minCount: 1, maxCount: 1, nodeKind: IRI, node: {properties: [{path: release:verdict, minCount: 1, maxCount: 1, in: [GO]}, {path: release:fresh, minCount: 1, maxCount: 1, in: [true]}]}} + # #3745 S2: the cells are DERIVED from the release candidate's own surface — no surface, no cells, no pass + - {path: release:surface, minCount: 1, maxCount: 1, nodeKind: IRI, class: cli:Surface} + - {path: release:generatesUndeclared, maxCount: 0} + # the two shrink-only counts (args no role type claims; stdin forms v1 cannot declare) never grow + - {path: release:ratchetGrew, maxCount: 0} + - {path: release:ratchetBaselineMissing, maxCount: 0} - id: release-readiness-v1.host targetClass: release:Host properties: @@ -140,15 +153,71 @@ shapes: {path: release:withinBound, minCount: 1, maxCount: 1, in: [true]}, {path: release:boundSource, minCount: 1}, {path: release:fresh, minCount: 1, maxCount: 1, in: [true]}]}} + # S2.4 (#3739): every derived verb has a CRUX entry naming every declared engine — a counterpart or `none` + reason + - id: release-readiness-v1.crux-verb + targetClass: release:CruxVerb + properties: + - {path: release:mappingEntry, minCount: 1, maxCount: 1} + - {path: release:engineMissing, maxCount: 0} + - {path: release:noneWithoutReason, maxCount: 0} + # ... and every (host, model, mapped verb, thinking, rung) was compared: RED, UNJUDGED and absent are violations; + # ALL_WRONG is named, not a violation, pending the cop's ruling + - id: release-readiness-v1.crux + targetClass: release:CruxObligation + properties: + # a verb whose correspondence entry declares request modes owes a :CruxCell in each (#3739 slice 4) + - {path: release:modeMissing, maxCount: 0} + - {path: release:cruxCell, minCount: 1, nodeKind: IRI, + node: {properties: [{path: release:verdict, minCount: 1, maxCount: 1, in: [GREEN, ALL_WRONG]}, + {path: release:fresh, minCount: 1, maxCount: 1, in: [true]}]}} + # S2.5, the flag-effect oracle: every (command, mode arg, level) is OBSERVED to change the output somewhere — + # an effect cell's output digest differs from its base's (#3745 issuecomment-5765674584) + - id: release-readiness-v1.effect + targetClass: release:ModeEffect + properties: + - {path: release:observedIn, minCount: 1, nodeKind: IRI} + # S2.5, a8's sampling controls over typed `sampling` args: sampled ≠ greedy, the same seed ×2 identical, seed A ≠ + # seed B, top-k 1 == T 0; a control the command cannot express (no typed knob) is named, never passed + - id: release-readiness-v1.sampling + targetClass: release:SamplingCheck + properties: + - {path: release:sampledDiffers, maxCount: 1, in: [true]} + - {path: release:seedRepeatable, maxCount: 1, in: [true]} + - {path: release:seedsDiffer, maxCount: 1, in: [true]} + - {path: release:greedyAgrees, maxCount: 1, in: [true]} + - {path: release:underivable, maxCount: 0} # a cell that does not FIT the host is owed as an HONEST PRE-LOAD REFUSAL naming its arithmetic — never an OOM # mid-run, never a silent truncation, never a pass (#3710 rule) - id: release-readiness-v1.refusal targetClass: release:RefusalCell + allowEmpty: "a RefusalCell exists only where a cell does not fit its host; a release where every cell fits has none (#3610)" properties: - {path: release:row, minCount: 1, maxCount: 1, nodeKind: IRI, node: {properties: [{path: release:verdict, minCount: 1, maxCount: 1, in: [refused]}, {path: release:arithmeticNamed, minCount: 1, maxCount: 1, in: [true]}, {path: release:fresh, minCount: 1, maxCount: 1, in: [true]}]}} + # a command with no model arg: it runs, or refuses by name (#3745 design 3, absorbs #3597) + - id: release-readiness-v1.probe + targetClass: release:ProbeCell + properties: + - {path: release:row, minCount: 1, maxCount: 1, nodeKind: IRI, + node: {properties: [{path: release:verdict, minCount: 1, maxCount: 1, in: [pass, refused]}, + {path: release:fresh, minCount: 1, maxCount: 1, in: [true]}]}} + # a model command that does not generate text (inspect-like): Pass, or a typed refusal by name + - id: release-readiness-v1.model-cell + targetClass: release:ModelCell + properties: + - {path: release:row, minCount: 1, maxCount: 1, nodeKind: IRI, + node: {properties: [{path: release:verdict, minCount: 1, maxCount: 1, in: [pass, refused]}, + {path: release:fresh, minCount: 1, maxCount: 1, in: [true]}]}} + # S2.5's one-factor-at-a-time cells: each must run, and say what it printed + - id: release-readiness-v1.effect-cell + targetClass: release:EffectCell + properties: + - {path: release:row, minCount: 1, maxCount: 1, nodeKind: IRI, + node: {properties: [{path: release:verdict, minCount: 1, maxCount: 1, in: [pass]}, + {path: release:fresh, minCount: 1, maxCount: 1, in: [true]}, + {path: release:outputSha, minCount: 1, maxCount: 1}]}} - id: release-readiness-v1 targetClass: release:Cell properties: @@ -159,7 +228,9 @@ shapes: {path: release:fresh, minCount: 1, maxCount: 1, in: [true]}, {path: release:contextMet, minCount: 1, maxCount: 1, in: [true]}, {path: release:thinkOk, minCount: 1, maxCount: 1, in: [true]}, - {path: release:answered, minCount: 1, maxCount: 1, in: [true]}]}} + {path: release:answered, minCount: 1, maxCount: 1, in: [true]}, + # #3748: the CPU reference ran fresh, or came from THIS binary's own receipt + {path: release:f2Measured, minCount: 1, maxCount: 1, in: [true]}]}} equations: cell_universe: diff --git a/contracts/rmsnorm-kernel-v1.yaml b/contracts/rmsnorm-kernel-v1.yaml index a709b49f19..0988adc3c1 100644 --- a/contracts/rmsnorm-kernel-v1.yaml +++ b/contracts/rmsnorm-kernel-v1.yaml @@ -221,7 +221,9 @@ falsification_tests: kani_harnesses: - id: KANI-RN-001 obligation: RN-INV-001 - property: Output always finite with ε > 0 + property: 'Output always finite with ε > 0, |x_i| ≤ 1e18 and |γ_i| ≤ 1e30. Past + |x| ~ 1e19 Σx² overflows and the output collapses to 0 (Kani flagged it via its sqrtf model, PMAT-3140); + |γ| > f32::MAX/√n overflows the output, and 1e30 is a conservative bound under it' bound: 16 strategy: exhaustive harness: verify_rmsnorm_finiteness diff --git a/contracts/shapes.ttl b/contracts/shapes.ttl index 667b5d4245..d9a0b72337 100644 --- a/contracts/shapes.ttl +++ b/contracts/shapes.ttl @@ -2,6 +2,119 @@ @prefix xsd: . @prefix ont: . + a sh:NodeShape ; + sh:targetClass ; + sh:closed true ; + sh:property [ + sh:path ; + sh:minCount 0 ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + ] ; + sh:property [ + sh:path ; + ] ; + sh:property [ + sh:path ; + ] ; + sh:property [ + sh:path ; + sh:in ( "pmat" "pv" "forjar" "bashrs" "cargo" "gh" ) ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + ] ; +. + + a sh:NodeShape ; + sh:targetClass ; + sh:closed true ; + sh:property [ + sh:path ; + sh:minCount 3 ; + sh:maxCount 3 ; + ] ; + sh:property [ + sh:path ; + sh:in ( "feature1,feature2,label" ) ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:datatype ; + ] ; + sh:property [ + sh:path ; + sh:in ( "string" "float" "int" ) ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + ] ; +. + + a sh:NodeShape ; + sh:targetClass ; + sh:closed true ; + sh:ignoredProperties ( ) ; + sh:property [ + sh:path ; + sh:in ( "apr" ) ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:pattern "^[0-9]+\\.[0-9]+$" ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:datatype ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( "4a75ee8031b3fbe0b17e1706a248aabbf158420c06f082bc29a89e2103d4057e" ) ; + sh:pattern "^[0-9a-f]{64}$" ; + ] ; +. + + a sh:NodeShape ; + sh:targetClass ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; +. + a sh:NodeShape ; sh:targetClass ; sh:property [ @@ -59,8 +172,57 @@ ] ; . + a sh:NodeShape ; + sh:targetClass ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:nodeKind sh:IRI ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:class ; + sh:nodeKind sh:IRI ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; +. + + a sh:NodeShape ; + sh:targetClass ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; +. + + a sh:NodeShape ; + sh:targetClass ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( "true" ) ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + ] ; +. + a sh:NodeShape ; - sh:targetClass ; + sh:targetClass ; sh:property [ sh:path ; sh:minCount 1 ; @@ -218,6 +380,37 @@ ] ; . + a sh:NodeShape ; + sh:targetClass ; + sh:closed true ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:pattern "^[0-9]+\\.[0-9]+$" ; + ] ; + sh:property [ + sh:path ; + sh:in ( "library" "cli" "service" "spec" ) ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + ] ; + sh:property [ + sh:path ; + sh:minCount 3 ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 1 ; + ] ; +. + a sh:NodeShape ; sh:targetClass ; sh:closed true ; @@ -269,6 +462,25 @@ sh:nodeKind sh:IRI ; sh:node ; ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:class ; + sh:nodeKind sh:IRI ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; . a sh:NodeShape ; @@ -475,6 +687,89 @@ ] ; . + a sh:NodeShape ; + sh:targetClass ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; +. + + a sh:NodeShape ; + sh:targetClass ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:nodeKind sh:IRI ; + sh:node ; + ] ; +. + + a sh:NodeShape ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( "GREEN" "ALL_WRONG" ) ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( "true"^^ ) ; + ] ; +. + + a sh:NodeShape ; + sh:targetClass ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:nodeKind sh:IRI ; + ] ; +. + + a sh:NodeShape ; + sh:targetClass ; + sh:property [ + sh:path ; + sh:maxCount 1 ; + sh:in ( "true"^^ ) ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 1 ; + sh:in ( "true"^^ ) ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 1 ; + sh:in ( "true"^^ ) ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 1 ; + sh:in ( "true"^^ ) ; + ] ; + sh:property [ + sh:path ; + sh:maxCount 0 ; + ] ; +. + a sh:NodeShape ; sh:targetClass ; sh:property [ @@ -507,6 +802,89 @@ ] ; . + a sh:NodeShape ; + sh:targetClass ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:nodeKind sh:IRI ; + sh:node ; + ] ; +. + + a sh:NodeShape ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( "pass" "refused" ) ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( "true"^^ ) ; + ] ; +. + + a sh:NodeShape ; + sh:targetClass ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:nodeKind sh:IRI ; + sh:node ; + ] ; +. + + a sh:NodeShape ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( "pass" "refused" ) ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( "true"^^ ) ; + ] ; +. + + a sh:NodeShape ; + sh:targetClass ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:nodeKind sh:IRI ; + sh:node ; + ] ; +. + + a sh:NodeShape ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( "pass" ) ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( "true"^^ ) ; + ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + ] ; +. + a sh:NodeShape ; sh:targetClass ; sh:property [ @@ -561,5 +939,11 @@ sh:maxCount 1 ; sh:in ( "true"^^ ) ; ] ; + sh:property [ + sh:path ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( "true"^^ ) ; + ] ; . diff --git a/contracts/simular/binding.yaml b/contracts/simular/binding.yaml new file mode 100644 index 0000000000..69f0e2cec7 --- /dev/null +++ b/contracts/simular/binding.yaml @@ -0,0 +1,108 @@ +version: 1.0.0 +target_crate: simular +critical_path: +- step +- simulate +- seek +- record_step +bindings: +- contract: iterator-v1.yaml + equation: step + function: step + module_path: simular::src::tui::tsp_app::step + status: implemented +- contract: iterator-v1.yaml + equation: step + function: new + module_path: simular::src::edd::audit::new + status: implemented +- contract: iterator-v1.yaml + equation: step + function: seek + module_path: simular::src::edd::audit::seek + status: implemented +- contract: qwen35-hybrid-forward-v1.yaml + equation: step + function: step_forward + module_path: simular::src::edd::audit::step_forward + status: implemented +- contract: iterator-v1.yaml + equation: step + function: find_by_step_id + module_path: simular::src::edd::audit::find_by_step_id + status: implemented +- contract: iterator-v1.yaml + equation: step + function: record_step + module_path: simular::src::edd::v2::record_step + status: implemented +- contract: iterator-v1.yaml + equation: step + function: record_step_with_equations + module_path: simular::src::edd::v2::record_step_with_equations + status: implemented +- contract: iterator-v1.yaml + equation: step + function: step_count + module_path: simular::src::edd::v2::step_count + status: implemented +- contract: iterator-v1.yaml + equation: step + function: register_journey + module_path: simular::src::edd::gui_coverage::register_journey + status: implemented +- contract: iterator-v1.yaml + equation: step + function: timestep_secs + module_path: simular::src::engine::clock::timestep_secs + status: implemented +- contract: iterator-v1.yaml + equation: step + function: steps_until + module_path: simular::src::engine::clock::steps_until + status: implemented +- contract: iterator-v1.yaml + equation: step + function: substep_multiplier + module_path: simular::src::orbit::heijunka::substep_multiplier + status: implemented +- contract: iterator-v1.yaml + equation: step + function: estimate_substeps + module_path: simular::src::orbit::heijunka::estimate_substeps + status: implemented +- contract: iterator-v1.yaml + equation: step + function: step_days + module_path: simular::src::orbit::wasm::step_days + status: implemented +- contract: iterator-v1.yaml + equation: step + function: step_hours + module_path: simular::src::orbit::wasm::step_hours + status: implemented +- contract: iterator-v1.yaml + equation: step + function: run_steps + module_path: simular::src::orbit::wasm::run_steps + status: implemented +- contract: iterator-v1.yaml + equation: step + function: run_periods + module_path: simular::src::demos::harmonic_oscillator::run_periods + status: implemented +- contract: iterator-v1.yaml + equation: step + function: run_orbits + module_path: simular::src::demos::kepler_orbit::run_orbits + status: implemented +- contract: iterator-v1.yaml + equation: simulate + function: simulate_path + module_path: simular::src::scenarios::portfolio::simulate_path + status: implemented +- contract: iterator-v1.yaml + equation: simulate + function: simulate + module_path: simular::src::domains::ml::mod::simulate + status: implemented diff --git a/contracts/trueno-db/binding.yaml b/contracts/trueno-db/binding.yaml new file mode 100644 index 0000000000..280defc528 --- /dev/null +++ b/contracts/trueno-db/binding.yaml @@ -0,0 +1,16 @@ +version: 1.0.0 +target_crate: trueno-db +critical_path: +- query +- get_or_insert +bindings: +- contract: configuration-v1.yaml + equation: query + function: query + module_path: trueno_db::src::wasm::query + status: implemented +- contract: configuration-v1.yaml + equation: insert + function: get_or_insert + module_path: trueno_db::src::gpu::jit::get_or_insert + status: implemented diff --git a/contracts/trueno-rag/binding.yaml b/contracts/trueno-rag/binding.yaml new file mode 100644 index 0000000000..a419e3427c --- /dev/null +++ b/contracts/trueno-rag/binding.yaml @@ -0,0 +1,108 @@ +version: 1.0.0 +target_crate: trueno-rag +critical_path: +- embed +- retrieve +- chunk +- insert +bindings: +- contract: embedding-algebra-v1.yaml + equation: embed + function: insert + module_path: trueno_rag::src::multivector::index::insert + status: implemented +- contract: embedding-algebra-v1.yaml + equation: embed + function: find_nearest_centroid + module_path: trueno_rag::src::multivector::codec::find_nearest_centroid + status: implemented +- contract: embedding-algebra-v1.yaml + equation: embed + function: compress + module_path: trueno_rag::src::multivector::codec::compress + status: implemented +- contract: embedding-algebra-v1.yaml + equation: embed + function: train + module_path: trueno_rag::src::multivector::codec::train + status: implemented +- contract: embedding-algebra-v1.yaml + equation: embed + function: new + module_path: trueno_rag::src::multivector::types::new + status: implemented +- contract: embedding-algebra-v1.yaml + equation: embed + function: embedder + module_path: trueno_rag::src::retrieve::embedder + status: implemented +- contract: embedding-algebra-v1.yaml + equation: embed + function: set_embedding + module_path: trueno_rag::src::chunk::mod::set_embedding + status: implemented +- contract: configuration-v1.yaml + equation: retrieve + function: retrieve + module_path: trueno_rag::src::retrieve::retrieve + status: implemented +- contract: configuration-v1.yaml + equation: retrieve + function: retrieve_dense + module_path: trueno_rag::src::retrieve::retrieve_dense + status: implemented +- contract: configuration-v1.yaml + equation: retrieve + function: retrieve_sparse + module_path: trueno_rag::src::retrieve::retrieve_sparse + status: implemented +- contract: configuration-v1.yaml + equation: retrieve + function: compute + module_path: trueno_rag::src::metrics::compute + status: implemented +- contract: configuration-v1.yaml + equation: retrieve + function: recall_at_k + module_path: trueno_rag::src::metrics::recall_at_k + status: implemented +- contract: configuration-v1.yaml + equation: retrieve + function: precision_at_k + module_path: trueno_rag::src::metrics::precision_at_k + status: implemented +- contract: pagerank-kernel-v1.yaml + equation: retrieve + function: mean_reciprocal_rank + module_path: trueno_rag::src::metrics::mean_reciprocal_rank + status: implemented +- contract: configuration-v1.yaml + equation: retrieve + function: ndcg_at_k + module_path: trueno_rag::src::metrics::ndcg_at_k + status: implemented +- contract: configuration-v1.yaml + equation: retrieve + function: average_precision + module_path: trueno_rag::src::metrics::average_precision + status: implemented +- contract: configuration-v1.yaml + equation: retrieve + function: f1_at_k + module_path: trueno_rag::src::metrics::f1_at_k + status: implemented +- contract: configuration-v1.yaml + equation: retrieve + function: hit_rate_at_k + module_path: trueno_rag::src::metrics::hit_rate_at_k + status: implemented +- contract: configuration-v1.yaml + equation: chunk + function: chunk_count + module_path: trueno_rag::src::sqlite::mod::chunk_count + status: implemented +- contract: configuration-v1.yaml + equation: chunk + function: get_chunk + module_path: trueno_rag::src::sqlite::mod::get_chunk + status: implemented diff --git a/contracts/trueno/binding.yaml b/contracts/trueno/binding.yaml new file mode 100644 index 0000000000..32a810f4ba --- /dev/null +++ b/contracts/trueno/binding.yaml @@ -0,0 +1,336 @@ +version: 1.0.0 +target_crate: trueno +bindings: +- contract: wgpu-forward-pass-v1.yaml + equation: rmsnorm_correctness + module_path: trueno::backends::gpu::device::linalg::wgsl_forward + function: forward_model + status: partial + notes: '#4369: #[contract] site at crates/aprender-compute/src/backends/gpu/device/linalg/wgsl_forward.rs:753 had no binding row, and contracts/wgpu-forward-pass-v1.yaml does not exist in-tree: the annotation cites no contract' +- contract: pipeline-cache-v1.yaml + equation: pipeline_cache + module_path: trueno::backends::gpu::batch::execute::dispatch + function: PipelineCache + signature: 'pub type PipelineCache = HashMap' + status: implemented + notes: 'Type alias + cache_key() pattern; get-or-insert via contains_key + conditional + insert in execute_inner()' +- contract: pipeline-cache-v1.yaml + equation: single_encoder + module_path: trueno::backends::gpu::batch::execute + function: GpuCommandBatch::execute + signature: 'pub async fn execute(&mut self) -> Result<(), String>' + status: implemented + notes: 'Single command encoder per execute() call; N ops batched (C-BATCH-EXEC-001)' +- contract: tiled-matmul-shader-v1.yaml + equation: matmul_shader + module_path: trueno::backends::gpu::shaders::basic_ops + function: MATMUL_SHADER + signature: "pub const MATMUL_SHADER: &str" + status: implemented + notes: '16x16 tiled WGSL shader with shared memory (KAIZEN-021, C-TILED-MATMUL-001)' +- contract: tiled-matmul-shader-v1.yaml + equation: matmul_dispatch + module_path: trueno::backends::gpu::device::linalg::matmul + function: matmul_async + signature: 'fn matmul_async(a: &GpuBuffer, b: &GpuBuffer, m: u32, n: u32, k: u32) + -> GpuBuffer' + status: implemented + notes: 'Dispatch: ceil(M/16) x ceil(N/16) x 1 workgroups' +- contract: tiled-matmul-shader-v1.yaml + equation: execute_matmul_op + module_path: trueno::backends::gpu::batch::execute::dispatch + function: encode_matmul_op + signature: 'pub(crate) fn encode_matmul_op(&self, encoder: &mut CommandEncoder, cache: + &mut PipelineCache, shader_source: &str, label: &str, a: &BufferId, b: &BufferId, + output: &BufferId, m: u32, k: u32, n: u32) -> Result<(), String>' + status: implemented + notes: 'Named encode_matmul_op in source (encoder pattern, not direct execute)' +- contract: gemm-backward-tiled-v1.yaml + equation: backward_a_gemm + module_path: trueno_gpu::kernels::backward::gemm + function: GemmBackwardAKernel::tiled + signature: 'pub const fn tiled(m: u32, n: u32, k: u32, tile_size: u32) -> Self' + status: implemented + notes: 'grad_A = grad_C @ B^T; tiled with shared memory (C-TILE-BWD-001). Outputs + gemm_backward_a_tiled kernel.' +- contract: gemm-backward-tiled-v1.yaml + equation: backward_b_gemm + module_path: trueno_gpu::kernels::backward::gemm + function: GemmBackwardBKernel::tiled + signature: 'pub const fn tiled(m: u32, n: u32, k: u32, tile_size: u32) -> Self' + status: implemented + notes: 'grad_B = A^T @ grad_C; tiled with shared memory. Outputs gemm_backward_b_tiled + kernel.' +- contract: gemm-backward-tiled-v1.yaml + equation: tiled_gemm_arithmetic_intensity + module_path: trueno_gpu::kernels::backward::gemm + function: GemmBackwardAKernel::tiled + signature: 'pub const fn tiled(m: u32, n: u32, k: u32, tile_size: u32) -> Self' + status: implemented + notes: 'Arithmetic intensity is a computed property of the tiled kernel variant (naive + ~0.5 vs tiled TILE/4 FLOP/byte). No separate accessor; the tiled constructor + implicitly sets this via tile_size parameter.' +- contract: gemm-backward-tiled-v1.yaml + equation: shared_memory_per_tile + module_path: trueno_gpu::kernels::backward::gemm + function: GemmBackwardAKernel::tiled + signature: 'pub const fn tiled(m: u32, n: u32, k: u32, tile_size: u32) -> Self' + status: implemented + notes: 'Shared memory = 2 * tile_size^2 * sizeof(f32) bytes per tile. Computed from + tile_size parameter, not a separate accessor.' +- contract: gemm-backward-tiled-v1.yaml + equation: unrolled_instruction_ratio + module_path: trueno_gpu::kernels::backward::gemm + function: GemmBackwardAKernel::tiled_unrolled + signature: 'pub const fn tiled_unrolled(m: u32, n: u32, k: u32, tile_size: u32) -> + Self' + status: implemented + notes: '4x unrolled variant; IPC ratio 0.57. Both GemmBackwardAKernel and GemmBackwardBKernel + have tiled_unrolled constructors.' +- contract: activation-kernel-v1.yaml + equation: relu + module_path: trueno + function: relu + status: implemented + notes: Auto-inferred by pv infer +- contract: silu-kernel-v1.yaml + equation: sigmoid + module_path: trueno + function: sigmoid + status: implemented + notes: Auto-inferred by pv infer +- contract: activation-kernel-v1.yaml + equation: gelu + module_path: trueno + function: gelu + status: implemented + notes: Auto-inferred by pv infer +- contract: softmax-kernel-v1.yaml + equation: softmax + module_path: trueno + function: softmax + status: implemented + notes: Auto-inferred by pv infer +- contract: cross-entropy-kernel-v1.yaml + equation: log_softmax + module_path: trueno + function: log_softmax + status: implemented + notes: Auto-inferred by pv infer +- contract: matmul-kernel-v1.yaml + equation: matmul + module_path: trueno + function: matmul + status: implemented + notes: Auto-inferred by pv infer +- contract: gated-delta-net-v1.yaml + equation: read + module_path: trueno + function: read + status: implemented + notes: Auto-inferred by pv infer +- contract: gated-delta-net-v1.yaml + equation: write + module_path: trueno + function: write + status: implemented + notes: Auto-inferred by pv infer +- contract: model-config-algebra-v1.yaml + equation: bounds + module_path: trueno + function: bounds + status: implemented + notes: Auto-inferred by pv infer +- contract: activation-kernel-v1.yaml + equation: silu + module_path: trueno + function: silu + status: implemented + notes: Auto-inferred by pv infer +- contract: random-forest-v1.yaml + equation: predict + module_path: trueno + function: predict + status: implemented + notes: Auto-inferred by pv infer +- contract: kmeans-kernel-v1.yaml + equation: update + module_path: trueno + function: update + status: implemented + notes: Auto-inferred by pv infer +- contract: quantization-ordering-v1.yaml + equation: bytes_per_param + module_path: trueno + function: bytes_per_param + status: implemented + notes: Auto-inferred by pv infer +- contract: embedding-lookup-v1.yaml + equation: embedding_lookup + module_path: trueno + function: embedding_lookup + status: implemented + notes: Auto-inferred by pv infer +- contract: graph-centrality-v1.yaml + equation: eigenvector + module_path: trueno + function: eigenvector + status: implemented + notes: Auto-inferred by pv infer +- contract: tensor-inventory-v1.yaml + equation: tensor_count + module_path: trueno::cbtop::quantize::gguf + function: GgufReader::tensor_count + signature: 'pub fn tensor_count(&self) -> u64' + status: implemented + notes: 'In crates/cbtop/src/quantize/gguf.rs. Counts tensors in GGUF model files.' +- contract: metrics-classification-v1.yaml + equation: precision + module_path: trueno::cbtop::adaptive_ml::types + function: ClassificationMetrics::precision + signature: 'pub fn precision(&self) -> f64' + status: implemented + notes: 'In crates/cbtop/src/adaptive_ml/types.rs. TP / (TP + FP).' +- contract: metrics-classification-v1.yaml + equation: recall + module_path: trueno::cbtop::adaptive_ml::types + function: ClassificationMetrics::recall + signature: 'pub fn recall(&self) -> f64' + status: implemented + notes: 'In crates/cbtop/src/adaptive_ml/types.rs. TP / (TP + FN).' +- contract: attention-kernel-v1.yaml + equation: attention + module_path: trueno_gpu::kernels::attention + function: AttentionKernel + signature: 'pub struct AttentionKernel (FlashAttention variants)' + status: implemented + notes: 'trueno-gpu/src/kernels/attention/mod.rs. FlashAttention (prefill) + Paged + (incremental) + MultiWarp + FlashDecoding variants.' +- contract: int8-symmetric-quant-v1.yaml + equation: quantize + module_path: trueno::crates::trueno_quant::quantize + function: quantize_q4_k + signature: 'pub fn quantize_q4_k(data: &[f32]) -> Vec' + status: implemented + notes: 'crates/trueno-quant/src/quantize.rs. Q4_K/Q5_K/Q6_K per-block scale-min + quantization. Also NF4 in src/brick/quant_ops/nf4.rs.' +- contract: roofline-model-v1.yaml + equation: bandwidth_ceiling + module_path: trueno::monitor::compute_device + function: ComputeDevice::memory_bandwidth_gbps + signature: 'fn memory_bandwidth_gbps(&self) -> anyhow::Result' + status: implemented + notes: 'Trait method on ComputeDevice (TRUENO-SPEC-020). Also in crates/cbtop/src/roofline.rs + HardwareProfile::peak_bandwidth_gbps.' +- contract: roofline-model-v1.yaml + equation: throughput_bound + module_path: trueno::cbtop::roofline + function: HardwareProfile::theoretical_peak_at_oi + signature: 'pub fn theoretical_peak_at_oi(&self, operational_intensity: f64) -> f64' + status: implemented + notes: 'Roofline model: min(compute_ceiling, bandwidth * operational_intensity). + classify_bottleneck() returns compute-bound vs memory-bound.' +- contract: gpu-context-health-v1.yaml + equation: culink_skip + module_path: trueno_gpu::driver::module + function: compile_ptx_to_cubin + signature: 'fn compile_ptx_to_cubin(driver: &CudaDriver, ptx: &str, jit_target: + c_uint) -> Result, GpuError>' + status: implemented + notes: 'PMAT-404: Always returns Err — cuLinkCreate skipped (PMAT-369). Legacy JIT + used.' +- contract: gpu-weight-residency-v1.yaml + equation: pcie_overhead + module_path: trueno_gpu::driver::memory::buffer + function: GpuBuffer::from_host_registered + signature: 'pub unsafe fn from_host_registered(host_ptr: *mut T, len: usize) -> + Result' + status: implemented + notes: 'PMAT-404: Zero-copy via cuMemHostRegister + cuMemHostGetDevicePointer. Eliminates + PCIe transfer on unified memory (cc>=120). PMAT-396/PMAT-420.' +- contract: gpu-decode-profiling-v1.yaml + equation: wall_coverage + module_path: trueno::brick::profiler + function: BrickProfiler::total_ns + signature: 'pub fn total_ns(&self) -> u64' + status: implemented + notes: 'PMAT-403: Total nanoseconds across all brick profiling. sum(brick_total_ns) + / wall_clock_ns >= 0.85 (PAR-200 fast path).' +- contract: gpu-decode-profiling-v1.yaml + equation: sync_verification + module_path: trueno::brick::profiler + function: BrickProfiler::set_sync_mode + signature: 'pub fn set_sync_mode(&mut self, mode: SyncMode)' + status: implemented + notes: 'PMAT-403: Modes: Immediate, PerLayer, Deferred, None. Deferred is default + for production (PAR-200).' +- contract: gpu-decode-profiling-v1.yaml + equation: token_accounting + module_path: trueno::brick::profiler + function: BrickProfiler::total_tokens + signature: 'pub fn total_tokens(&self) -> u64' + status: implemented + notes: 'PMAT-403: Tracks total elements processed. LmHead.count = decoded tokens.' +- contract: gpu-decode-profiling-v1.yaml + equation: brick_ordering + module_path: trueno::brick::profiler + function: BrickProfiler::brick_stats + signature: 'pub fn brick_stats(&self, brick_id: BrickId) -> &BrickStats' + status: implemented + notes: 'PMAT-403: O(1) per-brick access via BrickId array index (PAR-200). Per-call + avg respects kernel complexity (LmHead > Gate > RmsNorm).' +- contract: avx512-blis-v1.yaml + equation: flops_per_tile + module_path: trueno::blis::compute + function: gemm_blis_avx512_large + signature: 'unsafe fn gemm_blis_avx512_large(m: usize, n: usize, k: usize, a: &[f32], b: &[f32], c: &mut [f32]) -> Result<(), TruenoError>' + status: implemented + notes: 'PMAT-037: AVX-512 8×16 microkernel with zmm registers. MR=8, NR=16. BlisProfiler NOT yet wired (C-AVX512-PROF-001 PENDING).' +- contract: avx512-blis-v1.yaml + equation: peak_throughput + module_path: trueno::blis::compute + function: avx512_microkernel_8x16_rowmajor + signature: 'pub(super) unsafe fn avx512_microkernel_8x16_rowmajor(k: usize, a: *const f32, b: *const f32, c: *mut f32, ldc: usize)' + status: implemented + notes: '8 zmm accumulators, B as zmm load, A as scalar broadcast. 128 GFLOPS measured (98.5% of peak).' +- contract: avx512-blis-v1.yaml + equation: flops_per_tile + module_path: trueno::blis::compute + function: pack_b_block_nr16 + signature: 'pub(super) fn pack_b_block_nr16(b: &[f32], ldb: usize, pc: usize, jc: usize, kc: usize, nc: usize, packed: &mut [f32])' + status: implemented + notes: 'NR=16 B packing for AVX-512 zmm width. Zero-pads when nc < 16.' +- contract: blis-thread-cap-v1.yaml + equation: amdahl_speedup + module_path: trueno::blis::parallel + function: gemm_blis_parallel + signature: 'pub fn gemm_blis_parallel(m: usize, n: usize, k: usize, a: &[f32], b: &[f32], c: &mut [f32]) -> Result<(), TruenoError>' + status: implemented + notes: 'PMAT-037: 4-tier thread cap (2/4/phys÷2/all) based on FLOPs thresholds from cgp scaling measurements.' +- contract: avx512-q4k-v1.yaml + equation: dequant + module_path: trueno::backends::q4k::gemv::avx512 + function: matmul_q4k_f32_avx512 + signature: 'pub(crate) unsafe fn matmul_q4k_f32_avx512(q4k_data: &[u8], input: &[f32], out_dim: usize, in_dim: usize) -> Vec' + status: implemented + notes: '16-wide AVX-512 dequant+FMA. +5-35% vs AVX2. Bottleneck: scalar header parsing per super-block.' +- contract: avx512-q4k-v1.yaml + equation: dequant + module_path: trueno::backends::q4k::gemv::avx512 + function: compute_chunk_q4k_avx512 + signature: 'pub(crate) unsafe fn compute_chunk_q4k_avx512(...)' + status: implemented + notes: 'Parallel dispatch chunk processor for AVX-512 Q4K path.' +critical_path: +- softmax +- matmul +- silu +- gelu +- execute_matmul +- PipelineCache +- GemmBackward +- arithmetic_intensity +- gemm_blis_avx512_large +- gemm_blis_parallel +- matmul_q4k_f32_avx512 diff --git a/crates/apr-cli/Cargo.toml b/crates/apr-cli/Cargo.toml index 110432c9ce..e4a298bd88 100644 --- a/crates/apr-cli/Cargo.toml +++ b/crates/apr-cli/Cargo.toml @@ -66,7 +66,8 @@ path = "src/main.rs" # Task #91: dry-run scaffold for the SHIP-TWO-001 MODEL-2 corpus ingest. # Minimal, network-free. Does not depend on any apr-cli optional feature. [[bin]] -name = "apr-corpus-ingest" +# #4430: every shipped [[bin]] is aprender-* (was `apr-corpus-ingest`) +name = "aprender-corpus-ingest" path = "src/bin/apr-corpus-ingest.rs" [features] @@ -106,7 +107,10 @@ cuda = ["inference", "realizar/cuda", "entrenar/cuda", "trueno/cuda", "aprender- # the binary was built with it (§2.1 INVALID-BUILD reads that entry). cuda-batch = ["cuda"] # PMAT-335: WGPU backend for AMD/Intel/Apple GPU inference via Vulkan/Metal/WebGPU -wgpu = ["inference"] +# #4056: the finetune wgpu path (commands/finetune.rs) uses entrenar items gated +# behind entrenar's own `gpu` feature. Weak dep: enabled only when `training` +# brings entrenar in, which is exactly when that path compiles. +wgpu = ["inference", "entrenar?/gpu"] visualization = ["renacer", "trueno-viz"] zram = ["trueno-zram-core"] training = ["dep:entrenar", "dep:entrenar-lora", "dep:aprender-train-distill", "dep:aprender-train-common", "aprender-train-distill?/shard-batch-source"] @@ -126,23 +130,23 @@ libc = "0.2" # performed. A surface and its capability must not be able to drift. apr-test = { workspace = true } # Core aprender library -aprender = { path = "../aprender-core", version = "0.69.3", package = "aprender-core", default-features = true, features = ["format-compression"] } +aprender = { path = "../aprender-core", version = "0.70.0", package = "aprender-core", default-features = true, features = ["format-compression"] } # Provable-contracts engine — `apr beat-run` parses + evaluates BeatBenchmark contracts (PMAT-741) aprender-contracts = { workspace = true } # MCP (Model Context Protocol) server — `apr mcp` subcommand -aprender-mcp = { path = "../aprender-mcp", version = "0.69.3", features = ["apr-tools"] } # apr mcp serves apr's own tools (PMAT-3954) +aprender-mcp = { path = "../aprender-mcp", version = "0.70.0", features = ["apr-tools"] } # apr mcp serves apr's own tools (PMAT-3954) async-stream = "0.3" provable-contracts-macros = { workspace = true } # Shared formatting and system utilities (Batuta stack) -batuta-common = { workspace = true } +batuta-common = { workspace = true, features = ["cli-roles"] } # Sovereign coding assistant (apr code → batuta agent engine) — PMAT-182. # Non-optional since claude-code-parity-apr M150 (outcome-parity Phase 3 # requires apr code in default builds). GH-344: path dep via # .cargo/config.toml.dev-overrides; CI uses checkout+symlink. -batuta = { version = "0.69.3", path = "../aprender-orchestrate", package = "aprender-orchestrate", default-features = false, features = ["agents", "agents-inference", "agents-mcp", "rag"] } +batuta = { version = "0.70.0", path = "../aprender-orchestrate", package = "aprender-orchestrate", default-features = false, features = ["agents", "agents-inference", "agents-mcp", "rag"] } # Inference engine (workspace path dep — enables cuda/gpu feature forwarding) # GH-573: shim (0.9.1) didn't forward cuda feature → 0.7 tok/s instead of 273 tok/s @@ -238,7 +242,7 @@ pacha = { workspace = true } # published-crate dependency cycle the APR-MONO consolidation removed, and # (b) was never actually linked, so every `trueno_explain::` path in # commands/ptx_explain.rs failed to resolve. -trueno-explain = { path = "../aprender-explain", version = "0.69.3", package = "aprender-explain", optional = true } +trueno-explain = { path = "../aprender-explain", version = "0.70.0", package = "aprender-explain", optional = true } # ML tuning: LoRA/QLoRA configuration and memory planning (GH-176, PMAT-184) entrenar-lora = { workspace = true, optional = true } @@ -259,6 +263,8 @@ alimentar = { workspace = true } # `dispatch` fn those binaries call, so the surfaces cannot drift. aprender-rag-cli = { workspace = true } aprender-zram-cli = { workspace = true } +# `apr ptx-debug` (#4062): pure-Rust PTX falsification analyzer, always built. +aprender-ptx-debug = { workspace = true } # simular: hand-rolled parser, wired as a verbatim passthrough (apr sim ...) simular = { workspace = true } aprender-cgp = { workspace = true } diff --git a/crates/apr-cli/build.rs b/crates/apr-cli/build.rs index c950c1f129..8ee5028d1e 100644 --- a/crates/apr-cli/build.rs +++ b/crates/apr-cli/build.rs @@ -1,9 +1,10 @@ // Contract: apr-version-traceability-v1 F-VERSION-001..004 (paiml/aprender#597, #1862). // Resolve APR_GIT_SHA with a multi-source fallback hierarchy: // 1. APR_GIT_SHA_OVERRIDE env var (CI/release hook) -// 2. git rev-parse --short HEAD (dev builds from worktree or primary checkout) -// 3. committed .git-sha file (crates.io installs) -// 4. "v{CARGO_PKG_VERSION}+no-git" (informative fallback — never bare "unknown") +// 2. .cargo_vcs_info.json `git.sha1` (crates.io / packaged builds, #4110) +// 3. git rev-parse --short HEAD, retried with the checkout marked safe.directory (#4110) +// 4. committed .git-sha file +// 5. "v{CARGO_PKG_VERSION}+no-git" (informative fallback — never bare "unknown") // // The resolution and its rerun-if-changed triggers live in the shared // `aprender-build-sha` crate (#4219) so every workspace [[bin]] stamps the same SHA. diff --git a/crates/apr-cli/src/bin/apr-corpus-ingest.rs b/crates/apr-cli/src/bin/apr-corpus-ingest.rs index 3c53cd9f2b..a80079efe2 100644 --- a/crates/apr-cli/src/bin/apr-corpus-ingest.rs +++ b/crates/apr-cli/src/bin/apr-corpus-ingest.rs @@ -1,4 +1,4 @@ -//! `apr-corpus-ingest` — dry-run scaffolding for the SHIP-TWO-001 MODEL-2 +//! `aprender-corpus-ingest` — dry-run scaffolding for the SHIP-TWO-001 MODEL-2 //! Python-code pretraining corpus ingest pipeline. //! //! Implements task #91: minimal, NETWORK-FREE scaffolding that reads and @@ -51,7 +51,7 @@ const REQUIRED_TOP_KEYS: &[&str] = &[ #[derive(Parser)] #[command( - name = "apr-corpus-ingest", + name = "aprender-corpus-ingest", version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("APR_GIT_SHA"), ")"), about = "Dry-run scaffold for SHIP-TWO-001 MODEL-2 corpus ingest (C-DATA-THESTACK-PYTHON)" )] @@ -375,7 +375,7 @@ fn build_dry_run_manifest(contract: &CorpusContract, contract_path: &Path) -> Dr DryRunManifest { dry_run: true, generated_at_utc: now_utc_iso8601(), - generator: concat!("apr-corpus-ingest ", env!("CARGO_PKG_VERSION")), + generator: concat!("aprender-corpus-ingest ", env!("CARGO_PKG_VERSION")), contract_path: contract_path.display().to_string(), contract_id: contract.contract_id.clone(), contract_version: contract.version.clone(), @@ -436,21 +436,22 @@ fn civil_from_days(z: i64) -> (i32, u32, u32) { mod tests { use super::*; - /// Resolve the real corpus contract path relative to the workspace root. - /// Tests run with `CARGO_MANIFEST_DIR = .../crates/apr-cli`, so we walk - /// up two levels to reach the contracts directory. - fn real_contract_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("workspace root resolvable from CARGO_MANIFEST_DIR") - .join("contracts/dataset-thestack-python-v1.yaml") + /// The real corpus contract, a workspace file: `None` (a named SKIP) out of tree — the + /// published tarball does not carry `contracts/` — and a FAIL in tree when it is missing + /// (#4149). + fn real_contract_path(test: &str) -> Option { + provable_contracts::workspace_path_or_skip!( + test, + "contracts/dataset-thestack-python-v1.yaml" + ) } #[test] fn parses_real_contract_with_structural_invariants() { - let path = real_contract_path(); - assert!(path.exists(), "real contract missing at {}", path.display()); + let Some(path) = real_contract_path("parses_real_contract_with_structural_invariants") + else { + return; + }; let yaml = fs::read_to_string(&path).expect("read real contract"); let contract: CorpusContract = serde_yaml::from_str(&yaml).expect("contract deserializes"); assert_structural_minimums(&contract).expect("structural minimums hold"); @@ -496,7 +497,11 @@ mod tests { #[test] fn validate_contract_passes_on_real_contract_and_reports_top_keys() { - let path = real_contract_path(); + let Some(path) = + real_contract_path("validate_contract_passes_on_real_contract_and_reports_top_keys") + else { + return; + }; let report = validate_contract(&path).expect("validation passes"); assert_eq!(report.contract_id, "C-DATA-THESTACK-PYTHON"); assert_eq!(report.invariants, 7); diff --git a/crates/apr-cli/src/commands/canary.rs b/crates/apr-cli/src/commands/canary.rs index fc34168804..473a92b9d1 100644 --- a/crates/apr-cli/src/commands/canary.rs +++ b/crates/apr-cli/src/commands/canary.rs @@ -16,6 +16,7 @@ //! ``` use crate::error::{CliError, Result}; +use batuta_common::cli_roles::{ConfigPath, InputFile, ModelPath, OutputPath}; use clap::Subcommand; use colored::Colorize; use serde::{Deserialize, Serialize}; @@ -30,25 +31,25 @@ pub enum CanaryCommands { Create { /// Model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Input file (e.g. wav) #[arg(long)] - input: PathBuf, + input: InputFile, /// Output json file #[arg(long)] - output: PathBuf, + output: OutputPath, }, /// Check against a canary test Check { /// Model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Canary json file #[arg(long)] - canary: PathBuf, + canary: ConfigPath, }, } diff --git a/crates/apr-cli/src/commands/canary_tests.rs b/crates/apr-cli/src/commands/canary_tests.rs index 96d11e8d3c..8ce9a737a5 100644 --- a/crates/apr-cli/src/commands/canary_tests.rs +++ b/crates/apr-cli/src/commands/canary_tests.rs @@ -255,9 +255,9 @@ fn test_build_failure_message_std_drift() { #[test] fn test_canary_commands_create() { let cmd = CanaryCommands::Create { - file: PathBuf::from("model.safetensors"), - input: PathBuf::from("input.wav"), - output: PathBuf::from("canary.json"), + file: PathBuf::from("model.safetensors").into(), + input: PathBuf::from("input.wav").into(), + output: PathBuf::from("canary.json").into(), }; match cmd { CanaryCommands::Create { @@ -276,8 +276,8 @@ fn test_canary_commands_create() { #[test] fn test_canary_commands_check() { let cmd = CanaryCommands::Check { - file: PathBuf::from("model.safetensors"), - canary: PathBuf::from("canary.json"), + file: PathBuf::from("model.safetensors").into(), + canary: PathBuf::from("canary.json").into(), }; match cmd { CanaryCommands::Check { file, canary } => { @@ -291,9 +291,9 @@ fn test_canary_commands_check() { #[test] fn test_canary_commands_clone() { let cmd = CanaryCommands::Create { - file: PathBuf::from("model.safetensors"), - input: PathBuf::from("input.wav"), - output: PathBuf::from("canary.json"), + file: PathBuf::from("model.safetensors").into(), + input: PathBuf::from("input.wav").into(), + output: PathBuf::from("canary.json").into(), }; let cloned = cmd.clone(); match cloned { @@ -307,8 +307,8 @@ fn test_canary_commands_clone() { #[test] fn test_canary_commands_debug() { let cmd = CanaryCommands::Check { - file: PathBuf::from("model.safetensors"), - canary: PathBuf::from("canary.json"), + file: PathBuf::from("model.safetensors").into(), + canary: PathBuf::from("canary.json").into(), }; let debug = format!("{cmd:?}"); assert!(debug.contains("Check")); @@ -323,9 +323,9 @@ fn test_run_create_model_not_found() { let output = NamedTempFile::with_suffix(".json").expect("create output"); let input = NamedTempFile::with_suffix(".wav").expect("create input"); let cmd = CanaryCommands::Create { - file: PathBuf::from("/nonexistent/model.safetensors"), - input: input.path().to_path_buf(), - output: output.path().to_path_buf(), + file: PathBuf::from("/nonexistent/model.safetensors").into(), + input: input.path().to_path_buf().into(), + output: output.path().to_path_buf().into(), }; let result = run(cmd); assert!(result.is_err()); @@ -341,9 +341,9 @@ fn test_run_create_invalid_model() { let input = NamedTempFile::with_suffix(".wav").expect("create input"); let cmd = CanaryCommands::Create { - file: model.path().to_path_buf(), - input: input.path().to_path_buf(), - output: output.path().to_path_buf(), + file: model.path().to_path_buf().into(), + input: input.path().to_path_buf().into(), + output: output.path().to_path_buf().into(), }; let result = run(cmd); assert!(result.is_err()); @@ -357,8 +357,8 @@ fn test_run_check_model_not_found() { .expect("write"); let cmd = CanaryCommands::Check { - file: PathBuf::from("/nonexistent/model.safetensors"), - canary: canary.path().to_path_buf(), + file: PathBuf::from("/nonexistent/model.safetensors").into(), + canary: canary.path().to_path_buf().into(), }; let result = run(cmd); assert!(result.is_err()); @@ -370,8 +370,8 @@ fn test_run_check_canary_not_found() { model.write_all(b"fake model").expect("write"); let cmd = CanaryCommands::Check { - file: model.path().to_path_buf(), - canary: PathBuf::from("/nonexistent/canary.json"), + file: model.path().to_path_buf().into(), + canary: PathBuf::from("/nonexistent/canary.json").into(), }; let result = run(cmd); assert!(result.is_err()); @@ -385,8 +385,8 @@ fn test_run_check_invalid_canary() { canary.write_all(b"not valid json").expect("write"); let cmd = CanaryCommands::Check { - file: model.path().to_path_buf(), - canary: canary.path().to_path_buf(), + file: model.path().to_path_buf().into(), + canary: canary.path().to_path_buf().into(), }; let result = run(cmd); assert!(result.is_err()); @@ -1169,9 +1169,9 @@ fn test_run_create_input_not_found() { let output = NamedTempFile::with_suffix(".json").expect("create output"); let cmd = CanaryCommands::Create { - file: model.path().to_path_buf(), - input: PathBuf::from("/nonexistent/input.wav"), - output: output.path().to_path_buf(), + file: model.path().to_path_buf().into(), + input: PathBuf::from("/nonexistent/input.wav").into(), + output: output.path().to_path_buf().into(), }; let result = run(cmd); assert!(result.is_err(), "missing input file should error"); @@ -1185,9 +1185,9 @@ fn test_run_create_empty_input_path() { let output = NamedTempFile::with_suffix(".json").expect("create output"); let cmd = CanaryCommands::Create { - file: model.path().to_path_buf(), - input: PathBuf::from(""), - output: output.path().to_path_buf(), + file: model.path().to_path_buf().into(), + input: PathBuf::from("").into(), + output: output.path().to_path_buf().into(), }; // Will fail on format detection, not input validation let result = run(cmd); diff --git a/crates/apr-cli/src/commands/chat.rs b/crates/apr-cli/src/commands/chat.rs index 585221f3d1..31c72bdc5e 100644 --- a/crates/apr-cli/src/commands/chat.rs +++ b/crates/apr-cli/src/commands/chat.rs @@ -632,8 +632,8 @@ fn clean_chat_response(raw: &str) -> String { trimmed.to_string() } -/// Detect format from magic bytes (more reliable than extension) -#[cfg(feature = "inference")] +/// Detect format from magic bytes (more reliable than extension). Pure, so not feature-gated: its caller +/// `format_from_leading_bytes` is compiled in every build (#4041). fn detect_format_from_bytes(data: &[u8]) -> ModelFormat { if data.len() < 8 { return ModelFormat::Demo; diff --git a/crates/apr-cli/src/commands/diff.rs b/crates/apr-cli/src/commands/diff.rs index e8d77e1148..94b48a057a 100644 --- a/crates/apr-cli/src/commands/diff.rs +++ b/crates/apr-cli/src/commands/diff.rs @@ -245,7 +245,16 @@ pub(crate) fn run( // and run an element-wise diff per `apr-cli-trace-save-tensor-v1` // `apr_diff_values_compat` invariant. if is_aprt_stage_file(path1) && is_aprt_stage_file(path2) { + #[cfg(feature = "inference")] return run_aprt_stage_diff(path1, path2, limit, json_output); + // #4041: the stage-file reader is realizar's. A minimal build names the missing feature + // instead of falling through to the model walker, which would misread an APRT file. + #[cfg(not(feature = "inference"))] + return Err(CliError::ValidationFailed( + "comparing APRT stage tensor files needs the `inference` feature (this apr was built \ + without it)" + .to_string(), + )); } // Run tensor value comparison run_tensor_value_diff(path1, path2, filter, limit, transpose_aware, json_output) diff --git a/crates/apr-cli/src/commands/diff_05_aprt_stage.rs b/crates/apr-cli/src/commands/diff_05_aprt_stage.rs index 7dac722c9e..6cda8dc4c4 100644 --- a/crates/apr-cli/src/commands/diff_05_aprt_stage.rs +++ b/crates/apr-cli/src/commands/diff_05_aprt_stage.rs @@ -91,6 +91,7 @@ fn compute_aprt_stage_stats( // serde_json::json!() macro uses infallible unwrap internally #[allow(clippy::disallowed_methods)] +#[cfg(feature = "inference")] fn run_aprt_stage_diff( path1: &Path, path2: &Path, @@ -223,7 +224,7 @@ fn run_aprt_stage_diff( Ok(()) } -#[cfg(test)] +#[cfg(all(test, feature = "inference"))] mod aprt_stage_diff_tests { use super::*; use std::io::Write; diff --git a/crates/apr-cli/src/commands/explain.rs b/crates/apr-cli/src/commands/explain.rs index ec9d43c293..a1e39bbb44 100644 --- a/crates/apr-cli/src/commands/explain.rs +++ b/crates/apr-cli/src/commands/explain.rs @@ -217,6 +217,20 @@ fn diagnose_missing_model_type(path: &Path, content: &str, json: bool) { } } +/// `name` beside `path`, if it exists. With `inference`, realizar's finder, which also resolves hash-prefixed and +/// symlinked model paths (CB-510). A minimal build (#4041: `--no-default-features` has no realizar) checks the +/// literal sibling only, so a hash-prefixed HF-cache path finds nothing there and reports not-found. +fn sibling_file(path: &Path, name: &str) -> Option { + #[cfg(feature = "inference")] + { + realizar::safetensors::find_sibling_file(path, name) + } + #[cfg(not(feature = "inference"))] + { + path.parent().map(|d| d.join(name)).filter(|p| p.is_file()) + } +} + /// Resolve family from a model file by finding config.json in the same directory. fn resolve_family_from_model_file( path: &Path, @@ -228,7 +242,7 @@ fn resolve_family_from_model_file( // Companion lookup: find_sibling_file handles hash-prefixed / symlinked // model paths robustly (CB-510 / publish-safety) and only returns Some // when the file exists — subsuming the prior with_file_name + .exists(). - if let Some(config_path) = realizar::safetensors::find_sibling_file(&real_path, "config.json") { + if let Some(config_path) = sibling_file(&real_path, "config.json") { resolve_from_config_json(&config_path) } else { emit_kernel_error( @@ -341,7 +355,7 @@ fn resolve_config_mapping( // hash-prefixed / symlinked model paths); fall back to the literal // sibling dir so extract_config_mapping reports not-found as before // (parent().join avoids with_file_name per publish-safety). - realizar::safetensors::find_sibling_file(p, "config.json").unwrap_or_else(|| { + sibling_file(p, "config.json").unwrap_or_else(|| { p.parent().map_or_else( || std::path::PathBuf::from("config.json"), |d| d.join("config.json"), diff --git a/crates/apr-cli/src/commands/finetune.rs b/crates/apr-cli/src/commands/finetune.rs index 48acddf75a..7433cb2332 100644 --- a/crates/apr-cli/src/commands/finetune.rs +++ b/crates/apr-cli/src/commands/finetune.rs @@ -650,6 +650,8 @@ fn execute_training_wgpu( let mut fwd = trueno::backends::gpu::WgslForwardPass::new( gpu.device, gpu.queue, hidden, heads, kv_heads, head_dim, inter, ); + // #4056: the WGSL RMSNorm takes the model's eps (it hardcoded 1e-6). + fwd.set_rms_norm_eps(model_config.rms_norm_eps as f32); // 3. Streaming dequant + upload weights to GPU let lm_head_f32 = dequant_and_upload_weights(&mut fwd, &q_model)?; diff --git a/crates/apr-cli/src/commands/golden_output.rs b/crates/apr-cli/src/commands/golden_output.rs index 9cf72107e5..6833e3352b 100644 --- a/crates/apr-cli/src/commands/golden_output.rs +++ b/crates/apr-cli/src/commands/golden_output.rs @@ -2313,25 +2313,13 @@ include!("throughput.rs"); /// A workspace file read at RUN time for tests (#4129, the #4048 pattern agreed with #4130). /// /// `include_str!` of a path outside this crate cannot compile from the published tarball, and -/// a run-time read that `expect`s the file panics there. In tree (the workspace's `contracts/` -/// beside the crate) a missing file FAILS the test; only out of tree does it print -/// `SKIP : ...` and return `None`. `rel` is relative to the repository root. +/// a run-time read that `expect`s the file panics there. The in-tree decision is the shared +/// rule, `provable_contracts::workspace_file_or_skip!` (#4175): in tree a missing file FAILS the +/// test; only out of tree does it print `SKIP : ...` and return `None`. `rel` is relative +/// to the repository root. #[cfg(test)] fn workspace_file_or_skip(test: &str, rel: &str) -> Option { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - if !root.join("contracts").is_dir() { - eprintln!( - "SKIP {test}: out of tree (no {} beside this crate) - {rel} lives in the workspace, \ - which a published crate does not carry (#4129)", - root.join("contracts").display() - ); - return None; - } - let path = root.join(rel); - Some( - std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("in tree, {} must be readable: {e}", path.display())), - ) + provable_contracts::workspace_file_or_skip!(test, rel) } /// #3914: which string the template detector is keyed on. diff --git a/crates/apr-cli/src/commands/gpu_correct_subject.rs b/crates/apr-cli/src/commands/gpu_correct_subject.rs new file mode 100644 index 0000000000..d256d4d020 --- /dev/null +++ b/crates/apr-cli/src/commands/gpu_correct_subject.rs @@ -0,0 +1,528 @@ +// #3821: GPU-correct(model_sha256, host, apr_version) is ONE subject, not a +// per-surface opinion. +// +// Two surfaces decide it, in the same binary, on the same host, for the same +// model, and until now neither recorded the other's answer: +// +// * `apr run --gpu` — the F2 parity guard. REFUSES a diverging GPU +// (`BackendUnavailable`, exit 14). +// * `apr qa` `golden_output` — generates on the GPU and scores the text. +// It calls `generate_gpu_resident` directly, so it never reaches the +// guard (#3804: grepping a failing run's stderr for the guard's own lines +// returns zero occurrences). +// +// On qwen2.5-coder-0.5b-instruct-q4_k_m at `a9502d992` the guard refused at +// cosine 0.4153 while the gate shipped the symptom as ``` ``` ``` repetition. +// Both happened to say "bad", so nothing noticed they were never compared. +// The dangerous ordering is the other one: the guard refusing while the gate +// PASSES, which `APR_F2_PROBE_PATH=serial` produces on demand — serial parity +// is cosine >= 0.9978 at all 16 positions, so the guard accepts, and +// generation still runs the batched prefill that emits the fragment. +// +// REPORT_ONLY for 0.69.1 (#3821's ruling): this reconciles and NAMES, it does +// not yet block. Flipping it to blocking is a one-line change at the caller, +// deliberately deferred until it has been green on a real release commit — +// a gate never green on its own target is not a gate (#3731). + +/// One surface's verdict about the subject, with the evidence it decided on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SurfaceVerdict { + /// The surface that decided, named as the user would invoke it. + pub surface: &'static str, + pub verdict: provable_contracts::ontology::verdict::Verdict, + /// What it measured. Printed verbatim in a contradiction. + pub detail: String, +} + +impl SurfaceVerdict { + pub fn new( + surface: &'static str, + verdict: provable_contracts::ontology::verdict::Verdict, + detail: impl Into, + ) -> Self { + Self { + surface, + verdict, + detail: detail.into(), + } + } + + /// A surface that was never evaluated on this subject. Absence is + /// `Unknown(NotRun)`, never agreement (#3821 done_when 3). + pub fn not_run(surface: &'static str) -> Self { + use provable_contracts::ontology::verdict::{Reason, Verdict}; + Self::new( + surface, + Verdict::Unknown(Reason::NotRun), + "not evaluated on this subject", + ) + } +} + +/// The reconciled answer for one subject. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SubjectReport { + /// The ONT-6 meet over every surface. `Fail < Unknown(_) < Pass`, so this + /// is `min` and a single Fail carries. + pub verdict: provable_contracts::ontology::verdict::Verdict, + /// Set iff at least one surface said Pass AND at least one said Fail. + /// Names every surface and its value — the point of the row is that a + /// disagreement is reported rather than silently minned away. + pub contradiction: Option, + /// Surfaces that never evaluated this subject, named. + pub not_run: Vec<&'static str>, +} + +/// Reconcile every surface's verdict about ONE subject. +/// +/// Pure and GPU-free, so the contradiction logic is testable on a host with no +/// CUDA — which is the only way this row could have been tested at all. +pub(crate) fn reconcile_gpu_correct(subject: &str, surfaces: &[SurfaceVerdict]) -> SubjectReport { + use provable_contracts::ontology::verdict::{Reason, Verdict}; + + if surfaces.is_empty() { + return SubjectReport { + verdict: Verdict::Unknown(Reason::NotRun), + contradiction: None, + not_run: Vec::new(), + }; + } + + let verdict = surfaces + .iter() + .map(|s| s.verdict) + .fold(Verdict::Pass, Verdict::meet); + + let passed: Vec<&SurfaceVerdict> = surfaces + .iter() + .filter(|s| s.verdict == Verdict::Pass) + .collect(); + let failed: Vec<&SurfaceVerdict> = surfaces + .iter() + .filter(|s| s.verdict == Verdict::Fail) + .collect(); + + let contradiction = (!passed.is_empty() && !failed.is_empty()).then(|| { + let mut lines = vec![format!( + "CONTRADICTION on {subject}: GPU-correct is one subject and {} of {} surfaces \ + disagree about it.", + passed.len() + failed.len(), + surfaces.len() + )]; + for s in surfaces { + lines.push(format!(" - {}: {:?} — {}", s.surface, s.verdict, s.detail)); + } + lines.push( + " Both cannot be right about the same model on the same host in the same binary." + .to_string(), + ); + lines.join("\n") + }); + + let not_run = surfaces + .iter() + .filter(|s| s.verdict == Verdict::Unknown(Reason::NotRun)) + .map(|s| s.surface) + .collect(); + + SubjectReport { + verdict, + contradiction, + not_run, + } +} + +/// The subject tuple, rendered. Keyed on the model's CONTENT, not its path — +/// two files with the same name are not the same subject, and the same bytes +/// under two names are. +pub(crate) fn gpu_correct_subject(model_sha256: &str, host: &str, apr_version: &str) -> String { + let short = model_sha256.get(..12).unwrap_or(model_sha256); + format!("GPU-correct(model {short}, host {host}, apr {apr_version})") +} + +/// The two surfaces' names, as a user would invoke them. +pub(crate) const F2_GUARD_SURFACE: &str = "apr run --gpu (F2 parity guard)"; +pub(crate) const GOLDEN_SURFACE: &str = "apr qa golden_output"; + +/// The F2 guard's answer as a surface verdict. The guard is a probe that +/// always runs to a decision, so it is never `Unknown`. +pub(crate) fn f2_guard_verdict(admits: bool) -> SurfaceVerdict { + use provable_contracts::ontology::verdict::Verdict; + if admits { + SurfaceVerdict::new( + F2_GUARD_SURFACE, + Verdict::Pass, + "admitted: GPU first tokens match CPU", + ) + } else { + SurfaceVerdict::new( + F2_GUARD_SURFACE, + Verdict::Fail, + "REFUSED: GPU output diverges from CPU (apr run --gpu would fall back / exit 14)", + ) + } +} + +/// The golden gate's wiring (#3821): reconcile the guard's verdict with the +/// leg's, on the same model and host, and return the contradiction text when +/// they disagree. `subject` is only evaluated then — it hashes the model, which +/// is seconds on a multi-GB GGUF and not worth paying when they agree. +/// +/// REPORT_ONLY: the caller prints this and the gate verdict is unchanged. +pub(crate) fn golden_contradiction( + guard_admits: bool, + leg: &crate::commands::qa::GpuGoldenLeg, + subject: impl FnOnce() -> String, +) -> Option { + use provable_contracts::ontology::verdict::Verdict; + let guard = f2_guard_verdict(guard_admits); + let golden = SurfaceVerdict::new(GOLDEN_SURFACE, leg.subject_verdict(), leg.subject_detail()); + let pair = [guard, golden]; + let says = |v: Verdict| pair.iter().any(|s| s.verdict == v); + if !(says(Verdict::Pass) && says(Verdict::Fail)) { + return None; + } + reconcile_gpu_correct(&subject(), &pair).contradiction +} + +/// The subject for a mapped GGUF on this host and this binary. +pub(crate) fn subject_of_bytes(model_bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let sha = format!("{:x}", Sha256::digest(model_bytes)); + let host = std::fs::read_to_string("/etc/hostname") + .ok() + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()) + .or_else(|| std::env::var("HOSTNAME").ok()) + .unwrap_or_else(|| "unknown-host".to_string()); + gpu_correct_subject(&sha, &host, env!("CARGO_PKG_VERSION")) +} + +// NOTE ON NAMING: `Makefile`'s coverage target passes `--skip gpu_`, and +// libtest matches that substring against the WHOLE test path. Every name below +// therefore avoids the sequence `gpu_` — otherwise these rows would compile, +// pass locally, and be invisible to the coverage gate. The module is named +// `subject_verdict_tests` for the same reason (#3839). +// The golden gate's own leg, expressed in the same lattice as the guard so the +// two can be compared at all. #3821: until this existed the two surfaces spoke +// different languages — one returned bool, the other a five-variant enum — and +// "compare them" had no meaning. +impl crate::commands::qa::GpuGoldenLeg { + /// This leg's verdict ABOUT THE SUBJECT, which is not the same as whether + /// the gate passed. A leg that never ran, errored, or ran out of budget + /// says nothing about `GPU-correct`; only `Passed` and `WrongAnswer` do. + pub(crate) fn subject_verdict(&self) -> provable_contracts::ontology::verdict::Verdict { + use provable_contracts::ontology::verdict::{Reason, Verdict}; + match self { + Self::Passed => Verdict::Pass, + Self::WrongAnswer(_) => Verdict::Fail, + // Attempted, but produced nothing judgeable. Not evidence either way. + Self::Errored(_) | Self::Unclosed { .. } | Self::NotRun(_) => { + Verdict::Unknown(Reason::NotRun) + } + } + } + + /// What this leg measured, for a contradiction message. + pub(crate) fn subject_detail(&self) -> String { + match self { + Self::Passed => "golden patterns matched on the device".to_string(), + Self::WrongAnswer(why) => why.clone(), + Self::Errored(e) => format!("errored: {e}"), + Self::Unclosed { + budget, + generated_chars, + } => format!( + "still reasoning at the {budget}-token budget ({generated_chars} chars, no answer)" + ), + Self::NotRun(why) => (*why).to_string(), + } + } +} + +#[cfg(test)] +mod subject_verdict_tests { + use super::{gpu_correct_subject, reconcile_gpu_correct, SurfaceVerdict}; + use provable_contracts::ontology::verdict::{Reason, Verdict}; + + const RUN: &str = "apr run --gpu (F2 parity guard)"; + const QA: &str = "apr qa golden_output"; + + fn subject() -> String { + gpu_correct_subject("d98cdcbd03e17ce47681435b5150e34c", "lambda", "0.69.1") + } + + /// The row's whole reason for existing. `meet` already yields Fail, so a + /// silent `min` would look like an ordinary failure — indistinguishable + /// from both surfaces agreeing the model is bad. A contradiction is a + /// different fact and must READ differently. + #[test] + fn a_pass_and_a_fail_on_one_subject_are_named_as_a_contradiction() { + let r = reconcile_gpu_correct( + &subject(), + &[ + SurfaceVerdict::new(RUN, Verdict::Fail, "diverges at position 1, cosine 0.4153"), + SurfaceVerdict::new(QA, Verdict::Pass, "3 golden test cases passed"), + ], + ); + assert_eq!(r.verdict, Verdict::Fail, "the meet still carries the Fail"); + let msg = r + .contradiction + .expect("a Pass beside a Fail is a contradiction"); + assert!(msg.contains("CONTRADICTION")); + assert!( + msg.contains(RUN) && msg.contains(QA), + "both surfaces must be named: {msg}" + ); + assert!( + msg.contains("0.4153") && msg.contains("3 golden test cases passed"), + "both VALUES must be named, not just the surfaces: {msg}" + ); + } + + /// Agreement is not a contradiction, in either direction. A row that fired + /// on agreement would be noise and would be turned off. + #[test] + fn surfaces_that_agree_are_not_reported_as_contradicting() { + for v in [Verdict::Pass, Verdict::Fail] { + let r = reconcile_gpu_correct( + &subject(), + &[ + SurfaceVerdict::new(RUN, v, "x"), + SurfaceVerdict::new(QA, v, "y"), + ], + ); + assert_eq!(r.verdict, v); + assert!( + r.contradiction.is_none(), + "agreement on {v:?} is not a contradiction" + ); + } + } + + /// #3821 done_when 3. One surface passing while the other never ran must + /// NOT read as a pass — that is how a gate nobody executed becomes + /// "green". The lattice gives Unknown, and the unrun surface is named. + #[test] + fn absence_is_not_agreement() { + let r = reconcile_gpu_correct( + &subject(), + &[ + SurfaceVerdict::new(RUN, Verdict::Pass, "admitted"), + SurfaceVerdict::not_run(QA), + ], + ); + assert_eq!( + r.verdict, + Verdict::Unknown(Reason::NotRun), + "a Pass beside an unrun surface is Unknown, never Pass" + ); + assert!(!r.verdict.arm(), "Unknown must not arm"); + assert_eq!(r.not_run, vec![QA], "the unrun surface must be named"); + assert!(r.contradiction.is_none(), "not running is not disagreeing"); + } + + /// The meet is the ONT-6 lattice min over all three levels, and a Fail + /// beside an Unknown is still a Fail — the strongest claim wins. + #[test] + fn the_verdict_is_the_lattice_meet() { + let cases: &[(Verdict, Verdict, Verdict)] = &[ + (Verdict::Pass, Verdict::Pass, Verdict::Pass), + (Verdict::Pass, Verdict::Fail, Verdict::Fail), + ( + Verdict::Fail, + Verdict::Unknown(Reason::NotRun), + Verdict::Fail, + ), + ( + Verdict::Pass, + Verdict::Unknown(Reason::NotRun), + Verdict::Unknown(Reason::NotRun), + ), + (Verdict::Fail, Verdict::Fail, Verdict::Fail), + ]; + let wrong: Vec = cases + .iter() + .filter_map(|(a, b, want)| { + let got = reconcile_gpu_correct( + &subject(), + &[ + SurfaceVerdict::new(RUN, *a, ""), + SurfaceVerdict::new(QA, *b, ""), + ], + ) + .verdict; + (got != *want) + .then(|| format!("\n - {a:?} meet {b:?}: expected {want:?}, got {got:?}")) + }) + .collect(); + assert!( + wrong.is_empty(), + "the meet is not the lattice min:{}", + wrong.join("") + ); + } + + /// Nothing measured is Unknown, never Pass. An empty surface set is how a + /// subject nobody looked at would otherwise arrive as green. + #[test] + fn a_subject_no_surface_evaluated_is_unknown_not_pass() { + let r = reconcile_gpu_correct(&subject(), &[]); + assert_eq!(r.verdict, Verdict::Unknown(Reason::NotRun)); + assert!(!r.verdict.arm()); + assert!(r.contradiction.is_none()); + } + + /// The subject is keyed on the model's CONTENT. Two files with the same + /// name are not one subject; the same bytes under two names are. + #[test] + fn the_subject_is_keyed_on_content_and_host_and_version() { + let a = gpu_correct_subject("aaaaaaaaaaaabbbb", "lambda", "0.69.1"); + assert_eq!( + a, + gpu_correct_subject("aaaaaaaaaaaabbbb", "lambda", "0.69.1") + ); + assert_ne!( + a, + gpu_correct_subject("ccccccccccccbbbb", "lambda", "0.69.1") + ); + assert_ne!(a, gpu_correct_subject("aaaaaaaaaaaabbbb", "gx10", "0.69.1")); + assert_ne!( + a, + gpu_correct_subject("aaaaaaaaaaaabbbb", "lambda", "0.69.0") + ); + assert!(a.contains("aaaaaaaaaaaa"), "the sha must appear: {a}"); + assert!( + !a.contains("bbbb"), + "only the first 12 hex are printed: {a}" + ); + } + + /// Only `Passed` and `WrongAnswer` are evidence about the subject. The + /// other three mean the gate produced nothing judgeable, and a gate that + /// produced nothing must not read as agreement with whatever the guard + /// said — that is how an unrun surface becomes a green one. + #[test] + fn only_a_judged_answer_is_evidence_about_the_subject() { + use crate::commands::qa::GpuGoldenLeg; + let cases: Vec<(GpuGoldenLeg, Verdict)> = vec![ + (GpuGoldenLeg::Passed, Verdict::Pass), + (GpuGoldenLeg::WrongAnswer("gibberish".into()), Verdict::Fail), + ( + GpuGoldenLeg::Errored("CUDA init".into()), + Verdict::Unknown(Reason::NotRun), + ), + ( + GpuGoldenLeg::NotRun("no device"), + Verdict::Unknown(Reason::NotRun), + ), + ]; + let wrong: Vec = cases + .iter() + .filter_map(|(leg, want)| { + let got = leg.subject_verdict(); + (got != *want).then(|| format!("\n - {leg:?}: expected {want:?}, got {got:?}")) + }) + .collect(); + assert!( + wrong.is_empty(), + "leg-to-verdict mapping drifted:{}", + wrong.join("") + ); + } + + /// The detail is what a contradiction message prints, so a leg that says + /// nothing useful makes the contradiction unreadable. + #[test] + fn every_leg_states_what_it_measured() { + use crate::commands::qa::GpuGoldenLeg; + for leg in [ + GpuGoldenLeg::Passed, + GpuGoldenLeg::WrongAnswer("fragment repeats".into()), + GpuGoldenLeg::Errored("init".into()), + GpuGoldenLeg::NotRun("no device"), + ] { + let d = leg.subject_detail(); + assert!(!d.trim().is_empty(), "{leg:?} produced an empty detail"); + } + assert!( + GpuGoldenLeg::WrongAnswer("fragment repeats".into()) + .subject_detail() + .contains("fragment repeats"), + "the reason must survive into the detail" + ); + } + + /// The golden wiring: every (guard, leg) pair, and whether it names a + /// contradiction. The dangerous row is guard-refuses + gate-passes, which + /// `meet` alone would report as an ordinary Fail. + #[test] + fn the_golden_wiring_names_exactly_the_disagreeing_pairs() { + use super::golden_contradiction; + use crate::commands::qa::GpuGoldenLeg; + let legs = || { + vec![ + GpuGoldenLeg::Passed, + GpuGoldenLeg::WrongAnswer("fragment repeats".into()), + GpuGoldenLeg::Errored("init".into()), + GpuGoldenLeg::NotRun("no device"), + ] + }; + let mut got = Vec::new(); + for admits in [true, false] { + for leg in legs() { + let c = golden_contradiction(admits, &leg, subject); + got.push((admits, format!("{leg:?}"), c.is_some())); + } + } + let named: Vec<_> = got + .iter() + .filter(|r| r.2) + .map(|r| (r.0, r.1.clone())) + .collect(); + assert_eq!( + named, + vec![ + ( + true, + format!("{:?}", GpuGoldenLeg::WrongAnswer("fragment repeats".into())) + ), + (false, format!("{:?}", GpuGoldenLeg::Passed)), + ], + "only guard-admits+wrong-answer and guard-refuses+passed contradict" + ); + let text = + golden_contradiction(false, &GpuGoldenLeg::Passed, subject).expect("contradiction"); + assert!( + text.contains("REFUSED") && text.contains(QA) && text.contains(RUN), + "{text}" + ); + } + + /// The subject is hashed only when there is something to report. + #[test] + fn agreement_never_hashes_the_model() { + use super::golden_contradiction; + use crate::commands::qa::GpuGoldenLeg; + let c = golden_contradiction(true, &GpuGoldenLeg::Passed, || { + panic!("subject evaluated on agreement") + }); + assert!(c.is_none()); + } + + /// The subject names content, host and version. + #[test] + fn the_subject_of_bytes_is_the_content_digest() { + let s = super::subject_of_bytes(b"abc"); + // sha256("abc") = ba7816bf8f01... + assert!(s.contains("model ba7816bf8f01"), "{s}"); + assert!(s.contains(env!("CARGO_PKG_VERSION")), "{s}"); + } + + /// A short or empty sha must not panic on the 12-char slice. + #[test] + fn a_short_digest_does_not_panic() { + assert!(gpu_correct_subject("abc", "h", "v").contains("abc")); + assert!(gpu_correct_subject("", "h", "v").contains("host h")); + } +} diff --git a/crates/apr-cli/src/commands/mono.rs b/crates/apr-cli/src/commands/mono.rs index f6c4664bcd..f3fe5758c8 100644 --- a/crates/apr-cli/src/commands/mono.rs +++ b/crates/apr-cli/src/commands/mono.rs @@ -9,6 +9,7 @@ //! Contract: contracts/publish-workspace-v1.yaml use crate::error::CliError; +use batuta_common::cli_roles::FreeText; use clap::Subcommand; use std::collections::BTreeMap; use std::io::Write; @@ -28,7 +29,7 @@ pub enum MonoCommands { Shims { /// Output directory for shim crates #[arg(long, default_value = "shims")] - output: String, + output: FreeText, }, /// Verify workspace invariants (FALSIFY-MONO-* and FALSIFY-BUILD-*) diff --git a/crates/apr-cli/src/commands/output_verification.rs b/crates/apr-cli/src/commands/output_verification.rs index e5642dd5e9..783232c6e6 100644 --- a/crates/apr-cli/src/commands/output_verification.rs +++ b/crates/apr-cli/src/commands/output_verification.rs @@ -834,25 +834,37 @@ fn validate_gpu_golden_output( } let model = OwnedQuantizedModel::from_mapped(mapped) .map_err(|e| CliError::ValidationFailed(format!("Model failed: {e}")))?; - let generated = match OwnedQuantizedModelCuda::new(model, 0) { - Ok(cuda_model) => qa_dense_generate( - &mut qa_dense_cuda(cuda_model), - prompt_tokens, - gen_config, - true, - ) - .map(|gpu_tokens| gguf.decode(&gpu_tokens)) - .map_err(|e| format!("GPU generation: {e}")), - Err(e) => Err(format!("CUDA init on device 0: {e}")), + // #3821: consult the SAME F2 parity guard `apr run --gpu` does, on the same + // prompt, before generating — so the two surfaces that decide GPU-correct + // for this model are compared instead of forming separate opinions. + // `None` when the guard measured nothing: absence is never agreement. + let (guard_admits, generated) = match OwnedQuantizedModelCuda::new(model, 0) { + Ok(mut cuda_model) => { + let admits = + realizar::infer::gpu_parity_admits(&mut cuda_model, gen_config, prompt_tokens); + let generated = qa_dense_generate( + &mut qa_dense_cuda(cuda_model), + prompt_tokens, + gen_config, + true, + ) + .map(|gpu_tokens| gguf.decode(&gpu_tokens)) + .map_err(|e| format!("GPU generation: {e}")); + (admits, generated) + }, + Err(e) => (None, Err(format!("CUDA init on device 0: {e}"))), }; // #3711 + #3724: ONE typed leg. The budget is passed so the leg can // distinguish "still reasoning when the budget ran out" from "answered // wrongly" and from "never started" — three outcomes, not two. - Ok(GpuGoldenLeg::judge( - generated, - expected_patterns, - gen_config.max_tokens, - )) + let leg = GpuGoldenLeg::judge(generated, expected_patterns, gen_config.max_tokens); + // REPORT_ONLY (#3821's ruling): name a contradiction, do not change the verdict. + if let Some(admits) = guard_admits { + if let Some(text) = golden_contradiction(admits, &leg, || subject_of_bytes(mapped.data())) { + eprintln!("{}", text.red()); + } + } + Ok(leg) } /// Note, in a verbose human-readable run, that the GPU half of the golden gate diff --git a/crates/apr-cli/src/commands/pretrain.rs b/crates/apr-cli/src/commands/pretrain.rs index f7dac32a45..627087bdb0 100644 --- a/crates/apr-cli/src/commands/pretrain.rs +++ b/crates/apr-cli/src/commands/pretrain.rs @@ -1832,6 +1832,7 @@ mod tests { .expect("spawn parse thread") .join() .expect("parse thread must not panic") + .into() } #[test] @@ -1900,6 +1901,7 @@ mod tests { .expect("spawn parse thread") .join() .expect("parse thread must not panic") + .map(Into::into) } /// FALSIFY-APR-PRETRAIN-INIT-001: --init flag exists in clap surface. diff --git a/crates/apr-cli/src/commands/profile_ollama.rs b/crates/apr-cli/src/commands/profile_ollama.rs index 21f61ef7c2..666d4f22f6 100644 --- a/crates/apr-cli/src/commands/profile_ollama.rs +++ b/crates/apr-cli/src/commands/profile_ollama.rs @@ -404,8 +404,7 @@ fn safe_ratio(num: f64, denom: f64) -> f64 { /// Printed in place of a memory-vs-compute verdict while the arithmetic /// intensity is not measured. A magnitude may be reported; a verdict decided by -/// a number nothing measured may not. -#[cfg(feature = "inference")] +/// a number nothing measured may not. Not feature-gated: `print_roofline_section` reads it in every build (#4041). pub(crate) const ROOFLINE_AI_UNMEASURED: &str = "UNMEASURED"; /// Does the arithmetic intensity actually depend on the workload? diff --git a/crates/apr-cli/src/commands/qa.rs b/crates/apr-cli/src/commands/qa.rs index 8bd0d872ae..341e13303b 100644 --- a/crates/apr-cli/src/commands/qa.rs +++ b/crates/apr-cli/src/commands/qa.rs @@ -771,6 +771,7 @@ fn print_qa_summary(gates: &[GateResult], passed: bool, total_duration: Duration include!("qa_gguf.rs"); include!("output_verification.rs"); +include!("gpu_correct_subject.rs"); include!("golden_output.rs"); include!("speedup.rs"); include!("forward_error.rs"); diff --git a/crates/apr-cli/src/commands/qa_tests_report_pass_fail.rs b/crates/apr-cli/src/commands/qa_tests_report_pass_fail.rs index 1fd5a23701..6626dac1d6 100644 --- a/crates/apr-cli/src/commands/qa_tests_report_pass_fail.rs +++ b/crates/apr-cli/src/commands/qa_tests_report_pass_fail.rs @@ -605,3 +605,27 @@ "Empty temp file should map to 0.5b via file size heuristic" ); } + + /// #3714: the throughput label names what the timed runs REPORTED. A cuda + /// build whose runs all fell back to the CPU must not print "GPU". + #[cfg(feature = "inference")] + #[test] + fn runtime_backend_label_follows_used_gpu_not_the_build() { + assert_eq!( + runtime_backend_label(3, 3), + "runtime entry point, GPU on every timed run" + ); + assert_eq!( + runtime_backend_label(0, 3), + "runtime entry point, CPU on every timed run" + ); + assert_eq!( + runtime_backend_label(1, 3), + "runtime entry point, MIXED GPU/CPU timed runs" + ); + assert_eq!(runtime_backend_label(0, 0), "runtime entry point, no timed run"); + for (g, r) in [(0, 1), (0, 3), (1, 3), (2, 3)] { + assert!(!runtime_backend_label(g, r).contains("GPU on every"), "{g}/{r}"); + } + } + diff --git a/crates/apr-cli/src/commands/rosetta.rs b/crates/apr-cli/src/commands/rosetta.rs index b8797b2934..4c4a46cb9a 100644 --- a/crates/apr-cli/src/commands/rosetta.rs +++ b/crates/apr-cli/src/commands/rosetta.rs @@ -13,6 +13,9 @@ use aprender::format::rosetta::{ ConversionOptions, ConversionPath, ConversionReport, FormatType, InspectionReport, RosettaStone, TensorInfo, VerificationReport, }; +use batuta_common::cli_roles::{ + ConfigPath, DirPath, FreeText, InputFile, ModelPath, OutputPath, PromptText, +}; use clap::Subcommand; use colored::Colorize; use std::fmt::Write; @@ -25,7 +28,7 @@ pub enum RosettaCommands { Inspect { /// Path to model file (GGUF, SafeTensors, or APR) #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Show hex dump of file header #[arg(long)] @@ -40,15 +43,15 @@ pub enum RosettaCommands { Convert { /// Source model file #[arg(value_name = "SOURCE")] - source: PathBuf, + source: ModelPath, /// Target output file (format inferred from extension) #[arg(value_name = "TARGET")] - target: PathBuf, + target: OutputPath, /// Apply quantization during conversion (int8, int4, fp16) #[arg(long)] - quantize: Option, + quantize: Option, /// Verify conversion with round-trip check #[arg(long)] @@ -60,22 +63,22 @@ pub enum RosettaCommands { /// External tokenizer.json for weights-only models (PMAT-232) #[arg(long, value_name = "TOKENIZER")] - tokenizer: Option, + tokenizer: Option, }, /// Execute multi-step conversion chain Chain { /// Source model file #[arg(value_name = "SOURCE")] - source: PathBuf, + source: ModelPath, /// Format chain (e.g., gguf safetensors apr) #[arg(value_name = "FORMATS", num_args = 2..)] - formats: Vec, + formats: Vec, /// Working directory for intermediate files #[arg(long, default_value = "./rosetta-work")] - work_dir: PathBuf, + work_dir: DirPath, /// Output as JSON #[arg(long)] @@ -86,11 +89,11 @@ pub enum RosettaCommands { Verify { /// Source model file #[arg(value_name = "SOURCE")] - source: PathBuf, + source: ModelPath, /// Intermediate format for round-trip (gguf, safetensors, apr) #[arg(long, default_value = "safetensors")] - intermediate: String, + intermediate: FreeText, /// Tolerance for numerical differences (default: 1e-5) #[arg(long, default_value = "1e-5", @@ -103,25 +106,26 @@ pub enum RosettaCommands { }, /// Compare inference outputs between two models (PMAT-114) + #[command(groups(batuta_common::cli_roles::SamplingArg::groups()))] CompareInference { /// Reference model (typically GGUF) #[arg(value_name = "MODEL_A")] - model_a: PathBuf, + model_a: ModelPath, /// Test model (typically APR) #[arg(value_name = "MODEL_B")] - model_b: PathBuf, + model_b: ModelPath, /// Test prompt #[arg(long, default_value = "2+2=")] - prompt: String, + prompt: PromptText, /// Maximum tokens to generate #[arg(long, default_value = "5")] max_tokens: usize, /// Sampling temperature (0 = greedy) - #[arg(long, default_value = "0")] + #[arg(long, default_value = "0", group = batuta_common::cli_roles::SamplingKind::Temperature.id())] temperature: f32, /// Logit difference tolerance @@ -142,11 +146,11 @@ pub enum RosettaCommands { DiffTensors { /// Reference model (typically GGUF - the one that works) #[arg(value_name = "MODEL_A")] - model_a: PathBuf, + model_a: ModelPath, /// Test model (typically APR - the one producing garbage) #[arg(value_name = "MODEL_B")] - model_b: PathBuf, + model_b: ModelPath, /// Only show tensors with dimension mismatches #[arg(long)] @@ -158,7 +162,7 @@ pub enum RosettaCommands { /// Filter tensors by name pattern (e.g., "embed", "lm_head", "layer.0") #[arg(long)] - filter: Option, + filter: Option, /// Output as JSON #[arg(long)] @@ -173,19 +177,19 @@ pub enum RosettaCommands { Fingerprint { /// Model file to fingerprint #[arg(value_name = "MODEL")] - model: PathBuf, + model: ModelPath, /// Second model to compare (optional - enables diff mode) #[arg(value_name = "MODEL_B")] - model_b: Option, + model_b: Option, /// Output fingerprints to JSON file #[arg(long, short)] - output: Option, + output: Option, /// Filter tensors by name pattern #[arg(long)] - filter: Option, + filter: Option, /// Show detailed statistics for each tensor #[arg(long)] @@ -203,15 +207,15 @@ pub enum RosettaCommands { ValidateStats { /// Model to validate #[arg(value_name = "MODEL")] - model: PathBuf, + model: ModelPath, /// Reference model for comparison #[arg(long)] - reference: Option, + reference: Option, /// Fingerprint JSON file for comparison #[arg(long)] - fingerprints: Option, + fingerprints: Option, /// Deviation threshold in standard deviations (default: 3.0) #[arg(long, default_value = "3.0", diff --git a/crates/apr-cli/src/commands/rosetta_fingerprints_load.rs b/crates/apr-cli/src/commands/rosetta_fingerprints_load.rs index 4891db0b1e..bc195aa79d 100644 --- a/crates/apr-cli/src/commands/rosetta_fingerprints_load.rs +++ b/crates/apr-cli/src/commands/rosetta_fingerprints_load.rs @@ -53,9 +53,9 @@ #[test] fn test_rosetta_commands_compare_inference() { let cmd = RosettaCommands::CompareInference { - model_a: PathBuf::from("model_a.gguf"), - model_b: PathBuf::from("model_b.apr"), - prompt: "Hello".to_string(), + model_a: PathBuf::from("model_a.gguf").into(), + model_b: PathBuf::from("model_b.apr").into(), + prompt: "Hello".to_string().into(), max_tokens: 10, temperature: 0.0, tolerance: 0.1, @@ -77,11 +77,11 @@ #[test] fn test_rosetta_commands_diff_tensors() { let cmd = RosettaCommands::DiffTensors { - model_a: PathBuf::from("model_a.gguf"), - model_b: PathBuf::from("model_b.apr"), + model_a: PathBuf::from("model_a.gguf").into(), + model_b: PathBuf::from("model_b.apr").into(), mismatches_only: true, show_values: 5, - filter: Some("attention".to_string()), + filter: Some("attention".to_string().into()), json: false, }; match cmd { @@ -93,7 +93,7 @@ } => { assert!(mismatches_only); assert_eq!(show_values, 5); - assert_eq!(filter, Some("attention".to_string())); + assert_eq!(filter, Some("attention".to_string()).map(Into::into)); } _ => panic!("Wrong command variant"), } @@ -102,9 +102,9 @@ #[test] fn test_rosetta_commands_fingerprint() { let cmd = RosettaCommands::Fingerprint { - model: PathBuf::from("model.gguf"), + model: PathBuf::from("model.gguf").into(), model_b: None, - output: Some(PathBuf::from("fingerprints.json")), + output: Some(PathBuf::from("fingerprints.json").into()), filter: None, verbose: true, json: false, @@ -123,9 +123,9 @@ #[test] fn test_rosetta_commands_validate_stats() { let cmd = RosettaCommands::ValidateStats { - model: PathBuf::from("model.gguf"), + model: PathBuf::from("model.gguf").into(), reference: None, - fingerprints: Some(PathBuf::from("ref.json")), + fingerprints: Some(PathBuf::from("ref.json").into()), threshold: 0.01, strict: true, json: true, diff --git a/crates/apr-cli/src/commands/rosetta_rosetta.rs b/crates/apr-cli/src/commands/rosetta_rosetta.rs index aae267b131..f6107d9718 100644 --- a/crates/apr-cli/src/commands/rosetta_rosetta.rs +++ b/crates/apr-cli/src/commands/rosetta_rosetta.rs @@ -7,7 +7,7 @@ fn test_rosetta_commands_inspect_default() { // Test that the Inspect variant can be created let cmd = RosettaCommands::Inspect { - file: PathBuf::from("model.gguf"), + file: PathBuf::from("model.gguf").into(), hexdump: false, json: false, }; @@ -28,8 +28,8 @@ #[test] fn test_rosetta_commands_convert() { let cmd = RosettaCommands::Convert { - source: PathBuf::from("model.gguf"), - target: PathBuf::from("model.apr"), + source: PathBuf::from("model.gguf").into(), + target: PathBuf::from("model.apr").into(), quantize: None, verify: false, json: false, @@ -53,9 +53,9 @@ #[test] fn test_rosetta_commands_chain() { let cmd = RosettaCommands::Chain { - source: PathBuf::from("model.gguf"), - formats: vec!["safetensors".to_string(), "apr".to_string()], - work_dir: PathBuf::from("./work"), + source: PathBuf::from("model.gguf").into(), + formats: vec!["safetensors".to_string().into(), "apr".to_string().into()], + work_dir: PathBuf::from("./work").into(), json: false, }; match cmd { @@ -71,8 +71,8 @@ #[test] fn test_rosetta_commands_verify() { let cmd = RosettaCommands::Verify { - source: PathBuf::from("model.gguf"), - intermediate: "safetensors".to_string(), + source: PathBuf::from("model.gguf").into(), + intermediate: "safetensors".to_string().into(), tolerance: 1e-5, json: false, }; diff --git a/crates/apr-cli/src/commands/rosetta_tensor_fingerprint_statistical.rs b/crates/apr-cli/src/commands/rosetta_tensor_fingerprint_statistical.rs index f19225200f..0f498c1fbe 100644 --- a/crates/apr-cli/src/commands/rosetta_tensor_fingerprint_statistical.rs +++ b/crates/apr-cli/src/commands/rosetta_tensor_fingerprint_statistical.rs @@ -215,7 +215,7 @@ #[test] fn test_rosetta_commands_inspect_with_hexdump() { let cmd = RosettaCommands::Inspect { - file: PathBuf::from("model.gguf"), + file: PathBuf::from("model.gguf").into(), hexdump: true, json: true, }; @@ -231,9 +231,9 @@ #[test] fn test_rosetta_commands_convert_with_all_options() { let cmd = RosettaCommands::Convert { - source: PathBuf::from("in.safetensors"), - target: PathBuf::from("out.apr"), - quantize: Some("int4".to_string()), + source: PathBuf::from("in.safetensors").into(), + target: PathBuf::from("out.apr").into(), + quantize: Some("int4".to_string().into()), verify: true, json: true, tokenizer: None, @@ -245,7 +245,7 @@ json, .. } => { - assert_eq!(quantize, Some("int4".to_string())); + assert_eq!(quantize, Some("int4".to_string()).map(Into::into)); assert!(verify); assert!(json); } @@ -256,10 +256,10 @@ #[test] fn test_rosetta_commands_fingerprint_with_model_b() { let cmd = RosettaCommands::Fingerprint { - model: PathBuf::from("model_a.gguf"), - model_b: Some(PathBuf::from("model_b.apr")), + model: PathBuf::from("model_a.gguf").into(), + model_b: Some(PathBuf::from("model_b.apr").into()), output: None, - filter: Some("attn".to_string()), + filter: Some("attn".to_string().into()), verbose: false, json: true, }; @@ -271,7 +271,7 @@ .. } => { assert!(model_b.is_some()); - assert_eq!(filter, Some("attn".to_string())); + assert_eq!(filter, Some("attn".to_string()).map(Into::into)); assert!(json); } _ => panic!("Wrong command variant"), diff --git a/crates/apr-cli/src/commands/serve/chat.rs b/crates/apr-cli/src/commands/serve/chat.rs index ef240b175d..ebd2809400 100644 --- a/crates/apr-cli/src/commands/serve/chat.rs +++ b/crates/apr-cli/src/commands/serve/chat.rs @@ -266,6 +266,19 @@ pub(crate) async fn safetensors_chat_completions_handler( .get("temperature") .and_then(|t| t.as_f64()) .unwrap_or(0.0) as f32; + // #4334: stop at the tokenizer's EOS / chat-turn end and honour the request's + // top_p, through the same config builder as the APR CPU path (#4265). + let top_p = request + .get("top_p") + .and_then(serde_json::Value::as_f64) + .map(|p| p as f32); + let stop_tokens = state + .tokenizer_info + .as_ref() + .map(super::handlers::tokenizer_info_stop_tokens) + .unwrap_or_default(); + let gen_config = + super::handlers::apr_cpu_generate_config(max_tokens, temperature, top_p, stop_tokens); let output_ids = { // PMAT-189: Handle transformer lock poisoning gracefully let t = match transformer.lock() { @@ -280,7 +293,7 @@ pub(crate) async fn safetensors_chat_completions_handler( .into_response(); } }; - match st_cpu_generate(&t, &input_ids, max_tokens, temperature) { + match st_cpu_generate(&t, &input_ids, &gen_config) { Ok(ids) => ids, Err(e) => { return ( @@ -294,7 +307,7 @@ pub(crate) async fn safetensors_chat_completions_handler( let elapsed = start.elapsed(); // Decode output using BPE tokenizer (PMAT-093) - let new_tokens = &output_ids[input_ids.len()..]; + let new_tokens = super::handlers::generated_reply_tokens(&output_ids, input_ids.len(), &gen_config); let output_text = if let Some(ref tok_info) = state.tokenizer_info { match tok_info.tokenizer.decode(new_tokens) { Ok(text) => text, diff --git a/crates/apr-cli/src/commands/serve/handler_gpu_completion.rs b/crates/apr-cli/src/commands/serve/handler_gpu_completion.rs index 8e83f6b5b9..a5d45927f4 100644 --- a/crates/apr-cli/src/commands/serve/handler_gpu_completion.rs +++ b/crates/apr-cli/src/commands/serve/handler_gpu_completion.rs @@ -609,7 +609,7 @@ fn start_gguf_server_cuda( "{}", "qwen3moe: serving through the MoE CUDA dispatch (#3714, #3987)".cyan() ); - let model_source = measured_model_source(&quantized_model, config); + let model_source = measured_model_source(&quantized_model, Some(&mapped_model), config); let state = AppState::with_quantized_model_and_vocab(quantized_model, vocab) .map_err(|e| CliError::InferenceFailed(format!("Failed to create state: {e}")))? .with_model_source(model_source) @@ -632,6 +632,10 @@ fn start_gguf_server_cuda( ); println!(" Max sequence length: {max_seq_len}"); + // #4254: measured before the model moves into CUDA. Without it `/v1/effective-config` + // on a CUDA server reported quantization, path and parameter count all null. + let model_source = measured_model_source(&quantized_model, Some(&mapped_model), config); + match OwnedQuantizedModelCuda::with_max_seq_len(quantized_model, 0, max_seq_len) { Ok(mut cuda_model) => { preload_gpu_weights(&mut cuda_model); @@ -649,6 +653,7 @@ fn start_gguf_server_cuda( let state = AppState::with_cuda_model_and_vocab(cuda_model, vocab) .map_err(|e| CliError::InferenceFailed(format!("Failed to create state: {e}")))? .with_mapped_gguf_model(mapped_model.clone()) + .with_model_source(model_source) // PP-14/PP-15/§9 #8: the resolved offload AND this binary's // `cfg!` feature list (including `cuda-batch`) reach the served // process, so a receipt records what the build was rather than diff --git a/crates/apr-cli/src/commands/serve/handlers.rs b/crates/apr-cli/src/commands/serve/handlers.rs index afc052b238..8120dd763b 100644 --- a/crates/apr-cli/src/commands/serve/handlers.rs +++ b/crates/apr-cli/src/commands/serve/handlers.rs @@ -6,7 +6,6 @@ #![allow(unused_imports)] #![allow(unused_variables)] -#![allow(dead_code)] #[cfg(feature = "wgpu")] use axum::response::IntoResponse; @@ -115,22 +114,9 @@ fn wgpu_detokenize_one(id: u32, vocab: &[String]) -> String { String::from_utf8_lossy(&gpt2_token_bytes(token)).into_owned() } -/// PMAT-355: Qwen2 stop conditions for the WGPU greedy decode loop. +/// PMAT-355: Qwen2 stop tokens for the WGPU greedy decode loop (`<|im_end|>`, pad). #[cfg(feature = "wgpu")] -fn wgpu_is_stop_token(token: u32) -> bool { - token == 151645 || token == 0 -} - -/// PMAT-355: Greedy argmax over a logits vector (0 when empty). -#[cfg(feature = "wgpu")] -fn wgpu_argmax(logits: &[f32]) -> u32 { - logits - .iter() - .enumerate() - .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(i, _)| i as u32) - .unwrap_or(0) -} +const WGPU_STOP_TOKENS: [u32; 2] = [151645, 0]; /// PMAT-355: Tokens per second, guarding the zero-elapsed case. #[cfg(feature = "wgpu")] @@ -265,8 +251,8 @@ fn wgpu_stream_generate( let mut completion_tokens = 0u32; for step in 0..max_tokens { - let next_token = wgpu_argmax(&last_logits); - if wgpu_is_stop_token(next_token) { + let next_token = realizar::sampling::argmax(&last_logits); + if realizar::sampling::is_stop(next_token, &WGPU_STOP_TOKENS) { break; } let text = wgpu_detokenize_one(next_token, vocab); @@ -328,8 +314,8 @@ fn wgpu_chat_completion_blocking( }; for step in 0..max_tokens { - let next_token = wgpu_argmax(&last_logits); - if wgpu_is_stop_token(next_token) { + let next_token = realizar::sampling::argmax(&last_logits); + if realizar::sampling::is_stop(next_token, &WGPU_STOP_TOKENS) { break; } output_ids.push(next_token); @@ -781,6 +767,8 @@ fn serve_wgpu_backend( dims.head_dim, dims.intermediate_dim, ); + // #4056: the WGSL RMSNorm takes the model's eps (it hardcoded 1e-6). + fwd.set_rms_norm_eps(quantized.config().eps); upload_wgpu_weights(&mut fwd, quantized, weights, num_layers); @@ -1067,6 +1055,9 @@ struct AprServerState { /// always leaves this `None`; only `build_demo_apr_cpu_router_for_test` sets /// it, so the streaming falsifier can observe real multi-chunk NDJSON. demo_scripted_tokens: Option>, + /// #4295: the stop set, derived once from the tokenizers at load + /// ([`derive_apr_cpu_stop_tokens`]), never rescanned per request. + stop_tokens: Arc<[u32]>, } /// Output from a successful APR inference run @@ -1168,13 +1159,23 @@ fn run_apr_cpu_inference( const APR_CPU_TURN_END_TOKENS: &[&str] = &["<|im_end|>", "<|endoftext|>", "<|eot_id|>", ""]; -/// #4265: the token ids that end an APR CPU generation: every EOS the loaded -/// tokenizer declares plus the chat-turn terminators it has an id for. The -/// generation loop already stops at id 0 on its own (`is_eos_token`). +/// #4265: the token ids that end an APR CPU generation, as derived at load. #[cfg(feature = "inference")] fn apr_cpu_stop_tokens(state: &AprServerState) -> Vec { + state.stop_tokens.to_vec() +} + +/// #4265: every EOS the loaded tokenizers declare plus the chat-turn +/// terminators they have an id for. The generation loop already stops at id 0 +/// on its own (`is_eos_token`). It walks the whole tokenizer.json vocab, so it +/// runs once, when the state is built (#4295). +#[cfg(feature = "inference")] +fn derive_apr_cpu_stop_tokens( + embedded_tokenizer: Option<&realizar::apr::BpeTokenizer>, + tokenizer: Option<&SafeTensorsTokenizerInfo>, +) -> Arc<[u32]> { let mut stop = Vec::new(); - if let Some(tok) = &state.embedded_tokenizer { + if let Some(tok) = embedded_tokenizer { stop.extend(tok.eos_id); stop.extend( APR_CPU_TURN_END_TOKENS @@ -1182,19 +1183,44 @@ fn apr_cpu_stop_tokens(state: &AprServerState) -> Vec { .filter_map(|t| tok.special_tokens.get(*t).copied()), ); } - if let Some(tok) = &state.tokenizer { - stop.extend(tok.eos_token_id); - stop.extend(tok.vocab.iter().enumerate().filter_map(|(id, t)| { - APR_CPU_TURN_END_TOKENS - .contains(&t.as_str()) - .then_some(id as u32) - })); + if let Some(tok) = tokenizer { + stop.extend(tokenizer_info_stop_tokens(tok)); } stop.sort_unstable(); stop.dedup(); + stop.into() +} + +/// #4334: the stop ids a tokenizer.json-backed tokenizer declares: its EOS plus +/// the chat-turn terminators its vocab has. Shared by the APR CPU path and the +/// SafeTensors handlers (`chat.rs`, `simple.rs`), which hardcoded an empty set. +#[cfg(feature = "inference")] +pub(crate) fn tokenizer_info_stop_tokens(tok: &SafeTensorsTokenizerInfo) -> Vec { + let mut stop: Vec = tok.eos_token_id.into_iter().collect(); + stop.extend(tok.vocab.iter().enumerate().filter_map(|(id, t)| { + APR_CPU_TURN_END_TOKENS + .contains(&t.as_str()) + .then_some(id as u32) + })); + stop.sort_unstable(); + stop.dedup(); stop } +/// #4334: the reply tokens of an `st_cpu_generate` result: everything past the +/// prompt, minus the stop id the loop ended on (its stop set is +/// `gen_config.stop_tokens` plus token 0). +#[cfg(feature = "inference")] +pub(crate) fn generated_reply_tokens<'a>( + output_ids: &'a [u32], + prompt_len: usize, + gen_config: &realizar::gguf::QuantizedGenerateConfig, +) -> &'a [u32] { + let mut stop_ids = gen_config.stop_tokens.clone(); + stop_ids.push(0); + apr_cpu_reply_tokens(output_ids.get(prompt_len..).unwrap_or(&[]), &stop_ids) +} + /// The reply text's tokens: `new_tokens` without the stop id the loop ended on. /// `stop_ids` must be the loop's whole stop set, token 0 included — the loop /// ends on 0 even when it is not in `stop_tokens` (#4265). @@ -1211,7 +1237,7 @@ fn apr_cpu_reply_tokens<'a>(new_tokens: &'a [u32], stop_ids: &[u32]) -> &'a [u32 /// of its own, so 0 is added to the stop set here to keep the old /// `is_eos_token` contract (token 0 is always EOS). #[cfg(feature = "inference")] -fn apr_cpu_generate_config( +pub(crate) fn apr_cpu_generate_config( max_tokens: usize, temperature: f32, top_p: Option, @@ -1352,6 +1378,8 @@ fn load_apr_model_state(model_path: &Path, config: &ServerConfig) -> Result Result axum::Router { embedded_tokenizer: None, model_name: "apr".to_string(), demo_scripted_tokens: None, + stop_tokens: Arc::from([]), }; build_apr_cpu_router(state, super::auth::AuthGate::disabled()) } @@ -1711,6 +1741,7 @@ pub fn build_demo_streaming_apr_cpu_router_for_test() -> axum::Router { "world".to_string(), "!".to_string(), ]), + stop_tokens: Arc::from([]), }; build_apr_cpu_router(state, super::auth::AuthGate::disabled()) } diff --git a/crates/apr-cli/src/commands/serve/mod.rs b/crates/apr-cli/src/commands/serve/mod.rs index f096601761..232ee7bb6f 100644 --- a/crates/apr-cli/src/commands/serve/mod.rs +++ b/crates/apr-cli/src/commands/serve/mod.rs @@ -21,6 +21,11 @@ pub use types::*; // Test modules #[cfg(test)] mod tests; +// #3809: the apr-serve-v1 / http-api-v1 FALSIFY-SRV/HTTP enforcement suite. It sat +// beside `tests.rs` unwired, so its 25 tests never compiled and the contract's ids +// pointed at code that never ran. +#[cfg(test)] +mod tests_contract_enforcement; #[cfg(all(test, feature = "inference"))] mod tests_route_index_3979; // PP-LLAMA-001 PP-14/PP-15/§9 #8: the offload report the served process diff --git a/crates/apr-cli/src/commands/serve/safetensors.rs b/crates/apr-cli/src/commands/serve/safetensors.rs index e161493507..9aef365c86 100644 --- a/crates/apr-cli/src/commands/serve/safetensors.rs +++ b/crates/apr-cli/src/commands/serve/safetensors.rs @@ -380,39 +380,28 @@ fn classify_bos_eos(content: &str) -> (bool, bool) { include!("chat.rs"); include!("simple.rs"); +#[cfg(all(test, feature = "inference"))] +#[path = "tests_safetensors_stop_4334.rs"] +mod tests_safetensors_stop_4334; /// #4269 (workstream M of #4263): the ONE SafeTensors CPU generate both serve /// handlers (`/v1/chat/completions` in chat.rs, `/generate` in simple.rs) call, /// driven through the engine's `realizar::session::Session` /// instead of `AprTransformer::generate_with_cache`'s own decode loop. /// Returns the prompt followed by the generated tokens, as that loop did. +/// #4334: the stop set and top_p come from the handler's `gen_config` +/// (`handlers::apr_cpu_generate_config`): the tokenizer's EOS / chat-turn end, +/// plus token 0, which `apr_transformer::generation::is_eos_token` (GH-330) +/// stopped on unconditionally and Session has no builtin for. #[cfg(feature = "inference")] fn st_cpu_generate( model: &realizar::apr_transformer::AprTransformer, input_ids: &[u32], - max_tokens: usize, - temperature: f32, + gen_config: &realizar::gguf::QuantizedGenerateConfig, ) -> std::result::Result, String> { use realizar::safetensors_infer::StCpuForward; use realizar::session::Session; - let gen_config = realizar::gguf::QuantizedGenerateConfig { - max_tokens, - temperature, - top_k: 0, - top_p: 0.9, - // #3760: the sampler draws now; no seed is plumbed from this caller. - seed: realizar::apr_transformer::DEFAULT_SEED, - repeat_penalty: 1.0, - repeat_last_n: 0, - // apr_transformer::generation::is_eos_token (GH-330) stopped on token 0 - // unconditionally; Session has no such builtin, so it is an explicit - // stop token here to keep the handlers' stopping behavior identical. - stop_tokens: vec![0], - trace: false, - logprobs: false, - cancel: realizar::generate::CancelToken::never(), - }; Session::new(StCpuForward::new(model)) - .generate(input_ids, &gen_config, &mut |_tok| true) + .generate(input_ids, gen_config, &mut |_tok| true) .map(|turn| turn.tokens) .map_err(|e| e.to_string()) } diff --git a/crates/apr-cli/src/commands/serve/server.rs b/crates/apr-cli/src/commands/serve/server.rs index 480b9ebd94..60aae57c35 100644 --- a/crates/apr-cli/src/commands/serve/server.rs +++ b/crates/apr-cli/src/commands/serve/server.rs @@ -37,6 +37,7 @@ fn dominant_quantization(model: &realizar::gguf::OwnedQuantizedModel) -> Option< #[cfg(feature = "inference")] fn measured_model_source( model: &realizar::gguf::OwnedQuantizedModel, + mapped: Option<&realizar::gguf::MappedGGUFModel>, config: &ServerConfig, ) -> realizar::api::ModelSourceInfo { let base = config @@ -52,9 +53,50 @@ fn measured_model_source( if let Some(quantization) = dominant_quantization(model) { source = source.with_quantization(quantization); } + // #4254: counted from the file's own tensor table, which is the whole model. The + // owned model is not: the Qwen3.5 hybrid reaches the servers as its BASE (#3571). + if let Some(mapped) = mapped { + source = source.with_parameter_count(gguf_parameter_count(&mapped.model)); + } source } +/// #4254: [`dominant_quantization`]'s rule, read off the GGUF's own tensor table: the +/// modal qtype across every layer's attention-output and FFN projections. The Qwen3.5 +/// hybrid has no owned model to ask, so the tensor table is the only measurement it has. +/// The embedding is left out on both paths — on a 0.8B model its Q6_K would outweigh +/// every Q4_K projection, and it is not what "the model's quantization" names. +#[cfg(feature = "inference")] +fn gguf_dominant_quantization(model: &realizar::gguf::GGUFModel) -> Option<&'static str> { + use std::collections::HashMap; + + const PROJECTIONS: [&str; 4] = [ + ".attn_output.weight", + ".ffn_up.weight", + ".ffn_down.weight", + ".ffn_gate.weight", + ]; + let mut counts: HashMap = HashMap::new(); + for t in model + .tensors + .iter() + .filter(|t| t.name.starts_with("blk.") && PROJECTIONS.iter().any(|p| t.name.ends_with(p))) + { + *counts.entry(t.qtype).or_insert(0) += 1; + } + counts + .into_iter() + .filter_map(|(qtype, n)| realizar::api::gguf_qtype_name(qtype).map(|name| (name, n))) + .max_by_key(|&(_, n)| n) + .map(|(name, _)| name) +} + +/// #4254: every element of every tensor the GGUF declares. +#[cfg(feature = "inference")] +fn gguf_parameter_count(model: &realizar::gguf::GGUFModel) -> u64 { + model.tensors.iter().map(|t| t.dims.iter().product::()).sum() +} + /// Run the CPU inference server /// /// `mapped_model` is `None` for non-GGUF formats (APR / SafeTensors). For @@ -76,7 +118,7 @@ fn run_cpu_server( // Measure the model BEFORE it is moved into AppState. Anything not // measurable here stays absent — `/realize/model` no longer substitutes // `size_bytes: 0` / `context_length: 4096` / `quantization: "Q4_K_M"`. - let model_source = measured_model_source(&quantized_model, config); + let model_source = measured_model_source(&quantized_model, mapped_model.as_deref(), config); let mut state = AppState::with_quantized_model_and_vocab(quantized_model, vocab) .map_err(|e| CliError::InferenceFailed(format!("Failed to create app state: {e}")))? @@ -180,7 +222,13 @@ fn build_qwen35_state( .unwrap_or_default() .with_architecture("qwen35") .with_model_max_context_length(context_length) - .with_context_length(config.context_length); + .with_context_length(config.context_length) + .with_parameter_count(gguf_parameter_count(&mapped_model.model)); + // #4254: the v0.69.1 CPU asset served this model with `quantization: null`. + let model_source = match gguf_dominant_quantization(&mapped_model.model) { + Some(q) => model_source.with_quantization(q), + None => model_source, + }; let state = AppState::with_qwen35_session(session, mapped_model, vocab) .map_err(|e| CliError::InferenceFailed(format!("Failed to create app state: {e}")))? .with_model_source(model_source) @@ -247,6 +295,97 @@ mod qwen35_serve_route_tests { assert!(layers > 0, "the session holds the hybrid's layers: {layers}"); } + /// #4254: the v0.69.1 CPU asset served Qwen3.5 with `quantization: null` and + /// `parameter_count: null` in `/v1/effective-config` — the hybrid path built its + /// source from the path alone. Both are now read off the GGUF's own tensor table. + #[test] + fn qwen35_serve_source_carries_quantization_and_parameter_count_4254() { + if !Path::new(MODEL).exists() { + eprintln!("SKIP: {MODEL} is absent"); + return; + } + let mapped = std::sync::Arc::new( + realizar::gguf::MappedGGUFModel::from_path(MODEL).expect("map the GGUF"), + ); + let config = ServerConfig { + no_gpu: true, + ..ServerConfig::default() + }; + let state = build_qwen35_state(mapped, &config).expect("qwen35 serve passes"); + let body = realizar::api::effective_config(&state); + // A Q4_K_M file holds most of its weight elements in Q4_K (some + // projections are Q6_K); "Q4_K_M" is a file label, not a tensor type. + assert_eq!(body.model.quantization.as_deref(), Some("Q4_K")); + // Qwen3.5-0.8B: a count, not a label — anything off by 10x is a wrong sum. + let n = body.model.parameter_count.expect("parameter_count is measured"); + assert!((500_000_000..1_500_000_000).contains(&n), "parameter_count {n}"); + assert_eq!(body.compute_class, "cpu"); + } + + /// #4254: one process, two endpoints, one answer. `/v1/effective-config` on the + /// hybrid named no backend (`backend_loaded: []`, `compute_class: unknown`), no + /// quantization and no parameter count while `/health` said `cpu` — and listed a + /// `gpu` feature this binary cannot dispatch to. + #[test] + fn qwen35_effective_config_agrees_with_health() { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + if !Path::new(MODEL).exists() { + eprintln!("SKIP: {MODEL} is absent"); + return; + } + let mapped = std::sync::Arc::new( + realizar::gguf::MappedGGUFModel::from_path(MODEL).expect("map the GGUF"), + ); + let want_params = + realizar::gguf::forward_qwen35::qwen35_known_issue::gguf_param_count(&mapped.model); + let config = ServerConfig { + no_gpu: true, + ..ServerConfig::default() + }; + let state = build_qwen35_state(mapped, &config).expect("qwen35 state"); + let router = realizar::api::create_router_with_config(state, config.router_config()); + let get = |uri: &'static str| { + let router = router.clone(); + async move { + let req = Request::builder().uri(uri).body(Body::empty()).expect("request"); + let resp = router.oneshot(req).await.expect("route"); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body"); + serde_json::from_slice::(&bytes).expect("JSON") + } + }; + let (health, ec) = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(async { (get("/health").await, get("/v1/effective-config").await) }); + + assert_eq!(health["compute_mode"], "cpu", "{health}"); + assert_eq!(ec["compute_class"], "cpu", "must agree with /health: {ec}"); + assert_eq!(ec["backend_loaded"], serde_json::json!(["cpu"]), "{ec}"); + let q = ec["model"]["quantization"].as_str().expect("quantization named"); + assert!(q.starts_with('Q'), "a GGUF qtype name, got {q:?}"); + assert_eq!(ec["model"]["parameter_count"], want_params, "{ec}"); + assert!( + (600_000_000..1_000_000_000).contains(&want_params), + "fixture: the 0.8B file counts {want_params}" + ); + // The launcher's own set decides which accelerators are dispatchable. + let features: Vec<&str> = ec["build_features"] + .as_array() + .expect("build_features") + .iter() + .filter_map(serde_json::Value::as_str) + .collect(); + assert_eq!(features.contains(&"gpu"), cfg!(feature = "wgpu"), "{features:?}"); + assert_eq!(features.contains(&"cuda"), cfg!(feature = "cuda"), "{features:?}"); + let commit = ec["server"]["build_commit"].as_str().expect("build_commit"); + assert_eq!(commit, env!("APR_GIT_SHA")); + assert!(!commit.ends_with("+no-git"), "built from a checkout: {commit}"); + } + /// The startup line's thinking mode is read off what the template renders. #[test] fn the_thinking_mode_is_read_off_the_rendered_template() { diff --git a/crates/apr-cli/src/commands/serve/simple.rs b/crates/apr-cli/src/commands/serve/simple.rs index 04ee5b7d41..53713a38b6 100644 --- a/crates/apr-cli/src/commands/serve/simple.rs +++ b/crates/apr-cli/src/commands/serve/simple.rs @@ -39,6 +39,19 @@ pub(crate) async fn safetensors_generate_handler( .get("temperature") .and_then(|t| t.as_f64()) .unwrap_or(0.0) as f32; + // #4334: stop at the tokenizer's EOS / chat-turn end and honour the request's + // top_p, through the same config builder as the APR CPU path (#4265). + let top_p = request + .get("top_p") + .and_then(serde_json::Value::as_f64) + .map(|p| p as f32); + let stop_tokens = state + .tokenizer_info + .as_ref() + .map(super::handlers::tokenizer_info_stop_tokens) + .unwrap_or_default(); + let gen_config = + super::handlers::apr_cpu_generate_config(max_tokens, temperature, top_p, stop_tokens); let output_ids = { // PMAT-189: Handle transformer lock poisoning gracefully let t = match transformer.lock() { @@ -53,7 +66,7 @@ pub(crate) async fn safetensors_generate_handler( .into_response(); } }; - match st_cpu_generate(&t, &input_ids, max_tokens, temperature) { + match st_cpu_generate(&t, &input_ids, &gen_config) { Ok(ids) => ids, Err(e) => { return ( @@ -67,7 +80,7 @@ pub(crate) async fn safetensors_generate_handler( let elapsed = start.elapsed(); // Decode using BPE tokenizer (PMAT-093) - let new_tokens = &output_ids[input_ids.len()..]; + let new_tokens = super::handlers::generated_reply_tokens(&output_ids, input_ids.len(), &gen_config); let output_text = if let Some(ref tok_info) = state.tokenizer_info { match tok_info.tokenizer.decode(new_tokens) { Ok(text) => text, diff --git a/crates/apr-cli/src/commands/serve/tests_apr_cpu_gen_config_4265.rs b/crates/apr-cli/src/commands/serve/tests_apr_cpu_gen_config_4265.rs index d3d577885a..f01820e3cb 100644 --- a/crates/apr-cli/src/commands/serve/tests_apr_cpu_gen_config_4265.rs +++ b/crates/apr-cli/src/commands/serve/tests_apr_cpu_gen_config_4265.rs @@ -9,23 +9,31 @@ fn state_with( embedded: Option, eos_from_tokenizer_json: Option, ) -> AprServerState { + let tokenizer = eos_from_tokenizer_json.map(tokenizer_json); + let stop_tokens = derive_apr_cpu_stop_tokens(embedded.as_ref(), tokenizer.as_ref()); AprServerState { transformer: None, model_type: "test".to_string(), architecture: "test".to_string(), is_transformer: false, - tokenizer: eos_from_tokenizer_json.map(|eos| SafeTensorsTokenizerInfo { - tokenizer: std::sync::Arc::new( - realizar::tokenizer::BPETokenizer::new(vec!["".to_string()], vec![], "") - .expect("tiny tokenizer"), - ), - vocab: vec![], - bos_token_id: None, - eos_token_id: Some(eos), - }), + tokenizer, embedded_tokenizer: embedded, model_name: "apr".to_string(), demo_scripted_tokens: None, + stop_tokens, + } +} + +/// A sibling tokenizer.json with `eos` and an empty vocab. +fn tokenizer_json(eos: u32) -> SafeTensorsTokenizerInfo { + SafeTensorsTokenizerInfo { + tokenizer: std::sync::Arc::new( + realizar::tokenizer::BPETokenizer::new(vec!["".to_string()], vec![], "") + .expect("tiny tokenizer"), + ), + vocab: vec![], + bos_token_id: None, + eos_token_id: Some(eos), } } @@ -114,13 +122,24 @@ fn reply_keeps_a_budget_cut_and_an_inner_stop_id() { #[test] fn stop_set_holds_sibling_tokenizer_json_turn_end_by_vocab_index() { + let mut tok = tokenizer_json(2); + tok.vocab = ["a", "<|im_start|>", "b", "<|im_end|>"] + .iter() + .map(|t| (*t).to_string()) + .collect(); + // <|im_end|> sits at index 3; <|im_start|> (index 1) is not a stop. + assert_eq!(&*derive_apr_cpu_stop_tokens(None, Some(&tok)), &[2, 3]); +} + +/// #4295: a request reads the set derived at load; it never walks the vocab +/// again. The tokenizer changes after the state is built and the request's +/// stop set does not. +#[test] +fn a_request_reads_the_stop_set_derived_at_load() { let mut s = state_with(None, Some(2)); if let Some(tok) = s.tokenizer.as_mut() { - tok.vocab = ["a", "<|im_start|>", "b", "<|im_end|>"] - .iter() - .map(|t| (*t).to_string()) - .collect(); + tok.vocab = vec!["<|im_end|>".to_string()]; + tok.eos_token_id = Some(7); } - // <|im_end|> sits at index 3; <|im_start|> (index 1) is not a stop. - assert_eq!(apr_cpu_stop_tokens(&s), vec![2, 3]); + assert_eq!(apr_cpu_stop_tokens(&s), vec![2]); } diff --git a/crates/apr-cli/src/commands/serve/tests_contract_enforcement.rs b/crates/apr-cli/src/commands/serve/tests_contract_enforcement.rs index c3dd427a6b..704836f922 100644 --- a/crates/apr-cli/src/commands/serve/tests_contract_enforcement.rs +++ b/crates/apr-cli/src/commands/serve/tests_contract_enforcement.rs @@ -13,14 +13,16 @@ use std::sync::Arc; fn create_test_model_file() -> tempfile::NamedTempFile { let mut file = tempfile::NamedTempFile::new().expect("tempfile::NamedTempFile::new()"); - file.write_all(b"test model data").expect("write_all(b'test model data'"); + file.write_all(b"test model data") + .expect("write_all(b'test model data'"); file } fn create_test_state() -> Arc { let model = create_test_model_file(); let config = ServerConfig::default(); - let state = ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); + let state = + ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); // Keep the temp file alive by leaking the path std::mem::forget(model); Arc::new(state) @@ -34,7 +36,8 @@ fn falsify_srv_001_health_returns_503_when_not_ready() { // ServerState starts with ready=false. health_check() must return Unhealthy. let model = create_test_model_file(); let config = ServerConfig::default(); - let state = ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); + let state = + ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); // State starts not ready assert!( !state.is_ready(), @@ -53,7 +56,8 @@ fn falsify_srv_001b_health_returns_200_when_ready() { // FALSIFY-SRV-001b: /health returns Healthy once ready=true. let model = create_test_model_file(); let config = ServerConfig::default(); - let state = ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); + let state = + ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); state.ready.store(true, Ordering::Release); assert!(state.is_ready()); let health = health_check(&state); @@ -182,7 +186,8 @@ fn falsify_srv_006_health_response_fields() { // FALSIFY-SRV-006: HealthResponse has all required fields per contract. let model = create_test_model_file(); let config = ServerConfig::default(); - let state = ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); + let state = + ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); state.ready.store(true, Ordering::Release); let health = health_check(&state); // All fields must be present and valid @@ -205,14 +210,17 @@ fn falsify_srv_006_health_response_fields() { fn falsify_srv_007_health_status_serde() { // FALSIFY-SRV-007: HealthStatus enum serializes to lowercase strings. // Contract: "healthy", "degraded", "unhealthy" — not "Healthy", etc. - let json = serde_json::to_string(&HealthStatus::Healthy).expect("serde_json::to_string(&HealthS"); + let json = + serde_json::to_string(&HealthStatus::Healthy).expect("serde_json::to_string(&HealthS"); assert_eq!(json, "\"healthy\"", "FALSIFY-SRV-007: Healthy → lowercase"); - let json = serde_json::to_string(&HealthStatus::Degraded).expect("serde_json::to_string(&HealthS"); + let json = + serde_json::to_string(&HealthStatus::Degraded).expect("serde_json::to_string(&HealthS"); assert_eq!( json, "\"degraded\"", "FALSIFY-SRV-007: Degraded → lowercase" ); - let json = serde_json::to_string(&HealthStatus::Unhealthy).expect("serde_json::to_string(&HealthS"); + let json = + serde_json::to_string(&HealthStatus::Unhealthy).expect("serde_json::to_string(&HealthS"); assert_eq!( json, "\"unhealthy\"", "FALSIFY-SRV-007: Unhealthy → lowercase" @@ -380,7 +388,8 @@ fn falsify_srv_010_graceful_shutdown_ready_flag() { // When shutdown is initiated, health endpoint must return 503. let model = create_test_model_file(); let config = ServerConfig::default(); - let state = ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); + let state = + ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); // Simulate Ready → Draining transition state.ready.store(true, Ordering::Release); @@ -405,7 +414,8 @@ fn falsify_srv_011_degraded_health_on_high_latency() { // Contract: server_lifecycle invariant — Degraded when p99 > 1s. let model = create_test_model_file(); let config = ServerConfig::default(); - let state = ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); + let state = + ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); state.ready.store(true, Ordering::Release); // Record 10 requests with 2000ms average latency @@ -462,7 +472,8 @@ fn falsify_srv_013_mmap_threshold() { // Contract: ServerState determines mmap based on 50MB threshold. let model = create_test_model_file(); let config = ServerConfig::default(); - let state = ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); + let state = + ServerState::new(model.path().to_path_buf(), config).expect("to_path_buf(), config"); // Test file is small — should NOT use mmap assert!( diff --git a/crates/apr-cli/src/commands/serve/tests_safetensors_stop_4334.rs b/crates/apr-cli/src/commands/serve/tests_safetensors_stop_4334.rs new file mode 100644 index 0000000000..11daccb0b9 --- /dev/null +++ b/crates/apr-cli/src/commands/serve/tests_safetensors_stop_4334.rs @@ -0,0 +1,119 @@ +//! #4334: the SafeTensors serve handlers (`chat.rs`, `simple.rs`) stop at the +//! tokenizer's EOS / chat-turn end, like the APR CPU path does since #4265. +//! They hardcoded `stop_tokens: vec![]`, so every reply ran to `max_tokens`. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use super::*; +use axum::body::to_bytes; +use realizar::apr_transformer::{AprTransformer, AprTransformerConfig}; + +const VOCAB: usize = 100; +const IM_END: u32 = 7; +const EOS: u32 = 9; +const MAX_TOKENS: u64 = 8; + +/// A vocab with `<|im_end|>` at `IM_END`; every other entry is an ordinary piece. +fn vocab() -> Vec { + (0..VOCAB) + .map(|i| match i { + 0 => "".to_string(), + 7 => "<|im_end|>".to_string(), + _ => format!("t{i}"), + }) + .collect() +} + +fn tokenizer_info(eos: Option) -> SafeTensorsTokenizerInfo { + let vocab = vocab(); + SafeTensorsTokenizerInfo { + tokenizer: std::sync::Arc::new( + realizar::tokenizer::BPETokenizer::new(vocab.clone(), vec![], "") + .expect("tiny tokenizer"), + ), + vocab, + bos_token_id: None, + eos_token_id: eos, + } +} + +/// A tiny zero-weight transformer whose LM-head bias makes greedy decoding emit +/// `always` at every step, so the only way generation ends early is a stop id. +fn state(always: u32, eos: Option) -> SafeTensorsState { + let mut t = AprTransformer::new(AprTransformerConfig { + architecture: "test".to_string(), + hidden_dim: 16, + num_layers: 1, + num_heads: 2, + num_kv_heads: 2, + vocab_size: VOCAB, + intermediate_dim: 32, + context_length: 128, + rope_theta: 10000.0, + eps: 1e-5, + eos_token_id: None, + ..Default::default() + }); + let mut bias = vec![0.0; VOCAB]; + bias[always as usize] = 10.0; + t.lm_head_bias = Some(bias); + SafeTensorsState { + transformer: Some(std::sync::Arc::new(std::sync::Mutex::new(t))), + tokenizer_info: Some(tokenizer_info(eos)), + model_path: "test".to_string(), + } +} + +async fn json(resp: axum::response::Response) -> serde_json::Value { + let bytes = to_bytes(resp.into_body(), 64 * 1024).await.expect("body"); + serde_json::from_slice(&bytes).expect("json") +} + +#[test] +fn stop_set_holds_eos_and_turn_end_from_vocab() { + assert_eq!( + super::super::handlers::tokenizer_info_stop_tokens(&tokenizer_info(Some(EOS))), + vec![IM_END, EOS] + ); + assert_eq!( + super::super::handlers::tokenizer_info_stop_tokens(&tokenizer_info(None)), + vec![IM_END] + ); +} + +#[tokio::test] +async fn generate_handler_stops_at_eos() { + let resp = safetensors_generate_handler( + axum::extract::State(state(EOS, Some(EOS))), + axum::Json(serde_json::json!({"prompt": "t1", "max_tokens": MAX_TOKENS})), + ) + .await; + let v = json(resp).await; + // One step: the model emits EOS, the loop stops, and the stop id is not reply text. + assert_eq!(v["tokens_generated"], 0, "{v}"); +} + +#[tokio::test] +async fn generate_handler_runs_to_max_without_a_stop_id() { + // Positive control: the same model emitting an ordinary token runs to max_tokens, + // so the test above measures the stop set, not a broken generation loop. + let resp = safetensors_generate_handler( + axum::extract::State(state(11, Some(EOS))), + axum::Json(serde_json::json!({"prompt": "t1", "max_tokens": MAX_TOKENS})), + ) + .await; + assert_eq!(json(resp).await["tokens_generated"], MAX_TOKENS); +} + +#[tokio::test] +async fn chat_handler_stops_at_im_end() { + let resp = safetensors_chat_completions_handler( + axum::extract::State(state(IM_END, None)), + axum::Json(serde_json::json!({ + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": MAX_TOKENS, + })), + ) + .await; + let v = json(resp).await; + assert_eq!(v["usage"]["completion_tokens"], 0, "{v}"); +} diff --git a/crates/apr-cli/src/commands/serve/tests_st_serve_session_4269.rs b/crates/apr-cli/src/commands/serve/tests_st_serve_session_4269.rs index 01a4c627fb..599d94a926 100644 --- a/crates/apr-cli/src/commands/serve/tests_st_serve_session_4269.rs +++ b/crates/apr-cli/src/commands/serve/tests_st_serve_session_4269.rs @@ -2,6 +2,7 @@ //! `realizar::session::Session`, which leaves a witness entry per turn. use super::st_cpu_generate; +use crate::commands::serve::handlers::apr_cpu_generate_config; use realizar::apr_transformer::{AprTransformer, AprTransformerConfig, AprTransformerLayer}; use realizar::session::{entries_for, EntryKind}; @@ -40,7 +41,12 @@ fn st_serve_generate_runs_through_session_and_leaves_a_safetensors_witness() { let model = tiny_transformer(); // A prompt no other test in this binary uses, so the witness is this call's. let prompt = [7_u32, 5, 3, 9, 4269 % 12]; - let out = st_cpu_generate(&model, &prompt, 2, 0.0).expect("tiny model generates"); + let out = st_cpu_generate( + &model, + &prompt, + &apr_cpu_generate_config(2, 0.0, None, vec![]), + ) + .expect("tiny model generates"); assert_eq!(&out[..prompt.len()], &prompt, "prompt is echoed first"); assert!(out.len() > prompt.len(), "at least one token was generated"); let entries = entries_for(&prompt); diff --git a/crates/apr-cli/src/commands/speedup.rs b/crates/apr-cli/src/commands/speedup.rs index 8bab4bd801..ce7a3b87ea 100644 --- a/crates/apr-cli/src/commands/speedup.rs +++ b/crates/apr-cli/src/commands/speedup.rs @@ -119,7 +119,7 @@ fn run_throughput_gate(path: &Path, config: &QaConfig) -> Result { /// The threshold is untouched (10 tok/s for unasserted GGUF); what changes is /// that the number the gate compares is decode throughput. #[cfg(feature = "inference")] -fn throughput_runtime(path: &Path, config: &QaConfig) -> Result { +fn throughput_runtime(path: &Path, config: &QaConfig) -> Result<(f64, &'static str)> { use realizar::{run_inference, InferenceConfig}; let infer_config = InferenceConfig::new(path) @@ -139,17 +139,34 @@ fn throughput_runtime(path: &Path, config: &QaConfig) -> Result { let mut generated = 0usize; let mut seconds = 0.0_f64; + let (mut gpu_runs, mut runs) = (0usize, 0usize); for _ in 0..config.iterations.max(1) { let result = run()?; generated += result.generated_token_count; seconds += result.inference_ms / 1000.0; + runs += 1; + gpu_runs += usize::from(result.used_gpu); } - Ok(if seconds > 0.0 { + let tps = if seconds > 0.0 { generated as f64 / seconds } else { 0.0 - }) + }; + Ok((tps, runtime_backend_label(gpu_runs, runs))) +} + +/// #3714: the backend the timed runs REPORTED (`used_gpu`), never the build's +/// features — a cuda build whose device could not serve ran on the CPU, and the +/// gate used to print "GPU" for it (and "hybrid" for a MoE file). +#[cfg(feature = "inference")] +fn runtime_backend_label(gpu_runs: usize, runs: usize) -> &'static str { + match (gpu_runs, runs) { + (_, 0) => "runtime entry point, no timed run", + (g, r) if g == r => "runtime entry point, GPU on every timed run", + (0, _) => "runtime entry point, CPU on every timed run", + _ => "runtime entry point, MIXED GPU/CPU timed runs", + } } /// Gate 2 for an architecture the dense loader refuses: the same falsifiable @@ -168,14 +185,9 @@ fn run_throughput_gate_runtime(path: &Path, config: &QaConfig) -> Result= threshold { ">=" } else { "<" } diff --git a/crates/apr-cli/src/commands/test_llm.rs b/crates/apr-cli/src/commands/test_llm.rs index fb0cc47d36..35e1f21275 100644 --- a/crates/apr-cli/src/commands/test_llm.rs +++ b/crates/apr-cli/src/commands/test_llm.rs @@ -22,6 +22,9 @@ //! `decode_tok_per_sec` = 1000 / `itl_p50_ms`, the inter-token rate, which //! excludes time-to-first-token by construction and so cannot absorb a model //! load. Reading a field beats deriving a number. +// #4041: a `--no-default-features` build has no tokio, so `dispatch` cannot run a benchmark and the benchmark +// code is unreachable there. Dead in that build only; every build with `inference` still lints it. +#![cfg_attr(not(feature = "inference"), allow(dead_code))] use crate::error::{CliError, Result}; use apr_test::llm::{ benchmark::{Benchmark, BenchmarkConfig, BenchmarkReport}, @@ -411,6 +414,7 @@ use crate::LlmSubcommand; /// /// # Errors /// Propagates whichever mode ran. +#[cfg(feature = "inference")] pub fn dispatch(command: &LlmSubcommand) -> Result<()> { let rt = tokio::runtime::Runtime::new() .map_err(|e| CliError::InferenceFailed(format!("tokio runtime: {e}")))?; @@ -420,6 +424,18 @@ pub fn dispatch(command: &LlmSubcommand) -> Result<()> { } } +/// #4041: the benchmark drives its server through tokio, which only the `inference` feature brings. A minimal +/// build (`--no-default-features`) names the missing feature instead of failing to compile. +/// +/// # Errors +/// Always: this build cannot run the benchmark. +#[cfg(not(feature = "inference"))] +pub fn dispatch(_command: &LlmSubcommand) -> Result<()> { + Err(CliError::InferenceFailed( + "`apr test llm` needs the `inference` feature (this apr was built without it)".to_string(), + )) +} + /// TWO MODES, ONE ENTRYPOINT — the §4.4-conformant one. /// /// `--band` selects PERF-024's `run_band`, which until PERF-025 was called by @@ -479,7 +495,7 @@ async fn dispatch_band(command: &LlmSubcommand) -> Result<()> { accelerator, quantization, compute_class, - server_features, + server_features: &batuta_common::cli_roles::strings(server_features), tokenization, tokenizer_sha256: tokenizer_sha256.as_deref(), counts_special_tokens: *counts_special_tokens, diff --git a/crates/apr-cli/src/commands/test_llm_band.rs b/crates/apr-cli/src/commands/test_llm_band.rs index aecf2406a1..5d1ad0fde5 100644 --- a/crates/apr-cli/src/commands/test_llm_band.rs +++ b/crates/apr-cli/src/commands/test_llm_band.rs @@ -51,6 +51,9 @@ //! `receipt.r1.json` … `receipt.rN.json`. The cell's verdict is the conjunction //! over them, which is a decision for the runner, not something this producer //! should pre-collapse. +// #4041: a `--no-default-features` build has no tokio, so `dispatch` cannot run a benchmark and the benchmark +// code is unreachable there. Dead in that build only; every build with `inference` still lints it. +#![cfg_attr(not(feature = "inference"), allow(dead_code))] use crate::error::{CliError, Result}; use apr_test::llm::band::{run_band, BandRun, RequestExtra}; @@ -1770,7 +1773,12 @@ async fn cool(first_lane: &mut bool, cooldown: std::time::Duration, interleaved: *first_lane = false; return; } + #[cfg(feature = "inference")] tokio::time::sleep(cooldown).await; + // #4041: no tokio in a minimal build. Nothing reaches this there (`dispatch` refuses first); if it did, the + // cooldown would still be honoured, blocking. + #[cfg(not(feature = "inference"))] + std::thread::sleep(cooldown); } /// Run the §5.1 protocol over every requested band and write the receipts. diff --git a/crates/apr-cli/src/commands/test_llm_band_workload_binding_tests.rs b/crates/apr-cli/src/commands/test_llm_band_workload_binding_tests.rs index 9b639ec89e..557933f765 100644 --- a/crates/apr-cli/src/commands/test_llm_band_workload_binding_tests.rs +++ b/crates/apr-cli/src/commands/test_llm_band_workload_binding_tests.rs @@ -14,11 +14,13 @@ use super::*; use std::io::Write as _; -/// `crates/apr-cli`'s `CARGO_MANIFEST_DIR` sibling to -/// `crates/aprender-serve/benchmarks/qwen-coder/prompts-w1.jsonl`. -fn w1_corpus_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../aprender-serve/benchmarks/qwen-coder/prompts-w1.jsonl") +/// `crates/aprender-serve/benchmarks/qwen-coder/prompts-w1.jsonl`, a sibling crate's file: in +/// tree it must exist, out of tree (the published tarball) the test skips by name (#4149). +fn w1_corpus_path(test: &str) -> Option { + provable_contracts::workspace_path_or_skip!( + test, + "crates/aprender-serve/benchmarks/qwen-coder/prompts-w1.jsonl", + ) } /// A private, per-process scratch file — never the real corpus, never @@ -35,7 +37,9 @@ fn write_tmp(name: &str, content: &str) -> PathBuf { #[test] fn the_real_w1_corpus_binds_to_the_w1_label() { - let path = w1_corpus_path(); + let Some(path) = w1_corpus_path("the_real_w1_corpus_binds_to_the_w1_label") else { + return; + }; let binding = bind_workload(Workload::W1, &path) .unwrap_or_else(|e| panic!("the real W1 corpus must bind to the W1 label: {e}")); assert_eq!(binding.label, Workload::W1); diff --git a/crates/apr-cli/src/commands/train_tests.rs b/crates/apr-cli/src/commands/train_tests.rs index af22b3c12c..524ce804e8 100644 --- a/crates/apr-cli/src/commands/train_tests.rs +++ b/crates/apr-cli/src/commands/train_tests.rs @@ -320,7 +320,7 @@ mod tests { }) .expect("spawn parse thread") .join() - .expect("parse thread must not panic") + .expect("parse thread must not panic").into() } #[test] diff --git a/crates/apr-cli/src/commands_enum.rs b/crates/apr-cli/src/commands_enum.rs index 2eadea8471..4c96d94c15 100644 --- a/crates/apr-cli/src/commands_enum.rs +++ b/crates/apr-cli/src/commands_enum.rs @@ -128,19 +128,22 @@ pub enum CodeInputFormat { #[derive(Subcommand, Debug)] pub enum Commands { /// Run model directly (auto-download, cache, execute) + // #3745 S1 v1.1: the sampling controls below join this group, which is how + // `apr surface` reads them as role `sampling`. + #[command(groups(batuta_common::cli_roles::SamplingArg::groups()))] Run { /// Model source: local path, hf://org/repo, or URL #[arg(value_name = "SOURCE")] - source: String, + source: ModelRef, /// Text prompt (positional): `apr run model.gguf "What is 2+2?"` #[arg(value_name = "PROMPT")] - positional_prompt: Option, + positional_prompt: Option, /// Input file (audio, text, etc.) #[arg(short, long)] - input: Option, + input: Option, /// Text prompt for generation (for LLM models) #[arg(short, long)] - prompt: Option, + prompt: Option, /// Maximum tokens to generate (default: 32) #[arg(short = 'n', long, default_value = "32")] max_tokens: usize, @@ -149,10 +152,10 @@ pub enum Commands { stream: bool, /// Language code (for ASR models) #[arg(short, long)] - language: Option, + language: Option, /// Task (transcribe, translate) #[arg(short, long)] - task: Option, + task: Option, /// Output format (text, json, srt, vtt) #[arg(short = 'f', long, default_value = "text", value_parser = RUN_FORMAT_VALUES)] format: String, @@ -177,13 +180,13 @@ pub enum Commands { trace: bool, /// Trace specific steps only (comma-separated) #[arg(long, value_delimiter = ',')] - trace_steps: Option>, + trace_steps: Option>, /// Verbose tracing (show tensor values) #[arg(long)] trace_verbose: bool, /// Save trace output to JSON file #[arg(long, value_name = "FILE")] - trace_output: Option, + trace_output: Option, /// Trace detail level (none, basic, layer, payload, chrome) /// "chrome" outputs chrome://tracing JSON integrating layer trace + brick profile. /// F-CLIPARITY-01 / PMAT-386 / paiml/aprender#574 @@ -202,27 +205,27 @@ pub enum Commands { #[arg(long)] chat: bool, /// Sampling temperature (0.0 = greedy, default: 0.0) - #[arg(long, default_value = "0.0")] + #[arg(long, default_value = "0.0", group = batuta_common::cli_roles::SamplingKind::Temperature.id())] temperature: f32, /// Top-k sampling, used when --temperature > 0. `--temperature 0` (the default) /// or `--top-k 1` decodes greedily; `--top-k 0` disables the filter. - #[arg(long, default_value_t = crate::commands::run::DEFAULT_TOP_K)] + #[arg(long, default_value_t = crate::commands::run::DEFAULT_TOP_K, group = batuta_common::cli_roles::SamplingKind::TopK.id())] top_k: usize, /// Top-p nucleus sampling (0.0 = disabled). When set with --top-k, applies top-k first then top-p. /// F-CLIPARITY-01 / PMAT-381 / paiml/aprender#569 - #[arg(long)] + #[arg(long, group = batuta_common::cli_roles::SamplingKind::TopP.id())] top_p: Option, /// RNG seed for deterministic sampling (default: 299792458, matching Candle) /// F-CLIPARITY-01 / PMAT-382 / paiml/aprender#570 - #[arg(long, default_value = "299792458")] + #[arg(long, default_value = "299792458", group = batuta_common::cli_roles::SamplingKind::Seed.id())] seed: u64, /// Repetition penalty (1.0 = no penalty, >1.0 penalizes repeats) /// F-CLIPARITY-01 / PMAT-383 / paiml/aprender#571 - #[arg(long, default_value = "1.0")] + #[arg(long, default_value = "1.0", group = batuta_common::cli_roles::SamplingKind::RepeatPenalty.id())] repeat_penalty: f32, /// Context window for repetition penalty (number of recent tokens to check) /// F-CLIPARITY-01 / PMAT-384 / paiml/aprender#571 - #[arg(long, default_value = "64")] + #[arg(long, default_value = "64", group = batuta_common::cli_roles::SamplingKind::RepeatLastN.id())] repeat_last_n: usize, /// Process prompt tokens one-by-one instead of batched prefill. /// Useful for debugging prefill correctness (comparing per-token attention). @@ -234,7 +237,7 @@ pub enum Commands { /// Each input line: {"prompt": "...", "task_id": "..."} /// Chat template is applied automatically. #[arg(long, value_name = "FILE")] - batch_jsonl: Option, + batch_jsonl: Option, /// Show verbose output (model loading, backend info) #[arg(short, long)] verbose: bool, @@ -254,7 +257,7 @@ pub enum Commands { Inspect { /// Path to .apr model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Show vocabulary details #[arg(long)] vocab: bool, @@ -289,7 +292,7 @@ pub enum Commands { Debug { /// Path to .apr model file (omit only when using a subcommand) #[arg(value_name = "FILE")] - file: Option, + file: Option, /// Debug subcommand, e.g. `embed-viz` #[command(subcommand)] action: Option, @@ -310,7 +313,7 @@ pub enum Commands { Validate { /// Path to .apr model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Score the structural checks that ran #[arg(long)] quality: bool, @@ -328,10 +331,10 @@ pub enum Commands { ValidateManifest { /// Path to manifest YAML #[arg(value_name = "MANIFEST")] - file: PathBuf, + file: ConfigPath, /// Optional local .apr artifact to discharge FALSIFY-PM-002 (sha256 match) #[arg(long, value_name = "APR_FILE")] - artifact: Option, + artifact: Option, /// Discharge FALSIFY-PM-003 via network: HTTP HEAD + streaming sha256. /// Default is DEFERRED (offline-safe). Ignored when --offline is set. /// Closes F-PUBLISH-EXTRA-001::dogfood_ex05 (no Python in ex-05). @@ -342,10 +345,10 @@ pub enum Commands { Diff { /// First model file #[arg(value_name = "FILE1")] - file1: PathBuf, + file1: ModelPath, /// Second model file #[arg(value_name = "FILE2")] - file2: PathBuf, + file2: ModelPath, /// Show weight-level differences #[arg(long)] weights: bool, @@ -354,7 +357,7 @@ pub enum Commands { values: bool, /// Filter tensors by name pattern (for --values) #[arg(long)] - filter: Option, + filter: Option, /// Maximum number of tensors to compare (for --values) #[arg(long, default_value = "10")] limit: usize, @@ -381,13 +384,13 @@ pub enum Commands { Tensors { /// Path to .apr model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Show tensor statistics (mean, std, min, max) #[arg(long)] stats: bool, /// Filter tensors by name pattern #[arg(long)] - filter: Option, + filter: Option, /// Limit number of tensors shown (0 = unlimited) #[arg(long, default_value = "0")] limit: usize, @@ -399,13 +402,13 @@ pub enum Commands { Trace { /// Path to .apr model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Filter layers by name pattern #[arg(long)] - layer: Option, + layer: Option, /// Compare with reference model #[arg(long)] - reference: Option, + reference: Option, /// Output as JSON #[arg(long)] json: bool, @@ -428,22 +431,22 @@ pub enum Commands { /// stage. Output goes to `--save-tensor-dir` if provided, /// else `/trace-tensors//`. #[arg(long, value_name = "STAGES")] - save_tensor: Option, + save_tensor: Option, /// Output directory for `--save-tensor` (default: sibling /// `trace-tensors//`). #[arg(long, value_name = "DIR")] - save_tensor_dir: Option, + save_tensor_dir: Option, /// Layer-id range for `--save-tensor` (default: 0..1, i.e. /// layer 0 only). Format: `START..END` (Rust range syntax, /// END exclusive). #[arg(long, value_name = "RANGE", default_value = "0..1")] - save_tensor_layers: String, + save_tensor_layers: FreeText, }, /// Check for best practices and conventions Lint { /// Path to .apr model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Fail on warnings as well as errors. /// /// By default only ERROR-level findings fail the run. Every real model @@ -458,7 +461,7 @@ pub enum Commands { BeatRun { /// Path to a beat-benchmark contract YAML (e.g. contracts/beat-sklearn-iris-v1.yaml) #[arg(value_name = "CONTRACT")] - contract: PathBuf, + contract: ConfigPath, /// Measured metric value; when given, emit a WON/REGRESSED verdict and /// exit non-zero on regression. Omit to just report the pinned baseline. #[arg(long, value_name = "VALUE")] @@ -468,22 +471,22 @@ pub enum Commands { Manifest { /// Files to include in the manifest (one entry per file) #[arg(value_name = "FILES", num_args = 1..)] - files: Vec, + files: Vec, /// Output JSON manifest path #[arg(short, long, value_name = "MAN_JSON")] - output: PathBuf, + output: OutputPath, }, /// Explain errors, architecture, tensors, and kernel dispatch Explain { /// Error code, model file path, or family name (auto-detected) #[arg(value_name = "CODE_OR_FILE")] - code_or_file: Option, + code_or_file: Option, /// Path to .apr model file (optional context for --tensor) #[arg(short, long)] - file: Option, + file: Option, /// Explain a specific tensor #[arg(long)] - tensor: Option, + tensor: Option, /// Explain kernel dispatch pipeline for architecture #[arg(long)] kernel: bool, @@ -506,22 +509,22 @@ pub enum Commands { Export { /// Path to .apr model file #[arg(value_name = "FILE", required_unless_present = "list_formats")] - file: Option, + file: Option, /// Output format (safetensors, gguf, mlx, onnx, openvino, coreml) #[arg(long, default_value = "safetensors")] - format: String, + format: FreeText, /// Output file/directory path #[arg(short, long)] - output: Option, + output: Option, /// Apply quantization during export (int8, int4, fp16) #[arg(long)] - quantize: Option, + quantize: Option, /// List all supported export formats #[arg(long)] list_formats: bool, /// Batch export to multiple formats (comma-separated: gguf,mlx,safetensors) #[arg(long)] - batch: Option, + batch: Option, /// Output in JSON format #[arg(long)] json: bool, @@ -536,16 +539,16 @@ pub enum Commands { Import { /// Source: hf://org/repo, local file, or URL #[arg(value_name = "SOURCE")] - source: String, + source: ModelRef, /// Output .apr file path (default: derived from source name) #[arg(short, long)] - output: Option, + output: Option, /// Model architecture (whisper, llama, bert, qwen2, qwen3, gpt2, starcoder, gpt-neox, opt, phi, gemma, falcon, mamba, t5, auto) #[arg(long, default_value = "auto")] - arch: String, + arch: FreeText, /// Quantization (int8, int4, fp16) #[arg(long)] - quantize: Option, + quantize: Option, /// Strict mode: reject unverified architectures and fail on validation errors #[arg(long)] strict: bool, @@ -556,7 +559,7 @@ pub enum Commands { /// PMAT-232: External tokenizer.json for weights-only GGUF files. /// Required if the GGUF has no embedded tokenizer vocabulary. #[arg(long)] - tokenizer: Option, + tokenizer: Option, /// F-GT-001: Enforce provenance chain. Rejects pre-baked GGUF imports /// (only SafeTensors sources allowed). Ensures single-provenance testing. #[arg(long)] @@ -574,11 +577,11 @@ pub enum Commands { /// string "dataset", the next positional `repo` is the /// HuggingFace dataset repo and dataset-pull semantics apply. #[arg(value_name = "MODEL_OR_ASSET_TYPE")] - model_ref: String, + model_ref: ModelRef, /// Dataset repository (used only when model_ref == "dataset"). /// Per `apr-cli-pull-dataset-v1.yaml`. #[arg(value_name = "REPO")] - repo: Option, + repo: Option, /// Force re-download even if cached #[arg(long)] force: bool, @@ -600,7 +603,7 @@ pub enum Commands { /// CRUX-A-03: pin to a specific branch, tag, or git SHA on the remote /// (HuggingFace Hub). Defaults to "main" when omitted. #[arg(long, value_name = "REV")] - revision: Option, + revision: Option, /// CRUX-A-20: offline mode — forbid any outbound network I/O. /// Equivalent to APR_OFFLINE=1 or HF_HUB_OFFLINE=1 in the environment. #[arg(long)] @@ -609,11 +612,11 @@ pub enum Commands { /// multiple times; matches are unioned. fnmatch-compatible /// (`*`, `?`, `[a-z]`). No-match is fail-fast. #[arg(long, value_name = "GLOB")] - include: Vec, + include: Vec, /// (dataset mode) Output directory. Default: /// `~/.cache/aprender/datasets//`. #[arg(short = 'o', long)] - output: Option, + output: Option, }, /// Registry operations (CRUX-A-01): inspect alias map, etc. Registry { @@ -628,22 +631,22 @@ pub enum Commands { Rm { /// Model reference to remove #[arg(value_name = "MODEL")] - model_ref: String, + model_ref: ModelRef, }, /// Convert/optimize model Convert { /// Path to .apr model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Quantize to format (int8, int4, fp16, q4k) #[arg(long)] - quantize: Option, + quantize: Option, /// Compress output (none, zstd, zstd-max, lz4) #[arg(long)] - compress: Option, + compress: Option, /// Output file path #[arg(short, long)] - output: PathBuf, + output: OutputPath, /// Force overwrite existing files #[arg(short, long)] force: bool, @@ -657,16 +660,16 @@ pub enum Commands { Stamp { /// Path to input .apr model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// SPDX license identifier (e.g., Apache-2.0) #[arg(long)] - license: Option, + license: Option, /// Training-data source (e.g., huggingface.co/Qwen/Qwen2.5-Coder-7B-Instruct) #[arg(long = "data-source")] - data_source: Option, + data_source: Option, /// SPDX license for data_source (e.g., Apache-2.0) #[arg(long = "data-license")] - data_license: Option, + data_license: Option, /// HuggingFace class name (e.g., Qwen2ForCausalLM, LlamaForCausalLM). /// /// PMAT-690 P0-K extension (SPEC §86): patch the upstream @@ -674,12 +677,12 @@ pub enum Commands { /// consumers (apr inspect --quality, apr pretrain --init, /// apr export → llama-cli) see the correct HF identity. #[arg(long = "hf-architecture")] - hf_architecture: Option, + hf_architecture: Option, /// HuggingFace model_type slug (e.g., qwen2, llama). /// /// PMAT-690 P0-K extension (SPEC §86). #[arg(long = "hf-model-type")] - hf_model_type: Option, + hf_model_type: Option, /// Lowercase architecture family slug (e.g., qwen2, llama). /// /// PMAT-690 P0-K extension (SPEC §86). This is the field @@ -687,7 +690,7 @@ pub enum Commands { /// patching it, pre-P0-K checkpoints with the P0-H "LlamaForCausalLM" /// fallback in this field cannot be loaded as Qwen2 inits. #[arg(long)] - architecture: Option, + architecture: Option, /// Directory containing tokenizer files (vocab.json + merges.txt /// OR tokenizer.json). When provided, embeds the vocabulary + /// BPE merges into the APR's `custom.tokenizer.vocabulary` / @@ -700,10 +703,10 @@ pub enum Commands { /// the tokenizer post-hoc so the artifact is self-contained /// for inference (the apr binary's headline use case). #[arg(long = "tokenizer", value_name = "DIR")] - tokenizer_dir: Option, + tokenizer_dir: Option, /// Output file path #[arg(short, long)] - output: PathBuf, + output: OutputPath, /// Force overwrite existing files #[arg(short, long)] force: bool, @@ -712,16 +715,16 @@ pub enum Commands { Compile { /// Input .apr model file #[arg(value_name = "FILE", required_unless_present = "list_targets")] - file: Option, + file: Option, /// Output binary path (default: derived from model name) #[arg(short, long)] - output: Option, + output: Option, /// Target triple (e.g., x86_64-unknown-linux-musl) #[arg(long)] - target: Option, + target: Option, /// Quantize weights before embedding (int8, int4, fp16) #[arg(long)] - quantize: Option, + quantize: Option, /// Release mode (optimized) #[arg(long)] release: bool, @@ -739,19 +742,19 @@ pub enum Commands { Merge { /// Model files to merge #[arg(value_name = "FILES", num_args = 2..)] - files: Vec, + files: Vec, /// Merge strategy (average, weighted, slerp, ties, dare) #[arg(long, default_value = "average")] - strategy: String, + strategy: FreeText, /// Output file path (optional in --plan mode) #[arg(short, long, required_unless_present = "plan")] - output: Option, + output: Option, /// Weights for weighted merge (comma-separated, e.g., "0.7,0.3") #[arg(long, value_delimiter = ',')] weights: Option>, /// Base model for TIES/DARE (task vectors computed as delta from base) #[arg(long)] - base_model: Option, + base_model: Option, /// DARE drop probability (default: 0.9) #[arg(long, default_value = "0.9")] drop_rate: f32, @@ -772,19 +775,19 @@ pub enum Commands { Quantize { /// Input model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Quantization scheme: int8, int4, fp16, q4k #[arg(long, short = 's', default_value = "int4")] - scheme: String, + scheme: FreeText, /// Output file path (required unless --plan) #[arg(short, long)] - output: Option, + output: Option, /// Output format override (apr, gguf, safetensors) #[arg(long)] - format: Option, + format: Option, /// Batch quantization (comma-separated schemes) #[arg(long)] - batch: Option, + batch: Option, /// Plan mode (estimate only, no execution) #[arg(long)] plan: bool, @@ -799,18 +802,21 @@ pub enum Commands { /// /// Exposes `apr` as MCP tools for Claude Code, Cursor, Cline, and other /// MCP clients. Configure via `.mcp.json` with `{"command":"apr","args":["mcp"]}`. + // #3745 S1 v1.1: prompts arrive over stdio JSON-RPC (the apr.run / apr.serve tools), not through a PromptText arg, + // so the generator is declared by marker (`apr surface` → `generates: true`). + #[command(group(batuta_common::cli_roles::ServesGeneration::group()))] Mcp {}, /// Interactive terminal UI Tui { /// Path to .apr model file #[arg(value_name = "FILE")] - file: Option, + file: Option, }, /// Model self-test: 10-stage pipeline integrity check (APR-TRACE-001) Check { /// Path to model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Disable GPU acceleration #[arg(long)] no_gpu: bool, @@ -830,19 +836,19 @@ pub enum Commands { Code { /// Path to local GGUF/APR model file (prefers .apr format) #[arg(long)] - model: Option, + model: Option, /// Project directory (loads APR.md/CLAUDE.md from this path) #[arg(long, default_value = ".")] - project: PathBuf, + project: DirPath, /// Resume previous session (optionally by ID) #[arg(long)] - resume: Option>, + resume: Option>, /// Agent manifest (advanced — overrides defaults) #[arg(long)] - manifest: Option, + manifest: Option, /// Initial prompt (non-interactive: print response and exit) #[arg(short, long)] @@ -856,7 +862,7 @@ pub enum Commands { /// — a wrong-model execution with no diagnostic — and typo'd flags /// produced no parse error at all. Options are now parsed in any /// position; a prompt that genuinely starts with `-` needs `--`. - prompt: Vec, + prompt: Vec, /// Max turns before stopping #[arg(long, default_value = "50")] @@ -868,7 +874,7 @@ pub enum Commands { /// (`§ trace_schema`). Used by `ccpa measure` to score apr-code /// against canonical Claude Code reference fixtures. #[arg(long)] - emit_trace: Option, + emit_trace: Option, /// Output format for non-interactive (`-p`) mode (PMAT-CODE-OUTPUT-FORMAT-001). /// `text` (default): plain assistant text. @@ -940,6 +946,21 @@ pub enum Commands { #[command(subcommand)] Cgp(cgp::cli::Commands), + /// Print apr's own command surface as JSON (`apr-cli-surface/v1`, #3745). + /// + /// Hidden: it is a gate input, not a user command. Every subcommand path and + /// every argument, with each argument's role read from the type that built + /// it. Release cells are derived from this output rather than from a list + /// someone typed. + #[command(hide = true)] + Surface, + + /// PTX falsification analysis and FKR test generation: analyze, gen-fkr + /// (was the `aprender-ptx-debug` binary; pure Rust, so unlike `apr ptx` + /// it needs no build feature) + #[command(subcommand, name = "ptx-debug")] + PtxDebug(trueno_ptx_debug::cli::Command), + /// Provable-contracts: validate, lint, score, kani, proof-status /// (the `pv` binary keeps shipping under its own name; this is the /// in-apr route to the same commands) @@ -963,10 +984,10 @@ pub enum DebugCommands { EmbedViz { /// Model file holding the embedding table #[arg(long, value_name = "FILE")] - model: PathBuf, + model: ModelPath, /// Embedding tensor name (default: auto-detect the known names) #[arg(long, value_name = "NAME")] - tensor: Option, + tensor: Option, /// Projection method: exact `pca`, seeded `random`, or `umap` (refused) #[arg(long, value_enum, default_value_t = EmbedProjection::Pca)] projection: EmbedProjection, @@ -979,10 +1000,10 @@ pub enum DebugCommands { /// Token text, one per line, for the `token_str` column. Without it apr /// reads the GGUF vocabulary, or writes `` #[arg(long, value_name = "FILE")] - tokens: Option, + tokens: Option, /// Write the CSV here instead of stdout #[arg(short, long, value_name = "FILE")] - output: Option, + output: Option, /// Overwrite an existing --output file (refused without it) #[arg(short, long)] force: bool, diff --git a/crates/apr-cli/src/data_commands.rs b/crates/apr-cli/src/data_commands.rs index e09cabcb16..e847c9587d 100644 --- a/crates/apr-cli/src/data_commands.rs +++ b/crates/apr-cli/src/data_commands.rs @@ -54,25 +54,25 @@ pub enum DataCommands { Audit { /// Path to JSONL data file #[arg(value_name = "FILE")] - file: PathBuf, + file: InputFile, /// Number of output classes (for label range validation) #[arg(long, default_value = "5")] num_classes: usize, /// Input text column name #[arg(long, default_value = "input")] - input_column: String, + input_column: FreeText, /// Label column name #[arg(long, default_value = "label")] - label_column: String, + label_column: FreeText, /// Preamble prefix to detect (e.g., "#!/") #[arg(long, default_value = "#!/")] - preamble_prefix: Option, + preamble_prefix: Option, }, /// Stratified train/val/test split preserving class proportions Split { /// Path to JSONL data file #[arg(value_name = "FILE")] - file: PathBuf, + file: InputFile, /// Training set fraction #[arg(long, default_value = "0.8")] train: f64, @@ -84,22 +84,22 @@ pub enum DataCommands { test: f64, /// Label column name for stratification #[arg(long, default_value = "label")] - label_column: String, + label_column: FreeText, /// Random seed for deterministic split #[arg(long, default_value = "42")] seed: u64, /// Output directory for split files #[arg(short, long)] - output: PathBuf, + output: OutputPath, }, /// Check training data for benchmark contamination via n-gram overlap Decontaminate { /// Path to training JSONL data file #[arg(value_name = "FILE")] - file: PathBuf, + file: InputFile, /// Reference benchmark JSONL files to check against #[arg(long, required = true, num_args = 1..)] - reference: Vec, + reference: Vec, /// N-gram size for overlap detection (must be >= 1) #[arg(long, default_value = "10", value_parser = parse_ngram_size)] ngram: usize, @@ -114,10 +114,10 @@ pub enum DataCommands { Dedup { /// Path to JSONL data file #[arg(value_name = "FILE")] - file: PathBuf, + file: InputFile, /// Output file path for the deduplicated dataset #[arg(short, long)] - output: PathBuf, + output: OutputPath, /// Output as JSON #[arg(long)] json: bool, @@ -126,13 +126,13 @@ pub enum DataCommands { Balance { /// Path to JSONL data file #[arg(value_name = "FILE")] - file: PathBuf, + file: InputFile, /// Rebalancing strategy: oversample, undersample, sqrt-inverse #[arg(long, default_value = "oversample")] - strategy: String, + strategy: FreeText, /// Label column name #[arg(long, default_value = "label")] - label_column: String, + label_column: FreeText, /// Number of classes (for sqrt-inverse weight computation) #[arg(long)] num_classes: Option, @@ -141,6 +141,6 @@ pub enum DataCommands { seed: u64, /// Output file path (required for oversample/undersample) #[arg(short, long)] - output: Option, + output: Option, }, } diff --git a/crates/apr-cli/src/dispatch.rs b/crates/apr-cli/src/dispatch.rs index 22edb34697..2487a5bad9 100644 --- a/crates/apr-cli/src/dispatch.rs +++ b/crates/apr-cli/src/dispatch.rs @@ -5,6 +5,11 @@ fn dispatch_core_command(cli: &Cli) -> Option> { contract_pre_side_effect_classification!(); contract_pre_dispatch_completeness!(); contract_pre_output_format_fidelity!(); + // #3745 S1: the gate input, answered before any command that loads a model. + if matches!(cli.command.as_ref(), Commands::Surface) { + return Some(crate::surface::run()); + } + // Try runtime commands first (check, run, serve) if let Some(result) = dispatch_runtime_commands(cli) { return Some(result); @@ -53,7 +58,7 @@ fn dispatch_core_command(cli: &Cli) -> Option> { /// library: `apr rag` -> `aprender_rag_cli`, `apr zram` -> `aprender_zram_cli`. /// /// Each arm calls the SAME `dispatch` function the standalone binary calls, so -/// `apr rag index` and `trueno-rag index` cannot drift apart. Before this, both +/// `apr rag index` and `aprender-rag index` cannot drift apart. Before this, both /// command enums lived in a `main.rs`, which is importable by nothing -- the /// separate binary was the only way to reach any of it. fn dispatch_sibling_cli_commands(cli: &Cli) -> Option> { @@ -91,6 +96,9 @@ fn dispatch_sibling_cli_commands(cli: &Cli) -> Option> { cgp::cli::dispatch(command.clone(), cli.json) .map_err(|e| CliError::ValidationFailed(format!("cgp: {e}"))), ), + Commands::PtxDebug(command) => Some(ptx_debug_result( + trueno_ptx_debug::run::run(command.clone()), + )), Commands::Pv(command) => Some( aprender_contracts_cli::dispatch(command.clone()) .map_err(|e| CliError::ValidationFailed(format!("pv: {e}"))), @@ -105,6 +113,24 @@ fn dispatch_sibling_cli_commands(cli: &Cli) -> Option> { /// signatures that #3606 is changing at the same time; the flag is still a flag to the user, and /// the guard prints `--revalidate` as its reason when it fires. (Extracted from /// `dispatch_runtime_commands` unchanged, to keep it under the complexity ratchet.) +/// Map an `apr ptx-debug` outcome onto apr's error type (#4062). +/// +/// The standalone binary exits 1/2/3 for its analysis verdict (score < 90, +/// score < `--min-score`, critical bugs). apr has one exit-code convention, so +/// every non-zero verdict becomes a validation failure (exit 5) whose message +/// names the verdict — a failing analysis must never exit 0. +fn ptx_debug_result(outcome: Result) -> Result<(), CliError> { + let verdict = match outcome { + Ok(0) => return Ok(()), + Ok(1) => "score below 90", + Ok(2) => "score below --min-score", + Ok(3) => "critical bugs present", + Ok(_) => "analysis failed", + Err(e) => return Err(CliError::ValidationFailed(format!("ptx-debug: {e}"))), + }; + Err(CliError::ValidationFailed(format!("ptx-debug: {verdict}"))) +} + fn request_f2_revalidate(revalidate: bool) { if revalidate { // SAFETY-BY-ORDER: set before any inference thread exists; the @@ -175,10 +201,10 @@ fn check_run_backend(backend: Option<&str>) -> Result<(), CliError> { `cuda` feature, so the CUDA backend does not exist in this binary. \ Refusing to silently fall back to wgpu/CPU: that path is ~20x slower \ (~20 tok/s vs ~400) and makes any throughput measurement taken through it \ -meaningless. Rebuild the ROOT facade with CUDA: `cargo build --release \ ---features cuda` (build the root, not `-p apr-cli`: BOTH packages define a \ -binary named `apr`, and only the root's cuda = [\"cli\", \"apr-cli/cuda\"] \ -chain enables this path). To run on this build anyway, pass `--backend cpu` \ +meaningless. Rebuild with CUDA: `cargo build --release --features cuda` \ +(the root facade) or `cargo build --release -p apr-cli --features cuda` \ +(what the nightly CUDA asset builds). Both `apr` binaries run the same \ +`apr_cli::cli_main`. To run on this build anyway, pass `--backend cpu` \ or drop `--backend`." .to_string(), )); @@ -307,11 +333,12 @@ fn dispatch_runtime_commands(cli: &Cli) -> Option> { // GH-240: merge global --json flag into output format let effective_format = if cli.json { "json" } else { format.as_str() }; + let trace_steps = trace_steps.as_deref().map(batuta_common::cli_roles::strings); dispatch_run( source, - positional_prompt.as_ref(), + positional_prompt.as_ref().map(PromptText::as_string), input.as_deref(), - prompt.as_ref(), + prompt.as_ref().map(PromptText::as_string), *max_tokens, *stream, language.as_deref(), @@ -326,7 +353,7 @@ fn dispatch_runtime_commands(cli: &Cli) -> Option> { *trace_payload, trace_steps.as_deref(), *trace_verbose, - trace_output.clone(), + trace_output.as_deref().map(Path::to_path_buf), trace_level.as_str(), *profile, *chat, @@ -361,14 +388,14 @@ fn dispatch_runtime_commands(cli: &Cli) -> Option> { max_tokens, thinking, } => dispatch_code_command(CodeArgs { - model, + model: &model.as_deref().map(Path::to_path_buf), project, - resume, - prompt, + resume: &resume.as_ref().map(|r| r.as_ref().map(FreeText::to_string)), + prompt: &batuta_common::cli_roles::strings(prompt), print: *print, max_turns: *max_turns, - manifest, - emit_trace, + manifest: &manifest.as_deref().map(Path::to_path_buf), + emit_trace: &emit_trace.as_deref().map(Path::to_path_buf), output_format: *output_format, input_format: *input_format, no_gpu: *no_gpu, @@ -514,13 +541,13 @@ fn dispatch_debug( }) = action { return commands::embed_viz::run(&commands::embed_viz::EmbedVizArgs { - model: model.clone(), - tensor: tensor.clone(), + model: model.to_path_buf(), + tensor: tensor.as_deref().map(str::to_string), projection: *projection, seed: *seed, limit: *limit, - tokens: tokens.clone(), - output: output.clone(), + tokens: tokens.as_deref().map(Path::to_path_buf), + output: output.as_deref().map(Path::to_path_buf), force: *force, }); } @@ -600,7 +627,7 @@ fn dispatch_inspection_commands(cli: &Cli) -> Option> { Commands::Manifest { files, output } => { // CRUX-G-05 — SHA-256 manifest of the input file set. - commands::manifest::run(files, output, cli.json) + commands::manifest::run(&batuta_common::cli_roles::path_bufs(files), output, cli.json) } Commands::Explain { code_or_file, @@ -611,8 +638,8 @@ fn dispatch_inspection_commands(cli: &Cli) -> Option> { verbose, proof_status, } => explain::run( - code_or_file.clone(), - file.clone(), + code_or_file.as_deref().map(str::to_string), + file.as_deref().map(Path::to_path_buf), tensor.as_deref(), *kernel, *json || cli.json, @@ -859,7 +886,7 @@ fn dispatch_format_commands(cli: &Cli) -> Option> { quantize.as_deref(), *strict, *preserve_q4k, - tokenizer.as_ref(), + tokenizer.as_deref().map(Path::to_path_buf).as_ref(), *enforce_provenance, *allow_no_config, cli.json, @@ -992,7 +1019,7 @@ fn dispatch_model_commands(cli: &Cli) -> Option> { strategy, output.as_deref(), weights.clone(), - base_model.clone(), + base_model.as_deref().map(Path::to_path_buf), *drop_rate, *density, *seed, @@ -1067,7 +1094,7 @@ fn dispatch_model_commands(cli: &Cli) -> Option> { coordinator.as_deref(), *expect_workers, *wait_gpu, - adapters, + &batuta_common::cli_roles::strings(adapters), adapters_config.as_deref(), cli.json, *experimental_mps, @@ -1157,7 +1184,7 @@ fn dispatch_model_commands(cli: &Cli) -> Option> { // full downloads in violation of the contract. Some(r) => pull::run_dataset( r, - include, + &batuta_common::cli_roles::strings(include), revision.as_deref(), output.as_deref(), *dry_run, @@ -1185,7 +1212,7 @@ fn dispatch_model_commands(cli: &Cli) -> Option> { Commands::Registry { command } => crate::commands::registry::run(command.clone()), Commands::List => pull::list(cli.json, cli.quiet), Commands::Rm { model_ref } => pull::remove(model_ref, cli.json), - Commands::Tui { file } => tui::run(file.clone()), + Commands::Tui { file } => tui::run(file.as_deref().map(Path::to_path_buf)), Commands::Mcp {} => mcp::run(), _ => return None, diff --git a/crates/apr-cli/src/dispatch_analysis.rs b/crates/apr-cli/src/dispatch_analysis.rs index bee51a541f..9f322e82cc 100644 --- a/crates/apr-cli/src/dispatch_analysis.rs +++ b/crates/apr-cli/src/dispatch_analysis.rs @@ -381,7 +381,7 @@ fn dispatch_analysis_commands(cli: &Cli) -> Option> { /// Run one alimentar command through `alimentar::cli::dispatch` -- the SAME /// function the standalone binary calls, so `apr data x ` and -/// `alimentar ` cannot drift. +/// `aprender-data ` cannot drift. /// /// The command is re-parsed from argv rather than moved out of the parsed value: /// the apr dispatch chain takes `&Cli`, and alimentar's arg types are not all @@ -408,7 +408,7 @@ fn dispatch_alimentar_passthrough( Ok(()) } else { Err(CliError::ValidationFailed( - "alimentar command failed".to_string(), + "aprender-data command failed".to_string(), )) } } @@ -461,7 +461,13 @@ fn dispatch_data_command(command: &DataCommands, json: bool) -> std::result::Res ngram, threshold, json: json_flag, - } => data::run_decontaminate(file, reference, *ngram, *threshold, *json_flag || json), + } => data::run_decontaminate( + file, + &batuta_common::cli_roles::path_bufs(reference), + *ngram, + *threshold, + *json_flag || json, + ), DataCommands::Dedup { file, output, @@ -683,7 +689,9 @@ fn dispatch_analysis_commands_rest(cli: &Cli) -> Option> { *timeout, *json || cli.json, *verbose || cli.verbose, - skip.as_deref(), + skip.as_deref() + .map(batuta_common::cli_roles::strings) + .as_deref(), ) }), @@ -699,9 +707,9 @@ fn dispatch_analysis_commands_rest(cli: &Cli) -> Option> { validate, full, }) => oracle::run( - source.as_ref(), - family.as_ref(), - size.as_ref(), + source.as_ref().map(ModelRef::as_string), + family.as_ref().map(FreeText::as_string), + size.as_ref().map(FreeText::as_string), *compliance, *tensors, cli.json, @@ -897,20 +905,37 @@ fn dispatch_runs_command(command: &RunsCommands, cli: &Cli) -> std::result::Resu status, json, limit, - } => commands::runs::run_ls(dir, *global, status, *json || cli.json, *limit), + } => commands::runs::run_ls( + &dir.as_deref().map(Path::to_path_buf), + *global, + status, + *json || cli.json, + *limit, + ), RunsCommands::Show { run_id, dir, global, json, - } => commands::runs::run_show(run_id, dir, *global, *json || cli.json), + } => commands::runs::run_show( + run_id, + &dir.as_deref().map(Path::to_path_buf), + *global, + *json || cli.json, + ), RunsCommands::Diff { run_a, run_b, dir, global, json, - } => commands::runs::run_diff(run_a, run_b, dir, *global, *json || cli.json), + } => commands::runs::run_diff( + run_a, + run_b, + &dir.as_deref().map(Path::to_path_buf), + *global, + *json || cli.json, + ), } } @@ -921,9 +946,11 @@ fn dispatch_experiment_command( cli: &Cli, ) -> std::result::Result<(), CliError> { match command { - ExperimentCommands::View { db, global, json } => { - commands::experiment::experiment_view(db, *global, *json || cli.json) - } + ExperimentCommands::View { db, global, json } => commands::experiment::experiment_view( + &db.as_deref().map(Path::to_path_buf), + *global, + *json || cli.json, + ), } } @@ -1088,7 +1115,14 @@ fn dispatch_train_command(command: &TrainCommands, cli: &Cli) -> std::result::Re budget_mb, dry_run, } => train::run_submit( - cluster, model, adapters, *rank, *epochs, *budget_mb, *dry_run, cli.json, + cluster, + model, + &batuta_common::cli_roles::strings(adapters), + *rank, + *epochs, + *budget_mb, + *dry_run, + cli.json, ), TrainCommands::ClusterStatus { cluster } => train::run_cluster_status(cluster, cli.json), } @@ -1160,7 +1194,7 @@ fn dispatch_tokenize_command( estimate_only, estimate_sample_docs, } => tokenize::run_encode_corpus( - corpus.as_slice(), + batuta_common::cli_roles::path_bufs(corpus).as_slice(), tokenizer, output, *shard_tokens, @@ -1495,14 +1529,14 @@ fn dispatch_profiling_commands(cli: &Cli) -> Option> { *skip_contract, *skip_format_parity, *skip_ptx_parity, - safetensors_path.clone(), + safetensors_path.as_deref().map(Path::to_path_buf), *iterations, *warmup, *max_tokens, *json || cli.json, *verbose || cli.verbose, *min_executed, - previous_report.clone(), + previous_report.as_deref().map(Path::to_path_buf), *regression_threshold, *skip_gpu_state, *skip_metadata, @@ -1694,9 +1728,12 @@ fn dispatch_extended_command(cli: &Cli) -> Result<(), CliError> { effective_no_gpu, run_accelerator_forced(*gpu, *no_gpu, backend.as_deref()), *trace, - trace_steps.as_deref(), + trace_steps + .as_deref() + .map(batuta_common::cli_roles::strings) + .as_deref(), *trace_verbose, - trace_output.clone(), + trace_output.as_deref().map(Path::to_path_buf), trace_level.as_str(), *profile, cli.offline, @@ -1753,7 +1790,10 @@ fn dispatch_extended_command(cli: &Cli) -> Result<(), CliError> { license, pipeline_tag, library_name.as_deref(), - tags.as_ref().map_or(&[], std::vec::Vec::as_slice), + &tags + .as_deref() + .map(batuta_common::cli_roles::strings) + .unwrap_or_default(), message.as_deref(), *dry_run || *plan, cli.verbose, @@ -1805,7 +1845,7 @@ fn dispatch_extended_command(cli: &Cli) -> Result<(), CliError> { token_type_ids.as_deref(), query.as_deref(), passage.as_deref(), - passages, + &batuta_common::cli_roles::strings(passages), *sort, *top_k, vocab.as_deref(), @@ -1839,7 +1879,7 @@ fn dispatch_extended_command(cli: &Cli) -> Result<(), CliError> { json, } => commands::embed::run( model, - text, + &batuta_common::cli_roles::strings(text), text_file.as_deref(), vocab, pool, diff --git a/crates/apr-cli/src/dispatch_run.rs b/crates/apr-cli/src/dispatch_run.rs index 92fc146005..dfa2ccec3a 100644 --- a/crates/apr-cli/src/dispatch_run.rs +++ b/crates/apr-cli/src/dispatch_run.rs @@ -217,14 +217,14 @@ fn dispatch_serve_command(command: &ServeCommands, cli: &Cli) -> Result<(), CliE *no_metrics, *no_gpu, *gpu, - gpu_layers, + &gpu_layers.as_deref().map(str::to_string), *batch, *trace, trace_level, *profile, cli.verbose, backend, - otlp_endpoint, + &otlp_endpoint.as_deref().map(str::to_string), *context_length, *no_fp8_cache, *ollama_compat, @@ -301,7 +301,12 @@ fn dispatch_rosetta(action: &RosettaCommands, global_json: bool) -> Result<(), C formats, work_dir, json, - } => rosetta::run_chain(source, formats, work_dir, *json || global_json), + } => rosetta::run_chain( + source, + &batuta_common::cli_roles::strings(formats), + work_dir, + *json || global_json, + ), RosettaCommands::Verify { source, intermediate, @@ -349,8 +354,8 @@ fn dispatch_rosetta(action: &RosettaCommands, global_json: bool) -> Result<(), C json, } => rosetta::run_fingerprint( model, - model_b.as_ref().map(std::path::PathBuf::as_path), - output.as_ref().map(std::path::PathBuf::as_path), + model_b.as_deref(), + output.as_deref(), filter.as_deref(), *verbose, *json || global_json, @@ -364,8 +369,8 @@ fn dispatch_rosetta(action: &RosettaCommands, global_json: bool) -> Result<(), C json, } => rosetta::run_validate_stats( model, - reference.as_ref().map(std::path::PathBuf::as_path), - fingerprints.as_ref().map(std::path::PathBuf::as_path), + reference.as_deref(), + fingerprints.as_deref(), *threshold, *strict, *json || global_json, diff --git a/crates/apr-cli/src/extended_commands.rs b/crates/apr-cli/src/extended_commands.rs index 3b45813be0..4567bc6d8f 100644 --- a/crates/apr-cli/src/extended_commands.rs +++ b/crates/apr-cli/src/extended_commands.rs @@ -37,22 +37,26 @@ pub enum EmbedProjection { #[derive(Subcommand, Debug)] pub enum ExtendedCommands { /// Interactive chat with language model + // #3745 S1 v1.1: prompts arrive over stdin (its only PromptText is --system), not through a PromptText arg, + // so the generator is declared by marker (`apr surface` → `generates: true`). + #[command(group(batuta_common::cli_roles::ServesGeneration::group()))] + #[command(groups(batuta_common::cli_roles::SamplingArg::groups()))] Chat { /// Path to .apr model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Sampling temperature (0 = greedy, higher = more random) - #[arg(long, default_value = "0.7")] + #[arg(long, default_value = "0.7", group = batuta_common::cli_roles::SamplingKind::Temperature.id())] temperature: f32, /// Nucleus sampling threshold - #[arg(long, default_value = "0.9")] + #[arg(long, default_value = "0.9", group = batuta_common::cli_roles::SamplingKind::TopP.id())] top_p: f32, /// Maximum tokens to generate per response #[arg(long, default_value = "512")] max_tokens: usize, /// System prompt to set model behavior #[arg(long)] - system: Option, + system: Option, /// Show inspection info (top-k probs, tokens/sec) #[arg(long)] inspect: bool, @@ -67,13 +71,13 @@ pub enum ExtendedCommands { trace: bool, /// Trace specific steps only (comma-separated) #[arg(long, value_delimiter = ',')] - trace_steps: Option>, + trace_steps: Option>, /// Verbose tracing #[arg(long)] trace_verbose: bool, /// Save trace output to JSON file #[arg(long, value_name = "FILE")] - trace_output: Option, + trace_output: Option, /// Trace detail level (none, basic, layer, payload) #[arg(long, value_name = "LEVEL", default_value = "basic", value_parser = TRACE_LEVEL_VALUES)] trace_level: String, @@ -91,7 +95,7 @@ pub enum ExtendedCommands { Bench { /// Path to model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Number of warmup iterations #[arg(long, default_value = "3")] warmup: usize, @@ -103,13 +107,13 @@ pub enum ExtendedCommands { max_tokens: usize, /// Test prompt #[arg(long)] - prompt: Option, + prompt: Option, /// Use realizar for fast inference (vs aprender baseline) #[arg(long)] fast: bool, /// Benchmark specific brick #[arg(long)] - brick: Option, + brick: Option, /// Comma-separated latency percentile points for JSON output /// (CRUX-E-07). Default: `50,95,99`. Values must be in (0, 100]. #[arg( @@ -124,13 +128,13 @@ pub enum ExtendedCommands { Eval { /// Path to model file or checkpoint directory #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Dataset: wikitext-2, lambada, or custom #[arg(long, default_value = "wikitext-2")] - dataset: String, + dataset: FreeText, /// Custom text (when dataset=custom) #[arg(long)] - text: Option, + text: Option, /// Maximum tokens to evaluate #[arg(long, default_value = "512")] max_tokens: usize, @@ -140,13 +144,13 @@ pub enum ExtendedCommands { threshold: f32, /// Task type: omit for perplexity, "classify" for classification eval #[arg(long)] - task: Option, + task: Option, /// Test data file (JSONL) for classification evaluation #[arg(long, value_name = "FILE")] - data: Option, + data: Option, /// Model size hint: "0.5B", "tiny" (for classification eval) #[arg(long)] - model_size: Option, + model_size: Option, /// Number of output classes (default: 5) #[arg(long, default_value = "5")] num_classes: usize, @@ -168,16 +172,16 @@ pub enum ExtendedCommands { Profile { /// Path to model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Layer-by-layer granular analysis #[arg(long)] granular: bool, /// Output format (human, json, flamegraph) #[arg(long, default_value = "human")] - format: String, + format: FreeText, /// Focus on specific operation #[arg(long)] - focus: Option, + focus: Option, /// Detect naive implementations #[arg(long)] detect_naive: bool, @@ -187,7 +191,7 @@ pub enum ExtendedCommands { threshold: f64, /// [NOT IMPLEMENTED — accepted and ignored] Compare against HuggingFace baseline #[arg(long)] - compare_hf: Option, + compare_hf: Option, /// [NOT IMPLEMENTED — accepted and ignored] Measure energy consumption (requires RAPL) #[arg(long)] energy: bool, @@ -202,7 +206,7 @@ pub enum ExtendedCommands { fail_on_naive: bool, /// Output file path for flamegraph SVG (GH-174, PMAT-182) #[arg(long, short = 'o')] - output: Option, + output: Option, // PMAT-192: CI Assertion Mode (GH-180) /// Enable CI mode with assertion checks (exits 1 on failure) @@ -235,13 +239,13 @@ pub enum ExtendedCommands { no_gpu: bool, /// Compare against another model format (F-PROFILE-011) #[arg(long, value_name = "FILE")] - compare: Option, + compare: Option, }, /// Falsifiable QA checklist for model releases Qa { /// Path to model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Minimum throughput threshold in tok/s #[arg(long, value_name = "TPS", value_parser = commands::threshold_arg::parse_tolerance)] @@ -277,7 +281,7 @@ pub enum ExtendedCommands { skip_ptx_parity: bool, /// SafeTensors model path for cross-format parity test (F-QUAL-032) #[arg(long, value_name = "PATH")] - safetensors_path: Option, + safetensors_path: Option, /// Number of benchmark iterations #[arg(long, default_value = "10")] iterations: usize, @@ -298,7 +302,7 @@ pub enum ExtendedCommands { min_executed: Option, /// Previous QA report for regression detection #[arg(long, value_name = "FILE")] - previous_report: Option, + previous_report: Option, /// Maximum allowed performance regression ratio (default: 0.10 = 10%) #[arg(long, value_name = "RATIO", value_parser = commands::threshold_arg::parse_fraction)] @@ -320,10 +324,10 @@ pub enum ExtendedCommands { Parity { /// Path to GGUF model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Prompt text (default: "What is 2+2?") #[arg(short, long, default_value = "What is 2+2?")] - prompt: String, + prompt: PromptText, /// Assert parity (exit non-zero on divergence) #[arg(long)] assert: bool, @@ -333,7 +337,7 @@ pub enum ExtendedCommands { per_op: bool, /// Where --per-op writes the two APRT trees (default: a temp dir) #[arg(long, value_name = "DIR")] - out: Option, + out: Option, /// Cosine threshold for --per-op (basis: evidence/parity/thresholds.yaml) #[arg(long, default_value_t = 0.98)] threshold: f32, @@ -343,13 +347,13 @@ pub enum ExtendedCommands { PtxMap { /// Path to GGUF model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Filter to specific kernel (e.g., --kernel Q4KGemv) #[arg(long)] - kernel: Option, + kernel: Option, /// Reverse lookup: kernel name -> which layers/steps use it #[arg(long)] - reverse: Option, + reverse: Option, /// Output as JSON #[arg(long)] json: bool, @@ -369,16 +373,16 @@ pub enum ExtendedCommands { not(feature = "trueno-explain"), command( name = "ptx", - about = "PTX analysis and bug detection [unavailable in this build: cargo install aprender --features ptx]" + about = "PTX analysis and bug detection [unavailable in this build: cargo install aprender --features ptx; `apr ptx-debug` is always built]" ) )] Ptx { /// Path to a PTX source file #[arg(value_name = "FILE")] - file: Option, + file: Option, /// Analyze a named kernel from trueno-gpu #[arg(long, short)] - kernel: Option, + kernel: Option, /// Strict mode (no performance whitelist) #[arg(long)] strict: bool, @@ -397,10 +401,10 @@ pub enum ExtendedCommands { Tune { /// Path to model file (optional if using --model) #[arg(value_name = "FILE")] - file: Option, + file: Option, /// Tuning method: auto, full, lora, qlora #[arg(long, short = 'm', default_value = "auto")] - method: String, + method: FreeText, /// LoRA rank (default: auto-selected) #[arg(long, short = 'r')] rank: Option, @@ -412,56 +416,56 @@ pub enum ExtendedCommands { plan: bool, /// Model size for planning (e.g., "7B", "1.5B") #[arg(long, value_name = "SIZE")] - model: Option, + model: Option, /// Freeze base model weights #[arg(long)] freeze_base: bool, /// Training data file (JSONL format) #[arg(long, value_name = "FILE")] - train_data: Option, + train_data: Option, /// Output as JSON (for CI integration) #[arg(long)] json: bool, /// Task type for HPO: classify (SPEC-TUNE-2026-001) #[arg(long)] - task: Option, + task: Option, /// Number of HPO trials (default: 10) #[arg(long, default_value = "10")] budget: usize, /// HPO search strategy: tpe, grid, random #[arg(long, default_value = "tpe")] - strategy: String, + strategy: FreeText, /// HPO scheduler: asha, median, none #[arg(long, default_value = "asha")] - scheduler: String, + scheduler: FreeText, /// Scout mode: 1 epoch per trial for fast exploration #[arg(long)] scout: bool, /// Training data file for HPO (JSONL format) #[arg(long, value_name = "FILE")] - data: Option, + data: Option, /// Number of output classes for classification #[arg(long, default_value = "5")] num_classes: usize, /// Model size hint for HPO (e.g., "0.5B", "1.5B") #[arg(long)] - model_size: Option, + model_size: Option, /// Warm-start from scout phase results directory #[arg(long, value_name = "DIR")] - from_scout: Option, + from_scout: Option, /// Maximum epochs per trial (full mode, default: 20) #[arg(long, default_value = "20")] max_epochs: usize, /// Maximum wall-clock time (e.g., "8h", "30m") #[arg(long)] - time_limit: Option, + time_limit: Option, }, /// Attach live TUI to a running training session #[cfg(feature = "training")] Monitor { /// Experiment output directory (same as finetune -o) #[arg(value_name = "DIR")] - dir: Option, + dir: Option, /// Refresh interval in milliseconds #[arg(long, default_value = "500")] refresh_ms: u64, @@ -473,7 +477,7 @@ pub enum ExtendedCommands { json: bool, /// Output format: tui (default), json, text #[arg(long, default_value = "tui")] - format: String, + format: FreeText, }, /// List, show, and compare training experiment runs #[cfg(feature = "training")] @@ -491,13 +495,13 @@ pub enum ExtendedCommands { Cbtop { /// Model name (e.g., qwen2.5-coder-1.5b) #[arg(long)] - model: Option, + model: Option, /// Attach to running realizar process #[arg(long)] - attach: Option, + attach: Option, /// Path to GGUF model file for real profiling #[arg(long, value_name = "MODEL")] - model_path: Option, + model_path: Option, /// Run in headless mode (no TUI, for CI/automation) #[arg(long)] headless: bool, @@ -506,7 +510,7 @@ pub enum ExtendedCommands { json: bool, /// Output file path (requires --headless) #[arg(long, value_name = "FILE", requires = "headless")] - output: Option, + output: Option, /// CI mode: exit non-zero if thresholds are not met or the report status is FAIL #[arg(long)] ci: bool, @@ -531,7 +535,7 @@ pub enum ExtendedCommands { speculation_k: usize, /// PAR-099: Path to draft model for speculative decoding #[arg(long, value_name = "DRAFT_MODEL")] - draft_model: Option, + draft_model: Option, /// PAR-102: Number of concurrent requests #[arg(long, default_value = "1")] concurrent: usize, @@ -576,13 +580,13 @@ pub enum ExtendedCommands { CompareHf { /// Path to .apr model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// HuggingFace repo ID (e.g., openai/whisper-tiny) #[arg(long)] - hf: String, + hf: ModelRef, /// Filter tensors by name pattern #[arg(long)] - tensor: Option, + tensor: Option, /// Comparison threshold (default: 1e-5) #[arg(long, default_value = "1e-5", value_parser = commands::threshold_arg::parse_tolerance)] @@ -600,10 +604,10 @@ pub enum ExtendedCommands { Hex { /// Path to model file (APR, GGUF, or SafeTensors) #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Filter tensors by name pattern #[arg(long)] - tensor: Option, + tensor: Option, /// Limit bytes/values to display #[arg(long, default_value = "64")] limit: usize, @@ -636,13 +640,13 @@ pub enum ExtendedCommands { raw: bool, /// Start at byte offset (supports 0x prefix for hex) #[arg(long, default_value = "0")] - offset: String, + offset: FreeText, /// Bytes per row for raw output (default: 16) #[arg(long, default_value = "16")] width: usize, /// Slice range for partial tensor reads (e.g., 0:3 for first 3 elements) #[arg(long)] - slice: Option, + slice: Option, }, /// What this build can and cannot do, and why — read from the capability contract (#3856) Capability { @@ -661,10 +665,10 @@ pub enum ExtendedCommands { Tree { /// Path to .apr model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Filter by component pattern #[arg(long)] - filter: Option, + filter: Option, /// Output format: ascii, dot, mermaid, json /// /// #2394 finding 15: this was a `String` that the dispatcher parsed @@ -686,13 +690,13 @@ pub enum ExtendedCommands { Flow { /// Path to .apr model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Filter by layer pattern #[arg(long)] - layer: Option, + layer: Option, /// Component to visualize: full, encoder, decoder, etc. #[arg(long, default_value = "full")] - component: String, + component: FreeText, /// Verbose output with statistics #[arg(short, long)] verbose: bool, @@ -704,10 +708,10 @@ pub enum ExtendedCommands { Qualify { /// Path to model file (APR, GGUF, or SafeTensors) #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Testing tier: smoke (Phase 1), standard (+contracts), full (+playbook) #[arg(long, default_value = "smoke")] - tier: String, + tier: FreeText, /// Timeout per gate in seconds #[arg(long, default_value = "120")] timeout: u64, @@ -719,7 +723,7 @@ pub enum ExtendedCommands { verbose: bool, /// Skip specific gates (comma-separated) #[arg(long, value_delimiter = ',')] - skip: Option>, + skip: Option>, }, /// Training pipeline (plan/apply) — forjar-style pre-flight validation #[cfg(feature = "training")] @@ -738,13 +742,13 @@ pub enum ExtendedCommands { Pretrain { /// Dataset path (tokenized shard index or raw corpus). #[arg(long, value_name = "PATH")] - dataset: PathBuf, + dataset: InputFile, /// Tokenizer directory (vocab.json + merges.txt). #[arg(long, value_name = "DIR")] - tokenizer: PathBuf, + tokenizer: InputFile, /// Run output directory — checkpoints + metadata go to `{run_dir}/ckpt/`. #[arg(long, value_name = "DIR")] - run_dir: PathBuf, + run_dir: OutputPath, /// Training regime — finetune (MODEL-1) or from-scratch (MODEL-2 cold start). /// Per contract training-loop-pretrain-v1 §hyperparameter_defaults, /// this atomically flips (regime, lr_max, warmup_steps, target_val_loss) @@ -792,7 +796,7 @@ pub enum ExtendedCommands { /// spelling that may fall back silently — all other values /// hard-fail on missing runtime per GATE-GPUTRAIN-002). #[arg(long, default_value = "auto")] - device: String, + device: FreeText, /// Initial weights from a pretrained APR file /// (contract `apr-pretrain-from-init-v1`). Per spec §49's /// MODEL-2 pretrained-init pivot: when present, load weights @@ -802,7 +806,7 @@ pub enum ExtendedCommands { /// corrupted, or arch-mismatched APR files exit non-zero /// before step 1 (no silent random-init fallback). #[arg(long, value_name = "PATH")] - init: Option, + init: Option, /// SPEC §83 P0-J: bypass the Chinchilla compute-optimal hard /// gate (`chinchilla-gate-v1`). Default is fail-fast when /// D/N < 10× (severely under-provisioned per Hoffmann et al. @@ -831,7 +835,7 @@ pub enum ExtendedCommands { /// When omitted, falls back to the historical "first 16 /// batches of --dataset" behaviour for backwards compatibility. #[arg(long, value_name = "DIR")] - val_shard: Option, + val_shard: Option, }, /// Tokenizer training pipeline (plan/apply) — BPE vocabulary learning Tokenize { @@ -852,13 +856,13 @@ pub enum ExtendedCommands { Diagnose { /// Path to checkpoint directory #[arg(value_name = "CHECKPOINT_DIR")] - checkpoint_dir: PathBuf, + checkpoint_dir: ModelPath, /// Test data file (JSONL) for evaluation #[arg(long, value_name = "FILE")] - data: Option, + data: Option, /// Model size hint: "0.5B", "tiny" #[arg(long)] - model_size: Option, + model_size: Option, /// Number of output classes (default: 5) #[arg(long, default_value = "5")] num_classes: usize, @@ -867,7 +871,7 @@ pub enum ExtendedCommands { OllamaChatLint { /// Path to captured /api/chat response (JSON object, or NDJSON if --stream) #[arg(long, value_name = "FILE")] - response_file: PathBuf, + response_file: InputFile, /// Treat input as NDJSON stream (one frame per line) #[arg(long)] stream: bool, @@ -876,12 +880,12 @@ pub enum ExtendedCommands { OllamaToolsLint { /// Path to captured /api/chat response (JSON object, or NDJSON if --stream) #[arg(long, value_name = "FILE")] - response_file: PathBuf, + response_file: InputFile, /// Captured request JSON, required unless --stream — supplies the /// tool-name allowlist (every called tool name must appear in /// request.tools[*].function.name) #[arg(long, value_name = "FILE")] - request_file: Option, + request_file: Option, /// Treat input as NDJSON stream (one frame per line) #[arg(long)] stream: bool, @@ -890,46 +894,46 @@ pub enum ExtendedCommands { DrySamplingLint { /// Path to observation JSON #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Lint a captured AWQ quality/compression/flags observation (CRUX-B-08) AwqLint { /// Path to captured AWQ observation JSON #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Lint a captured FP8 (E4M3) round-trip + SM-capability observation (CRUX-B-11) Fp8Lint { /// Path to captured observation JSON (frobenius, capability blocks) #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Lint a captured NF4 codebook/roundtrip/storage/parity observation (CRUX-B-10) Nf4Lint { /// Path to captured NF4 observation JSON #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Lint a captured GPTQ compression/cosine/flags observation (CRUX-B-09) GptqLint { /// Path to captured GPTQ observation JSON #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Lint a captured CUDA OOM postmortem report (CRUX-F-13) OomLint { /// Path to captured OOM postmortem JSON (e.g. /tmp/apr-oom-.json) #[arg(long, value_name = "FILE")] - report_file: PathBuf, + report_file: InputFile, /// Optional captured stderr log to verify the OOM_REPORT breadcrumb #[arg(long, value_name = "FILE")] - stderr_file: Option, + stderr_file: Option, }, /// Lint a captured NCCL failure-diagnostics JSON from stderr (CRUX-F-15) NcclDiagLint { /// Path to captured stderr JSON diagnostic #[arg(long, value_name = "FILE")] - diag_file: PathBuf, + diag_file: InputFile, /// Optional observed exit code (gate: >= 128 = NCCL class) #[arg(long, value_name = "I32")] exit_code: Option, @@ -941,7 +945,7 @@ pub enum ExtendedCommands { ReactTraceLint { /// Path to captured trace JSON #[arg(long, value_name = "FILE")] - trace_file: PathBuf, + trace_file: InputFile, /// Optional max_iterations budget the trace was produced under #[arg(long, value_name = "N")] max_iterations: Option, @@ -953,10 +957,10 @@ pub enum ExtendedCommands { HangTraceLint { /// Path to the captured trace directory #[arg(long, value_name = "DIR")] - trace_dir: PathBuf, + trace_dir: DirPath, /// Inspection mode: `timeout` (expects per-rank dumps) or `success` (expects empty dir) #[arg(long, value_name = "MODE", default_value = "timeout")] - mode: String, + mode: FreeText, /// Expected world_size when mode=timeout (number of rank{N}.py.txt files) #[arg(long, value_name = "N", default_value_t = 2)] world_size: usize, @@ -971,10 +975,10 @@ pub enum ExtendedCommands { DdpMetricsLint { /// Path to N=1 metrics JSON #[arg(long, value_name = "FILE")] - metrics_1gpu_file: PathBuf, + metrics_1gpu_file: InputFile, /// Path to N=world_size metrics JSON #[arg(long, value_name = "FILE")] - metrics_ngpu_file: PathBuf, + metrics_ngpu_file: InputFile, /// World size used for --metrics-ngpu-file run (>= 2) #[arg(long, value_name = "N")] world_size: i64, @@ -1002,7 +1006,7 @@ pub enum ExtendedCommands { AudioInspectLint { /// Path to the JSON body written by `apr dataset audio-inspect --format json` #[arg(long, value_name = "FILE")] - json_file: PathBuf, + json_file: InputFile, /// Optional expected sample_rate (typically the `--resample-to` arg) #[arg(long, value_name = "U32")] expected_sample_rate: Option, @@ -1016,16 +1020,16 @@ pub enum ExtendedCommands { /// Parity JSON body (`max_abs_diff`, `cosine_sim`), as written by /// `apr kernel parity --json` #[arg(long, value_name = "FILE")] - parity_file: Option, + parity_file: Option, /// Provenance JSON body (`attn_impl`, `kernel_source`, `fallback`). /// `apr kernel parity --json` writes both gates' fields into one body, /// so the same file may be passed here and to --parity-file #[arg(long, value_name = "FILE")] - provenance_file: Option, + provenance_file: Option, /// head_dim refusal JSON, as written by /// `apr kernel parity --impl flash2 --head-dim 96 --json` (which exits non-zero) #[arg(long, value_name = "FILE")] - head_dim_error_file: Option, + head_dim_error_file: Option, /// Max absolute diff tolerance (default 5e-3, FlashAttention-2 bound) #[arg(long, value_name = "F", default_value_t = 5e-3, value_parser = commands::threshold_arg::parse_tolerance)] @@ -1039,10 +1043,10 @@ pub enum ExtendedCommands { AttnVizLint { /// Path to attention dump in JSON form (4-D [layers][heads][rows][cols] floats) #[arg(long, value_name = "FILE")] - attn_file: Option, + attn_file: Option, /// Path to HTML heatmap output #[arg(long, value_name = "FILE")] - html_file: Option, + html_file: Option, /// Minimum , + error_file: Option, /// Externally captured check-finite layer-coverage JSON #[arg(long, value_name = "FILE")] - list_file: Option, + list_file: Option, /// Minimum layer-coverage count when `--list-file` is supplied (default 100) #[arg(long, value_name = "N", default_value_t = 100)] min_layers: usize, @@ -1072,19 +1076,19 @@ pub enum ExtendedCommands { EmbedVizLint { /// Path to the `token_id,token_str,x,y` CSV written by `apr debug embed-viz` #[arg(long, value_name = "FILE")] - csv_file: PathBuf, + csv_file: InputFile, /// Expected row count == vocab_size (optional) #[arg(long, value_name = "N")] expected_vocab_size: Option, /// Second CSV from a rerun at the same --seed, for the determinism gate (optional) #[arg(long, value_name = "FILE")] - csv_file_b: Option, + csv_file_b: Option, }, /// Lint an externally captured token-selection JSONL trace (CRUX-F-19 — no apr producer yet) ExplainTokenLint { /// Path to captured JSONL body (one sampled-token record per line) #[arg(long, value_name = "FILE")] - jsonl_file: PathBuf, + jsonl_file: InputFile, /// Tolerance for `Σ post_prob ≈ 1.0` (default 1e-5) #[arg(long, value_name = "F64", default_value_t = 1e-5, value_parser = commands::threshold_arg::parse_tolerance)] @@ -1097,13 +1101,13 @@ pub enum ExtendedCommands { GpuMemtraceLint { /// Path to an externally captured GPU-memory Chrome Trace JSON (no apr producer yet) #[arg(long, value_name = "FILE")] - trace_file: PathBuf, + trace_file: InputFile, }, /// Lint a captured KV-cache utilization timeline (CRUX-F-06) KvTimelineLint { /// Path to an externally captured KV-cache timeline JSON body (no apr producer yet) #[arg(long, value_name = "FILE")] - timeline_file: PathBuf, + timeline_file: InputFile, /// Preemption threshold (default 0.95, vLLM canonical) #[arg(long, value_name = "FRACTION", default_value_t = 0.95, value_parser = commands::threshold_arg::parse_fraction)] @@ -1116,7 +1120,7 @@ pub enum ExtendedCommands { OtlpLint { /// Path to captured OTLP/JSON export body #[arg(long, value_name = "FILE")] - otlp_file: PathBuf, + otlp_file: InputFile, /// Require at least one `apr.inference` span to be present #[arg(long)] require_apr_span: bool, @@ -1125,16 +1129,16 @@ pub enum ExtendedCommands { require_genai_attrs: bool, /// Verify W3C trace-context propagation: expect this 32-hex traceId #[arg(long, value_name = "HEX32")] - expect_trace_id: Option, + expect_trace_id: Option, }, /// Lint a captured Prometheus /metrics response (CRUX-K-07) PrometheusLint { /// Path to captured /metrics response body (text/plain; version=0.0.4) #[arg(long, value_name = "FILE")] - metrics_file: PathBuf, + metrics_file: InputFile, /// Optional captured Content-Type header to verify against version=0.0.4 #[arg(long, value_name = "HEADER")] - content_type: Option, + content_type: Option, /// Require the K-07 metric set (apr_num_requests_running, ...) to be present #[arg(long)] require_k07_metrics: bool, @@ -1143,26 +1147,26 @@ pub enum ExtendedCommands { ToolUseLint { /// Path to captured OpenAI tool-use response JSON #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Lint a GBNF grammar-constrained observation (CRUX-C-10) GbnfLint { /// Path to captured GBNF observation JSON #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Lint a typical-p sampling observation (CRUX-C-22) TypicalPLint { /// Path to captured typical-p observation JSON, with any of the /// sections range/identity/mass/sort/renorm #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Gradient-norm telemetry analysis (CRUX-F-09) GradNorm { /// Path to JSON file of per-step grad-norm records #[arg(long, value_name = "FILE")] - history_file: PathBuf, + history_file: InputFile, /// Maximum allowed clipped grad-norm (for cap-violation check) #[arg(long, value_name = "M", value_parser = commands::threshold_arg::parse_tolerance)] @@ -1179,66 +1183,66 @@ pub enum ExtendedCommands { RegistryQuotaLint { /// Path to captured quota/atomic/ceiling observation JSON #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Lint a captured imatrix calibration observation (CRUX-B-07) ImatrixLint { /// Path to captured imatrix observation JSON #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Lint a captured /v1/embeddings observation (CRUX-C-13) EmbeddingsLint { /// Path to captured /v1/embeddings observation JSON, with any of the /// sections shape/determinism/usage/flag #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Lint a captured Hub+local unified-search merge observation (CRUX-A-23) UnifiedSearchLint { /// Path to captured unified-search observation JSON #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Lint a captured `apr rm` / externally captured gc blob-GC observation (CRUX-A-25) RmGcLint { /// Path to captured rm/gc observation JSON #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Lint a captured APR_MODELS shared-cache observation (CRUX-A-21) SharedCacheLint { /// Path to captured dedup/permission observation JSON #[arg(long, value_name = "FILE")] - observation_file: PathBuf, + observation_file: InputFile, }, /// Perplexity classifier (CRUX-E-02) Ppl { /// JSON file containing an array of per-token natural-log /// probabilities (e.g. `[-1.2, -0.5, -2.1, ...]`). Required. #[arg(long, value_name = "FILE")] - log_probs_file: PathBuf, + log_probs_file: InputFile, }, /// Validate dequant→requant metadata preservation (CRUX-B-19) QuantPreservationLint { /// Reference GGUF (pre-roundtrip) #[arg(long, value_name = "REF.gguf")] - reference: PathBuf, + reference: ModelPath, /// Requantized GGUF (post-roundtrip) #[arg(long, value_name = "REQ.gguf")] - requant: PathBuf, + requant: ModelPath, }, /// Split a safetensors file into shards + weight-map index (CRUX-B-05) Shard { /// Single-file safetensors model to split #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Maximum size of each shard (e.g. 5GB, 500MB, 1.5GiB) #[arg(long, value_name = "SIZE", default_value = "5GB")] - max_shard_size: String, + max_shard_size: FreeText, /// Output directory for shards + model.safetensors.index.json #[arg(short, long, value_name = "DIR")] - output: PathBuf, + output: OutputPath, /// #2392: Overwrite an existing shard set in the output directory #[arg(short, long)] force: bool, @@ -1247,10 +1251,10 @@ pub enum ExtendedCommands { Unshard { /// Sharded directory containing model.safetensors.index.json #[arg(value_name = "DIR")] - input: PathBuf, + input: ModelPath, /// Output single-file safetensors path #[arg(short, long, value_name = "FILE")] - output: PathBuf, + output: OutputPath, /// #2392: Overwrite an existing output file (refused without it) #[arg(short, long)] force: bool, @@ -1271,26 +1275,26 @@ pub enum ExtendedCommands { Rerank { /// Path to the APR file containing the cross-encoder weights. #[arg(value_name = "MODEL")] - model: PathBuf, + model: ModelPath, /// Pre-tokenised input ids (comma-separated `u32`s). Mutually /// exclusive with `--query`+`--passage`+`--vocab` (Phase 3b). /// Example: `--input-ids 101,2024,102,3456,102` for `[CLS] q [SEP] p [SEP]`. #[arg(long, value_name = "IDS")] - input_ids: Option, + input_ids: Option, /// Pre-tokenised token-type ids (comma-separated `u32`s). /// Same length as `--input-ids`. 0 for query side, 1 for passage. #[arg(long, value_name = "IDS")] - token_type_ids: Option, + token_type_ids: Option, /// Phase 3b — query text. Pair with `--passage` + `--vocab` to enable /// in-process WordPiece tokenisation. The tokeniser builds /// `[CLS] query [SEP] passage [SEP]` with `token_type_ids = 0` for /// the query side and `1` for the passage side. #[arg(long, value_name = "TEXT")] - query: Option, + query: Option, /// Phase 3b — passage text. Required when `--query` is supplied /// in single-pair mode (use `--passages` for batch ranking). #[arg(long, value_name = "TEXT")] - passage: Option, + passage: Option, /// Phase 5 — batch ranking mode (#326). Passage candidates to /// score against `--query`. May be supplied multiple times: /// `apr rerank model.apr --query "..." --passages "p1" --passages "p2"`. @@ -1299,7 +1303,7 @@ pub enum ExtendedCommands { /// of `{passage, logit, score}` objects sorted by descending /// score when `--sort` is set. #[arg(long, value_name = "TEXT")] - passages: Vec, + passages: Vec, /// Phase 5 — sort batch output by descending score (highest /// relevance first). Only meaningful with `--passages` and /// `--json`. Default: preserve input order. @@ -1314,7 +1318,7 @@ pub enum ExtendedCommands { /// Must contain entries for `[CLS]`, `[SEP]`, and `[UNK]`. /// Phase 4 accepts HuggingFace `tokenizer.json` (extension-detected). #[arg(long, value_name = "FILE")] - vocab: Option, + vocab: Option, /// Override hidden_dim (default: 384 / MiniLM-L-6). #[arg(long, default_value_t = 384)] hidden_dim: usize, @@ -1376,10 +1380,10 @@ pub enum ExtendedCommands { Embed { /// Path to the APR file containing the encoder weights (BertModel). #[arg(value_name = "MODEL")] - model: PathBuf, + model: ModelPath, /// Text to encode. Repeatable: `apr embed model.apr --text "a" --text "b" --vocab tok.json`. #[arg(long, value_name = "TEXT")] - text: Vec, + text: Vec, /// Phase 7 (GH-326) — read texts from a file, one per line. /// Concatenated with `--text` inputs in order: `--text` first, /// then `--text-file` rows. Blank lines and lines starting @@ -1387,14 +1391,14 @@ pub enum ExtendedCommands { /// retrieval where the second-stage rerank candidate set /// (50-100 documents) is the embed input. #[arg(long, value_name = "FILE")] - text_file: Option, + text_file: Option, /// Path to a WordPiece `vocab.txt` or HF `tokenizer.json`. #[arg(long, value_name = "FILE")] - vocab: PathBuf, + vocab: InputFile, /// Pooling strategy (`cls` or `mean`). Default: `mean` /// (matches sentence-transformers convention). #[arg(long, default_value = "mean")] - pool: String, + pool: FreeText, /// L2-normalise the output embedding. Default: true (matches /// sentence-transformers convention). Pass `--normalize false` /// to keep raw magnitudes. @@ -1459,14 +1463,14 @@ pub enum DatasetCommands { AudioInspect { /// Path to the .wav file to decode #[arg(value_name = "FILE")] - file: PathBuf, + file: InputFile, /// Output format: `json` for the lint-readable body, `text` for humans #[arg(long, value_name = "FORMAT", default_value = "text", value_parser = ["json", "text"])] format: String, /// Write the observation here instead of stdout #[arg(short, long, value_name = "FILE")] - output: Option, + output: Option, /// Overwrite an existing --output file (refused without it) #[arg(short, long)] force: bool, @@ -1514,7 +1518,7 @@ pub enum KernelCommands { json: bool, /// Write the observation here instead of stdout #[arg(short, long, value_name = "FILE")] - output: Option, + output: Option, /// Overwrite an existing --output file (refused without it) #[arg(short, long)] force: bool, @@ -1529,13 +1533,13 @@ pub enum RunsCommands { Ls { /// Directory to scan for experiments (default: current dir) #[arg(long, value_name = "DIR")] - dir: Option, + dir: Option, /// Read from global experiment registry (~/.entrenar/experiments.db) #[arg(long)] global: bool, /// Filter by status: all, pending, running, completed, failed, cancelled #[arg(long, default_value = "all")] - status: String, + status: FreeText, /// Output as JSON #[arg(long)] json: bool, @@ -1547,10 +1551,10 @@ pub enum RunsCommands { Show { /// Run ID #[arg(value_name = "RUN_ID")] - run_id: String, + run_id: FreeText, /// Directory containing experiment DB #[arg(long, value_name = "DIR")] - dir: Option, + dir: Option, /// Read from global registry #[arg(long)] global: bool, @@ -1562,13 +1566,13 @@ pub enum RunsCommands { Diff { /// First run ID #[arg(value_name = "RUN_A")] - run_a: String, + run_a: FreeText, /// Second run ID #[arg(value_name = "RUN_B")] - run_b: String, + run_b: FreeText, /// Directory containing experiment DB #[arg(long, value_name = "DIR")] - dir: Option, + dir: Option, /// Read from global registry #[arg(long)] global: bool, @@ -1586,7 +1590,7 @@ pub enum ExperimentCommands { View { /// Path to experiment database file #[arg(long, value_name = "FILE")] - db: Option, + db: Option, /// Read from global experiment registry (~/.entrenar/experiments.db) #[arg(long)] global: bool, @@ -1608,10 +1612,10 @@ pub enum ModelfileSubcommand { Parse { /// Path to the Modelfile #[arg(value_name = "FILE")] - file: PathBuf, + file: ConfigPath, /// Output format: `json` or `human` #[arg(long, default_value = "json")] - format: String, + format: FreeText, }, } @@ -1641,19 +1645,19 @@ pub enum TestSubcommand { Tensor { /// Path to .apr model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Output directory for test artifacts #[arg(short, long, default_value = "./probar-export")] - output: PathBuf, + output: OutputPath, /// Export format: json, png, or both #[arg(long, default_value = "both")] - format: String, + format: FreeText, /// Golden reference directory for comparison #[arg(long)] - golden: Option, + golden: Option, /// Filter layers by name pattern #[arg(long)] - layer: Option, + layer: Option, /// Exit non-zero on golden divergence (CI mode, PMAT-481) #[arg(long)] assert: bool, @@ -1689,15 +1693,15 @@ pub enum LlmSubcommand { Bench { /// Endpoint to measure. #[arg(short, long, default_value = "http://127.0.0.1:8080")] - url: String, + url: FreeText, /// Model name sent in the request body. Most OpenAI-compatible /// servers ignore it; `apr serve` and vLLM do not. #[arg(short, long, default_value = "default")] - model: String, + model: FreeText, /// Command that starts the runtime. Omit to measure something already /// running. #[arg(long)] - start: Option, + start: Option, /// Seconds to wait for the endpoint to become healthy. #[arg(long, default_value = "120")] health_timeout: u64, @@ -1722,16 +1726,16 @@ pub enum LlmSubcommand { cooldown: u64, /// Label recorded in the report, e.g. `apr-cuda` or `llamacpp-39173bcac`. #[arg(long, default_value = "apr")] - runtime_name: String, + runtime_name: FreeText, /// Prior report to compare against. #[arg(long)] - baseline: Option, + baseline: Option, /// Fractional regression that fails the run, e.g. 0.10 for 10%. #[arg(long)] fail_on_regression: Option, /// Write the JSON report here. #[arg(short, long)] - output: Option, + output: Option, /// Use streaming responses (measures TTFT and TPOT). #[arg(long)] stream: bool, @@ -1743,7 +1747,7 @@ pub enum LlmSubcommand { /// knob with a recorded value rather than a constant buried in a /// helper. #[arg(long, default_value = "medium")] - profile: String, + profile: FreeText, /// Prompt corpus, JSONL: one JSON object per line, `{"prompt": "..."}`, /// with optional `max_tokens`, `temperature`, `seed`, `ignore_eos`. /// Overrides `--profile`. @@ -1758,7 +1762,7 @@ pub enum LlmSubcommand { /// so an operator who followed the help produced a file the loader /// refused. #[arg(long)] - prompts: Option, + prompts: Option, // ------------------------------------------------------------------ // PERF-025 — the APR-PERF-GATE-001 v2.2 §4.4 conformant mode. @@ -1804,10 +1808,10 @@ pub enum LlmSubcommand { band: bool, /// Directory receiving `receipt.rN.json` and the gzipped JSONL samples. #[arg(long)] - receipt: Option, + receipt: Option, /// Concurrency levels for --band. Arm A needs `c=1` to be present. #[arg(long, default_value = "1,4,8,16")] - bands: String, + bands: FreeText, /// Full band replicates per cell (PP-LLAMA-001 v3.0 §4.3: n >= 5). /// Each replicate writes its own independently judgeable receipt. /// @@ -1825,13 +1829,13 @@ pub enum LlmSubcommand { workload: String, /// Join key: which machine measured. Required by the receipt schema. #[arg(long, required_if_eq("band", "true"), default_value = "")] - host: String, + host: FreeText, /// Join key: which accelerator served the request. #[arg(long, required_if_eq("band", "true"), default_value = "")] - accelerator: String, + accelerator: FreeText, /// Join key: which quantization the served model uses. #[arg(long, required_if_eq("band", "true"), default_value = "")] - quantization: String, + quantization: FreeText, /// The dispatch path the SERVER took — not the hardware present. #[arg(long, default_value = "unknown", value_parser = ["cpu", "cuda", "metal", "wgpu", "unknown"])] @@ -1843,14 +1847,14 @@ pub enum LlmSubcommand { /// pointing that check at the measuring binary instead of the measured /// one would make it read green while checking nothing. #[arg(long = "server-feature")] - server_features: Vec, + server_features: Vec, /// How tokens were counted (§4.4.6). Absence of a valid value is fatal. #[arg(long, default_value = "server_usage", value_parser = ["server_usage", "client_tokenizer"])] tokenization: String, /// Tokenizer digest, required when --tokenization client_tokenizer. #[arg(long)] - tokenizer_sha256: Option, + tokenizer_sha256: Option, /// Whether the token counts include special tokens (§4.4.6). #[arg(long)] counts_special_tokens: bool, @@ -1866,7 +1870,7 @@ pub enum LlmSubcommand { /// `$(git rev-parse HEAD)`: a full 40-character lowercase object name, /// because an abbreviated sha cannot be tested for ancestry (PP-18). #[arg(long)] - commit: Option, + commit: Option, /// Who owes the comparator measurement this producer refuses to invent. /// /// §4.7.1: without `--comparator-url` every cell is NOT_APPLICABLE or @@ -1874,7 +1878,7 @@ pub enum LlmSubcommand { /// SAME run (PP-3), joined on the PP-22 key and driven by this same /// client binary (PP-25); one lane cannot produce one. #[arg(long, default_value = "perf-gate")] - comparator_owner: String, + comparator_owner: FreeText, // ------------------------------------------------------------------ // PP-LLAMA-001 v3.0 §4.3 / §5.3 — the COMPARATOR LANE. @@ -1897,25 +1901,25 @@ pub enum LlmSubcommand { /// Comparator endpoint. Measured by THIS binary, interleaved with the /// subject lane band by band, and joined on a shared `run_id`. #[arg(long)] - comparator_url: Option, + comparator_url: Option, /// Model name the comparator server expects in the request body. /// Defaults to --model; `llama-server` serves whatever it was /// launched with and ignores the field. #[arg(long)] - comparator_model: Option, + comparator_model: Option, /// PP-20: the comparator build's upstream commit. #[arg(long)] - comparator_commit: Option, + comparator_commit: Option, /// PP-20: the `cmake` line the comparator was configured with. #[arg(long)] - comparator_cmake: Option, + comparator_cmake: Option, /// PP-20: the comparator binary's sha256, 64 lowercase hex. #[arg(long)] - comparator_sha256: Option, + comparator_sha256: Option, /// PP-20: the instant after which every ratio against this pin is /// COMPARATOR_STALE, as `YYYY-MM-DDTHH:MM:SS.mmmZ`. #[arg(long)] - comparator_pin_expiry: Option, + comparator_pin_expiry: Option, // ------------------------------------------------------------------ // PP-22 / §5.3 — the comparator lane's CONFIGURATION, declared. @@ -1947,10 +1951,10 @@ pub enum LlmSubcommand { /// server resolved it to, and a guessed join-key field silently joins /// two different configurations. #[arg(long)] - comparator_fa: Option, + comparator_fa: Option, /// §5.3: `-ctk/-ctv` as launched, e.g. `f16`. #[arg(long)] - comparator_kv_type: Option, + comparator_kv_type: Option, /// PP-26: `witness.json` from /// `scripts/perf041_batched_parity_probe.py`. @@ -1961,12 +1965,12 @@ pub enum LlmSubcommand { /// emitting one token id forever at full speed, and every throughput /// number it produced was arithmetically correct and meaningless. #[arg(long)] - witness_json: Option, + witness_json: Option, /// PP-18: the `apr serve` binary that served the bands, when it is not /// this same `apr`. Hashed into `provenance.subject`. #[arg(long)] - subject_binary: Option, + subject_binary: Option, /// PP-21: sign each receipt with this key id after writing it. /// @@ -1975,11 +1979,11 @@ pub enum LlmSubcommand { /// non-zero exit is fatal: an unsigned receipt reported as signed is /// exactly the document `ArmC-sig` exists to catch. #[arg(long)] - key_id: Option, + key_id: Option, /// PP-21: keyring for --key-id. Defaults to /// `$APR_PERF_RECEIPT_KEYRING` inside the signing script. #[arg(long)] - keyring: Option, + keyring: Option, }, } diff --git a/crates/apr-cli/src/lib.rs b/crates/apr-cli/src/lib.rs index 72230fa4c9..b84ca22bc9 100644 --- a/crates/apr-cli/src/lib.rs +++ b/crates/apr-cli/src/lib.rs @@ -31,6 +31,13 @@ )] use clap::{Parser, Subcommand, ValueEnum}; +// #3745 S1: every free-form argument of the clap tree below is declared through +// one of these role types (`batuta_common::cli_roles`), which is how `apr +// surface` reads its role. A raw `PathBuf`/`String` there is RED in the marker guard. +pub use batuta_common::cli_roles::{ + ConfigPath, DirPath, EncodeText, FreeText, InputFile, ModelPath, ModelRef, OutputPath, + PromptText, +}; use std::path::{Path, PathBuf}; // Contract assertions from YAML (pv codegen) @@ -48,10 +55,13 @@ pub mod verbosity; /// PERF-021: the accelerator refusal, shared by serve, run and chat. mod accel; + mod commands; pub mod error; mod output; pub mod pipe; +// #3745 S1: the surface the binary actually has, emitted by the binary. +pub mod surface; pub use error::CliError; @@ -69,9 +79,11 @@ pub mod model_pull { // `tests/falsify_auth_*.rs` can construct gates and middleware without // reaching into the private `commands` tree. pub mod serve_auth { + #[cfg(feature = "inference")] + pub use crate::commands::serve::auth::apply; #[cfg(feature = "inference")] pub use crate::commands::serve::auth::layer; - pub use crate::commands::serve::auth::{apply, AuthGate}; + pub use crate::commands::serve::auth::AuthGate; } // PMAT-923: e2e seam so `tests/ollama_api_serve_compat.rs` can build the REAL @@ -180,6 +192,14 @@ include!("dispatch.rs"); include!("dispatch_analysis.rs"); include!("lib_07.rs"); +/// G0.5 (#2582): the heap profiler's allocator lives HERE, next to `cli_main`, +/// so both `apr` bin targets (the root facade's `src/bin/apr.rs` and +/// `crates/apr-cli/src/main.rs`) get it. It used to exist only in apr-cli's +/// `main.rs`, one of the ways the two hand-copied entry points had diverged. +#[cfg(feature = "dhat-heap")] +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + /// Full CLI entry point for `cargo install aprender`. /// /// This function encapsulates the complete `apr` binary logic so that @@ -189,6 +209,8 @@ pub fn cli_main() -> std::process::ExitCode { // GH-667: Reset SIGPIPE to default so piping to head/less doesn't panic. #[cfg(unix)] #[allow(unsafe_code)] + // SAFETY: signal(SIGPIPE, SIG_DFL) is async-signal-safe per POSIX. + // Called once at program start before any threads are spawned. unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } @@ -196,6 +218,8 @@ pub fn cli_main() -> std::process::ExitCode { // GH-646: Clear FPCR.FZ16 on aarch64 so f16 subnormals work. #[cfg(target_arch = "aarch64")] #[allow(unsafe_code)] + // SAFETY: Reading/writing FPCR only affects floating-point behavior for the + // current thread. Called once at program start before any FP operations. unsafe { let fpcr: u64; core::arch::asm!("mrs {}, fpcr", out(reg) fpcr); @@ -205,6 +229,9 @@ pub fn cli_main() -> std::process::ExitCode { } } + #[cfg(feature = "dhat-heap")] + let _profiler = dhat::Profiler::new_heap(); + // GH-662: Respect NO_COLOR env var and non-TTY output. let no_color = std::env::var("NO_COLOR").is_ok(); let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout()); @@ -308,3 +335,64 @@ pub fn emit_version_json() { serde_json::to_string_pretty(&body).expect("build version json") ); } + +/// G0.5 (#2582): both `apr` bin targets must BE `cli_main`, not copies of it. +#[cfg(test)] +mod entry_point_is_shared { + /// What a hand-copied prologue re-introduces. Any of these in a bin target + /// means the entry point is being duplicated again instead of shared. + const PROLOGUE_TOKENS: [&str; 7] = [ + "Cli::parse", + "execute_command", + "emit_version_json", + "global_allocator", + "set_override", + "SIGPIPE", + "fpcr", + ]; + + /// The source with `//` comments removed, so a doc comment that NAMES a + /// token (as main.rs's header does) is not read as code. + fn code_of(rel: &str) -> String { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(rel); + let src = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + src.lines() + .map(|l| l.split("//").next().unwrap_or("")) + .collect::>() + .join("\n") + } + + fn violations(code: &str) -> Vec<&'static str> { + let mut v: Vec<&'static str> = PROLOGUE_TOKENS + .iter() + .copied() + .filter(|t| code.contains(t)) + .collect(); + if !code.contains("apr_cli::cli_main()") { + v.push("does not call apr_cli::cli_main()"); + } + v + } + + #[test] + fn both_apr_bins_delegate_to_cli_main() { + for rel in ["src/main.rs", "../../src/bin/apr.rs"] { + let v = violations(&code_of(rel)); + assert!(v.is_empty(), "{rel}: {v:?}"); + } + } + + /// Case table: the detector must fire on the pre-#2582 main.rs shape and + /// stay quiet on a comment that only names the tokens. + #[test] + fn detector_case_table() { + let copied = "use apr_cli::{execute_command, Cli};\nfn main() { let cli = Cli::parse(); }"; + assert!(violations(copied).contains(&"Cli::parse")); + assert!(violations(copied).contains(&"does not call apr_cli::cli_main()")); + let alloc = "static ALLOC: dhat::Alloc = dhat::Alloc; #[global_allocator]\nfn main() { apr_cli::cli_main() }"; + assert_eq!(violations(alloc), vec!["global_allocator"]); + let clean = "fn main() -> std::process::ExitCode {\n apr_cli::cli_main()\n}"; + assert!(violations(clean).is_empty()); + } +} diff --git a/crates/apr-cli/src/lib_07.rs b/crates/apr-cli/src/lib_07.rs index 43ba59258c..17c4a96484 100644 --- a/crates/apr-cli/src/lib_07.rs +++ b/crates/apr-cli/src/lib_07.rs @@ -22,4 +22,5 @@ include!("lib_parse_serve.rs"); include!("lib_dispatch_coverage.rs"); include!("lib_dogfood_2392.rs"); include!("lib_falsify_2606_mcp_serve_argv.rs"); +include!("lib_parse_ptx_debug_4062.rs"); } diff --git a/crates/apr-cli/src/lib_dispatch_coverage.rs b/crates/apr-cli/src/lib_dispatch_coverage.rs index 0c73ab61d4..4ed027f7f5 100644 --- a/crates/apr-cli/src/lib_dispatch_coverage.rs +++ b/crates/apr-cli/src/lib_dispatch_coverage.rs @@ -10,7 +10,7 @@ fn test_dispatch_model_commands_returns_none_for_inspect() { // Inspect is an inspection command, not a model management command let cli = make_cli(Commands::Inspect { - file: PathBuf::from("model.apr"), + file: PathBuf::from("model.apr").into(), vocab: false, filters: false, weights: false, @@ -32,7 +32,7 @@ #[test] fn test_dispatch_model_commands_pull_nonexistent() { let cli = make_cli(Commands::Pull { - model_ref: "nonexistent-model-that-does-not-exist-xyz123".to_string(), + model_ref: "nonexistent-model-that-does-not-exist-xyz123".to_string().into(), repo: None, force: false, dry_run: false, @@ -50,7 +50,7 @@ #[test] fn test_dispatch_model_commands_rm_nonexistent() { let cli = make_cli(Commands::Rm { - model_ref: "nonexistent-model-xyz789".to_string(), + model_ref: "nonexistent-model-xyz789".to_string().into(), }); let result = dispatch_model_commands(&cli); assert!(result.is_some(), "Rm should be handled by dispatch_model_commands"); @@ -66,7 +66,7 @@ #[test] fn test_dispatch_model_commands_tui_with_file() { let cli = make_cli(Commands::Tui { - file: Some(PathBuf::from("/tmp/nonexistent_tui_model.apr")), + file: Some(PathBuf::from("/tmp/nonexistent_tui_model.apr").into()), }); let result = dispatch_model_commands(&cli); assert!(result.is_some(), "Tui with file should be handled by dispatch_model_commands"); @@ -76,11 +76,11 @@ fn test_dispatch_model_commands_merge_nonexistent() { let cli = make_cli(Commands::Merge { files: vec![ - PathBuf::from("/tmp/nonexistent_merge_a.apr"), - PathBuf::from("/tmp/nonexistent_merge_b.apr"), + PathBuf::from("/tmp/nonexistent_merge_a.apr").into(), + PathBuf::from("/tmp/nonexistent_merge_b.apr").into(), ], - strategy: "average".to_string(), - output: Some(PathBuf::from("/tmp/merged_out.apr")), + strategy: "average".to_string().into(), + output: Some(PathBuf::from("/tmp/merged_out.apr").into()), weights: None, base_model: None, drop_rate: 0.9, @@ -99,10 +99,10 @@ fn test_dispatch_model_commands_merge_plan_mode() { let cli = make_cli(Commands::Merge { files: vec![ - PathBuf::from("/tmp/nonexistent_merge_plan_a.apr"), - PathBuf::from("/tmp/nonexistent_merge_plan_b.apr"), + PathBuf::from("/tmp/nonexistent_merge_plan_a.apr").into(), + PathBuf::from("/tmp/nonexistent_merge_plan_b.apr").into(), ], - strategy: "slerp".to_string(), + strategy: "slerp".to_string().into(), output: None, // plan mode doesn't need output weights: Some(vec![0.5, 0.5]), base_model: None, @@ -119,7 +119,7 @@ #[test] fn test_dispatch_model_commands_returns_none_for_validate() { let cli = make_cli(Commands::Validate { - file: PathBuf::from("model.apr"), + file: PathBuf::from("model.apr").into(), quality: false, strict: false, min_score: None, @@ -131,7 +131,7 @@ #[test] fn test_dispatch_model_commands_returns_none_for_debug() { let cli = make_cli(Commands::Debug { - file: Some(PathBuf::from("model.apr")), + file: Some(PathBuf::from("model.apr").into()), action: None, drama: false, hex: false, @@ -145,11 +145,11 @@ #[test] fn test_dispatch_model_commands_prune_nonexistent() { let cli = make_cli(Commands::ModelOps(ModelOpsCommands::Prune { - file: PathBuf::from("/tmp/nonexistent_prune_model.apr"), - method: "magnitude".to_string(), + file: PathBuf::from("/tmp/nonexistent_prune_model.apr").into(), + method: "magnitude".to_string().into(), target_ratio: 0.5, sparsity: 0.0, - output: Some(PathBuf::from("/tmp/pruned_out.apr")), + output: Some(PathBuf::from("/tmp/pruned_out.apr").into()), remove_layers: None, analyze: false, plan: false, @@ -173,7 +173,7 @@ fn test_dispatch_analysis_returns_none_for_chat() { // Chat is Extended but not an analysis command let cli = make_cli(Commands::Extended(ExtendedCommands::Chat { - file: PathBuf::from("model.apr"), + file: PathBuf::from("model.apr").into(), temperature: 0.7, top_p: 0.9, max_tokens: 512, @@ -197,7 +197,7 @@ #[test] fn test_dispatch_analysis_tree_nonexistent_file() { let cli = make_cli(Commands::Extended(ExtendedCommands::Tree { - file: PathBuf::from("/tmp/nonexistent_tree_model.apr"), + file: PathBuf::from("/tmp/nonexistent_tree_model.apr").into(), filter: None, format: crate::commands::tree::TreeFormat::Ascii, sizes: false, @@ -211,7 +211,7 @@ #[test] fn test_dispatch_analysis_hex_nonexistent_file() { let cli = make_cli(Commands::Extended(ExtendedCommands::Hex { - file: PathBuf::from("/tmp/nonexistent_hex_model.apr"), + file: PathBuf::from("/tmp/nonexistent_hex_model.apr").into(), tensor: None, limit: 64, stats: false, @@ -223,7 +223,7 @@ contract: false, entropy: false, raw: false, - offset: "0".to_string(), + offset: "0".to_string().into(), width: 16, slice: None, })); @@ -235,9 +235,9 @@ #[test] fn test_dispatch_analysis_flow_nonexistent_file() { let cli = make_cli(Commands::Extended(ExtendedCommands::Flow { - file: PathBuf::from("/tmp/nonexistent_flow_model.apr"), + file: PathBuf::from("/tmp/nonexistent_flow_model.apr").into(), layer: None, - component: "full".to_string(), + component: "full".to_string().into(), verbose: false, json: false, })); @@ -249,8 +249,8 @@ #[test] fn test_dispatch_analysis_qualify_nonexistent_file() { let cli = make_cli(Commands::Extended(ExtendedCommands::Qualify { - file: PathBuf::from("/tmp/nonexistent_qualify_model.apr"), - tier: "basic".to_string(), + file: PathBuf::from("/tmp/nonexistent_qualify_model.apr").into(), + tier: "basic".to_string().into(), timeout: 30, json: false, verbose: false, @@ -264,7 +264,7 @@ #[test] fn test_dispatch_analysis_diagnose_nonexistent() { let cli = make_cli(Commands::Extended(ExtendedCommands::Diagnose { - checkpoint_dir: PathBuf::from("/tmp/nonexistent_diagnose_dir"), + checkpoint_dir: PathBuf::from("/tmp/nonexistent_diagnose_dir").into(), data: None, model_size: None, num_classes: 5, @@ -336,9 +336,9 @@ fn test_dispatch_analysis_probar_rejects_unknown_format() { let cli = make_cli(Commands::Extended(ExtendedCommands::Test { command: TestSubcommand::Tensor { - file: PathBuf::from("/tmp/nonexistent_probar_model.apr"), - output: PathBuf::from("/tmp/nonexistent_probar_out"), - format: "bogus".to_string(), + file: PathBuf::from("/tmp/nonexistent_probar_model.apr").into(), + output: PathBuf::from("/tmp/nonexistent_probar_out").into(), + format: "bogus".to_string().into(), golden: None, layer: None, assert: false, @@ -363,9 +363,9 @@ for format in ["json", "png", "both", "all"] { let cli = make_cli(Commands::Extended(ExtendedCommands::Test { command: TestSubcommand::Tensor { - file: PathBuf::from("/tmp/nonexistent_probar_model.apr"), - output: PathBuf::from("/tmp/nonexistent_probar_out"), - format: format.to_string(), + file: PathBuf::from("/tmp/nonexistent_probar_model.apr").into(), + output: PathBuf::from("/tmp/nonexistent_probar_out").into(), + format: format.to_string().into(), golden: None, layer: None, assert: false, @@ -385,8 +385,8 @@ #[test] fn test_dispatch_analysis_compare_hf_offline_rejected() { let mut cli = make_cli(Commands::Extended(ExtendedCommands::CompareHf { - file: PathBuf::from("/tmp/nonexistent_compare_hf.apr"), - hf: "org/repo".to_string(), + file: PathBuf::from("/tmp/nonexistent_compare_hf.apr").into(), + hf: "org/repo".to_string().into(), tensor: None, threshold: 0.01, json: false, @@ -410,7 +410,7 @@ #[test] fn test_dispatch_profiling_returns_none_for_chat() { let cli = make_cli(Commands::Extended(ExtendedCommands::Chat { - file: PathBuf::from("model.apr"), + file: PathBuf::from("model.apr").into(), temperature: 0.7, top_p: 0.9, max_tokens: 512, @@ -443,7 +443,7 @@ #[test] fn ptx_without_analyzer_reports_the_feature_not_the_path() { let cli = make_cli(Commands::Extended(ExtendedCommands::Ptx { - file: Some(PathBuf::from("/nonexistent_ptx_source_xyz.ptx")), + file: Some(PathBuf::from("/nonexistent_ptx_source_xyz.ptx").into()), kernel: None, strict: false, bugs: false, @@ -475,7 +475,7 @@ fn ptx_without_analyzer_reports_remedy_for_kernel_form_too() { let cli = make_cli(Commands::Extended(ExtendedCommands::Ptx { file: None, - kernel: Some("Q4KGemv".to_string()), + kernel: Some("Q4KGemv".to_string().into()), strict: false, bugs: false, json: false, @@ -494,7 +494,7 @@ #[test] fn test_dispatch_profiling_bench_nonexistent() { let cli = make_cli(Commands::Extended(ExtendedCommands::Bench { - file: PathBuf::from("/tmp/nonexistent_bench_model.apr"), + file: PathBuf::from("/tmp/nonexistent_bench_model.apr").into(), warmup: 1, iterations: 1, max_tokens: 10, @@ -511,9 +511,9 @@ #[test] fn test_dispatch_profiling_profile_nonexistent() { let cli = make_cli(Commands::Extended(ExtendedCommands::Profile { - file: PathBuf::from("/tmp/nonexistent_profile_model.apr"), + file: PathBuf::from("/tmp/nonexistent_profile_model.apr").into(), granular: false, - format: "text".to_string(), + format: "text".to_string().into(), focus: None, detect_naive: false, threshold: 0.01, @@ -542,8 +542,8 @@ #[test] fn test_dispatch_profiling_eval_nonexistent() { let cli = make_cli(Commands::Extended(ExtendedCommands::Eval { - file: PathBuf::from("/tmp/nonexistent_eval_model.apr"), - dataset: "test".to_string(), + file: PathBuf::from("/tmp/nonexistent_eval_model.apr").into(), + dataset: "test".to_string().into(), text: None, max_tokens: 32, threshold: 90.0, @@ -564,7 +564,7 @@ #[test] fn test_dispatch_profiling_qa_nonexistent() { let cli = make_cli(Commands::Extended(ExtendedCommands::Qa { - file: PathBuf::from("/tmp/nonexistent_qa_model.apr"), + file: PathBuf::from("/tmp/nonexistent_qa_model.apr").into(), assert_tps: None, assert_speedup: None, assert_gpu_speedup: None, @@ -601,14 +601,14 @@ // Train Plan with nonexistent data — tests train routing path let cli = make_cli(Commands::Extended(ExtendedCommands::Train { command: TrainCommands::Plan { - data: Some(PathBuf::from("/tmp/nonexistent_train_data.jsonl")), - model_size: "tiny".to_string(), + data: Some(PathBuf::from("/tmp/nonexistent_train_data.jsonl").into()), + model_size: "tiny".to_string().into(), model_path: None, num_classes: 5, - task: "classify".to_string(), + task: "classify".to_string().into(), config: None, - output: PathBuf::from("/tmp/train_plan_out"), - strategy: "auto".to_string(), + output: PathBuf::from("/tmp/train_plan_out").into(), + strategy: "auto".to_string().into(), budget: 100, scout: false, max_epochs: 3, @@ -617,7 +617,7 @@ batch_size: Some(4), val_data: None, test_data: None, - format: "text".to_string(), + format: "text".to_string().into(), }, })); let result = dispatch_analysis_commands(&cli); @@ -629,10 +629,10 @@ fn test_dispatch_train_sweep() { let cli = make_cli(Commands::Extended(ExtendedCommands::Train { command: TrainCommands::Sweep { - config: PathBuf::from("/tmp/nonexistent_sweep_config.toml"), - strategy: "grid".to_string(), + config: PathBuf::from("/tmp/nonexistent_sweep_config.toml").into(), + strategy: "grid".to_string().into(), num_configs: 5, - output_dir: PathBuf::from("/tmp/sweep_out"), + output_dir: PathBuf::from("/tmp/sweep_out").into(), seed: 42, }, })); @@ -645,7 +645,7 @@ fn test_dispatch_train_cluster_status() { let cli = make_cli(Commands::Extended(ExtendedCommands::Train { command: TrainCommands::ClusterStatus { - cluster: PathBuf::from("/tmp/nonexistent_cluster_config.yaml"), + cluster: PathBuf::from("/tmp/nonexistent_cluster_config.yaml").into(), }, })); let result = dispatch_analysis_commands(&cli); @@ -659,7 +659,7 @@ #[test] fn test_dispatch_inspection_routes_inspect() { let cli = make_cli(Commands::Inspect { - file: PathBuf::from("/tmp/nonexistent_pmat540.apr"), + file: PathBuf::from("/tmp/nonexistent_pmat540.apr").into(), vocab: false, filters: false, weights: false, @@ -673,7 +673,7 @@ #[test] fn test_dispatch_inspection_routes_debug() { let cli = make_cli(Commands::Debug { - file: Some(PathBuf::from("/tmp/nonexistent_pmat540.apr")), + file: Some(PathBuf::from("/tmp/nonexistent_pmat540.apr").into()), action: None, drama: false, hex: false, @@ -687,7 +687,7 @@ #[test] fn test_dispatch_inspection_routes_validate() { let cli = make_cli(Commands::Validate { - file: PathBuf::from("/tmp/nonexistent_pmat540.apr"), + file: PathBuf::from("/tmp/nonexistent_pmat540.apr").into(), strict: false, quality: false, min_score: None, @@ -699,7 +699,7 @@ #[test] fn test_dispatch_inspection_routes_lint() { let cli = make_cli(Commands::Lint { - file: PathBuf::from("/tmp/nonexistent_pmat540.apr"), + file: PathBuf::from("/tmp/nonexistent_pmat540.apr").into(), strict: false, }); let result = dispatch_inspection_commands(&cli); @@ -709,8 +709,8 @@ #[test] fn test_dispatch_inspection_returns_none_for_export() { let cli = make_cli(Commands::Export { - file: Some(PathBuf::from("/tmp/nonexistent_pmat540.apr")), - format: "gguf".to_string(), + file: Some(PathBuf::from("/tmp/nonexistent_pmat540.apr").into()), + format: "gguf".to_string().into(), output: None, quantize: None, list_formats: false, @@ -730,7 +730,7 @@ #[test] fn test_dispatch_diagnostic_routes_trace() { let cli = make_cli(Commands::Trace { - file: PathBuf::from("/tmp/nonexistent_pmat540.apr"), + file: PathBuf::from("/tmp/nonexistent_pmat540.apr").into(), layer: None, reference: None, json: false, @@ -740,7 +740,7 @@ interactive: false, save_tensor: None, save_tensor_dir: None, - save_tensor_layers: "0..1".to_string(), + save_tensor_layers: "0..1".to_string().into(), }); let result = dispatch_diagnostic_commands(&cli); assert!(result.is_some(), "Trace should be handled by diagnostic dispatcher"); @@ -749,7 +749,7 @@ #[test] fn test_dispatch_diagnostic_routes_tensors() { let cli = make_cli(Commands::Tensors { - file: PathBuf::from("/tmp/nonexistent_pmat540.apr"), + file: PathBuf::from("/tmp/nonexistent_pmat540.apr").into(), stats: false, filter: None, limit: 0, @@ -762,8 +762,8 @@ #[test] fn test_dispatch_diagnostic_routes_diff() { let cli = make_cli(Commands::Diff { - file1: PathBuf::from("/tmp/nonexistent_a.apr"), - file2: PathBuf::from("/tmp/nonexistent_b.apr"), + file1: PathBuf::from("/tmp/nonexistent_a.apr").into(), + file2: PathBuf::from("/tmp/nonexistent_b.apr").into(), weights: false, values: false, filter: None, @@ -781,7 +781,7 @@ #[test] fn test_dispatch_diagnostic_returns_none_for_inspect() { let cli = make_cli(Commands::Inspect { - file: PathBuf::from("/tmp/nonexistent_pmat540.apr"), + file: PathBuf::from("/tmp/nonexistent_pmat540.apr").into(), vocab: false, filters: false, weights: false, @@ -799,9 +799,9 @@ #[test] fn test_dispatch_format_routes_import() { let cli = make_cli(Commands::Import { - source: "/tmp/nonexistent_pmat540.safetensors".to_string(), + source: "/tmp/nonexistent_pmat540.safetensors".to_string().into(), output: None, - arch: "auto".to_string(), + arch: "auto".to_string().into(), quantize: None, strict: false, preserve_q4k: false, @@ -816,10 +816,10 @@ #[test] fn test_dispatch_format_routes_convert() { let cli = make_cli(Commands::Convert { - file: PathBuf::from("/tmp/nonexistent_pmat540.apr"), + file: PathBuf::from("/tmp/nonexistent_pmat540.apr").into(), quantize: None, compress: None, - output: PathBuf::from("/tmp/nonexistent_pmat540_out.apr"), + output: PathBuf::from("/tmp/nonexistent_pmat540_out.apr").into(), force: false, }); let result = dispatch_format_commands(&cli); @@ -829,8 +829,8 @@ #[test] fn test_dispatch_format_routes_export() { let cli = make_cli(Commands::Export { - file: Some(PathBuf::from("/tmp/nonexistent_pmat540.apr")), - format: "gguf".to_string(), + file: Some(PathBuf::from("/tmp/nonexistent_pmat540.apr").into()), + format: "gguf".to_string().into(), output: None, quantize: None, list_formats: false, @@ -846,7 +846,7 @@ #[test] fn test_dispatch_format_returns_none_for_inspect() { let cli = make_cli(Commands::Inspect { - file: PathBuf::from("/tmp/nonexistent_pmat540.apr"), + file: PathBuf::from("/tmp/nonexistent_pmat540.apr").into(), vocab: false, filters: false, weights: false, @@ -864,7 +864,7 @@ #[test] fn test_dispatch_runtime_routes_check() { let cli = make_cli(Commands::Check { - file: PathBuf::from("/tmp/nonexistent_pmat540.gguf"), + file: PathBuf::from("/tmp/nonexistent_pmat540.gguf").into(), no_gpu: true, json: false, }); @@ -875,7 +875,7 @@ #[test] fn test_dispatch_runtime_returns_none_for_inspect() { let cli = make_cli(Commands::Inspect { - file: PathBuf::from("/tmp/nonexistent_pmat540.apr"), + file: PathBuf::from("/tmp/nonexistent_pmat540.apr").into(), vocab: false, filters: false, weights: false, @@ -893,7 +893,7 @@ #[test] fn test_dispatch_core_routes_inspect_via_inspection() { let cli = make_cli(Commands::Inspect { - file: PathBuf::from("/tmp/nonexistent_pmat540.apr"), + file: PathBuf::from("/tmp/nonexistent_pmat540.apr").into(), vocab: false, filters: false, weights: false, @@ -908,7 +908,7 @@ #[test] fn test_dispatch_core_routes_tensors_via_diagnostic() { let cli = make_cli(Commands::Tensors { - file: PathBuf::from("/tmp/nonexistent_pmat540.apr"), + file: PathBuf::from("/tmp/nonexistent_pmat540.apr").into(), stats: false, filter: None, limit: 0, @@ -921,9 +921,9 @@ #[test] fn test_dispatch_core_routes_import_via_format() { let cli = make_cli(Commands::Import { - source: "/tmp/nonexistent_pmat540.safetensors".to_string(), + source: "/tmp/nonexistent_pmat540.safetensors".to_string().into(), output: None, - arch: "auto".to_string(), + arch: "auto".to_string().into(), quantize: None, strict: false, preserve_q4k: false, @@ -938,7 +938,7 @@ #[test] fn test_dispatch_core_returns_none_for_extended() { let cli = make_cli(Commands::Extended(ExtendedCommands::Chat { - file: PathBuf::from("/tmp/nonexistent_pmat540.gguf"), + file: PathBuf::from("/tmp/nonexistent_pmat540.gguf").into(), temperature: 0.7, top_p: 0.9, max_tokens: 512, diff --git a/crates/apr-cli/src/lib_execute_export_convert.rs b/crates/apr-cli/src/lib_execute_export_convert.rs index 9ca5c9e01b..1eafa8a6a2 100644 --- a/crates/apr-cli/src/lib_execute_export_convert.rs +++ b/crates/apr-cli/src/lib_execute_export_convert.rs @@ -3,9 +3,9 @@ #[test] fn test_execute_export_file_not_found() { let cli = make_cli(Commands::Export { - file: Some(PathBuf::from("/tmp/nonexistent_model_export_test.apr")), - format: "safetensors".to_string(), - output: Some(PathBuf::from("/tmp/out.safetensors")), + file: Some(PathBuf::from("/tmp/nonexistent_model_export_test.apr").into()), + format: "safetensors".to_string().into(), + output: Some(PathBuf::from("/tmp/out.safetensors").into()), quantize: None, list_formats: false, batch: None, @@ -21,10 +21,10 @@ #[test] fn test_execute_convert_file_not_found() { let cli = make_cli(Commands::Convert { - file: PathBuf::from("/tmp/nonexistent_model_convert_test.apr"), + file: PathBuf::from("/tmp/nonexistent_model_convert_test.apr").into(), quantize: None, compress: None, - output: PathBuf::from("/tmp/out.apr"), + output: PathBuf::from("/tmp/out.apr").into(), force: false, }); let result = execute_command(&cli); @@ -38,7 +38,7 @@ #[test] fn test_execute_hex_file_not_found() { let cli = make_cli(Commands::Extended(ExtendedCommands::Hex { - file: PathBuf::from("/tmp/nonexistent_model_hex_test.apr"), + file: PathBuf::from("/tmp/nonexistent_model_hex_test.apr").into(), tensor: None, limit: 64, stats: false, @@ -50,7 +50,7 @@ contract: false, entropy: false, raw: false, - offset: String::new(), + offset: String::new().into(), width: 16, slice: None, })); @@ -62,7 +62,7 @@ #[test] fn test_execute_tree_file_not_found() { let cli = make_cli(Commands::Extended(ExtendedCommands::Tree { - file: PathBuf::from("/tmp/nonexistent_model_tree_test.apr"), + file: PathBuf::from("/tmp/nonexistent_model_tree_test.apr").into(), filter: None, format: crate::commands::tree::TreeFormat::Ascii, sizes: false, @@ -76,9 +76,9 @@ #[test] fn test_execute_flow_file_not_found() { let cli = make_cli(Commands::Extended(ExtendedCommands::Flow { - file: PathBuf::from("/tmp/nonexistent_model_flow_test.apr"), + file: PathBuf::from("/tmp/nonexistent_model_flow_test.apr").into(), layer: None, - component: "full".to_string(), + component: "full".to_string().into(), verbose: false, json: false, })); @@ -93,9 +93,9 @@ use TestSubcommand; let cli = make_cli(Commands::Extended(ExtendedCommands::Test { command: TestSubcommand::Tensor { - file: PathBuf::from("/tmp/nonexistent_model_probar_test.apr"), - output: PathBuf::from("/tmp/probar-out"), - format: "both".to_string(), + file: PathBuf::from("/tmp/nonexistent_model_probar_test.apr").into(), + output: PathBuf::from("/tmp/probar-out").into(), + format: "both".to_string().into(), golden: None, layer: None, assert: false, @@ -110,7 +110,7 @@ #[test] fn test_execute_check_file_not_found() { let cli = make_cli(Commands::Check { - file: PathBuf::from("/tmp/nonexistent_model_check_test.apr"), + file: PathBuf::from("/tmp/nonexistent_model_check_test.apr").into(), no_gpu: true, json: false, }); @@ -148,7 +148,7 @@ #[test] fn test_execute_explain_with_code() { let cli = make_cli(Commands::Explain { - code_or_file: Some("E001".to_string()), + code_or_file: Some("E001".to_string().into()), file: None, tensor: None, kernel: false, @@ -166,18 +166,18 @@ fn test_execute_tune_plan_no_file() { let cli = make_cli(Commands::Extended(ExtendedCommands::Tune { file: None, - method: "auto".to_string(), + method: "auto".to_string().into(), rank: None, vram: 16.0, plan: true, - model: Some("7B".to_string()), + model: Some("7B".to_string().into()), freeze_base: false, train_data: None, json: false, task: None, budget: 10, - strategy: "tpe".to_string(), - scheduler: "asha".to_string(), + strategy: "tpe".to_string().into(), + scheduler: "asha".to_string().into(), scout: false, data: None, num_classes: 5, @@ -200,7 +200,7 @@ #[test] fn test_execute_qa_all_skips_succeeds() { let cli = make_cli(Commands::Extended(ExtendedCommands::Qa { - file: PathBuf::from("/tmp/nonexistent_model_qa_test.gguf"), + file: PathBuf::from("/tmp/nonexistent_model_qa_test.gguf").into(), assert_tps: None, assert_speedup: None, assert_gpu_speedup: None, @@ -236,7 +236,7 @@ #[test] fn test_execute_qa_with_gates_file_not_found() { let cli = make_cli(Commands::Extended(ExtendedCommands::Qa { - file: PathBuf::from("/tmp/nonexistent_model_qa_gates_test.gguf"), + file: PathBuf::from("/tmp/nonexistent_model_qa_gates_test.gguf").into(), assert_tps: None, assert_speedup: None, assert_gpu_speedup: None, @@ -272,9 +272,9 @@ #[test] fn test_execute_import_invalid_source() { let cli = make_cli(Commands::Import { - source: "/tmp/nonexistent_model_import_test.gguf".to_string(), + source: "/tmp/nonexistent_model_import_test.gguf".to_string().into(), output: None, - arch: "auto".to_string(), + arch: "auto".to_string().into(), quantize: None, strict: false, preserve_q4k: false, @@ -382,7 +382,12 @@ .expect("join"); // Exactly what dispatch_run does with those fields. let (run_prompt, chat_template) = - run_prompt_and_chat(prompt.as_ref(), positional.as_ref(), &source, chat); + run_prompt_and_chat( + prompt.as_ref().map(batuta_common::cli_roles::PromptText::as_string), + positional.as_ref().map(batuta_common::cli_roles::PromptText::as_string), + &source, + chat, + ); let options = crate::commands::run::RunOptions { prompt: run_prompt, chat_template, @@ -390,7 +395,7 @@ }; let config = crate::commands::run::realizar_config( std::path::Path::new(&source), - input.as_ref(), + input.as_deref().map(std::path::Path::to_path_buf).as_ref(), &options, ) .expect("config"); diff --git a/crates/apr-cli/src/lib_extract_paths.rs b/crates/apr-cli/src/lib_extract_paths.rs index 51e20478eb..41978db33b 100644 --- a/crates/apr-cli/src/lib_extract_paths.rs +++ b/crates/apr-cli/src/lib_extract_paths.rs @@ -3,7 +3,7 @@ #[test] fn test_extract_paths_tree_exempt() { let cmd = Commands::Extended(ExtendedCommands::Tree { - file: PathBuf::from("model.apr"), + file: PathBuf::from("model.apr").into(), filter: None, format: crate::commands::tree::TreeFormat::Ascii, sizes: false, @@ -17,9 +17,9 @@ #[test] fn test_extract_paths_flow_exempt() { let cmd = Commands::Extended(ExtendedCommands::Flow { - file: PathBuf::from("model.apr"), + file: PathBuf::from("model.apr").into(), layer: None, - component: "full".to_string(), + component: "full".to_string().into(), verbose: false, json: false, }); @@ -31,11 +31,11 @@ #[test] fn test_extract_paths_publish_exempt() { let cmd = Commands::Extended(ExtendedCommands::Tools(ToolCommands::Publish { - directory: PathBuf::from("/tmp/models"), - repo_id: "org/repo".to_string(), + directory: PathBuf::from("/tmp/models").into(), + repo_id: "org/repo".to_string().into(), model_name: None, - license: "mit".to_string(), - pipeline_tag: "text-generation".to_string(), + license: "mit".to_string().into(), + pipeline_tag: "text-generation".to_string().into(), library_name: None, tags: None, message: None, @@ -50,8 +50,8 @@ #[test] fn test_extract_paths_tune_exempt() { let cmd = Commands::Extended(ExtendedCommands::Tune { - file: Some(PathBuf::from("model.apr")), - method: "auto".to_string(), + file: Some(PathBuf::from("model.apr").into()), + method: "auto".to_string().into(), rank: None, vram: 16.0, plan: false, @@ -61,8 +61,8 @@ json: false, task: None, budget: 10, - strategy: "tpe".to_string(), - scheduler: "asha".to_string(), + strategy: "tpe".to_string().into(), + scheduler: "asha".to_string().into(), scout: false, data: None, num_classes: 5, @@ -79,7 +79,7 @@ #[test] fn test_extract_paths_pull_exempt() { let cmd = Commands::Pull { - model_ref: "hf://org/repo".to_string(), + model_ref: "hf://org/repo".to_string().into(), repo: None, force: false, dry_run: false, @@ -97,7 +97,7 @@ #[test] fn test_extract_paths_rm_exempt() { let cmd = Commands::Rm { - model_ref: "model-name".to_string(), + model_ref: "model-name".to_string().into(), }; let paths = extract_model_paths(&cmd); assert!(paths.is_empty(), "Rm is a diagnostic command (exempt)"); @@ -108,8 +108,8 @@ fn test_extract_paths_canary_exempt() { let cmd = Commands::Canary { command: CanaryCommands::Check { - file: PathBuf::from("model.apr"), - canary: PathBuf::from("canary.json"), + file: PathBuf::from("model.apr").into(), + canary: PathBuf::from("canary.json").into(), }, }; let paths = extract_model_paths(&cmd); @@ -120,7 +120,7 @@ #[test] fn test_extract_paths_oracle_exempt() { let cmd = Commands::Extended(ExtendedCommands::Tools(ToolCommands::Oracle { - source: Some("model.gguf".to_string()), + source: Some("model.gguf".to_string().into()), family: None, size: None, compliance: false, @@ -141,9 +141,9 @@ let cmd = Commands::Extended(ExtendedCommands::Tools(ToolCommands::Showcase { auto_verify: false, step: None, - tier: "small".to_string(), - model_dir: PathBuf::from("./models"), - baseline: "llama-cpp,ollama".to_string(), + tier: "small".to_string().into(), + model_dir: PathBuf::from("./models").into(), + baseline: "llama-cpp,ollama".to_string().into(), zram: false, runs: 30, gpu: false, @@ -163,8 +163,8 @@ fn test_extract_paths_rosetta_convert() { let cmd = Commands::Extended(ExtendedCommands::Tools(ToolCommands::Rosetta { action: RosettaCommands::Convert { - source: PathBuf::from("model.gguf"), - target: PathBuf::from("out.safetensors"), + source: PathBuf::from("model.gguf").into(), + target: PathBuf::from("out.safetensors").into(), quantize: None, verify: false, json: false, @@ -180,9 +180,9 @@ fn test_extract_paths_rosetta_chain() { let cmd = Commands::Extended(ExtendedCommands::Tools(ToolCommands::Rosetta { action: RosettaCommands::Chain { - source: PathBuf::from("model.gguf"), - formats: vec!["safetensors".to_string(), "apr".to_string()], - work_dir: PathBuf::from("/tmp"), + source: PathBuf::from("model.gguf").into(), + formats: vec!["safetensors".to_string().into(), "apr".to_string().into()], + work_dir: PathBuf::from("/tmp").into(), json: false, }, })); @@ -195,8 +195,8 @@ fn test_extract_paths_rosetta_verify() { let cmd = Commands::Extended(ExtendedCommands::Tools(ToolCommands::Rosetta { action: RosettaCommands::Verify { - source: PathBuf::from("model.apr"), - intermediate: "safetensors".to_string(), + source: PathBuf::from("model.apr").into(), + intermediate: "safetensors".to_string().into(), tolerance: 1e-5, json: false, }, @@ -210,9 +210,9 @@ fn test_extract_paths_rosetta_compare_inference() { let cmd = Commands::Extended(ExtendedCommands::Tools(ToolCommands::Rosetta { action: RosettaCommands::CompareInference { - model_a: PathBuf::from("model_a.gguf"), - model_b: PathBuf::from("model_b.apr"), - prompt: "test".to_string(), + model_a: PathBuf::from("model_a.gguf").into(), + model_b: PathBuf::from("model_b.apr").into(), + prompt: "test".to_string().into(), max_tokens: 5, temperature: 0.0, tolerance: 0.1, @@ -231,7 +231,7 @@ fn test_extract_paths_rosetta_inspect_exempt() { let cmd = Commands::Extended(ExtendedCommands::Tools(ToolCommands::Rosetta { action: RosettaCommands::Inspect { - file: PathBuf::from("model.gguf"), + file: PathBuf::from("model.gguf").into(), hexdump: false, json: false, }, @@ -248,8 +248,8 @@ fn test_extract_paths_rosetta_diff_tensors_exempt() { let cmd = Commands::Extended(ExtendedCommands::Tools(ToolCommands::Rosetta { action: RosettaCommands::DiffTensors { - model_a: PathBuf::from("a.gguf"), - model_b: PathBuf::from("b.apr"), + model_a: PathBuf::from("a.gguf").into(), + model_b: PathBuf::from("b.apr").into(), mismatches_only: false, show_values: 0, filter: None, @@ -268,7 +268,7 @@ fn test_extract_paths_rosetta_fingerprint_exempt() { let cmd = Commands::Extended(ExtendedCommands::Tools(ToolCommands::Rosetta { action: RosettaCommands::Fingerprint { - model: PathBuf::from("model.gguf"), + model: PathBuf::from("model.gguf").into(), model_b: None, output: None, filter: None, @@ -288,7 +288,7 @@ fn test_extract_paths_rosetta_validate_stats_exempt() { let cmd = Commands::Extended(ExtendedCommands::Tools(ToolCommands::Rosetta { action: RosettaCommands::ValidateStats { - model: PathBuf::from("model.apr"), + model: PathBuf::from("model.apr").into(), reference: None, fingerprints: None, threshold: 3.0, @@ -353,7 +353,7 @@ #[test] fn test_execute_inspect_file_not_found() { let cli = make_cli(Commands::Inspect { - file: PathBuf::from("/tmp/nonexistent_model_inspect_test.apr"), + file: PathBuf::from("/tmp/nonexistent_model_inspect_test.apr").into(), vocab: false, filters: false, weights: false, @@ -371,7 +371,7 @@ #[test] fn test_execute_debug_file_not_found() { let cli = make_cli(Commands::Debug { - file: Some(PathBuf::from("/tmp/nonexistent_model_debug_test.apr")), + file: Some(PathBuf::from("/tmp/nonexistent_model_debug_test.apr").into()), action: None, drama: false, hex: false, @@ -386,7 +386,7 @@ #[test] fn test_execute_validate_file_not_found() { let cli = make_cli(Commands::Validate { - file: PathBuf::from("/tmp/nonexistent_model_validate_test.apr"), + file: PathBuf::from("/tmp/nonexistent_model_validate_test.apr").into(), quality: false, strict: false, min_score: None, @@ -402,8 +402,8 @@ #[test] fn test_execute_diff_file_not_found() { let cli = make_cli(Commands::Diff { - file1: PathBuf::from("/tmp/nonexistent_model_diff1.apr"), - file2: PathBuf::from("/tmp/nonexistent_model_diff2.apr"), + file1: PathBuf::from("/tmp/nonexistent_model_diff1.apr").into(), + file2: PathBuf::from("/tmp/nonexistent_model_diff2.apr").into(), weights: false, values: false, filter: None, @@ -422,7 +422,7 @@ #[test] fn test_execute_tensors_file_not_found() { let cli = make_cli(Commands::Tensors { - file: PathBuf::from("/tmp/nonexistent_model_tensors_test.apr"), + file: PathBuf::from("/tmp/nonexistent_model_tensors_test.apr").into(), stats: false, filter: None, limit: 0, @@ -439,7 +439,7 @@ #[test] fn test_execute_lint_file_not_found() { let cli = make_cli(Commands::Lint { - file: PathBuf::from("/tmp/nonexistent_model_lint_test.apr"), + file: PathBuf::from("/tmp/nonexistent_model_lint_test.apr").into(), strict: false, }); let result = execute_command(&cli); @@ -450,7 +450,7 @@ #[test] fn test_execute_trace_file_not_found() { let cli = make_cli(Commands::Trace { - file: PathBuf::from("/tmp/nonexistent_model_trace_test.apr"), + file: PathBuf::from("/tmp/nonexistent_model_trace_test.apr").into(), layer: None, reference: None, json: false, @@ -460,7 +460,7 @@ interactive: false, save_tensor: None, save_tensor_dir: None, - save_tensor_layers: "0..1".to_string(), + save_tensor_layers: "0..1".to_string().into(), }); let result = execute_command(&cli); assert!(result.is_err(), "Trace should fail with non-existent file"); diff --git a/crates/apr-cli/src/lib_parse.rs b/crates/apr-cli/src/lib_parse.rs index cf733773cc..7a1cbe1a50 100644 --- a/crates/apr-cli/src/lib_parse.rs +++ b/crates/apr-cli/src/lib_parse.rs @@ -21,9 +21,9 @@ task, .. } => { - assert_eq!(input, Some(PathBuf::from("audio.wav"))); - assert_eq!(language, Some("en".to_string())); - assert_eq!(task, Some("transcribe".to_string())); + assert_eq!(input, Some(PathBuf::from("audio.wav")).map(Into::into)); + assert_eq!(language, Some("en".to_string()).map(Into::into)); + assert_eq!(task, Some("transcribe".to_string()).map(Into::into)); } _ => panic!("Expected Run command"), } @@ -89,7 +89,7 @@ assert!(skip_gpu_speedup); assert!(skip_contract); assert!(skip_format_parity); - assert_eq!(safetensors_path, Some(PathBuf::from("model.safetensors"))); + assert_eq!(safetensors_path, Some(PathBuf::from("model.safetensors")).map(Into::into)); assert_eq!(iterations, 20); assert_eq!(warmup, 5); assert_eq!(max_tokens, 64); @@ -185,9 +185,9 @@ assert_eq!(warmup, 10); assert_eq!(iterations, 20); assert_eq!(max_tokens, 64); - assert_eq!(prompt, Some("The quick brown fox".to_string())); + assert_eq!(prompt, Some("The quick brown fox".to_string()).map(Into::into)); assert!(fast); - assert_eq!(brick, Some("attention".to_string())); + assert_eq!(brick, Some("attention".to_string()).map(Into::into)); } _ => panic!("Expected Bench command"), } @@ -221,10 +221,10 @@ simulated, .. }) => { - assert_eq!(model_path, Some(PathBuf::from("model.gguf"))); + assert_eq!(model_path, Some(PathBuf::from("model.gguf")).map(Into::into)); assert!(speculative); assert_eq!(speculation_k, 8); - assert_eq!(draft_model, Some(PathBuf::from("draft.gguf"))); + assert_eq!(draft_model, Some(PathBuf::from("draft.gguf")).map(Into::into)); assert_eq!(concurrent, 4); assert!(simulated); } @@ -260,8 +260,8 @@ assert!(energy); assert!(perf_grade); assert!(callgraph); - assert_eq!(compare_hf, Some("openai/whisper-tiny".to_string())); - assert_eq!(output, Some(PathBuf::from("/tmp/flame.svg"))); + assert_eq!(compare_hf, Some("openai/whisper-tiny".to_string()).map(Into::into)); + assert_eq!(output, Some(PathBuf::from("/tmp/flame.svg")).map(Into::into)); } _ => panic!("Expected Profile command"), } @@ -300,15 +300,15 @@ profile, .. }) => { - assert_eq!(system, Some("You are a helpful assistant.".to_string())); + assert_eq!(system, Some("You are a helpful assistant.".to_string()).map(Into::into)); assert!(inspect); assert!(trace); assert_eq!( trace_steps, - Some(vec!["Tokenize".to_string(), "Decode".to_string()]) + Some(vec!["Tokenize".to_string(), "Decode".to_string()]).map(|v| v.into_iter().map(Into::into).collect()) ); assert!(trace_verbose); - assert_eq!(trace_output, Some(PathBuf::from("/tmp/chat-trace.json"))); + assert_eq!(trace_output, Some(PathBuf::from("/tmp/chat-trace.json")).map(Into::into)); assert_eq!(trace_level, "payload"); assert!(profile); } @@ -335,7 +335,7 @@ quiet, .. })) => { - assert_eq!(step, Some("bench".to_string())); + assert_eq!(step, Some("bench".to_string()).map(Into::into)); assert_eq!(tier, "tiny"); assert!(zram); assert_eq!(runs, 50); @@ -423,7 +423,7 @@ assert_eq!(model_b, PathBuf::from("test.apr")); assert!(mismatches_only); assert_eq!(show_values, 5); - assert_eq!(filter, Some("lm_head".to_string())); + assert_eq!(filter, Some("lm_head".to_string()).map(Into::into)); assert!(json); } _ => panic!("Expected DiffTensors subcommand"), diff --git a/crates/apr-cli/src/lib_parse_eval.rs b/crates/apr-cli/src/lib_parse_eval.rs index 3004151ce9..2d90e93b64 100644 --- a/crates/apr-cli/src/lib_parse_eval.rs +++ b/crates/apr-cli/src/lib_parse_eval.rs @@ -32,7 +32,7 @@ }) => { assert_eq!(file, PathBuf::from("model.gguf")); assert_eq!(dataset, "lambada"); - assert_eq!(text, Some("The quick brown fox".to_string())); + assert_eq!(text, Some("The quick brown fox".to_string()).map(Into::into)); assert_eq!(max_tokens, 256); assert!((threshold - 15.5).abs() < f32::EPSILON); assert!(task.is_none()); @@ -90,7 +90,7 @@ json: _, }) => { assert_eq!(file, PathBuf::from("model.apr")); - assert_eq!(layer, Some("encoder.0".to_string())); + assert_eq!(layer, Some("encoder.0".to_string()).map(Into::into)); assert_eq!(component, "encoder"); assert!(verbose); } @@ -145,7 +145,7 @@ .. }) => { assert_eq!(file, PathBuf::from("model.apr")); - assert_eq!(tensor, Some("embed.weight".to_string())); + assert_eq!(tensor, Some("embed.weight".to_string()).map(Into::into)); assert_eq!(limit, 128); assert!(stats); assert!(list); @@ -204,7 +204,7 @@ depth, }) => { assert_eq!(file, PathBuf::from("model.apr")); - assert_eq!(filter, Some("encoder".to_string())); + assert_eq!(filter, Some("encoder".to_string()).map(Into::into)); assert_eq!(format, crate::commands::tree::TreeFormat::Mermaid); assert!(sizes); assert_eq!(depth, Some(3)); @@ -273,8 +273,8 @@ assert_eq!(file, PathBuf::from("model.apr")); assert_eq!(output, PathBuf::from("/tmp/probar")); assert_eq!(format, "json"); - assert_eq!(golden, Some(PathBuf::from("/refs/golden"))); - assert_eq!(layer, Some("layer.0".to_string())); + assert_eq!(golden, Some(PathBuf::from("/refs/golden")).map(Into::into)); + assert_eq!(layer, Some("layer.0".to_string()).map(Into::into)); assert!(!assert); assert!((tolerance - 0.98).abs() < 0.01); }, @@ -333,7 +333,7 @@ strings, limit, } => { - assert_eq!(file, Some(PathBuf::from("model.apr"))); + assert_eq!(file, Some(PathBuf::from("model.apr")).map(Into::into)); assert!(action.is_none(), "no subcommand was given"); assert!(drama); assert!(hex); @@ -373,7 +373,7 @@ let cli = parse_cli(args).expect("Failed to parse"); match *cli.command { Commands::Tui { file } => { - assert_eq!(file, Some(PathBuf::from("model.apr"))); + assert_eq!(file, Some(PathBuf::from("model.apr")).map(Into::into)); } _ => panic!("Expected Tui command"), } @@ -424,12 +424,12 @@ allow_no_config, } => { assert_eq!(source, "hf://openai/whisper-tiny"); - assert_eq!(output, Some(PathBuf::from("whisper.apr"))); + assert_eq!(output, Some(PathBuf::from("whisper.apr")).map(Into::into)); assert_eq!(arch, "whisper"); - assert_eq!(quantize, Some("int8".to_string())); + assert_eq!(quantize, Some("int8".to_string()).map(Into::into)); assert!(strict); assert!(preserve_q4k); - assert_eq!(tokenizer, Some(PathBuf::from("/path/to/tokenizer.json"))); + assert_eq!(tokenizer, Some(PathBuf::from("/path/to/tokenizer.json")).map(Into::into)); assert!(!enforce_provenance); assert!(!allow_no_config); } diff --git a/crates/apr-cli/src/lib_parse_export.rs b/crates/apr-cli/src/lib_parse_export.rs index 2ea477e5a1..353fdcf74d 100644 --- a/crates/apr-cli/src/lib_parse_export.rs +++ b/crates/apr-cli/src/lib_parse_export.rs @@ -22,10 +22,10 @@ quantize, .. } => { - assert_eq!(file, Some(PathBuf::from("model.apr"))); + assert_eq!(file, Some(PathBuf::from("model.apr")).map(Into::into)); assert_eq!(format, "gguf"); - assert_eq!(output, Some(PathBuf::from("model.gguf"))); - assert_eq!(quantize, Some("int4".to_string())); + assert_eq!(output, Some(PathBuf::from("model.gguf")).map(Into::into)); + assert_eq!(quantize, Some("int4".to_string()).map(Into::into)); } _ => panic!("Expected Export command"), } @@ -72,8 +72,8 @@ force, } => { assert_eq!(file, PathBuf::from("model.apr")); - assert_eq!(quantize, Some("q4k".to_string())); - assert_eq!(compress, Some("zstd".to_string())); + assert_eq!(quantize, Some("q4k".to_string()).map(Into::into)); + assert_eq!(compress, Some("zstd".to_string()).map(Into::into)); assert_eq!(output, PathBuf::from("model-q4k.apr")); assert!(force); } @@ -130,7 +130,7 @@ family, size, })) => { - assert_eq!(source, Some("model.gguf".to_string())); + assert_eq!(source, Some("model.gguf".to_string()).map(Into::into)); assert!(compliance); assert!(tensors); assert!(stats); @@ -158,8 +158,8 @@ .. })) => { assert!(source.is_none()); - assert_eq!(family, Some("qwen2".to_string())); - assert_eq!(size, Some("7b".to_string())); + assert_eq!(family, Some("qwen2".to_string()).map(Into::into)); + assert_eq!(size, Some("7b".to_string()).map(Into::into)); } _ => panic!("Expected Oracle command"), } @@ -172,7 +172,7 @@ let cli = parse_cli(args).expect("Failed to parse"); match *cli.command { Commands::Extended(ExtendedCommands::Tools(ToolCommands::Oracle { source, .. })) => { - assert_eq!(source, Some("hf://Qwen/Qwen2.5-Coder-1.5B".to_string())); + assert_eq!(source, Some("hf://Qwen/Qwen2.5-Coder-1.5B".to_string()).map(Into::into)); } _ => panic!("Expected Oracle command"), } @@ -259,7 +259,7 @@ }) => { assert_eq!(file, PathBuf::from("model.apr")); assert_eq!(hf, "openai/whisper-tiny"); - assert_eq!(tensor, Some("encoder.0".to_string())); + assert_eq!(tensor, Some("encoder.0".to_string()).map(Into::into)); assert!((threshold - 1e-3).abs() < f64::EPSILON); assert!(json); } @@ -403,14 +403,14 @@ json, .. }) => { - assert_eq!(file, Some(PathBuf::from("model.apr"))); + assert_eq!(file, Some(PathBuf::from("model.apr")).map(Into::into)); assert_eq!(method, "lora"); assert_eq!(rank, Some(16)); assert!((vram - 24.0).abs() < f64::EPSILON); assert!(plan); - assert_eq!(model, Some("7B".to_string())); + assert_eq!(model, Some("7B".to_string()).map(Into::into)); assert!(freeze_base); - assert_eq!(train_data, Some(PathBuf::from("data.jsonl"))); + assert_eq!(train_data, Some(PathBuf::from("data.jsonl")).map(Into::into)); assert!(json); } _ => panic!("Expected Tune command"), diff --git a/crates/apr-cli/src/lib_parse_ptx_debug_4062.rs b/crates/apr-cli/src/lib_parse_ptx_debug_4062.rs new file mode 100644 index 0000000000..b9542ec101 --- /dev/null +++ b/crates/apr-cli/src/lib_parse_ptx_debug_4062.rs @@ -0,0 +1,64 @@ +// #4062: `apr ptx-debug` absorbs the `aprender-ptx-debug` binary. These pin +// that apr parses its argv, dispatches it to the same library code, and never +// turns a failing analysis verdict into exit 0. + +const PTX_HEADER_ONLY: &str = ".version 8.0\n.target sm_70\n.address_size 64\n"; + +#[test] +fn apr_ptx_debug_parses_the_standalone_analyze_argv() { + let cli = parse_cli(vec![ + "apr", + "ptx-debug", + "analyze", + "k.ptx", + "--min-score", + "80", + "--json", + ]) + .expect("apr ptx-debug analyze must parse"); + match *cli.command { + Commands::PtxDebug(trueno_ptx_debug::cli::Command::Analyze(a)) => { + assert_eq!(a.file, "k.ptx"); + assert!((a.min_score - 80.0).abs() < f64::EPSILON); + assert!(a.json); + } + other => panic!("expected ptx-debug analyze, got {other:?}"), + } + assert!(parse_cli(vec!["apr", "ptx-debug", "bogus"]).is_err()); +} + +#[test] +fn apr_ptx_debug_gen_fkr_dispatches_to_the_library() { + let dir = tempfile::tempdir().expect("tempdir"); + let ptx = dir.path().join("k.ptx"); + let out = dir.path().join("fkr.rs"); + std::fs::write(&ptx, PTX_HEADER_ONLY).expect("write ptx"); + let cli = parse_cli_owned(vec![ + "apr".into(), + "ptx-debug".into(), + "gen-fkr".into(), + ptx.display().to_string(), + "-o".into(), + out.display().to_string(), + ]) + .expect("apr ptx-debug gen-fkr must parse"); + let result = dispatch_sibling_cli_commands(&cli).expect("ptx-debug is a sibling CLI route"); + assert!(result.is_ok(), "gen-fkr failed: {result:?}"); + let written = std::fs::read_to_string(&out).expect("gen-fkr must write -o"); + assert!(!written.trim().is_empty()); +} + +#[test] +fn apr_ptx_debug_a_failing_verdict_is_never_exit_zero() { + assert!(ptx_debug_result(Ok(0)).is_ok()); + let mut messages = Vec::new(); + for code in [1, 2, 3, 42] { + let err = ptx_debug_result(Ok(code)).expect_err("non-zero verdict must fail"); + assert_eq!(err.exit_code_value(), 5, "verdict {code}"); + messages.push(err.to_string()); + } + messages.dedup(); + assert_eq!(messages.len(), 4, "each verdict names itself: {messages:?}"); + let io = ptx_debug_result(Err("Failed to read x.ptx".into())).expect_err("io error"); + assert!(io.to_string().contains("Failed to read x.ptx")); +} diff --git a/crates/apr-cli/src/lib_parse_rosetta.rs b/crates/apr-cli/src/lib_parse_rosetta.rs index d96b2f2fbb..66d5336f35 100644 --- a/crates/apr-cli/src/lib_parse_rosetta.rs +++ b/crates/apr-cli/src/lib_parse_rosetta.rs @@ -136,7 +136,7 @@ // Diagnostic commands should return no paths (exempt from validation) let diagnostic_commands = vec![ Commands::Inspect { - file: PathBuf::from("m.apr"), + file: PathBuf::from("m.apr").into(), vocab: false, filters: false, weights: false, @@ -144,7 +144,7 @@ quality: false, }, Commands::Debug { - file: Some(PathBuf::from("m.apr")), + file: Some(PathBuf::from("m.apr").into()), action: None, drama: false, hex: false, @@ -152,24 +152,24 @@ limit: 256, }, Commands::Validate { - file: PathBuf::from("m.apr"), + file: PathBuf::from("m.apr").into(), quality: false, strict: false, min_score: None, }, Commands::Tensors { - file: PathBuf::from("m.apr"), + file: PathBuf::from("m.apr").into(), stats: false, filter: None, limit: 0, json: false, }, Commands::Lint { - file: PathBuf::from("m.apr"), + file: PathBuf::from("m.apr").into(), strict: false, }, Commands::Extended(ExtendedCommands::Qa { - file: PathBuf::from("m.apr"), + file: PathBuf::from("m.apr").into(), assert_tps: None, assert_speedup: None, assert_gpu_speedup: None, @@ -195,7 +195,7 @@ assert_classifier_head: false, }), Commands::Extended(ExtendedCommands::Hex { - file: PathBuf::from("m.apr"), + file: PathBuf::from("m.apr").into(), tensor: None, limit: 64, stats: false, @@ -207,21 +207,21 @@ contract: false, entropy: false, raw: false, - offset: "0".to_string(), + offset: "0".to_string().into(), width: 16, slice: None, }), Commands::Extended(ExtendedCommands::Tree { - file: PathBuf::from("m.apr"), + file: PathBuf::from("m.apr").into(), filter: None, format: crate::commands::tree::TreeFormat::Ascii, sizes: false, depth: None, }), Commands::Extended(ExtendedCommands::Flow { - file: PathBuf::from("m.apr"), + file: PathBuf::from("m.apr").into(), layer: None, - component: "full".to_string(), + component: "full".to_string().into(), verbose: false, json: false, }), @@ -250,9 +250,9 @@ fn test_extract_paths_action_commands() { let serve_cmd = Commands::Serve { command: ServeCommands::Run { - file: Some(PathBuf::from("model.gguf")), + file: Some(PathBuf::from("model.gguf").into()), port: 8080, - host: "127.0.0.1".to_string(), + host: "127.0.0.1".to_string().into(), no_cors: false, no_metrics: false, no_gpu: false, @@ -261,7 +261,7 @@ list_devices: false, batch: false, trace: false, - trace_level: "basic".to_string(), + trace_level: "basic".to_string().into(), profile: false, backend: BackendArg::default(), otlp_endpoint: None, @@ -275,7 +275,7 @@ assert_eq!(paths, Vec::::new()); let bench_cmd = Commands::Extended(ExtendedCommands::Bench { - file: PathBuf::from("model.apr"), + file: PathBuf::from("model.apr").into(), warmup: 3, iterations: 5, max_tokens: 32, @@ -292,7 +292,7 @@ #[test] fn test_extract_paths_run_hf_url() { let cmd = Commands::Run { - source: "hf://org/repo".to_string(), + source: "hf://org/repo".to_string().into(), positional_prompt: None, input: None, prompt: None, @@ -338,12 +338,12 @@ fn test_extract_paths_merge_multiple() { let cmd = Commands::Merge { files: vec![ - PathBuf::from("a.apr"), - PathBuf::from("b.apr"), - PathBuf::from("c.apr"), + PathBuf::from("a.apr").into(), + PathBuf::from("b.apr").into(), + PathBuf::from("c.apr").into(), ], - strategy: "average".to_string(), - output: Some(PathBuf::from("merged.apr")), + strategy: "average".to_string().into(), + output: Some(PathBuf::from("merged.apr").into()), weights: None, base_model: None, drop_rate: 0.9, @@ -413,19 +413,19 @@ })) => { assert_eq!(directory, PathBuf::from("/tmp/models")); assert_eq!(repo_id, "paiml/whisper-apr-tiny"); - assert_eq!(model_name, Some("Whisper Tiny".to_string())); + assert_eq!(model_name, Some("Whisper Tiny".to_string()).map(Into::into)); assert_eq!(license, "apache-2.0"); assert_eq!(pipeline_tag, "automatic-speech-recognition"); - assert_eq!(library_name, Some("whisper-apr".to_string())); + assert_eq!(library_name, Some("whisper-apr".to_string()).map(Into::into)); assert_eq!( tags, Some(vec![ "whisper".to_string(), "tiny".to_string(), "asr".to_string() - ]) + ]).map(|v| v.into_iter().map(Into::into).collect()) ); - assert_eq!(message, Some("Initial release".to_string())); + assert_eq!(message, Some("Initial release".to_string()).map(Into::into)); assert!(dry_run); } _ => panic!("Expected Publish command"), diff --git a/crates/apr-cli/src/lib_parse_rosetta_02.rs b/crates/apr-cli/src/lib_parse_rosetta_02.rs index 48880ae5cd..074b975ef1 100644 --- a/crates/apr-cli/src/lib_parse_rosetta_02.rs +++ b/crates/apr-cli/src/lib_parse_rosetta_02.rs @@ -27,9 +27,9 @@ json, } => { assert_eq!(model, PathBuf::from("model.gguf")); - assert_eq!(model_b, Some(PathBuf::from("model2.apr"))); - assert_eq!(output, Some(PathBuf::from("fingerprints.json"))); - assert_eq!(filter, Some("encoder".to_string())); + assert_eq!(model_b, Some(PathBuf::from("model2.apr")).map(Into::into)); + assert_eq!(output, Some(PathBuf::from("fingerprints.json")).map(Into::into)); + assert_eq!(filter, Some("encoder".to_string()).map(Into::into)); assert!(verbose); assert!(json); } @@ -68,8 +68,8 @@ json, } => { assert_eq!(model, PathBuf::from("model.apr")); - assert_eq!(reference, Some(PathBuf::from("ref.gguf"))); - assert_eq!(fingerprints, Some(PathBuf::from("fp.json"))); + assert_eq!(reference, Some(PathBuf::from("ref.gguf")).map(Into::into)); + assert_eq!(fingerprints, Some(PathBuf::from("fp.json")).map(Into::into)); assert!((threshold - 5.0).abs() < f32::EPSILON); assert!(strict); assert!(json); @@ -141,9 +141,9 @@ #[test] fn test_extract_paths_export() { let cmd = Commands::Export { - file: Some(PathBuf::from("model.apr")), - format: "gguf".to_string(), - output: Some(PathBuf::from("out.gguf")), + file: Some(PathBuf::from("model.apr").into()), + format: "gguf".to_string().into(), + output: Some(PathBuf::from("out.gguf").into()), quantize: None, list_formats: false, batch: None, @@ -159,10 +159,10 @@ #[test] fn test_extract_paths_convert() { let cmd = Commands::Convert { - file: PathBuf::from("model.apr"), - quantize: Some("q4k".to_string()), + file: PathBuf::from("model.apr").into(), + quantize: Some("q4k".to_string().into()), compress: None, - output: PathBuf::from("out.apr"), + output: PathBuf::from("out.apr").into(), force: false, }; let paths = extract_model_paths(&cmd); @@ -173,7 +173,7 @@ #[test] fn test_extract_paths_check() { let cmd = Commands::Check { - file: PathBuf::from("model.gguf"), + file: PathBuf::from("model.gguf").into(), no_gpu: false, json: false, }; @@ -185,7 +185,7 @@ #[test] fn test_extract_paths_trace() { let cmd = Commands::Trace { - file: PathBuf::from("model.apr"), + file: PathBuf::from("model.apr").into(), layer: None, reference: None, json: false, @@ -195,7 +195,7 @@ interactive: false, save_tensor: None, save_tensor_dir: None, - save_tensor_layers: "0..1".to_string(), + save_tensor_layers: "0..1".to_string().into(), }; let paths = extract_model_paths(&cmd); assert_eq!(paths, vec![PathBuf::from("model.apr")]); @@ -207,9 +207,9 @@ use TestSubcommand; let cmd = Commands::Extended(ExtendedCommands::Test { command: TestSubcommand::Tensor { - file: PathBuf::from("model.apr"), - output: PathBuf::from("./probar-export"), - format: "both".to_string(), + file: PathBuf::from("model.apr").into(), + output: PathBuf::from("./probar-export").into(), + format: "both".to_string().into(), golden: None, layer: None, assert: false, @@ -224,8 +224,8 @@ #[test] fn test_extract_paths_compare_hf() { let cmd = Commands::Extended(ExtendedCommands::CompareHf { - file: PathBuf::from("model.apr"), - hf: "openai/whisper-tiny".to_string(), + file: PathBuf::from("model.apr").into(), + hf: "openai/whisper-tiny".to_string().into(), tensor: None, threshold: 1e-5, json: false, @@ -238,7 +238,7 @@ #[test] fn test_extract_paths_chat() { let cmd = Commands::Extended(ExtendedCommands::Chat { - file: PathBuf::from("model.gguf"), + file: PathBuf::from("model.gguf").into(), temperature: 0.7, top_p: 0.9, max_tokens: 512, @@ -263,8 +263,8 @@ #[test] fn test_extract_paths_eval() { let cmd = Commands::Extended(ExtendedCommands::Eval { - file: PathBuf::from("model.gguf"), - dataset: "wikitext-2".to_string(), + file: PathBuf::from("model.gguf").into(), + dataset: "wikitext-2".to_string().into(), text: None, max_tokens: 512, threshold: 20.0, @@ -285,9 +285,9 @@ #[test] fn test_extract_paths_profile() { let cmd = Commands::Extended(ExtendedCommands::Profile { - file: PathBuf::from("model.apr"), + file: PathBuf::from("model.apr").into(), granular: false, - format: "human".to_string(), + format: "human".to_string().into(), focus: None, detect_naive: false, threshold: 10.0, @@ -316,9 +316,9 @@ #[test] fn test_extract_paths_import_hf_url() { let cmd = Commands::Import { - source: "hf://openai/whisper-tiny".to_string(), - output: Some(PathBuf::from("whisper.apr")), - arch: "auto".to_string(), + source: "hf://openai/whisper-tiny".to_string().into(), + output: Some(PathBuf::from("whisper.apr").into()), + arch: "auto".to_string().into(), quantize: None, strict: false, preserve_q4k: false, @@ -337,9 +337,9 @@ #[test] fn test_extract_paths_import_nonexistent_local() { let cmd = Commands::Import { - source: "/tmp/nonexistent_model_abc123.gguf".to_string(), + source: "/tmp/nonexistent_model_abc123.gguf".to_string().into(), output: None, - arch: "auto".to_string(), + arch: "auto".to_string().into(), quantize: None, strict: false, preserve_q4k: false, @@ -358,7 +358,7 @@ #[test] fn test_extract_paths_tui_with_file() { let cmd = Commands::Tui { - file: Some(PathBuf::from("model.apr")), + file: Some(PathBuf::from("model.apr").into()), }; let paths = extract_model_paths(&cmd); assert_eq!(paths, vec![PathBuf::from("model.apr")]); @@ -378,7 +378,7 @@ let cmd = Commands::Extended(ExtendedCommands::Cbtop { model: None, attach: None, - model_path: Some(PathBuf::from("model.gguf")), + model_path: Some(PathBuf::from("model.gguf").into()), headless: false, json: false, output: None, @@ -401,7 +401,7 @@ #[test] fn test_extract_paths_cbtop_no_model_path() { let cmd = Commands::Extended(ExtendedCommands::Cbtop { - model: Some("qwen2.5-coder".to_string()), + model: Some("qwen2.5-coder".to_string().into()), attach: None, model_path: None, headless: false, @@ -426,8 +426,8 @@ #[test] fn test_extract_paths_diff_exempt() { let cmd = Commands::Diff { - file1: PathBuf::from("a.apr"), - file2: PathBuf::from("b.apr"), + file1: PathBuf::from("a.apr").into(), + file2: PathBuf::from("b.apr").into(), weights: false, values: false, filter: None, @@ -446,7 +446,7 @@ #[test] fn test_extract_paths_hex_exempt() { let cmd = Commands::Extended(ExtendedCommands::Hex { - file: PathBuf::from("model.apr"), + file: PathBuf::from("model.apr").into(), tensor: None, limit: 64, stats: false, @@ -458,7 +458,7 @@ contract: false, entropy: false, raw: false, - offset: "0".to_string(), + offset: "0".to_string().into(), width: 16, slice: None, }); diff --git a/crates/apr-cli/src/lib_parse_tensors.rs b/crates/apr-cli/src/lib_parse_tensors.rs index f3d27c4734..09e1a84401 100644 --- a/crates/apr-cli/src/lib_parse_tensors.rs +++ b/crates/apr-cli/src/lib_parse_tensors.rs @@ -24,7 +24,7 @@ } => { assert_eq!(file, PathBuf::from("model.apr")); assert!(stats); - assert_eq!(filter, Some("encoder".to_string())); + assert_eq!(filter, Some("encoder".to_string()).map(Into::into)); assert_eq!(limit, 20); assert!(json); } @@ -44,7 +44,7 @@ tensor, .. } => { - assert_eq!(code_or_file, Some("E001".to_string())); + assert_eq!(code_or_file, Some("E001".to_string()).map(Into::into)); assert!(file.is_none()); assert!(tensor.is_none()); } @@ -72,8 +72,8 @@ .. } => { assert!(code_or_file.is_none()); - assert_eq!(file, Some(PathBuf::from("model.apr"))); - assert_eq!(tensor, Some("embed.weight".to_string())); + assert_eq!(file, Some(PathBuf::from("model.apr")).map(Into::into)); + assert_eq!(tensor, Some("embed.weight".to_string()).map(Into::into)); } _ => panic!("Expected Explain command"), } @@ -112,8 +112,8 @@ save_tensor_layers: _, } => { assert_eq!(file, PathBuf::from("model.apr")); - assert_eq!(layer, Some("layer.0".to_string())); - assert_eq!(reference, Some(PathBuf::from("ref.apr"))); + assert_eq!(layer, Some("layer.0".to_string()).map(Into::into)); + assert_eq!(reference, Some(PathBuf::from("ref.apr")).map(Into::into)); assert!(json); assert!(verbose); assert!(payload); @@ -171,7 +171,7 @@ assert_eq!(file1, PathBuf::from("a.apr")); assert_eq!(file2, PathBuf::from("b.apr")); assert!(values); - assert_eq!(filter, Some("embed".to_string())); + assert_eq!(filter, Some("embed".to_string()).map(Into::into)); assert_eq!(limit, 5); assert!(transpose_aware); assert!(json); @@ -200,7 +200,7 @@ .. } => { assert!(chat); - assert_eq!(prompt, Some("Hello world".to_string())); + assert_eq!(prompt, Some("Hello world".to_string()).map(Into::into)); assert_eq!(source, "model.gguf"); } _ => panic!("Expected Run command"), @@ -248,7 +248,7 @@ .. } => { assert_eq!(source, "model.gguf"); - assert_eq!(positional_prompt, Some("What is 2+2?".to_string())); + assert_eq!(positional_prompt, Some("What is 2+2?".to_string()).map(Into::into)); assert_eq!(prompt, None); } _ => panic!("Expected Run command"), @@ -273,8 +273,8 @@ prompt, .. } => { - assert_eq!(positional_prompt, Some("positional text".to_string())); - assert_eq!(prompt, Some("flag text".to_string())); + assert_eq!(positional_prompt, Some("positional text".to_string()).map(Into::into)); + assert_eq!(prompt, Some("flag text".to_string()).map(Into::into)); } _ => panic!("Expected Run command"), } @@ -293,7 +293,7 @@ .. } => { assert_eq!(source, "model.gguf"); - assert_eq!(positional_prompt, Some("What is 2+2?".to_string())); + assert_eq!(positional_prompt, Some("What is 2+2?".to_string()).map(Into::into)); assert_eq!(max_tokens, 64); } _ => panic!("Expected Run command"), @@ -366,10 +366,10 @@ "Tokenize".to_string(), "Embed".to_string(), "Attention".to_string() - ]) + ]).map(|v| v.into_iter().map(Into::into).collect()) ); assert!(trace_verbose); - assert_eq!(trace_output, Some(PathBuf::from("/tmp/trace.json"))); + assert_eq!(trace_output, Some(PathBuf::from("/tmp/trace.json")).map(Into::into)); assert_eq!(trace_level, "layer"); } _ => panic!("Expected Run command"), diff --git a/crates/apr-cli/src/lib_verbose_inheritance_parse.rs b/crates/apr-cli/src/lib_verbose_inheritance_parse.rs index 16b7bdbf39..2ac1333566 100644 --- a/crates/apr-cli/src/lib_verbose_inheritance_parse.rs +++ b/crates/apr-cli/src/lib_verbose_inheritance_parse.rs @@ -161,7 +161,7 @@ fn test_execute_with_contract_gate_nonexistent() { let cli = Cli { command: Box::new(Commands::Inspect { - file: PathBuf::from("/tmp/nonexistent_contract_test.apr"), + file: PathBuf::from("/tmp/nonexistent_contract_test.apr").into(), vocab: false, filters: false, weights: false, @@ -207,7 +207,7 @@ fn test_execute_rosetta_inspect_file_not_found() { let cli = make_cli(Commands::Extended(ExtendedCommands::Tools(ToolCommands::Rosetta { action: RosettaCommands::Inspect { - file: PathBuf::from("/tmp/nonexistent_rosetta_inspect.gguf"), + file: PathBuf::from("/tmp/nonexistent_rosetta_inspect.gguf").into(), hexdump: false, json: false, }, @@ -224,8 +224,8 @@ fn test_execute_rosetta_convert_file_not_found() { let cli = make_cli(Commands::Extended(ExtendedCommands::Tools(ToolCommands::Rosetta { action: RosettaCommands::Convert { - source: PathBuf::from("/tmp/nonexistent_rosetta_convert.gguf"), - target: PathBuf::from("/tmp/out.safetensors"), + source: PathBuf::from("/tmp/nonexistent_rosetta_convert.gguf").into(), + target: PathBuf::from("/tmp/out.safetensors").into(), quantize: None, verify: false, json: false, @@ -244,7 +244,7 @@ fn test_execute_rosetta_fingerprint_file_not_found() { let cli = make_cli(Commands::Extended(ExtendedCommands::Tools(ToolCommands::Rosetta { action: RosettaCommands::Fingerprint { - model: PathBuf::from("/tmp/nonexistent_rosetta_fingerprint.gguf"), + model: PathBuf::from("/tmp/nonexistent_rosetta_fingerprint.gguf").into(), model_b: None, output: None, filter: None, @@ -263,7 +263,7 @@ #[test] fn test_execute_bench_file_not_found() { let cli = make_cli(Commands::Extended(ExtendedCommands::Bench { - file: PathBuf::from("/tmp/nonexistent_model_bench_test.gguf"), + file: PathBuf::from("/tmp/nonexistent_model_bench_test.gguf").into(), warmup: 1, iterations: 1, max_tokens: 1, @@ -280,8 +280,8 @@ #[test] fn test_execute_eval_file_not_found() { let cli = make_cli(Commands::Extended(ExtendedCommands::Eval { - file: PathBuf::from("/tmp/nonexistent_model_eval_test.gguf"), - dataset: "wikitext-2".to_string(), + file: PathBuf::from("/tmp/nonexistent_model_eval_test.gguf").into(), + dataset: "wikitext-2".to_string().into(), text: None, max_tokens: 32, threshold: 20.0, @@ -302,9 +302,9 @@ #[test] fn test_execute_profile_file_not_found() { let cli = make_cli(Commands::Extended(ExtendedCommands::Profile { - file: PathBuf::from("/tmp/nonexistent_model_profile_test.apr"), + file: PathBuf::from("/tmp/nonexistent_model_profile_test.apr").into(), granular: false, - format: "human".to_string(), + format: "human".to_string().into(), focus: None, detect_naive: false, threshold: 10.0, @@ -336,8 +336,8 @@ #[test] fn test_execute_compare_hf_file_not_found() { let cli = make_cli(Commands::Extended(ExtendedCommands::CompareHf { - file: PathBuf::from("/tmp/nonexistent_model_compare_hf_test.apr"), - hf: "openai/whisper-tiny".to_string(), + file: PathBuf::from("/tmp/nonexistent_model_compare_hf_test.apr").into(), + hf: "openai/whisper-tiny".to_string().into(), tensor: None, threshold: 1e-5, json: false, @@ -354,8 +354,8 @@ fn test_execute_canary_check_file_not_found() { let cli = make_cli(Commands::Canary { command: CanaryCommands::Check { - file: PathBuf::from("/tmp/nonexistent_canary_check.apr"), - canary: PathBuf::from("/tmp/nonexistent_canary.json"), + file: PathBuf::from("/tmp/nonexistent_canary_check.apr").into(), + canary: PathBuf::from("/tmp/nonexistent_canary.json").into(), }, }); let result = execute_command(&cli); @@ -369,11 +369,11 @@ #[test] fn test_execute_publish_dir_not_found() { let cli = make_cli(Commands::Extended(ExtendedCommands::Tools(ToolCommands::Publish { - directory: PathBuf::from("/tmp/nonexistent_publish_dir_test"), - repo_id: "test/test".to_string(), + directory: PathBuf::from("/tmp/nonexistent_publish_dir_test").into(), + repo_id: "test/test".to_string().into(), model_name: None, - license: "mit".to_string(), - pipeline_tag: "text-generation".to_string(), + license: "mit".to_string().into(), + pipeline_tag: "text-generation".to_string().into(), library_name: None, tags: None, message: None, diff --git a/crates/apr-cli/src/main.rs b/crates/apr-cli/src/main.rs index b3b60e2bd7..02891ac9d0 100644 --- a/crates/apr-cli/src/main.rs +++ b/crates/apr-cli/src/main.rs @@ -1,88 +1,11 @@ //! apr - APR Model Operations CLI //! -//! Entry point shim. See lib.rs for implementation. - -use apr_cli::{execute_command, Cli}; -use clap::Parser; -use colored::control; -use std::process::ExitCode; - -#[cfg(feature = "dhat-heap")] -#[global_allocator] -static ALLOC: dhat::Alloc = dhat::Alloc; - -/// GH-667: Reset SIGPIPE to SIG_DFL so piping to head/less exits cleanly. -#[cfg(unix)] -#[allow(unsafe_code)] -fn reset_sigpipe() { - // SAFETY: signal(SIGPIPE, SIG_DFL) is async-signal-safe per POSIX. - // Called once at program start before any threads are spawned. - unsafe { - libc::signal(libc::SIGPIPE, libc::SIG_DFL); - } -} - -#[cfg(not(unix))] -fn reset_sigpipe() {} - -/// GH-646: Clear FPCR.FZ16 on aarch64 so f16 subnormal scales aren't flushed to zero. -/// The ARM `fcvt s, h` instruction (used by the `half` crate) respects FPCR.FZ16. -/// When set (e.g., by NVIDIA's CUDA runtime on Jetson), subnormal f16 scale values -/// in Q6_K super-blocks are flushed to 0.0, causing `d * scale * q = 0` for all elements. -/// This produces all-zero tensors that pass on x86_64 but fail on aarch64. -#[cfg(target_arch = "aarch64")] -#[allow(unsafe_code)] -fn clear_fz16() { - // SAFETY: Reading/writing FPCR is safe — it only affects floating-point behavior - // for the current thread. Called once at program start before any FP operations. - unsafe { - let fpcr: u64; - core::arch::asm!("mrs {}, fpcr", out(reg) fpcr); - if fpcr & (1 << 19) != 0 { - // FZ16 is set — clear it - let new_fpcr = fpcr & !(1 << 19); - core::arch::asm!("msr fpcr, {}", in(reg) new_fpcr); - } - } -} - -#[cfg(not(target_arch = "aarch64"))] -fn clear_fz16() {} - -fn main() -> ExitCode { - // GH-667: Reset SIGPIPE to default so piping to head/less doesn't panic. - reset_sigpipe(); - // GH-646: Clear FPCR.FZ16 on ARM so f16 subnormals dequantize correctly. - clear_fz16(); - - #[cfg(feature = "dhat-heap")] - let _profiler = dhat::Profiler::new_heap(); - // GH-662: Respect NO_COLOR env var and non-TTY output. - // The `colored` crate's auto-detect doesn't reliably work in all environments. - let no_color = std::env::var("NO_COLOR").is_ok(); - let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout()); - if no_color || !is_tty { - control::set_override(false); - } - // FALSIFY-GPUTRAIN-007 / INV-GPUTRAIN-007: intercept `apr --version - // --json` BEFORE clap exits on the plain --version flag. Emits the - // 3-key CUDA metadata schema required by gputrain_007. Handler lives - // in lib.rs::emit_version_json to keep the schema next to its tests. - let raw: Vec = std::env::args().collect(); - if raw.iter().any(|a| a == "--version") && raw.iter().any(|a| a == "--json") { - apr_cli::emit_version_json(); - return ExitCode::SUCCESS; - } - if let Some(rc) = apr_cli::update_or_check(&raw) { - return rc; - } - - let cli = Cli::parse(); - match execute_command(&cli) { - Ok(()) => ExitCode::SUCCESS, - Err(e) => { - eprintln!("error: {e}"); - e.exit_code() - } - } +//! Entry point shim. The program is `apr_cli::cli_main`, the SAME function the +//! root facade's `src/bin/apr.rs` calls: both packages define a binary named +//! `apr`, and G0.5 (#2582) found the two had been kept equal by hand-copying +//! the prologue, and had already diverged (the dhat allocator existed only +//! here). `entry_point_is_shared` in lib.rs fails if a prologue comes back. + +fn main() -> std::process::ExitCode { + apr_cli::cli_main() } diff --git a/crates/apr-cli/src/model_ops_commands.rs b/crates/apr-cli/src/model_ops_commands.rs index 1319b986b6..f3c50f688e 100644 --- a/crates/apr-cli/src/model_ops_commands.rs +++ b/crates/apr-cli/src/model_ops_commands.rs @@ -6,10 +6,10 @@ pub enum ModelOpsCommands { Finetune { /// Input model file #[arg(value_name = "FILE")] - file: Option, + file: Option, /// Fine-tuning method: auto, full, lora, qlora #[arg(long, short = 'm', default_value = "auto")] - method: String, + method: FreeText, /// LoRA rank (default: auto-selected) #[arg(long, short = 'r')] rank: Option, @@ -21,13 +21,13 @@ pub enum ModelOpsCommands { plan: bool, /// Training data file (JSONL format) #[arg(long, short = 'd', value_name = "FILE")] - data: Option, + data: Option, /// Output path (adapter dir or merged model) #[arg(short, long)] - output: Option, + output: Option, /// Adapter path for merge mode #[arg(long)] - adapter: Option, + adapter: Option, /// Merge adapter into base model #[arg(long)] merge: bool, @@ -42,16 +42,16 @@ pub enum ModelOpsCommands { learning_rate: Option, /// Model size for planning (e.g., "7B", "1.5B") #[arg(long, value_name = "SIZE")] - model_size: Option, + model_size: Option, /// Fine-tuning task: classify (sequence classification) #[arg(long)] - task: Option, + task: Option, /// Number of classes for classification task #[arg(long, default_value = "5")] num_classes: usize, /// Output format for checkpoints: apr, safetensors, or both (comma-separated) #[arg(long, value_name = "FORMAT", default_value = "apr,safetensors")] - checkpoint_format: String, + checkpoint_format: FreeText, /// Oversample minority classes to match majority (for imbalanced datasets) #[arg(long)] oversample: bool, @@ -63,7 +63,7 @@ pub enum ModelOpsCommands { quantize_nf4: bool, /// GPU indices for data-parallel training (e.g., "0,1" for dual GPU) #[arg(long, value_name = "INDICES")] - gpus: Option, + gpus: Option, /// GPU backend selection: auto, cuda, wgpu // #2583: no `value_parser` here meant `--gpu-backend cudaa` parsed and // silently took the `_ =>` ("auto") arm of `gpu_backend_notice`. @@ -72,13 +72,13 @@ pub enum ModelOpsCommands { gpu_backend: String, /// Distributed training role: coordinator or worker #[arg(long, value_name = "ROLE")] - role: Option, + role: Option, /// Address to bind (coordinator) or connect to (worker) #[arg(long, value_name = "ADDR")] - bind: Option, + bind: Option, /// Coordinator address for worker nodes (e.g., "intel:9000") #[arg(long, value_name = "ADDR")] - coordinator: Option, + coordinator: Option, /// Expected number of workers (coordinator only) #[arg(long, value_name = "N")] expect_workers: Option, @@ -89,11 +89,11 @@ pub enum ModelOpsCommands { /// Format: --adapters data/corpus-a.jsonl:checkpoints/adapter-a /// Can be specified multiple times for concurrent adapter training. #[arg(long, value_name = "DATA:CHECKPOINT")] - adapters: Vec, + adapters: Vec, /// Multi-adapter config file: TOML with [[adapter]] entries (GPU-SHARE §2.4) #[arg(long, value_name = "FILE")] - adapters_config: Option, + adapters_config: Option, /// Enable experimental CUDA MPS for concurrent GPU sharing (GPU-SHARE §1.5). /// WARNING: A GPU fault in any MPS client will crash ALL clients on that GPU. @@ -113,10 +113,10 @@ pub enum ModelOpsCommands { Prune { /// Input model file #[arg(value_name = "FILE")] - file: PathBuf, + file: ModelPath, /// Pruning method: magnitude, structured, depth, width, wanda, sparsegpt #[arg(long, short = 'm', default_value = "magnitude")] - method: String, + method: FreeText, /// Target pruning ratio (0-1) #[arg(long, default_value = "0.5")] target_ratio: f32, @@ -125,10 +125,10 @@ pub enum ModelOpsCommands { sparsity: f32, /// Output file path #[arg(short, long)] - output: Option, + output: Option, /// Layers to remove for depth pruning (e.g., "20-24") #[arg(long)] - remove_layers: Option, + remove_layers: Option, /// Analyze mode (identify pruning opportunities) #[arg(long)] analyze: bool, @@ -137,25 +137,25 @@ pub enum ModelOpsCommands { plan: bool, /// Calibration data file #[arg(long, value_name = "FILE")] - calibration: Option, + calibration: Option, }, /// Knowledge distillation (teacher -> student) (GH-247, ALB-011) Distill { /// Teacher model file (positional, for file-based mode) #[arg(value_name = "TEACHER")] - teacher: Option, + teacher: Option, /// Student model file #[arg(long, value_name = "FILE")] - student: Option, + student: Option, /// Training data file #[arg(long, short = 'd', value_name = "FILE")] - data: Option, + data: Option, /// Output file path #[arg(short, long)] - output: Option, + output: Option, /// Distillation strategy: standard, progressive, ensemble #[arg(long, default_value = "standard")] - strategy: String, + strategy: FreeText, /// Temperature for softmax scaling #[arg(long, default_value = "3.0")] temperature: f64, @@ -170,10 +170,10 @@ pub enum ModelOpsCommands { plan: bool, /// YAML config file for two-stage distillation (ALB-011) #[arg(long, value_name = "FILE")] - config: Option, + config: Option, /// Distillation stage: precompute, train (logit KD), or generate (text-based, GH-455) #[arg(long, value_name = "STAGE")] - stage: Option, + stage: Option, /// SPEC-DISTILL-001 Phase 3-prep (PMAT-697): teacher/student backend /// selector. `fixture` (default) uses the in-memory FixtureTeacher /// + FixtureStudent (CPU-only, useful for plumbing tests + CI). @@ -196,6 +196,6 @@ pub enum ModelOpsCommands { /// Phase 4 50K-step dispatch sets this to a tokenized Python /// corpus directory. #[arg(long, value_name = "DIR")] - dataset: Option, + dataset: Option, }, } diff --git a/crates/apr-cli/src/parsing.rs b/crates/apr-cli/src/parsing.rs index cd3e23c326..5e2def1613 100644 --- a/crates/apr-cli/src/parsing.rs +++ b/crates/apr-cli/src/parsing.rs @@ -42,7 +42,7 @@ assert!(print, "-p must still set print"); assert_eq!( model, - Some(PathBuf::from("/tmp/named.gguf")), + Some(PathBuf::from("/tmp/named.gguf")).map(Into::into), "--model written after the prompt must be honoured, not swallowed" ); assert_eq!( @@ -149,7 +149,7 @@ .. } => { assert_eq!(source, "hf://openai/whisper-tiny"); - assert_eq!(prompt, Some("Hello".to_string())); + assert_eq!(prompt, Some("Hello".to_string()).map(Into::into)); assert_eq!(max_tokens, 64); } _ => panic!("Expected Run command"), @@ -481,7 +481,7 @@ } => { assert_eq!(files.len(), 2); assert_eq!(strategy, "weighted"); - assert_eq!(output, Some(PathBuf::from("merged.apr"))); + assert_eq!(output, Some(PathBuf::from("merged.apr")).map(Into::into)); assert_eq!(weights, Some(vec![0.7, 0.3])); } _ => panic!("Expected Merge command"), diff --git a/crates/apr-cli/src/pipeline_commands.rs b/crates/apr-cli/src/pipeline_commands.rs index ee04a38ae8..7a83c5de68 100644 --- a/crates/apr-cli/src/pipeline_commands.rs +++ b/crates/apr-cli/src/pipeline_commands.rs @@ -9,15 +9,15 @@ pub enum PipelineCommands { Plan { /// Path to forjar pipeline manifest (YAML) #[arg(value_name = "MANIFEST")] - manifest: PathBuf, + manifest: ConfigPath, /// Target specific machine #[arg(short, long)] - machine: Option, + machine: Option, /// Filter to resources with this tag #[arg(short, long)] - tag: Option, + tag: Option, /// Show estimated change cost per resource type #[arg(long)] @@ -28,15 +28,15 @@ pub enum PipelineCommands { Apply { /// Path to forjar pipeline manifest (YAML) #[arg(value_name = "MANIFEST")] - manifest: PathBuf, + manifest: ConfigPath, /// Target specific machine #[arg(short, long)] - machine: Option, + machine: Option, /// Filter to resources with this tag #[arg(short, long)] - tag: Option, + tag: Option, /// Number of parallel SSH sessions (default: 5) #[arg(short, long)] @@ -51,13 +51,13 @@ pub enum PipelineCommands { Status { /// Path to forjar pipeline manifest (YAML) #[arg(value_name = "MANIFEST")] - manifest: PathBuf, + manifest: ConfigPath, }, /// Validate pipeline manifest without connecting to machines. Validate { /// Path to forjar pipeline manifest (YAML) #[arg(value_name = "MANIFEST")] - manifest: PathBuf, + manifest: ConfigPath, }, } diff --git a/crates/apr-cli/src/serve_commands.rs b/crates/apr-cli/src/serve_commands.rs index e6f283c435..2332f54f71 100644 --- a/crates/apr-cli/src/serve_commands.rs +++ b/crates/apr-cli/src/serve_commands.rs @@ -17,7 +17,7 @@ pub enum ServeCommands { Plan { /// Model source: local path or HuggingFace repo (hf://org/repo, org/repo) #[arg(value_name = "MODEL")] - model: String, + model: ModelRef, /// Detect GPU via nvidia-smi for VRAM budget #[arg(long)] gpu: bool, @@ -29,25 +29,28 @@ pub enum ServeCommands { seq_len: usize, /// Output format: text, json, yaml #[arg(long, default_value = "text")] - format: String, + format: FreeText, /// Quantization override for HF models (e.g., Q4_K_M, Q6_K, F16) #[arg(long)] - quant: Option, + quant: Option, }, /// Start inference server (REST API, streaming, metrics) + // #3745 S1 v1.1: prompts arrive over HTTP, not through a PromptText arg, + // so the generator is declared by marker (`apr surface` → `generates: true`). + #[command(group(batuta_common::cli_roles::ServesGeneration::group()))] Run { /// Path to model file /// /// Not required with `--list-devices`, which asks what this BUILD can /// dispatch to and needs no model to answer. #[arg(value_name = "FILE", required_unless_present = "list_devices")] - file: Option, + file: Option, /// Port to listen on #[arg(short, long, default_value = "8080")] port: u16, /// Host to bind to #[arg(long, default_value = "127.0.0.1")] - host: String, + host: FreeText, /// Disable CORS #[arg(long)] no_cors: bool, @@ -76,7 +79,7 @@ pub enum ServeCommands { /// Mirrors llama.cpp's `-ngl`, which takes an integer, `auto` or `all` /// and reports what it resolved. Neither comparator has a boolean. #[arg(long, value_name = "N|auto|all|0")] - gpu_layers: Option, + gpu_layers: Option, /// List the accelerators this BUILD can dispatch to, then exit. /// /// Answers "what does this binary actually support" without starting a @@ -91,7 +94,7 @@ pub enum ServeCommands { trace: bool, /// Trace detail level (none, basic, layer) #[arg(long, value_name = "LEVEL", default_value = "basic")] - trace_level: String, + trace_level: FreeText, /// Enable inline Roofline profiling (adds X-Profile headers) #[arg(long)] profile: bool, @@ -107,7 +110,7 @@ pub enum ServeCommands { /// Each request = parent span, each layer = child span with TensorStats. /// Example: --otlp-endpoint http://localhost:4317 #[arg(long, value_name = "URL")] - otlp_endpoint: Option, + otlp_endpoint: Option, /// GH-286: Max context/sequence length for KV cache. Lower = less RSS. #[arg(long, default_value = "4096")] context_length: usize, diff --git a/crates/apr-cli/src/surface.rs b/crates/apr-cli/src/surface.rs new file mode 100644 index 0000000000..931cc9c468 --- /dev/null +++ b/crates/apr-cli/src/surface.rs @@ -0,0 +1,614 @@ +//! `apr surface`: the interface the binary actually has, emitted by the binary +//! (#3745 S1, PMAT-3749). +//! +//! Operator, 2026-09-21, verbatim: "the way to prevent testing leakage is tests +//! are dervived from surface and SHACL enforced...WE ARE NOT ENFORCING our +//! interface AT ALL..it is hand coded not derived". +//! +//! Every release cell used to come from a list someone typed: the ladder's +//! rungs, `{run, chat, serve, code}`, `release_evidence.rs`'s `VERBS`, the +//! hand-maintained `apr-cli-commands-v1.yaml`. A verb, flag or input shape +//! nobody typed was invisible to the gate, and 0.69.0 leaked three that way +//! (`serve` on Qwen3.5 #3571, `code` #3719, `run --prompt P --chat` #3743). +//! +//! This module walks `::command()` and returns every +//! subcommand path and every argument, with the argument's ROLE read from how it +//! is BUILT: +//! +//! * `model` / `prompt` / `input-file` / `other`: the value parser's output type +//! is one of the role types in `batuta_common::cli_roles` (compared by +//! `TypeId`, never by name); +//! * `backend`: the argument belongs to the group the shared [`BackendArg`] +//! flatten creates (`::group_id()`); +//! * `mode`: structural, meaning a flag action or a finite value set; +//! * `unknown`: a free-form value (raw `PathBuf`, or raw `String` without a +//! finite value set) that no role type declares. [`unknown_outside_foreign`] +//! is the marker guard: it must be empty. +//! +//! The JSON (`apr-cli-surface/v1`, agreed on #3745) is produced from the binary +//! under test at gate time and is never committed. [`emit`] is the library entry +//! point that in-process gates call; `apr surface --json` is a thin wrapper +//! over it, and a test pins the two equal. + +use std::any::TypeId; +use std::path::PathBuf; + +use batuta_common::cli_roles::{self, Carrier, Role}; +use clap::{ArgAction, CommandFactory}; +use serde::Serialize; + +use crate::{BackendArg, Cli}; + +/// The schema string consumers refuse to read if it is not one they know. +/// +/// v1.1 (cop ruling on #3745, after S2 found `serve run`'s prompts arrive over +/// HTTP) adds `commands[].generates`. +pub const SCHEMA: &str = "apr-cli-surface/v1.1"; + +/// Stack for the recursive walk. Building and walking the full `apr` clap tree +/// overflows a default test-thread stack (see the note at +/// `commands/tokenize.rs` on FALSIFY-APR-TOK-PAR-004), so [`emit`] always runs +/// it on a thread of its own. +const WALK_STACK: usize = 64 * 1024 * 1024; + +/// The whole surface: one entry per subcommand path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Surface { + /// Always [`SCHEMA`]. + pub schema: &'static str, + /// The binary this surface was read from. + pub binary: BinaryId, + /// Every role an argument can carry, in the order the schema lists them. + pub roles: Vec<&'static str>, + /// Arguments the root declares with `global = true`; clap propagates each + /// of them to every command path, and they are not repeated per command. + pub global_args: Vec, + /// Every subcommand path, nested and group nodes included, sorted by path. + pub commands: Vec, +} + +/// Name, version and source revision of the binary under test. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BinaryId { + pub name: String, + pub version: String, + pub git_sha: String, +} + +/// One subcommand path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CommandEntry { + /// The subcommand names from the root, e.g. `["serve", "run"]`. + pub path: Vec, + /// `path` joined with single spaces: the key #3739's CRUX mapping uses. + pub key: String, + /// Other names clap accepts for this subcommand (visible and hidden). + pub aliases: Vec, + /// Absent from `--help`. + pub hidden: bool, + /// Declared by another crate's `Subcommand` type rather than by apr-cli. + pub foreign: bool, + /// True iff the command has no subcommands. + pub leaf: bool, + /// The command generates text from prompts (v1.1): it has a `PromptText` + /// argument, or it carries the `ServesGeneration` marker because its + /// prompts arrive another way (`serve run` over HTTP, `chat` on stdin, + /// `mcp` as JSON-RPC). Read from how the command is built, never its name. + pub generates: bool, + /// Names of the direct subcommands, in declaration order. + pub subcommands: Vec, + /// Arguments declared on this command (not the propagated globals). + pub args: Vec, +} + +/// One argument. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ArgEntry { + pub id: String, + /// Long name, without dashes. + pub long: Option, + pub short: Option, + /// Every other long name clap accepts (visible and hidden). + pub long_aliases: Vec, + /// Every other short name clap accepts (visible and hidden). + pub short_aliases: Vec, + pub positional: bool, + /// 1-based position of a positional argument. + pub index: Option, + pub required: bool, + pub takes_value: bool, + pub num_args: NumArgs, + /// `flag`, `count`, `enum`, `path`, `text`, `int`, `float` or `other`. + pub value_type: &'static str, + /// The finite set of accepted values (hidden values excluded), else empty. + pub values: Vec, + pub default: Vec, + /// One of [`Surface::roles`]. + pub role: &'static str, + /// The role type that built this argument (`ModelPath`, `BackendArg`, …). + pub marker: Option<&'static str>, + /// For role `sampling` (v1.1): `seed`, `temperature`, `top_k`, `top_p`, + /// `min_p`, `repeat_penalty` or `repeat_last_n`, read from the sampling + /// group the argument joined, never from its name. `null` otherwise. + pub sampling_kind: Option<&'static str>, + pub hidden: bool, + /// Ids of the arguments this one conflicts with. + pub conflicts_with: Vec, +} + +/// How many values one occurrence takes. `max: None` is unbounded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct NumArgs { + pub min: usize, + pub max: Option, +} + +impl Surface { + /// The versioned JSON document `apr surface --json` prints. + #[must_use] + pub fn to_json(&self) -> String { + serde_json::to_string_pretty(self).expect("surface is plain data and always serializes") + } + + /// The entry for a subcommand path, e.g. `["serve", "run"]`. + #[must_use] + pub fn command(&self, path: &[&str]) -> Option<&CommandEntry> { + self.commands + .iter() + .find(|c| c.path.iter().map(String::as_str).eq(path.iter().copied())) + } +} + +impl CommandEntry { + /// The argument with this id. + #[must_use] + pub fn arg(&self, id: &str) -> Option<&ArgEntry> { + self.args.iter().find(|a| a.id == id) + } +} + +/// The surface of the `apr` binary this library is linked into. +/// +/// Runs the walk on its own thread with an explicit stack (see [`WALK_STACK`]), +/// so it is safe to call from a test thread or from `main`. +#[must_use] +pub fn emit() -> Surface { + std::thread::Builder::new() + .name("apr-surface".to_string()) + .stack_size(WALK_STACK) + .spawn(|| emit_from(&Cli::command())) + .expect("spawn the surface walk thread") + .join() + .expect("the surface walk panicked") +} + +/// The surface of an arbitrary clap tree rooted at `root`. +/// +/// Membership (which commands and arguments exist) is read from `root` as the +/// code declared it. Finalised attributes (`num_args`, positional indices, +/// conflicts) are read from a BUILT clone. Building also adds clap's generated +/// `help` subcommand and the `--help`/`--version` flags; those are clap's +/// surface, not apr's, so they come from nowhere in the unbuilt tree and are +/// not emitted. +/// +/// Call this on a thread with a large stack when `root` is the full `apr` tree. +#[must_use] +pub fn emit_from(root: &clap::Command) -> Surface { + let mut built = root.clone(); + built.build(); + let foreign = foreign_mounts(root).mounts; + + let global_args = root + .get_arguments() + .filter(|a| a.is_global_set()) + .map(|a| arg_entry(a, &built, root)) + .collect(); + + let mut commands = Vec::new(); + for sub in root.get_subcommands() { + let built_sub = child(&built, sub.get_name()); + walk(sub, built_sub, &mut vec![], &foreign, false, &mut commands); + } + commands.sort_by(|a, b| a.path.cmp(&b.path)); + + Surface { + schema: SCHEMA, + binary: BinaryId { + name: root.get_name().to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + git_sha: env!("APR_GIT_SHA").to_string(), + }, + roles: ROLES.iter().map(|r| r.as_str()).collect(), + global_args, + commands, + } +} + +/// Every role, in schema order. +const ROLES: [Role; 8] = [ + Role::Model, + Role::Prompt, + Role::InputFile, + Role::Backend, + Role::Sampling, + Role::Mode, + Role::Other, + Role::Unknown, +]; + +fn child<'a>(built: &'a clap::Command, name: &str) -> &'a clap::Command { + built + .get_subcommands() + .find(|s| s.get_name() == name) + .expect("a built tree has every subcommand its unbuilt source declares") +} + +fn walk( + cmd: &clap::Command, + built: &clap::Command, + parent: &mut Vec, + foreign: &[Vec], + inherited_foreign: bool, + out: &mut Vec, +) { + let mut path = parent.clone(); + path.push(cmd.get_name().to_string()); + let is_foreign = inherited_foreign || foreign.iter().any(|m| *m == path); + + let subcommands: Vec = cmd + .get_subcommands() + .map(|s| s.get_name().to_string()) + .collect(); + let args: Vec = cmd + .get_arguments() + .filter(|a| !a.is_global_set()) + .map(|a| arg_entry(a, built, cmd)) + .collect(); + out.push(CommandEntry { + key: path.join(" "), + path: path.clone(), + aliases: cmd.get_all_aliases().map(str::to_string).collect(), + hidden: cmd.is_hide_set(), + foreign: is_foreign, + leaf: subcommands.is_empty(), + generates: generates(cmd, &args), + subcommands, + args, + }); + + for sub in cmd.get_subcommands() { + walk( + sub, + child(built, sub.get_name()), + &mut path, + foreign, + is_foreign, + out, + ); + } +} + +fn arg_entry(arg: &clap::Arg, built_cmd: &clap::Command, cmd: &clap::Command) -> ArgEntry { + let built_arg = built_cmd + .get_arguments() + .find(|b| b.get_id() == arg.get_id()) + .expect("a built command keeps every argument its source declares"); + let class = classify(arg, cmd, built_cmd); + let takes_value = built_arg.get_action().takes_values(); + let num_args = built_arg + .get_num_args() + .map(|r| NumArgs { + min: r.min_values(), + max: (r.max_values() != usize::MAX).then_some(r.max_values()), + }) + .unwrap_or(if takes_value { + NumArgs { + min: 1, + max: Some(1), + } + } else { + NumArgs { + min: 0, + max: Some(0), + } + }); + ArgEntry { + id: arg.get_id().to_string(), + long: arg.get_long().map(str::to_string), + short: arg.get_short(), + long_aliases: arg + .get_all_aliases() + .unwrap_or_default() + .into_iter() + .map(str::to_string) + .collect(), + short_aliases: arg.get_all_short_aliases().unwrap_or_default(), + positional: arg.is_positional(), + index: built_arg.get_index(), + required: arg.is_required_set(), + takes_value, + num_args, + value_type: class.value_type, + values: finite_values(arg), + default: arg + .get_default_values() + .iter() + .map(|v| v.to_string_lossy().into_owned()) + .collect(), + role: class.role.as_str(), + marker: class.marker, + sampling_kind: cli_roles::SamplingArg::kind_of(built_cmd, built_arg) + .map(cli_roles::SamplingKind::as_str), + hidden: arg.is_hide_set(), + conflicts_with: built_cmd + .get_arg_conflicts_with(built_arg) + .into_iter() + .map(|c| c.get_id().to_string()) + .collect(), + } +} + +/// True iff the command generates from prompts: a `PromptText` argument, or the +/// command-level `ServesGeneration` marker. Both are read from construction. +fn generates(cmd: &clap::Command, args: &[ArgEntry]) -> bool { + cli_roles::ServesGeneration::is_on(cmd) || args.iter().any(|a| a.role == Role::Prompt.as_str()) +} + +/// An argument's role, the type that declared it, and the shape of its value. +struct Class { + role: Role, + marker: Option<&'static str>, + value_type: &'static str, +} + +/// Decide an argument's role from how it is built. Nothing here reads a name. +fn classify(arg: &clap::Arg, cmd: &clap::Command, built: &clap::Command) -> Class { + let ty = arg.get_value_parser().type_id(); + let finite = !finite_values(arg).is_empty(); + let value_type = match arg.get_action() { + ArgAction::SetTrue | ArgAction::SetFalse => "flag", + ArgAction::Count => "count", + _ if finite => "enum", + _ => scalar_type(arg.get_value_parser()), + }; + + if in_backend_group(arg, cmd) { + return Class { + role: Role::Backend, + marker: Some("BackendArg"), + value_type, + }; + } + // v1.1: membership in the SamplingArg group, which is visible only on the + // BUILT command (clap merges `#[arg(group = …)]` there). + if cli_roles::SamplingArg::kind_of(built, arg).is_some() { + return Class { + role: Role::Sampling, + marker: Some("SamplingArg"), + value_type, + }; + } + if let Some(m) = cli_roles::MARKERS.iter().find(|m| ty == m.type_id()) { + let value_type = match m.carrier { + Carrier::Path => "path", + Carrier::Text => "text", + }; + return Class { + role: m.role, + marker: Some(m.name), + value_type, + }; + } + let role = match value_type { + "flag" | "count" | "enum" => Role::Mode, + "path" | "text" => Role::Unknown, + _ => Role::Other, + }; + Class { + role, + marker: None, + value_type, + } +} + +/// `path`/`text` for the two raw free-form types, `int`/`float` for numbers, +/// `other` for anything else a value parser can yield. +fn scalar_type(parser: &clap::builder::ValueParser) -> &'static str { + let ty = parser.type_id(); + let is = |t: TypeId| ty == t; + if is(TypeId::of::()) { + "path" + } else if is(TypeId::of::()) { + "text" + } else if [ + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + ] + .into_iter() + .any(is) + { + "int" + } else if is(TypeId::of::()) || is(TypeId::of::()) { + "float" + } else { + "other" + } +} + +/// Values the argument's parser declares as its whole domain. clap's bool +/// parser lists `true`/`false`, which is a finite domain and so a mode. +fn finite_values(arg: &clap::Arg) -> Vec { + arg.get_possible_values() + .iter() + .filter(|v| !v.is_hide_set()) + .map(|v| v.get_name().to_string()) + .collect() +} + +/// True iff `arg` is declared through the shared [`BackendArg`] flatten. The +/// group id comes from the type, through `clap::Args`, never from a literal. +fn in_backend_group(arg: &clap::Arg, cmd: &clap::Command) -> bool { + let Some(group) = ::group_id() else { + return false; + }; + cmd.get_groups() + .any(|g| g.get_id() == &group && g.get_args().any(|a| a == arg.get_id())) +} + +/// The subtrees of `root` that another crate's `Subcommand` type declares. +/// +/// apr embeds these CLIs whole (`apr pv …` is `aprender-contracts-cli`'s own +/// enum). Their free-form arguments cannot all carry apr's role types yet, so +/// they are emitted as `foreign: true` and a remaining untyped argument there +/// is role `unknown`, which S2 charges a runs-or-refuses cell and ratchets +/// shrink-only (cop ruling on #3745). +/// +/// A mount is found by TYPE: each listed type renders its own subcommand set, +/// and a subtree of `root` whose children are exactly that set (order ignored) +/// is its mount. +/// The list fails closed both ways. A foreign CLI missing from it has its +/// untyped arguments counted as apr-cli's, which the marker guard refuses. A +/// listed type that is mounted zero times or more than once is reported in +/// [`ForeignScan::problems`], which the marker guard requires to be empty for +/// the real `apr` tree, so a stale entry cannot sit in the list unnoticed. +fn foreign_mounts(root: &clap::Command) -> ForeignScan { + let probes: [(&str, clap::Command); 7] = [ + ( + "aprender_contracts_cli::cli::Commands", + probe::(), + ), + ( + "alimentar::cli::Commands", + probe::(), + ), + ("simular::cli::Commands", probe::()), + ( + "aprender_rag_cli::Commands", + probe::(), + ), + ( + "aprender_zram_cli::Commands", + probe::(), + ), + ("cgp::cli::Commands", probe::()), + // `apr ptx-debug` mounts the former `aprender-ptx-debug` binary's enum (#4062). + ( + "trueno_ptx_debug::cli::Command", + probe::(), + ), + ]; + let mut scan = ForeignScan::default(); + for (ty, p) in &probes { + // A set, not a sequence: declaration order is not identity, and clap's + // own `mut_subcommand` moves the child it mutates to the end. + let mut want: Vec<&str> = p.get_subcommands().map(clap::Command::get_name).collect(); + want.sort_unstable(); + let mut hits = Vec::new(); + find_mounts(root, &mut Vec::new(), &want, &mut hits); + if hits.len() != 1 { + scan.problems.push(format!( + "foreign CLI `{ty}` must be mounted exactly once in the tree, found {} mount(s): {hits:?}", + hits.len() + )); + } + scan.mounts.extend(hits); + } + scan +} + +/// Where the foreign CLIs are mounted, and what is wrong with the list. +#[derive(Debug, Default)] +pub(crate) struct ForeignScan { + /// Path of each mount point. + pub(crate) mounts: Vec>, + /// A listed type mounted zero times, or more than once. + pub(crate) problems: Vec, +} + +/// The foreign-list problems for the real `apr` tree. The marker guard requires +/// this to be empty. +#[must_use] +pub fn foreign_list_problems() -> Vec { + std::thread::Builder::new() + .stack_size(WALK_STACK) + .spawn(|| foreign_mounts(&Cli::command()).problems) + .expect("spawn") + .join() + .expect("join") +} + +fn probe() -> clap::Command { + T::augment_subcommands(clap::Command::new("probe")) +} + +fn find_mounts( + cmd: &clap::Command, + path: &mut Vec, + want: &[&str], + hits: &mut Vec>, +) { + for sub in cmd.get_subcommands() { + path.push(sub.get_name().to_string()); + let mut names: Vec<&str> = sub.get_subcommands().map(clap::Command::get_name).collect(); + names.sort_unstable(); + if !names.is_empty() && names == want { + hits.push(path.clone()); + } + find_mounts(sub, path, want, hits); + path.pop(); + } +} + +/// Every argument outside a foreign subtree whose role is `unknown`, as +/// `" "`. This is the marker guard's reading, and it must be empty: +/// a free-form argument that no role type declares is how a model or prompt +/// argument hides from the cells derived from this surface. +#[must_use] +pub fn unknown_outside_foreign(surface: &Surface) -> Vec { + let mut out: Vec = surface + .global_args + .iter() + .filter(|a| a.role == Role::Unknown.as_str()) + .map(|a| format!(" {}", a.id)) + .collect(); + for c in surface.commands.iter().filter(|c| !c.foreign) { + for a in c.args.iter().filter(|a| a.role == Role::Unknown.as_str()) { + out.push(format!("{} {}", c.key, a.id)); + } + } + out +} + +/// `apr surface`: print [`emit`]'s JSON and nothing else on stdout. +/// +/// Written with `std::io::Write`, not the crate's shadowed `println!`, so +/// `--quiet` cannot suppress the only thing the verb exists to produce. +pub(crate) fn run() -> Result<(), crate::CliError> { + write_surface(&mut std::io::stdout().lock()) + .map_err(|e| crate::CliError::ValidationFailed(format!("apr surface: writing stdout: {e}"))) +} + +/// The bytes `apr surface` writes: [`emit`]'s JSON and one newline. +fn write_surface(out: &mut impl std::io::Write) -> std::io::Result<()> { + out.write_all(emit().to_json().as_bytes())?; + out.write_all(b"\n")?; + out.flush() +} + +#[cfg(test)] +#[path = "surface_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "surface_guard_tests.rs"] +mod guard; diff --git a/crates/apr-cli/src/surface_guard_tests.rs b/crates/apr-cli/src/surface_guard_tests.rs new file mode 100644 index 0000000000..9e07cb6185 --- /dev/null +++ b/crates/apr-cli/src/surface_guard_tests.rs @@ -0,0 +1,323 @@ +//! #3745 S1.3: the marker guard. +//! +//! Issue #3745 mutant 3, verbatim: "a command that loads a model with a plain +//! `PathBuf` arg instead of `ModelArg` → the marker guard is RED." +//! +//! The guard does not try to detect model loading; apr-cli has no loader funnel +//! to detect it at (about 13 loader APIs across realizar and aprender-core), and +//! a list of loaders would rot like the verb lists #3745 removes. Instead it +//! makes an undeclared role inexpressible: outside a foreign subtree, every +//! free-form argument (raw `PathBuf`, or raw `String` without a finite value +//! set) is RED until it is built through a role type. A model argument can +//! therefore only be written down as a model argument, or as a typed lie that +//! is visible in the diff. + +use super::*; +use clap::Arg; + +fn big_stack(f: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new() + .stack_size(WALK_STACK) + .spawn(f) + .expect("spawn") + .join() + .expect("join") +} + +const REMEDY: &str = "declare each through a role type from batuta_common::cli_roles \ + (ModelPath/ModelRef for a model, PromptText for a prompt, InputFile for input data, \ + OutputPath/DirPath/ConfigPath/FreeText otherwise) — a raw PathBuf/String hides the \ + argument's role from the cells derived from `apr surface` (#3745)"; + +/// The guard itself, on the tree this binary ships. +#[test] +fn no_free_form_arg_is_undeclared_outside_foreign_subtrees() { + let unknown = big_stack(|| unknown_outside_foreign(&emit())); + assert!( + unknown.is_empty(), + "{} free-form argument(s) with no declared role:\n {}\n{REMEDY}", + unknown.len(), + unknown.join("\n ") + ); +} + +/// The foreign-CLI list fails closed: every listed type is mounted exactly once. +#[test] +fn the_foreign_list_matches_the_tree() { + let problems = foreign_list_problems(); + assert!(problems.is_empty(), "{}", problems.join("\n")); +} + +/// The verbs whose leaks #3745 names, and the two foreign model arguments the +/// cop ruled into S1, carry the roles their cells are derived from. +#[test] +fn the_leak_verbs_declare_model_prompt_and_input_roles() { + let s = big_stack(emit); + let role = |path: &[&str], id: &str| { + s.command(path) + .and_then(|c| c.arg(id)) + .map(|a| a.role) + .unwrap_or_else(|| panic!("{path:?} {id} is on the surface")) + }; + // #3743: `run --prompt P --chat` vs `-i file --chat`. + assert_eq!(role(&["run"], "source"), "model"); + assert_eq!(role(&["run"], "prompt"), "prompt"); + assert_eq!(role(&["run"], "positional_prompt"), "prompt"); + assert_eq!(role(&["run"], "input"), "input-file"); + assert_eq!(role(&["chat"], "file"), "model"); + // #3571: `apr serve` on Qwen3.5. + assert_eq!(role(&["serve", "run"], "file"), "model"); + // #3719: `apr code`'s backend is `apr serve`. + assert_eq!(role(&["code"], "model"), "model"); + assert_eq!(role(&["code"], "prompt"), "prompt"); + // Cop ruling on #3745: foreign, but typed in S1 itself. + assert_eq!(role(&["pv", "verify-structure"], "model"), "model"); + assert_eq!(role(&["rag", "transcribe"], "model"), "model"); +} + +// ── mutants, planted in the real tree on every run ────────────────────────── + +fn unknown_after( + mutate: impl FnOnce(clap::Command) -> clap::Command + Send + 'static, +) -> Vec { + big_stack(move || unknown_outside_foreign(&emit_from(&mutate(Cli::command())))) +} + +/// Mutant 3a: `inspect`'s model argument rebuilt as a plain `PathBuf`. +#[test] +fn mutant_an_existing_model_arg_reverted_to_plain_pathbuf_is_red() { + let unknown = unknown_after(|root| { + root.mut_subcommand("inspect", |c| { + c.mut_arg("file", |a| a.value_parser(clap::value_parser!(PathBuf))) + }) + }); + assert_eq!( + unknown, + ["inspect file"], + "the guard must name exactly the reverted argument" + ); +} + +/// Mutant 3b: a new command that takes its model through a plain `PathBuf`. +#[test] +fn mutant_a_new_command_with_a_plain_pathbuf_model_is_red() { + let unknown = unknown_after(|root| { + root.subcommand( + clap::Command::new("zz-new-verb").arg( + Arg::new("model") + .long("model") + .value_parser(clap::value_parser!(PathBuf)), + ), + ) + }); + assert_eq!(unknown, ["zz-new-verb model"]); +} + +/// Mutant 3c: a prompt taken as a plain `String` (the #3743 class). +#[test] +fn mutant_a_prompt_as_plain_string_is_red() { + let unknown = unknown_after(|root| { + root.mut_subcommand("run", |c| { + c.mut_arg("prompt", |a| a.value_parser(clap::value_parser!(String))) + }) + }); + assert_eq!(unknown, ["run prompt"]); +} + +/// A raw argument in a nested apr-cli subcommand is caught as well. +#[test] +fn mutant_a_nested_plain_pathbuf_is_red() { + let unknown = unknown_after(|root| { + root.mut_subcommand("serve", |serve| { + serve.mut_subcommand("run", |c| { + c.mut_arg("file", |a| a.value_parser(clap::value_parser!(PathBuf))) + }) + }) + }); + assert_eq!(unknown, ["serve run file"]); +} + +/// A raw global argument is caught: globals reach every command path. +#[test] +fn mutant_a_plain_global_is_red() { + let unknown = unknown_after(|root| { + root.arg( + Arg::new("zz-config") + .long("zz-config") + .global(true) + .value_parser(clap::value_parser!(PathBuf)), + ) + }); + assert_eq!(unknown, [" zz-config"]); +} + +/// Inside a foreign subtree a raw argument is emitted as `unknown` (S2 owes +/// its verb a runs-or-refuses cell and ratchets the count), and it is not the +/// marker guard's to refuse. (Reverting THIS argument is still caught, by +/// `the_leak_verbs_declare_model_prompt_and_input_roles`.) +#[test] +fn a_foreign_raw_arg_is_unknown_not_red() { + let s = big_stack(|| { + emit_from(&Cli::command().mut_subcommand("pv", |pv| { + pv.mut_subcommand("verify-structure", |c| { + c.mut_arg("model", |a| a.value_parser(clap::value_parser!(PathBuf))) + }) + })) + }); + let arg = s + .command(&["pv", "verify-structure"]) + .and_then(|c| c.arg("model")) + .expect("pv verify-structure --model"); + assert_eq!(arg.role, "unknown"); + assert!( + unknown_outside_foreign(&s).is_empty(), + "foreign subtrees are not the marker guard's" + ); +} + +/// v1.1: the generators S2 derives prompt-shape cells for. `serve run`, `chat` +/// and `mcp` take their prompts over HTTP, stdin and JSON-RPC, so only the +/// `ServesGeneration` marker makes them generators; `run` and `code` are by +/// their `PromptText` args. Model tools that never generate are not. +#[test] +fn the_generators_are_declared() { + let s = big_stack(emit); + let generates = |path: &[&str]| { + s.command(path) + .map(|c| c.generates) + .unwrap_or_else(|| panic!("{path:?} is on the surface")) + }; + for path in [ + &["serve", "run"][..], + &["chat"], + &["mcp"], + &["run"], + &["code"], + ] { + assert!(generates(path), "{path:?} must be a generator"); + } + for path in [ + &["inspect"][..], + &["tensors"], + &["serve", "plan"], + &["surface"], + // Encoders and scorers take EncodeText, not PromptText (aprender-97): + // thinking × context-rung cells mean nothing for them. + &["embed"], + &["rerank"], + &["eval"], + // Tokenizes its --prompt and never runs the weights (#3749 rebase). + &["tokenize", "encode"], + ] { + assert!(!generates(path), "{path:?} does not generate"); + } +} + +/// Mutant: `serve run` with its ServesGeneration marker dropped. Its real +/// argument set, rebuilt without the marker, reads `generates: false`. So the +/// marker, not any argument, is what makes it a generator (#3571's verb). +#[test] +fn mutant_serve_run_without_its_marker_does_not_generate() { + let (real, stripped) = big_stack(|| { + let root = Cli::command(); + let run = root + .find_subcommand("serve") + .and_then(|s| s.find_subcommand("run")) + .expect("serve run") + .clone(); + let mut bare = clap::Command::new("run"); + for a in run.get_arguments() { + bare = bare.arg(a.clone()); + } + let real = emit_from(&clap::Command::new("apr").subcommand(run)); + let stripped = emit_from(&clap::Command::new("apr").subcommand(bare)); + (real, stripped) + }); + assert!(real.command(&["run"]).expect("run").generates); + assert!( + !stripped.command(&["run"]).expect("run").generates, + "without the marker serve run must read generates=false — the marker is load-bearing" + ); +} + +/// v1.1 `sampling` (for S2.5's flag-effect sampling controls): the generation +/// sampling controls carry the SamplingArg marker. Numbers with the same names +/// that are not generation sampling (distillation temperature, data seeds, +/// rerank's top-k) are not. +#[test] +fn the_sampling_controls_are_declared() { + let s = big_stack(emit); + let role = |path: &[&str], id: &str| { + s.command(path) + .and_then(|c| c.arg(id)) + .map(|a| a.role) + .unwrap_or_else(|| panic!("{path:?} {id} is on the surface")) + }; + let kind = |path: &[&str], id: &str| { + s.command(path) + .and_then(|c| c.arg(id)) + .and_then(|a| a.sampling_kind) + }; + // `run`'s field ids happen to equal their kinds' spellings; the kind is read + // from the group each field joined, and the case table below the name level + // (`ungrouped_temperature`) shows a name alone yields no kind. + for (id, want) in [ + ("temperature", "temperature"), + ("top_k", "top_k"), + ("top_p", "top_p"), + ("seed", "seed"), + ("repeat_penalty", "repeat_penalty"), + ("repeat_last_n", "repeat_last_n"), + ] { + assert_eq!(role(&["run"], id), "sampling", "run --{id}"); + assert_eq!(kind(&["run"], id), Some(want), "run --{id} sampling_kind"); + } + assert_eq!(kind(&["chat"], "top_p"), Some("top_p")); + assert_eq!( + kind(&["distill"], "temperature"), + None, + "KD temperature has no kind" + ); + assert_eq!(role(&["chat"], "temperature"), "sampling"); + assert_eq!(role(&["chat"], "top_p"), "sampling"); + assert_eq!( + role(&["rosetta", "compare-inference"], "temperature"), + "sampling" + ); + assert_ne!( + role(&["distill"], "temperature"), + "sampling", + "KD temperature" + ); + assert_ne!(role(&["data", "split"], "seed"), "sampling", "a data seed"); + assert_ne!(role(&["rerank"], "top_k"), "sampling", "a ranking cut-off"); +} + +/// Mutant: `run --temperature` with its SamplingArg marker dropped reads as an +/// ordinary number, so `the_sampling_controls_are_declared` would go RED. +#[test] +fn mutant_temperature_without_its_marker_is_not_sampling() { + let s = big_stack(|| { + emit_from(&Cli::command().mut_subcommand("run", |c| { + c.mut_arg("temperature", |a| { + a.group(clap::builder::Resettable::::Reset) + }) + })) + }); + let t = s + .command(&["run"]) + .and_then(|c| c.arg("temperature")) + .expect("run --temperature"); + assert_ne!( + t.role, "sampling", + "without the marker the role must not be sampling" + ); + let k = s + .command(&["run"]) + .and_then(|c| c.arg("top_k")) + .expect("top_k"); + assert_eq!( + k.role, "sampling", + "only the mutated argument loses its role" + ); +} diff --git a/crates/apr-cli/src/surface_tests.rs b/crates/apr-cli/src/surface_tests.rs new file mode 100644 index 0000000000..c4d7e9f5dc --- /dev/null +++ b/crates/apr-cli/src/surface_tests.rs @@ -0,0 +1,390 @@ +//! #3745 S1: the surface emitter, read back. + +use super::*; +use clap::{Arg, ArgAction}; + +/// Run `f` on a thread with the walk's stack. The full `apr` tree does not fit +/// a default test-thread stack. +fn big_stack(f: impl FnOnce() -> T + Send + 'static) -> T { + std::thread::Builder::new() + .stack_size(WALK_STACK) + .spawn(f) + .expect("spawn") + .join() + .expect("join") +} + +/// S1.1: a flag added to the clap tree appears in the JSON, under its command +/// path, with a role. The emitter reads the tree it is given; it keeps no list +/// that a new flag could be missing from. +#[test] +fn the_emitter_sees_a_flag_added_to_the_clap_tree() { + let (before, after) = big_stack(|| { + let root = Cli::command(); + let before = emit_from(&root); + let mutated = root.mut_subcommand("run", |run| { + run.arg( + Arg::new("zz-dummy") + .long("zz-dummy") + .action(ArgAction::SetTrue), + ) + }); + (before, emit_from(&mutated)) + }); + let run_before = before.command(&["run"]).expect("run is on the surface"); + assert!( + run_before.arg("zz-dummy").is_none(), + "the dummy flag must be absent before it is added" + ); + + let run = after.command(&["run"]).expect("run is on the surface"); + let dummy = run + .arg("zz-dummy") + .expect("a flag added to the clap tree must appear in the surface"); + assert_eq!(dummy.long.as_deref(), Some("zz-dummy")); + assert_eq!(dummy.value_type, "flag"); + assert_eq!(dummy.role, "mode"); + let json = after.to_json(); + assert!( + json.contains("\"zz-dummy\""), + "the dummy flag must be in the JSON text too" + ); +} + +#[test] +fn the_surface_is_versioned_and_deterministic() { + let (a, b) = big_stack(|| (emit().to_json(), emit().to_json())); + assert_eq!(a, b, "two emissions from one binary must be byte-identical"); + let v: serde_json::Value = serde_json::from_str(&a).expect("the surface is JSON"); + assert_eq!(v["schema"], SCHEMA); + assert_eq!(v["schema"], "apr-cli-surface/v1.1"); + assert_eq!(v["binary"]["name"], "apr"); + let roles: Vec<&str> = v["roles"] + .as_array() + .expect("roles") + .iter() + .filter_map(|r| r.as_str()) + .collect(); + assert_eq!( + roles, + [ + "model", + "prompt", + "input-file", + "backend", + "sampling", + "mode", + "other", + "unknown" + ] + ); + let paths: Vec> = v["commands"] + .as_array() + .expect("commands") + .iter() + .map(|c| serde_json::from_value(c["path"].clone()).expect("path is a string array")) + .collect(); + let mut sorted = paths.clone(); + sorted.sort(); + assert_eq!(paths, sorted, "commands are sorted by path"); +} + +/// clap's generated `help` subcommand and `--help`/`--version` flags are not +/// apr's declared surface; emitting them would owe a release cell per group. +#[test] +fn clap_generated_help_and_version_are_not_emitted() { + let s = big_stack(emit); + // `help` is never an apr-declared id. `version` can be (`data x registry + // delete --version` names a registry entry's version), so the generated + // `--version` is checked where apr declares no such flag: the root and `run`. + for c in &s.commands { + assert!( + c.args.iter().all(|a| a.id != "help"), + "`{} --help` is clap-generated", + c.key + ); + } + assert!(s + .global_args + .iter() + .all(|a| a.id != "help" && a.id != "version")); + assert!( + s.command(&["run"]).expect("run").arg("version").is_none(), + "propagated --version" + ); + // `serve` has subcommands and does not disable clap's help subcommand. + let serve = s.command(&["serve"]).expect("serve"); + assert!(!serve.leaf); + assert!( + s.command(&["serve", "help"]).is_none(), + "clap's generated `serve help` must not be emitted" + ); +} + +#[test] +fn globals_are_emitted_once_at_the_root() { + let s = big_stack(emit); + let ids: Vec<&str> = s.global_args.iter().map(|a| a.id.as_str()).collect(); + assert!( + ids.contains(&"json") && ids.contains(&"quiet") && ids.contains(&"verbose"), + "{ids:?}" + ); + let run = s.command(&["run"]).expect("run"); + assert!( + run.arg("json").is_none(), + "a propagated global is not repeated per command" + ); +} + +#[test] +fn the_backend_role_comes_from_the_backend_arg_group() { + let s = big_stack(emit); + for path in [&["run"][..], &["chat"][..], &["serve", "run"][..]] { + let c = s + .command(path) + .unwrap_or_else(|| panic!("{path:?} is on the surface")); + let b = c + .arg("backend") + .unwrap_or_else(|| panic!("{path:?} has --backend")); + assert_eq!(b.role, "backend", "{path:?} --backend"); + assert_eq!(b.marker, Some("BackendArg")); + assert_eq!( + b.values, + crate::BACKEND_VALUES.to_vec(), + "{path:?} --backend values" + ); + } +} + +/// The `surface` verb itself is hidden from `--help` but present on the surface, +/// so S3 derives it as a verb and S2 owes it a runs cell. +#[test] +fn the_surface_verb_is_hidden_and_listed() { + let s = big_stack(emit); + let c = s.command(&["surface"]).expect("surface lists itself"); + assert!(c.hidden); + assert!(c.leaf); + assert!(!c.foreign); +} + +/// Every foreign CLI the walker lists is found mounted exactly once, and every +/// command under a mount is marked foreign (the mount point included). +#[test] +fn foreign_mounts_are_found_by_type() { + let s = big_stack(emit); + for mount in [ + &["pv"][..], + &["data", "x"][..], + &["sim"][..], + &["rag"][..], + &["zram"][..], + &["cgp"][..], + ] { + let c = s + .command(mount) + .unwrap_or_else(|| panic!("{mount:?} is on the surface")); + assert!(c.foreign, "{mount:?} is a foreign mount"); + for sub in &c.subcommands { + let mut p: Vec<&str> = mount.to_vec(); + p.push(sub); + assert!( + s.command(&p).is_some_and(|e| e.foreign), + "{p:?} is under a foreign mount" + ); + } + } + let data = s.command(&["data"]).expect("data"); + assert!( + !data.foreign, + "`data` is apr-cli's own; only its `x` arm is alimentar's" + ); + assert!(!s.command(&["run"]).expect("run").foreign); +} + +// ── classification case table ──────────────────────────────────────────────── + +/// One row per way an argument can be built. `expect` is (value_type, role, +/// marker). The table is the specification of `classify`: a class of argument +/// with no row here is a class nobody decided. +#[test] +fn classify_case_table() { + use batuta_common::cli_roles::{ + ConfigPath, DirPath, EncodeText, FreeText, InputFile, ModelPath, ModelRef, OutputPath, + PromptText, + }; + #[derive(clap::Parser)] + #[command(groups(batuta_common::cli_roles::SamplingArg::groups()))] + struct Probe { + // v1.1: a sampling control, by group membership; its twin with no group + // is an ordinary number. + #[arg(long, group = batuta_common::cli_roles::SamplingKind::Temperature.id())] + temperature: Option, + #[arg(long, group = batuta_common::cli_roles::SamplingKind::TopK.id())] + top_k: Option, + #[arg(long)] + ungrouped_temperature: Option, + model_path: ModelPath, + #[arg(long)] + model_ref: Option, + #[arg(long)] + prompt: Option, + #[arg(long)] + input: Option, + #[arg(long)] + output: Option, + #[arg(long)] + dir: Option, + #[arg(long)] + config: Option, + #[arg(long)] + name: Option, + #[arg(long)] + encoded: Option, + #[arg(long)] + raw_path: Option, + #[arg(long)] + raw_text: Option, + #[arg(long, value_parser = ["a", "b"])] + listed: Option, + #[arg(long)] + flag: bool, + #[arg(long, action = ArgAction::Count)] + level: u8, + #[arg(long)] + n: Option, + #[arg(long)] + t: Option, + #[arg(long, value_enum)] + fmt: Option, + #[arg(long)] + c: Option, + #[command(flatten)] + backend: BackendArg, + } + let rows: &[(&str, (&str, &str, Option<&str>))] = &[ + ("model_path", ("path", "model", Some("ModelPath"))), + ("model_ref", ("text", "model", Some("ModelRef"))), + ("prompt", ("text", "prompt", Some("PromptText"))), + ("input", ("path", "input-file", Some("InputFile"))), + ("output", ("path", "other", Some("OutputPath"))), + ("dir", ("path", "other", Some("DirPath"))), + ("config", ("path", "other", Some("ConfigPath"))), + ("name", ("text", "other", Some("FreeText"))), + // Encoder/scorer input: a model input, but not a prompt (v1.1). + ("encoded", ("text", "other", Some("EncodeText"))), + // The two rows the marker guard exists for. + ("raw_path", ("path", "unknown", None)), + ("raw_text", ("text", "unknown", None)), + ("listed", ("enum", "mode", None)), + ("flag", ("flag", "mode", None)), + ("level", ("count", "mode", None)), + ("n", ("int", "other", None)), + ("t", ("float", "other", None)), + ("fmt", ("enum", "mode", None)), + ("c", ("other", "other", None)), + ("backend", ("enum", "backend", Some("BackendArg"))), + ("temperature", ("float", "sampling", Some("SamplingArg"))), + ("top_k", ("int", "sampling", Some("SamplingArg"))), + // The name says "temperature"; without the group it is not sampling. + ("ungrouped_temperature", ("float", "other", None)), + ]; + let cmd = ::command(); + let root = clap::Command::new("apr").subcommand(cmd.name("probe")); + let s = emit_from(&root); + let probe = s.command(&["probe"]).expect("probe"); + assert_eq!( + probe.args.len(), + rows.len(), + "every arg of the probe has a row" + ); + for (id, (value_type, role, marker)) in rows { + let a = probe.arg(id).unwrap_or_else(|| panic!("probe has {id}")); + assert_eq!( + (a.value_type, a.role, a.marker), + (*value_type, *role, *marker), + "row {id}" + ); + } +} + +/// The verb writes [`emit`]'s document and nothing else. The binary-level pin +/// (the spawned `apr surface --json` equals `emit()`) is the integration test +/// `tests/surface_binary_pin.rs`. +#[test] +fn the_verb_writes_exactly_what_emit_returns() { + let (written, emitted) = big_stack(|| { + let mut buf = Vec::new(); + write_surface(&mut buf).expect("write to a Vec"); + (buf, emit().to_json()) + }); + assert_eq!(written, format!("{emitted}\n").into_bytes()); +} + +/// `apr surface` parses to the hidden verb, with and without `--json`/`--quiet`. +#[test] +fn apr_surface_parses_to_the_surface_verb() { + use clap::Parser; + for argv in [ + &["apr", "surface"][..], + &["apr", "surface", "--json"][..], + &["apr", "surface", "--quiet"][..], + ] { + let cli = big_stack({ + let argv: Vec = argv.iter().map(|s| s.to_string()).collect(); + move || { + Cli::try_parse_from(argv).map(|c| matches!(*c.command, crate::Commands::Surface)) + } + }); + assert_eq!( + cli.ok(), + Some(true), + "{argv:?} must parse to Commands::Surface" + ); + } +} + +/// v1.1 `generates`: one row per way a command can be built. A command +/// generates iff it has a `PromptText` argument or carries the +/// `ServesGeneration` marker; a name — the arg's or the group's — never counts. +#[test] +fn generates_case_table() { + use batuta_common::cli_roles::{ModelPath, PromptText, ServesGeneration}; + let model = || Arg::new("model").value_parser(clap::value_parser!(ModelPath)); + let prompt_arg = + |p: clap::builder::ValueParser| Arg::new("prompt").long("prompt").value_parser(p); + let root = clap::Command::new("apr") + .subcommand( + clap::Command::new("with-prompt") + .arg(model()) + .arg(prompt_arg(clap::value_parser!(PromptText).into())), + ) + .subcommand( + clap::Command::new("marked") + .arg(model()) + .group(ServesGeneration::group()), + ) + .subcommand(clap::Command::new("model-only").arg(model())) + .subcommand( + clap::Command::new("lookalike-group") + .arg(model()) + .group(clap::ArgGroup::new("ServesGeneration").multiple(true)), + ) + .subcommand( + clap::Command::new("raw-prompt-name") + .arg(model()) + .arg(prompt_arg(clap::value_parser!(String))), + ); + let s = emit_from(&root); + let rows = [ + ("with-prompt", true), + ("marked", true), + ("model-only", false), + ("lookalike-group", false), + ("raw-prompt-name", false), + ]; + assert_eq!(s.commands.len(), rows.len(), "every command has a row"); + for (name, want) in rows { + let c = s.command(&[name]).unwrap_or_else(|| panic!("{name}")); + assert_eq!(c.generates, want, "row {name}"); + } +} diff --git a/crates/apr-cli/src/tokenize_commands.rs b/crates/apr-cli/src/tokenize_commands.rs index 08cf198b51..587749661c 100644 --- a/crates/apr-cli/src/tokenize_commands.rs +++ b/crates/apr-cli/src/tokenize_commands.rs @@ -14,19 +14,19 @@ pub enum TokenizeCommands { Plan { /// Path to training corpus (text file, one document per line) #[arg(long, value_name = "FILE")] - data: PathBuf, + data: InputFile, /// Target vocabulary size #[arg(long, default_value = "32000")] vocab_size: usize, /// Tokenizer algorithm: bpe, wordpiece, unigram #[arg(long, default_value = "bpe")] - algorithm: String, + algorithm: FreeText, /// Output directory for trained tokenizer #[arg(short, long, default_value = "./tokenizer-output")] - output: PathBuf, + output: OutputPath, /// Output format: text, json, yaml #[arg(long, default_value = "text")] - format: String, + format: FreeText, }, /// Train a tokenizer on the corpus. @@ -38,16 +38,16 @@ pub enum TokenizeCommands { Apply { /// Path to training corpus (text file, one document per line) #[arg(long, value_name = "FILE")] - data: PathBuf, + data: InputFile, /// Target vocabulary size #[arg(long, default_value = "32000")] vocab_size: usize, /// Tokenizer algorithm: bpe, wordpiece, unigram #[arg(long, default_value = "bpe")] - algorithm: String, + algorithm: FreeText, /// Output directory for trained tokenizer #[arg(short, long, default_value = "./tokenizer-output")] - output: PathBuf, + output: OutputPath, /// Maximum number of lines to read from corpus (0 = all) #[arg(long, default_value = "0")] max_lines: usize, @@ -64,7 +64,7 @@ pub enum TokenizeCommands { /// Path to corpus: a `.jsonl` file or a directory containing `.jsonl` files. /// Each line must be a JSON object with a `content` field. #[arg(long, value_name = "PATH")] - corpus: PathBuf, + corpus: InputFile, /// Target vocabulary size. Default 50_257 matches GPT-2 convention /// (50_000 BPE merges + 256 byte-level fallback tokens + 1 sentinel) /// and the MODEL-2 albor tokenizer contract (tokenizer-bpe-v1 v1.2.0). @@ -78,10 +78,10 @@ pub enum TokenizeCommands { min_frequency: usize, /// Output directory; will contain vocab.json and merges.txt. #[arg(long, default_value = "./tokenizer-output")] - output: PathBuf, + output: OutputPath, /// Unicode normalization form applied to each document before training. #[arg(long, default_value = "nfc")] - normalization: String, + normalization: FreeText, }, /// Import a HuggingFace tokenizer.json into aprender's two-file @@ -104,10 +104,10 @@ pub enum TokenizeCommands { ImportHf { /// Path to input HuggingFace tokenizer.json (BPE model required). #[arg(long, value_name = "FILE")] - input: PathBuf, + input: InputFile, /// Output directory; will contain vocab.json + merges.txt + manifest.json. #[arg(long, value_name = "DIR")] - output: PathBuf, + output: OutputPath, /// Include `added_tokens` in vocab.json (default: BPE state machine only). /// Use this when the downstream consumer needs special tokens (e.g., /// `<|im_start|>`, `<|endoftext|>`) materialized in vocab.json itself. @@ -125,13 +125,13 @@ pub enum TokenizeCommands { Encode { /// GGUF model file whose tokenizer to run #[arg(value_name = "MODEL")] - model: PathBuf, + model: ModelPath, /// Text to encode #[arg(short = 'p', long, value_name = "TEXT", conflicts_with = "file")] - prompt: Option, + prompt: Option, /// File whose contents to encode (read as UTF-8, verbatim) #[arg(short = 'f', long, value_name = "FILE")] - file: Option, + file: Option, }, /// Encode a JSONL corpus into `.bin` shards per contracts/pretokenize-bin-v1.yaml. /// @@ -154,25 +154,25 @@ pub enum TokenizeCommands { /// are encoded in command-line order and shard numbering is /// continuous across sources. #[arg(long, value_name = "PATH", required = true)] - corpus: Vec, + corpus: Vec, /// Directory containing vocab.json + merges.txt from `apr tokenize train`. #[arg(long, value_name = "DIR")] - tokenizer: PathBuf, + tokenizer: InputFile, /// Output directory for shard-NNNN.bin + manifest.json. #[arg(long, value_name = "DIR")] - output: PathBuf, + output: OutputPath, /// Target tokens per shard (shard closes once this limit is reached). #[arg(long, default_value = "10000000")] shard_tokens: usize, /// JSONL field to encode (default: `content`). #[arg(long, default_value = "content")] - content_field: String, + content_field: FreeText, /// Unicode normalization (must match tokenizer training). #[arg(long, default_value = "nfc")] - normalization: String, + normalization: FreeText, /// EOS insertion policy: none|between|after. #[arg(long, default_value = "between")] - eos_policy: String, + eos_policy: FreeText, /// Number of rayon workers for per-document BPE encoding. /// /// Defaults to `std::thread::available_parallelism()` (logical CPU count). @@ -240,13 +240,13 @@ pub enum TokenizeCommands { /// Output directory containing shard-NNNN.bin files. /// `manifest.json` will be written into this directory. #[arg(long, value_name = "DIR")] - output: PathBuf, + output: OutputPath, /// Optional tokenizer directory; when provided, `vocab.json` /// is read for the manifest's `vocab_size` field. Without it, /// `vocab_size` is recorded as `null` (provenance-incomplete /// but otherwise valid). #[arg(long, value_name = "DIR")] - tokenizer: Option, + tokenizer: Option, /// Emit the manifest body as JSON to stdout (in addition to /// writing to disk). #[arg(long, default_value_t = false)] diff --git a/crates/apr-cli/src/tool_commands.rs b/crates/apr-cli/src/tool_commands.rs index e18ff68307..11ce2264b9 100644 --- a/crates/apr-cli/src/tool_commands.rs +++ b/crates/apr-cli/src/tool_commands.rs @@ -8,16 +8,16 @@ pub enum ToolCommands { auto_verify: bool, /// Run specific step #[arg(long)] - step: Option, + step: Option, /// Model tier: tiny (0.5B), small (1.5B), medium (7B), large (32B) #[arg(long, default_value = "small")] - tier: String, + tier: FreeText, /// Model directory #[arg(long, default_value = "./models")] - model_dir: PathBuf, + model_dir: DirPath, /// Baselines to compare: llama-cpp,ollama #[arg(long, default_value = "llama-cpp,ollama")] - baseline: String, + baseline: FreeText, /// Enable ZRAM compression #[arg(long)] zram: bool, @@ -46,28 +46,28 @@ pub enum ToolCommands { Publish { /// Directory containing model files to publish #[arg(value_name = "DIRECTORY")] - directory: PathBuf, + directory: DirPath, /// HuggingFace repository ID (e.g., paiml/whisper-apr-tiny) #[arg(value_name = "REPO_ID")] - repo_id: String, + repo_id: FreeText, /// Model display name #[arg(long)] - model_name: Option, + model_name: Option, /// License (SPDX identifier, default: mit) #[arg(long, default_value = "mit")] - license: String, + license: FreeText, /// Pipeline tag (e.g., automatic-speech-recognition, text-generation) #[arg(long, default_value = "text-generation")] - pipeline_tag: String, + pipeline_tag: FreeText, /// Library name (e.g., whisper-apr, aprender) #[arg(long)] - library_name: Option, + library_name: Option, /// Additional tags (comma-separated) #[arg(long, value_delimiter = ',')] - tags: Option>, + tags: Option>, /// Commit message #[arg(long)] - message: Option, + message: Option, /// Dry run (preview without uploading) #[arg(long)] dry_run: bool, @@ -84,13 +84,13 @@ pub enum ToolCommands { Oracle { /// Model file path or hf:// URI #[arg(value_name = "SOURCE")] - source: Option, + source: Option, /// Show contract for a model family (e.g., qwen2, llama, whisper, bert) #[arg(long)] - family: Option, + family: Option, /// Filter to a specific size variant (e.g., 0.5b, 7b) #[arg(long)] - size: Option, + size: Option, /// Run full contract compliance check #[arg(long)] compliance: bool, @@ -121,15 +121,15 @@ pub enum ToolCommands { Encrypt { /// Path to model file (safetensors, GGUF, etc.) #[arg(value_name = "FILE")] - file: PathBuf, + file: InputFile, /// Output path for encrypted file #[arg(short, long, value_name = "FILE")] - output: PathBuf, + output: OutputPath, /// Encryption key file (32 bytes). If omitted, reads passphrase from stdin. #[arg(long, value_name = "FILE")] - key_file: Option, + key_file: Option, /// Force overwrite if output file exists #[arg(long)] @@ -140,15 +140,15 @@ pub enum ToolCommands { Decrypt { /// Path to encrypted `.enc` file #[arg(value_name = "FILE")] - file: PathBuf, + file: InputFile, /// Output path for decrypted model #[arg(short, long, value_name = "FILE")] - output: PathBuf, + output: OutputPath, /// Decryption key file (32 bytes). If omitted, reads passphrase from stdin. #[arg(long, value_name = "FILE")] - key_file: Option, + key_file: Option, /// Force overwrite if output file exists #[arg(long)] diff --git a/crates/apr-cli/src/train_commands.rs b/crates/apr-cli/src/train_commands.rs index d32063a009..3ffda0bcc6 100644 --- a/crates/apr-cli/src/train_commands.rs +++ b/crates/apr-cli/src/train_commands.rs @@ -14,29 +14,29 @@ pub enum TrainCommands { Plan { /// Path to training data (JSONL). Only read by `apr finetune --task classify`. #[arg(long, value_name = "FILE")] - data: Option, + data: Option, /// Model size: "0.5B", "9B", "7B", "13B" #[arg(long, default_value = "0.5B")] - model_size: String, + model_size: FreeText, /// Path to model weights directory #[arg(long, value_name = "DIR")] - model_path: Option, + model_path: Option, /// Number of output classes #[arg(long, default_value = "5")] num_classes: usize, /// Task type: pretrain (causal LM). Classification fine-tuning is /// `apr finetune --task classify`, not this command. #[arg(long, default_value = "pretrain")] - task: String, + task: FreeText, /// YAML training config (for --task pretrain) #[arg(long, value_name = "FILE")] - config: Option, + config: Option, /// Output directory for checkpoints #[arg(short, long, default_value = "/tmp/training-output")] - output: PathBuf, + output: OutputPath, /// HPO strategy: tpe, grid, random, manual #[arg(long, default_value = "tpe")] - strategy: String, + strategy: FreeText, /// HPO budget (number of trials) #[arg(long, default_value = "20")] budget: usize, @@ -57,13 +57,13 @@ pub enum TrainCommands { batch_size: Option, /// Validation data file (JSONL) #[arg(long, value_name = "FILE")] - val_data: Option, + val_data: Option, /// Test data file (JSONL) #[arg(long, value_name = "FILE")] - test_data: Option, + test_data: Option, /// Output format: text, json, yaml #[arg(long, default_value = "text")] - format: String, + format: FreeText, }, /// Execute a training plan (allocate GPU, run trials). @@ -76,27 +76,27 @@ pub enum TrainCommands { Apply { /// Path to a saved plan file (YAML or JSON from `apr train plan`) #[arg(long, value_name = "FILE")] - plan: Option, + plan: Option, /// YAML training config (for --task pretrain) #[arg(long, value_name = "FILE")] - config: Option, + config: Option, /// Task type: pretrain (causal LM). Classification fine-tuning is /// `apr finetune --task classify`, not this command. #[arg(long, default_value = "pretrain")] - task: String, + task: FreeText, // ── Inline plan params (used when no --plan file is given) ───── /// Path to training data (JSONL) #[arg(long, value_name = "FILE")] - data: Option, + data: Option, /// Model size: "0.5B", "9B", "7B", "13B" #[arg(long, default_value = "0.5B")] - model_size: String, + model_size: FreeText, /// Path to model weights directory #[arg(long, value_name = "DIR")] - model_path: Option, + model_path: Option, /// Number of output classes #[arg(long, default_value = "5")] num_classes: usize, @@ -106,10 +106,10 @@ pub enum TrainCommands { /// When omitted, the config's `training.output_dir` is used (default /// `./checkpoints`). The directory is created if it does not exist. #[arg(short, long, value_name = "DIR")] - output: Option, + output: Option, /// HPO strategy: tpe, grid, random, manual #[arg(long, default_value = "tpe")] - strategy: String, + strategy: FreeText, /// HPO budget (number of trials) #[arg(long, default_value = "20")] budget: usize, @@ -141,7 +141,7 @@ pub enum TrainCommands { rank: Option, /// Coordinator address for distributed training (default: 0.0.0.0:9000) #[arg(long, value_name = "HOST:PORT")] - coordinator_addr: Option, + coordinator_addr: Option, // ── Reproducibility params (R-084 C-DETERM-001) ── /// Enable bitwise deterministic training (CUBLAS_WORKSPACE_CONFIG, cuDNN deterministic) @@ -172,7 +172,7 @@ pub enum TrainCommands { Watch { /// YAML training config to run and watch #[arg(long, value_name = "FILE")] - config: PathBuf, + config: ConfigPath, /// Maximum number of restart attempts #[arg(long, default_value = "5")] @@ -201,11 +201,11 @@ pub enum TrainCommands { Sweep { /// Base YAML training config to sweep from #[arg(long, value_name = "FILE")] - config: PathBuf, + config: ConfigPath, /// Search strategy: grid or random #[arg(long, default_value = "random")] - strategy: String, + strategy: FreeText, /// Number of configs to generate (random) or max combinations (grid) #[arg(long, default_value = "10")] @@ -213,7 +213,7 @@ pub enum TrainCommands { /// Output directory for generated configs #[arg(long, default_value = "sweeps/")] - output_dir: PathBuf, + output_dir: OutputPath, /// Seed for random search reproducibility #[arg(long, default_value = "42")] @@ -232,7 +232,7 @@ pub enum TrainCommands { Halving { /// Directory containing sweep-*.yaml configs (from `apr train sweep`) #[arg(long, value_name = "DIR")] - sweep_dir: PathBuf, + sweep_dir: DirPath, /// Number of halving rounds (default: 3) #[arg(long, default_value = "3")] @@ -252,7 +252,7 @@ pub enum TrainCommands { /// Output JSON file for results #[arg(long, default_value = "sweeps/hpo-results.json")] - output: PathBuf, + output: OutputPath, }, /// Archive a checkpoint into a release bundle. @@ -262,19 +262,19 @@ pub enum TrainCommands { Archive { /// Path to checkpoint directory #[arg(value_name = "CHECKPOINT_DIR")] - checkpoint_dir: PathBuf, + checkpoint_dir: ModelPath, /// Output archive directory #[arg(short, long, value_name = "DIR")] - output: PathBuf, + output: OutputPath, /// Release version tag (e.g., "v1.0") #[arg(long = "release-version")] - release_version: Option, + release_version: Option, /// Release notes #[arg(long)] - notes: Option, + notes: Option, }, /// Submit multi-adapter training jobs to a cluster (GPU-SHARE Phase 3). @@ -284,15 +284,15 @@ pub enum TrainCommands { Submit { /// Path to cluster config YAML #[arg(long, value_name = "FILE")] - cluster: PathBuf, + cluster: ConfigPath, /// Model checkpoint path (.apr) #[arg(long, value_name = "FILE")] - model: PathBuf, + model: ModelPath, /// Adapter specs: DATA:CHECKPOINT pairs (one per adapter) #[arg(long = "adapter", value_name = "DATA:CHECKPOINT")] - adapters: Vec, + adapters: Vec, /// LoRA rank #[arg(long, default_value = "16")] @@ -318,6 +318,6 @@ pub enum TrainCommands { ClusterStatus { /// Path to cluster config YAML #[arg(long, value_name = "FILE")] - cluster: PathBuf, + cluster: ConfigPath, }, } diff --git a/crates/apr-cli/src/validate.rs b/crates/apr-cli/src/validate.rs index 3651c634dd..586a17c28a 100644 --- a/crates/apr-cli/src/validate.rs +++ b/crates/apr-cli/src/validate.rs @@ -11,7 +11,7 @@ fn extract_extended_model_paths(command: &ExtendedCommands) -> Vec { // active subcommand variant. ExtendedCommands::Test { command: probar_sub } => match probar_sub { TestSubcommand::Tensor { file, .. } => { - vec![file.clone()] + vec![file.to_path_buf()] }, // `llm` measures an HTTP endpoint, not a local model file. Listed // explicitly so a future subcommand that DOES name a file has to @@ -22,19 +22,19 @@ fn extract_extended_model_paths(command: &ExtendedCommands) -> Vec { | ExtendedCommands::Chat { file, .. } | ExtendedCommands::Bench { file, .. } | ExtendedCommands::Eval { file, .. } - | ExtendedCommands::Profile { file, .. } => vec![file.clone()], + | ExtendedCommands::Profile { file, .. } => vec![file.to_path_buf()], - ExtendedCommands::Cbtop { model_path, .. } => model_path.iter().cloned().collect(), + ExtendedCommands::Cbtop { model_path, .. } => model_path.iter().map(|p| p.to_path_buf()).collect(), // Rosetta action subcommands ExtendedCommands::Tools(ToolCommands::Rosetta { action }) => match action { RosettaCommands::Convert { source, .. } | RosettaCommands::Chain { source, .. } - | RosettaCommands::Verify { source, .. } => vec![source.clone()], + | RosettaCommands::Verify { source, .. } => vec![source.to_path_buf()], RosettaCommands::CompareInference { model_a, model_b, .. } => { - vec![model_a.clone(), model_b.clone()] + vec![model_a.to_path_buf(), model_b.to_path_buf()] } // Diagnostic rosetta commands — exempt _ => vec![], @@ -64,7 +64,7 @@ fn extract_model_paths(command: &Commands) -> Vec { } } Commands::Export { plan: true, .. } => vec![], - Commands::Export { file, .. } => file.iter().cloned().collect(), + Commands::Export { file, .. } => file.iter().map(|p| p.to_path_buf()).collect(), // GH-471: Serve is exempt from pre-dispatch contract validation. // The model loader itself fails fast on corrupt files. // Contract gate was loading 17 GB+ models just to check tensor counts — @@ -72,22 +72,22 @@ fn extract_model_paths(command: &Commands) -> Vec { Commands::Serve { .. } => vec![], Commands::Trace { file, .. } | Commands::Convert { file, .. } - | Commands::Check { file, .. } => vec![file.clone()], + | Commands::Check { file, .. } => vec![file.to_path_buf()], Commands::Merge { plan: true, .. } => vec![], - Commands::Merge { files, .. } => files.clone(), + Commands::Merge { files, .. } => files.iter().map(|p| p.to_path_buf()).collect(), - Commands::Quantize { file, .. } => vec![file.clone()], - Commands::ModelOps(ModelOpsCommands::Prune { file, .. }) => vec![file.clone()], + Commands::Quantize { file, .. } => vec![file.to_path_buf()], + Commands::ModelOps(ModelOpsCommands::Prune { file, .. }) => vec![file.to_path_buf()], Commands::ModelOps(ModelOpsCommands::Distill { teacher, .. }) => { - teacher.iter().cloned().collect() + teacher.iter().map(|p| p.to_path_buf()).collect() } #[cfg(feature = "training")] Commands::ModelOps(ModelOpsCommands::Finetune { file, .. }) => { - file.iter().cloned().collect() + file.iter().map(|p| p.to_path_buf()).collect() } - Commands::Tui { file, .. } => file.iter().cloned().collect(), + Commands::Tui { file, .. } => file.iter().map(|p| p.to_path_buf()).collect(), Commands::Import { source, .. } => { let path = PathBuf::from(source); if path.exists() { diff --git a/crates/apr-cli/tests/beat_apr_sibling_cli_reach.rs b/crates/apr-cli/tests/beat_apr_sibling_cli_reach.rs index 85cff5d78e..4bbfed7cfc 100644 --- a/crates/apr-cli/tests/beat_apr_sibling_cli_reach.rs +++ b/crates/apr-cli/tests/beat_apr_sibling_cli_reach.rs @@ -223,7 +223,7 @@ fn apr_zram_benchmark_runs_instead_of_panicking() { ); } -// A cross-binary byte-identity check (`apr rag query` vs `trueno-rag query`) +// A cross-binary byte-identity check (`apr rag query` vs `aprender-rag query`) // belongs here in spirit, but `CARGO_BIN_EXE_*` only exposes the binaries of // THIS package, so writing it from apr-cli yields an apr-vs-apr comparison -- // an oracle that agrees with itself by construction. The structural guarantee @@ -263,17 +263,18 @@ fn every_simular_command_is_reachable_through_apr_sim() { #[test] fn apr_sim_validates_a_real_emc_file() { - let emc = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../aprender-simulate/docs/emc/ml/linear_regression.emc.yaml" - ); - assert!( - std::path::Path::new(emc).is_file(), - "fixture moved: {emc} -- fix the path rather than weakening the test" - ); + // A sibling crate's file: in tree a moved fixture FAILS (fix the path rather than weakening + // the test); out of tree (the published tarball) the test skips by name (#4149). + let Some(emc) = provable_contracts::workspace_path_or_skip!( + "apr_sim_validates_a_real_emc_file", + "crates/aprender-simulate/docs/emc/ml/linear_regression.emc.yaml", + ) else { + return; + }; let out = apr() - .args(["sim", "emc-validate", emc]) + .args(["sim", "emc-validate"]) + .arg(&emc) .output() .expect("apr sim emc-validate"); assert!( @@ -411,16 +412,15 @@ fn apr_pv_reaches_the_contract_engine() { assert!(!help.contains("wharrgarbl"), "contains proves nothing here"); // Execution: validate a contract that really is in the tree. - let contract = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../contracts/apr-cli-commands-v1.yaml" - ); - assert!( - std::path::Path::new(contract).is_file(), - "fixture moved: {contract}" - ); + let Some(contract) = provable_contracts::workspace_path_or_skip!( + "apr_pv_reaches_the_contract_engine", + "contracts/apr-cli-commands-v1.yaml", + ) else { + return; + }; let out = apr() - .args(["pv", "validate", contract]) + .args(["pv", "validate"]) + .arg(&contract) .output() .expect("apr pv validate"); assert!( diff --git a/crates/apr-cli/tests/cli_commands.rs b/crates/apr-cli/tests/cli_commands.rs index c8ef00228b..939ab9ade5 100644 --- a/crates/apr-cli/tests/cli_commands.rs +++ b/crates/apr-cli/tests/cli_commands.rs @@ -87,6 +87,7 @@ fn registered_commands() -> Vec<&'static str> { "gpu", "profile", "ptx", + "ptx-debug", "ptx-map", "cbtop", "data", @@ -638,13 +639,11 @@ fn help_subcommands(path: &[&str]) -> Vec { } /// `(parent, [children])` for every parent the CONTRACT declares as having a -/// `subcommands:` list. -fn contract_subcommands() -> Vec<(String, Vec)> { - let path = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../contracts/apr-cli-commands-v1.yaml" - ); - let text = std::fs::read_to_string(path).expect("read the command contract"); +/// `subcommands:` list. The contract is a workspace file: `None` (a named SKIP) out of tree, +/// a FAIL in tree when it is missing (#4149). +fn contract_subcommands(test: &str) -> Option)>> { + let text = + provable_contracts::workspace_file_or_skip!(test, "contracts/apr-cli-commands-v1.yaml")?; let mut out = Vec::new(); let mut current: Option = None; @@ -663,12 +662,15 @@ fn contract_subcommands() -> Vec<(String, Vec)> { } } } - out + Some(out) } #[test] fn every_declared_subcommand_exists_in_the_binary() { - let declared = contract_subcommands(); + let Some(declared) = contract_subcommands("every_declared_subcommand_exists_in_the_binary") + else { + return; + }; // Vacuity: a parse that found nothing would make the loop below pass // trivially -- which is exactly how a depth-2 gate reports green while @@ -703,8 +705,10 @@ fn every_declared_subcommand_exists_in_the_binary() { #[test] fn every_subcommand_in_the_binary_is_declared() { - let declared: std::collections::HashMap> = - contract_subcommands().into_iter().collect(); + let Some(declared) = contract_subcommands("every_subcommand_in_the_binary_is_declared") else { + return; + }; + let declared: std::collections::HashMap> = declared.into_iter().collect(); let mut undeclared: Vec = Vec::new(); let mut seen = 0usize; diff --git a/crates/apr-cli/tests/falsification_crux_a_01.rs b/crates/apr-cli/tests/falsification_crux_a_01.rs index 3bec50e21f..09cd0a4ff0 100644 --- a/crates/apr-cli/tests/falsification_crux_a_01.rs +++ b/crates/apr-cli/tests/falsification_crux_a_01.rs @@ -32,29 +32,24 @@ #![allow(clippy::unwrap_used)] use std::collections::BTreeMap; -use std::path::PathBuf; use std::process::Command; -fn repo_root() -> PathBuf { - let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - manifest - .ancestors() - .nth(2) - .expect("CARGO_MANIFEST_DIR has repo root 2 ancestors up") - .to_path_buf() -} - -fn load_aliases() -> BTreeMap { - let path = repo_root().join("configs").join("aliases.yaml"); - let text = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("FALSIFY-CRUX-A-01-002: cannot read {path:?}: {e}")); - serde_yaml::from_str::>(&text) - .unwrap_or_else(|e| panic!("FALSIFY-CRUX-A-01-002: aliases.yaml not a str→str map: {e}")) +/// `configs/aliases.yaml` is a workspace file: `None` (a named SKIP) out of tree — the published +/// tarball does not carry it — and a FAIL in tree when it is missing or unparseable (#4149). +fn load_aliases(test: &str) -> Option> { + let text = provable_contracts::workspace_file_or_skip!(test, "configs/aliases.yaml")?; + Some( + serde_yaml::from_str::>(&text).unwrap_or_else(|e| { + panic!("FALSIFY-CRUX-A-01-002: aliases.yaml not a str→str map: {e}") + }), + ) } #[test] fn falsify_crux_a_01_002_aliases_yaml_present_and_parseable() { - let map = load_aliases(); + let Some(map) = load_aliases("falsify_crux_a_01_002_aliases_yaml_present_and_parseable") else { + return; + }; assert!( !map.is_empty(), "FALSIFY-CRUX-A-01-002: aliases.yaml parsed empty" @@ -63,7 +58,9 @@ fn falsify_crux_a_01_002_aliases_yaml_present_and_parseable() { #[test] fn falsify_crux_a_01_002_canonical_short_names_present() { - let map = load_aliases(); + let Some(map) = load_aliases("falsify_crux_a_01_002_canonical_short_names_present") else { + return; + }; for canonical in ["llama3", "mistral", "phi3", "qwen2"] { assert!( map.contains_key(canonical), @@ -74,7 +71,9 @@ fn falsify_crux_a_01_002_canonical_short_names_present() { #[test] fn falsify_crux_a_01_002_every_value_has_known_scheme() { - let map = load_aliases(); + let Some(map) = load_aliases("falsify_crux_a_01_002_every_value_has_known_scheme") else { + return; + }; for (name, url) in &map { assert!( url.starts_with("hf://") || url.starts_with("https://"), @@ -85,8 +84,12 @@ fn falsify_crux_a_01_002_every_value_has_known_scheme() { #[test] fn falsify_crux_a_01_002_resolution_deterministic() { - let a = load_aliases(); - let b = load_aliases(); + let (Some(a), Some(b)) = ( + load_aliases("falsify_crux_a_01_002_resolution_deterministic"), + load_aliases("falsify_crux_a_01_002_resolution_deterministic"), + ) else { + return; + }; assert_eq!( a, b, "FALSIFY-CRUX-A-01-002: two reads of aliases.yaml must yield byte-identical maps" diff --git a/crates/apr-cli/tests/pixel_regression.rs b/crates/apr-cli/tests/pixel_regression.rs index bcdfdd81fd..5451d96ccc 100644 --- a/crates/apr-cli/tests/pixel_regression.rs +++ b/crates/apr-cli/tests/pixel_regression.rs @@ -41,6 +41,14 @@ fn snapshots_dir() -> PathBuf { .join("snapshots") } +/// The golden snapshots are `exclude`d from the published package (`Cargo.toml`), so out of +/// tree (the unpacked `.crate`) there is nothing to compare against: the test skips by name. In +/// tree the directory must exist — a missing one FAILS (#4149). +fn snapshots_present(test: &str) -> bool { + provable_contracts::workspace_path_or_skip!(test, "crates/apr-cli/playbooks/snapshots") + .is_some() +} + fn test_apr_file() -> PathBuf { let path = snapshots_dir().join("test.apr"); // Ensure the test file exists and is v2 format @@ -226,6 +234,9 @@ fn create_diff(expected: &str, actual: &str) -> String { #[test] fn test_pixel_hex_dump() { + if !snapshots_present("test_pixel_hex_dump") { + return; + } let output = apr() .args([ "hex", @@ -248,6 +259,9 @@ fn test_pixel_hex_dump() { #[test] fn test_pixel_tree_ascii() { + if !snapshots_present("test_pixel_tree_ascii") { + return; + } let output = apr() .args(["tree", test_apr_file().to_str().unwrap(), "--sizes"]) .output() @@ -259,6 +273,9 @@ fn test_pixel_tree_ascii() { #[test] fn test_pixel_tree_mermaid() { + if !snapshots_present("test_pixel_tree_mermaid") { + return; + } let output = apr() .args([ "tree", @@ -279,6 +296,9 @@ fn test_pixel_tree_mermaid() { #[test] fn test_pixel_flow_cross_attn() { + if !snapshots_present("test_pixel_flow_cross_attn") { + return; + } let output = apr() .args([ "flow", @@ -295,6 +315,9 @@ fn test_pixel_flow_cross_attn() { #[test] fn test_pixel_flow_full() { + if !snapshots_present("test_pixel_flow_full") { + return; + } let output = apr() .args(["flow", test_apr_file().to_str().unwrap()]) .output() diff --git a/crates/apr-cli/tests/registry_failure_catalogue.rs b/crates/apr-cli/tests/registry_failure_catalogue.rs index b6e6cc5e60..faddbacd6a 100644 --- a/crates/apr-cli/tests/registry_failure_catalogue.rs +++ b/crates/apr-cli/tests/registry_failure_catalogue.rs @@ -5,6 +5,7 @@ //! host-dogfood rows recorded as receipts, not CI claims (design quorum //! 2026-09-06). Every hermetic row has its must-RED twin: a defective fixture //! under `tests/fixtures/registry/defective/` that the same check refuses. + use std::path::PathBuf; use std::process::{Command, Output}; @@ -239,11 +240,12 @@ fn reg12_nothing_is_persisted_between_discoveries() { /// `contracts/apr-devices-schema-v1.yaml` binds). #[test] fn json_output_validates_against_the_schema_on_fixtures_and_on_this_machine() { - let schema_text = std::fs::read_to_string( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../contracts/schemas/apr-devices-v1.schema.json"), - ) - .expect("schema file"); + let Some(schema_text) = provable_contracts::workspace_file_or_skip!( + "json_output_validates_against_the_schema_on_fixtures_and_on_this_machine", + "contracts/schemas/apr-devices-v1.schema.json", + ) else { + return; + }; let schema: serde_json::Value = serde_json::from_str(&schema_text).expect("schema json"); let validator = jsonschema::validator_for(&schema).expect("schema compiles"); let mut runs: Vec<(String, Output)> = ["cpu-only.json", "one-cuda.json", "two-vendors.json"] @@ -282,11 +284,12 @@ fn json_output_validates_against_the_schema_on_fixtures_and_on_this_machine() { /// (`minItems: 5`) — the schema discriminates, it is not decoration. #[test] fn schema_twin_the_defective_fixture_is_refused_by_the_schema() { - let schema_text = std::fs::read_to_string( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../contracts/schemas/apr-devices-v1.schema.json"), - ) - .expect("schema file"); + let Some(schema_text) = provable_contracts::workspace_file_or_skip!( + "schema_twin_the_defective_fixture_is_refused_by_the_schema", + "contracts/schemas/apr-devices-v1.schema.json", + ) else { + return; + }; let schema: serde_json::Value = serde_json::from_str(&schema_text).expect("schema json"); let validator = jsonschema::validator_for(&schema).expect("schema compiles"); let out = apr_devices( @@ -320,11 +323,12 @@ fn a_malformed_reserve_override_is_refused_by_name() { /// stray field on an entry are all rejected. #[test] fn schema_twin_documents_serde_refuses_do_not_validate() { - let schema_text = std::fs::read_to_string( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../contracts/schemas/apr-devices-v1.schema.json"), - ) - .expect("schema file"); + let Some(schema_text) = provable_contracts::workspace_file_or_skip!( + "schema_twin_documents_serde_refuses_do_not_validate", + "contracts/schemas/apr-devices-v1.schema.json", + ) else { + return; + }; let schema: serde_json::Value = serde_json::from_str(&schema_text).expect("schema json"); let validator = jsonschema::validator_for(&schema).expect("schema compiles"); let out = apr_devices( diff --git a/crates/apr-cli/tests/surface_binary_pin.rs b/crates/apr-cli/tests/surface_binary_pin.rs new file mode 100644 index 0000000000..335ea07ccc --- /dev/null +++ b/crates/apr-cli/tests/surface_binary_pin.rs @@ -0,0 +1,58 @@ +//! #3745 S1: `apr surface --json` from the built binary is exactly the document +//! the library's `surface::emit()` returns. +//! +//! In-process gates (S3's hand-list guard, S2's cell derivation in lib tests) +//! read `emit()`. The release gate reads the release-candidate binary's +//! `apr surface --json`. If the two could differ, a gate would pass on one +//! surface and ship the other. + +use std::process::Command; + +fn apr(args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_apr")) + .args(args) + .output() + .expect("spawn the apr binary") +} + +#[test] +fn the_binary_prints_the_library_surface() { + let expected = format!("{}\n", apr_cli::surface::emit().to_json()); + for argv in [ + &["surface", "--json"][..], + &["surface"][..], + &["surface", "--quiet"][..], + ] { + let out = apr(argv); + assert!( + out.status.success(), + "`apr {}` exited {:?}; stderr:\n{}", + argv.join(" "), + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).expect("utf-8 JSON"); + assert!( + stdout == expected, + "`apr {}` stdout differs from surface::emit() ({} vs {} bytes)", + argv.join(" "), + stdout.len(), + expected.len() + ); + let v: serde_json::Value = + serde_json::from_str(&stdout).expect("stdout is exactly one JSON document"); + assert_eq!(v["schema"], "apr-cli-surface/v1.1"); + } +} + +/// Hidden means absent from `--help`, not absent from the binary. +#[test] +fn surface_is_hidden_from_help() { + let out = apr(&["--help"]); + assert!(out.status.success()); + let help = String::from_utf8_lossy(&out.stdout); + assert!( + !help.lines().any(|l| l.trim_start().starts_with("surface ")), + "`surface` must not be listed in `apr --help`" + ); +} diff --git a/crates/apr-cli/tests/thinking_budgets_mirror.rs b/crates/apr-cli/tests/thinking_budgets_mirror.rs index 438fb88170..014bd8f7d3 100644 --- a/crates/apr-cli/tests/thinking_budgets_mirror.rs +++ b/crates/apr-cli/tests/thinking_budgets_mirror.rs @@ -24,20 +24,8 @@ const MIRROR: &str = include_str!("../contracts/thinking-budgets-v1.yaml"); /// The linted source, or `None` (after naming the skip) when out of tree. fn source_or_skip(test: &str) -> Option { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - if !root.join("contracts").is_dir() { - eprintln!( - "SKIP {test}: out of tree (no {} beside this crate) - the linted source lives in the \ - workspace, which a published crate does not carry (#4129)", - root.join("contracts").display() - ); - return None; - } - let path = root.join("contracts/thinking-budgets-v1.yaml"); - Some( - std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("in tree, {} must be readable: {e}", path.display())), - ) + // The shared in-tree rule (#4175): in tree a missing source FAILS, out of tree it skips by name. + provable_contracts::workspace_file_or_skip!(test, "contracts/thinking-budgets-v1.yaml") } #[test] diff --git a/crates/aprender-bench-compute/Cargo.toml b/crates/aprender-bench-compute/Cargo.toml index f19a38693e..f3a31eadab 100644 --- a/crates/aprender-bench-compute/Cargo.toml +++ b/crates/aprender-bench-compute/Cargo.toml @@ -11,7 +11,7 @@ disallowed_methods = "allow" [dependencies] # The implementation under test -aprender = { path = "../aprender-core", version = "0.69.3", package = "aprender-core", features = ["format-quantize"] } +aprender = { path = "../aprender-core", version = "0.70.0", package = "aprender-core", features = ["format-quantize"] } # Reference implementation for A/B comparison # Pure Rust (no BLAS) to keep comparison fair — both are pure Rust SIMD diff --git a/crates/aprender-bench-tokenizer/Cargo.toml b/crates/aprender-bench-tokenizer/Cargo.toml index cc9cbd4416..158cc95a9f 100644 --- a/crates/aprender-bench-tokenizer/Cargo.toml +++ b/crates/aprender-bench-tokenizer/Cargo.toml @@ -11,7 +11,7 @@ disallowed_methods = "allow" [dependencies] # The implementation under test -aprender = { path = "../aprender-core", version = "0.69.3", package = "aprender-core" } +aprender = { path = "../aprender-core", version = "0.70.0", package = "aprender-core" } # HuggingFace reference for A/B comparison # Regular dep (not dev-dep) because [[bench]] harness=false binaries can't see dev-deps diff --git a/crates/aprender-build-sha/Cargo.toml b/crates/aprender-build-sha/Cargo.toml index fb7a687314..5647c1d3f7 100644 --- a/crates/aprender-build-sha/Cargo.toml +++ b/crates/aprender-build-sha/Cargo.toml @@ -19,3 +19,6 @@ name = "build_sha" [lints] workspace = true + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/aprender-build-sha/src/emit_tests.rs b/crates/aprender-build-sha/src/emit_tests.rs new file mode 100644 index 0000000000..76a08cff52 --- /dev/null +++ b/crates/aprender-build-sha/src/emit_tests.rs @@ -0,0 +1,229 @@ +//! #4110 — a crates.io install of apr names its commit, never `+no-git`. Since #4219 every +//! workspace [[bin]] stamps its SHA through this crate, so the table runs against it. +//! +//! The 0.69.1 post-publish install printed `apr 0.69.1 (v0.69.1+no-git)` (0.68.1 did too) although +//! the published tarball carried the commit: `cargo package` writes `.cargo_vcs_info.json` into +//! every crate. These tests compile the SHIPPED `lib.rs` plus a `fn main() { emit() }` with rustc and run it the way cargo runs a +//! build script (CARGO_MANIFEST_DIR, CARGO_PKG_VERSION, cwd = the package dir) in a temp package +//! dir with no `.git` of ours above it. The vcs file is byte-exact from the real apr-cli-0.69.1 crate. +//! The release binaries printed `+no-git` too: binary-release.yml builds as root in a container over +//! the runner-owned checkout, and git refuses it ("dubious ownership"); row `foreign-owner`. +//! `planted_regressions_turn_red` re-runs the table on three planted copies of `lib.rs` — the vcs +//! rung removed (the pre-#4110 script), the vcs rung behind `git rev-parse`, and the safe.directory +//! retry removed — and requires each to fail, so the table cannot pass vacuously. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +const LIB_RS: &str = include_str!("lib.rs"); + +/// What a package's `build.rs` compiles to: this crate's code, entered through [`super::emit`]. +fn shipped() -> String { + format!("{LIB_RS}\nfn main() {{\n emit();\n}}\n") +} +const VCS_CALL: &str = " if let Some(sha) = vcs_info_sha() {"; + +/// Byte-exact `.cargo_vcs_info.json` of apr-cli-0.69.1 as downloaded from crates.io. +const VCS_REAL: &str = "{\n \"git\": {\n \"sha1\": \"d8a6df53a6c7104ef4eabfd2284908f93f1d6a3f\"\n },\n \"path_in_vcs\": \"crates/apr-cli\"\n}"; +const VCS_DIRTY: &str = "{\n \"git\": {\n \"sha1\": \"d8a6df53a6c7104ef4eabfd2284908f93f1d6a3f\",\n \"dirty\": true\n },\n \"path_in_vcs\": \"crates/apr-cli\"\n}"; +const VCS_BAD: &str = + "{\n \"git\": {\n \"sha1\": \"not-a-sha\"\n },\n \"path_in_vcs\": \"crates/apr-cli\"\n}"; + +fn compile(source: &str, dir: &Path, name: &str) -> PathBuf { + let src = dir.join(format!("{name}.rs")); + let bin = dir.join(name); + std::fs::write(&src, source).expect("write build script copy"); + let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); + let out = Command::new(rustc) + .args([ + "--edition", + "2021", + "--crate-name", + "build_script_build", + "-o", + ]) + .arg(&bin) + .arg(&src) + .output() + .expect("rustc must be runnable: this test compiles the shipped build-sha source"); + assert!( + out.status.success(), + "rustc rejected {name}: {}", + String::from_utf8_lossy(&out.stderr) + ); + bin +} + +fn git(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .args([ + "-c", + "core.hooksPath=/dev/null", + "-c", + "user.email=f@x", + "-c", + "user.name=f", + ]) + .args(args) + .current_dir(dir) + .output() + .expect("git must be runnable"); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +/// A fresh package dir under `root`; the vcs file written when given; optionally its own repo. +fn package(root: &Path, name: &str, vcs: Option<&str>) -> PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(&dir).expect("mkdir package"); + if let Some(v) = vcs { + std::fs::write(dir.join(".cargo_vcs_info.json"), v).expect("write vcs file"); + } + dir +} + +/// Runs the compiled build script like cargo does; returns the APR_GIT_SHA it emits. +/// `ceiling` stops git's upward search there (None: search freely, as a real install would). +fn run(bin: &Path, pkg: &Path, ceiling: Option<&Path>, env: &[(&str, &str)]) -> String { + let out_dir = pkg.join("target-out"); + std::fs::create_dir_all(&out_dir).expect("mkdir OUT_DIR"); + let mut cmd = Command::new(bin); + cmd.current_dir(pkg) + .env("CARGO_MANIFEST_DIR", pkg) + .env("OUT_DIR", &out_dir) + .env("CARGO_PKG_VERSION", "9.9.9") + .env_remove("APR_GIT_SHA_OVERRIDE") + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env( + "GIT_CEILING_DIRECTORIES", + ceiling.unwrap_or_else(|| Path::new("/nonexistent")), + ) + .envs(env.iter().copied()); + let out = cmd.output().expect("run build script"); + String::from_utf8_lossy(&out.stdout) + .lines() + .find_map(|l| l.strip_prefix("cargo:rustc-env=APR_GIT_SHA=")) + .unwrap_or("") + .to_string() +} + +/// The case table. Returns the names of the rows that landed wrong (empty = all as expected). +fn table(build_rs: &str, label: &str) -> Vec { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path(); + let bin = compile(build_rs, root, label); + let mut wrong = Vec::new(); + let mut expect = |row: &str, got: String, want: &str| { + if got != want { + wrong.push(format!("{row}: got {got:?}, want {want:?}")); + } + }; + let pkgs = root.join("pkgs"); + let p = package(&pkgs, "packaged", Some(VCS_REAL)); + expect("packaged", run(&bin, &p, Some(&pkgs), &[]), "d8a6df53a"); + // an unrelated repository ABOVE the unpacked crate, with a commit, and no ceiling between them + let foreign = package(&pkgs, "foreign", None); + git(&foreign, &["init", "-q"]); + git( + &foreign, + &["commit", "-q", "--allow-empty", "-m", "foreign"], + ); + let p = package(&foreign, "crate", Some(VCS_REAL)); + expect("foreign-repo", run(&bin, &p, None, &[]), "d8a6df53a"); + let p = package(&pkgs, "dirty", Some(VCS_DIRTY)); + expect("dirty", run(&bin, &p, Some(&pkgs), &[]), "d8a6df53a-dirty"); + let p = package(&pkgs, "malformed", Some(VCS_BAD)); + expect( + "malformed", + run(&bin, &p, Some(&pkgs), &[]), + "v9.9.9+no-git", + ); + let p = package(&pkgs, "no-vcs", None); + expect("no-vcs", run(&bin, &p, Some(&pkgs), &[]), "v9.9.9+no-git"); + let p = package(&pkgs, "override", Some(VCS_REAL)); + expect( + "override", + run( + &bin, + &p, + Some(&pkgs), + &[("APR_GIT_SHA_OVERRIDE", "release123")], + ), + "release123", + ); + // a dev checkout: no vcs file, its own repo -> `git rev-parse --short HEAD` (worktree freshness) + let dev = package(&pkgs, "dev", None); + git(&dev, &["init", "-q"]); + git(&dev, &["commit", "-q", "--allow-empty", "-m", "dev"]); + let head = git(&dev, &["rev-parse", "--short", "HEAD"]); + expect("dev-checkout", run(&bin, &dev, None, &[]), &head); + // the release lane: root in a container over the runner-owned checkout. git's own test knob + // makes it see another owner; the precondition proves plain git really refuses the repo here + let other_owner = [("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1")]; + let refused = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&dev) + .envs(other_owner) + .output(); + assert!( + !refused.expect("git").status.success(), + "cannot simulate a checkout owned by another user: this git ignores GIT_TEST_ASSUME_DIFFERENT_OWNER" + ); + expect("foreign-owner", run(&bin, &dev, None, &other_owner), &head); + wrong +} + +#[test] +fn packaged_build_rs_names_its_commit() { + let wrong = table(&shipped(), "shipped"); + assert!( + wrong.is_empty(), + "shipped build-sha rows landed wrong: {wrong:#?}" + ); +} + +#[test] +fn planted_regressions_turn_red() { + let shipped = shipped(); + assert_eq!( + shipped.matches(VCS_CALL).count(), + 1, + "the vcs rung call site moved; re-anchor the plants" + ); + let no_rung = shipped.replace(VCS_CALL, " if let Some(sha) = None:: {"); + let git_first = shipped.replace( + VCS_CALL, + " if let Some(sha) = run_git(&[\"rev-parse\", \"--short\", \"HEAD\"]).or_else(vcs_info_sha) {", + ); + let w1 = table(&no_rung, "plant_no_rung"); + assert!( + w1.iter().any(|r| r.starts_with("packaged:")), + "no-vcs-rung plant survived: {w1:#?}" + ); + // #4254 split the chain across lines and appended the `.git` read after the retry. + let retry = "\n .or_else(|| trusted_git_retry(&head))"; + assert_eq!( + shipped.matches(retry).count(), + 1, + "the git retry call site moved; re-anchor the plant" + ); + let w3 = table(&shipped.replace(retry, ""), "plant_no_retry"); + assert!( + w3.iter().any(|r| r.starts_with("foreign-owner:")), + "no-retry plant survived: {w3:#?}" + ); + let w2 = table(&git_first, "plant_git_first"); + assert!( + w2.iter().any(|r| r.starts_with("foreign-repo:")), + "git-first plant survived: {w2:#?}" + ); + assert!( + !w2.iter().any(|r| r.starts_with("packaged:")), + "git-first plant should still pass packaged: {w2:#?}" + ); +} diff --git a/crates/aprender-build-sha/src/lib.rs b/crates/aprender-build-sha/src/lib.rs index fa491dc461..8406df6b6b 100644 --- a/crates/aprender-build-sha/src/lib.rs +++ b/crates/aprender-build-sha/src/lib.rs @@ -7,13 +7,20 @@ //! Contract: apr-version-traceability-v1 F-VERSION-001..004 (paiml/aprender#597, //! #1862). `APR_GIT_SHA` is resolved with a multi-source fallback hierarchy: //! 1. `APR_GIT_SHA_OVERRIDE` env var (CI/release hook) -//! 2. `git rev-parse --short HEAD` (dev builds from worktree or primary checkout) -//! 3. committed `.git-sha` file (crates.io installs) -//! 4. `v{CARGO_PKG_VERSION}+no-git` (informative fallback — never bare "unknown") +//! 2. `.cargo_vcs_info.json` `git.sha1` — written by `cargo package` into every published +//! tarball, so a crates.io install knows its commit (#4110). Ahead of `git rev-parse` +//! because an unpacked crate may sit inside SOME OTHER repository whose HEAD is not ours +//! 3. `git rev-parse --short HEAD` (dev builds from worktree or primary checkout; retried with +//! the enclosing checkout marked safe.directory when git refuses a checkout owned by another +//! user — binary-release.yml builds as root in a container over the runner's checkout) +//! then, with no git binary that answers, HEAD read straight from `.git` (#4254: the +//! release asset said "v0.69.1+no-git" inside the sibling build container) +//! 4. committed `.git-sha` file +//! 5. `v{CARGO_PKG_VERSION}+no-git` (informative fallback — never bare "unknown") //! -//! This logic was moved out of `crates/apr-cli/build.rs` unchanged; only the -//! pure resolution order was factored into [`resolve`] so it can be tested -//! without running git. +//! This logic was moved out of `crates/apr-cli/build.rs`; the pure resolution +//! order is factored into [`resolve`] so it can be tested without running git, +//! and `emit_tests` compiles this file as a build script and runs it for real. /// Resolve `APR_GIT_SHA`, print it as `cargo:rustc-env`, and register the /// rerun-if-changed triggers. Call once from `build.rs`. @@ -25,12 +32,11 @@ pub fn emit() { // in a worktree `.git` is a file pointer to `/worktrees//`. // Resolve the actual git directory(ies) via `git rev-parse` so both layouts // are watched correctly (#1862). - for path in git_rerun_triggers() { - println!("cargo:rerun-if-changed={path}"); - } + register_git_rerun_triggers(); println!("cargo:rerun-if-env-changed=APR_GIT_SHA_OVERRIDE"); println!("cargo:rerun-if-changed=.git-sha"); + println!("cargo:rerun-if-changed=.cargo_vcs_info.json"); } /// The pure resolution order. Each argument is the raw result of one source, @@ -39,6 +45,7 @@ pub fn emit() { #[must_use] pub fn resolve( override_env: Option<&str>, + vcs_info: Option<&str>, git_head: Option<&str>, sha_file: Option<&str>, version: Option<&str>, @@ -47,15 +54,19 @@ pub fn resolve( if let Some(s) = nonblank(override_env) { return s.to_string(); } - // 2. Live git hash from worktree (works for both primary checkouts and worktrees) + // 2. The commit `cargo package` recorded (crates.io / packaged builds) + if let Some(s) = nonblank(vcs_info) { + return s.to_string(); + } + // 3. Live git hash from worktree (works for both primary checkouts and worktrees) if let Some(s) = nonblank(git_head) { return s.to_string(); } - // 3. Committed .git-sha fallback (updated by release script at publish time) + // 4. Committed .git-sha fallback (updated by release script at publish time) if let Some(s) = nonblank(sha_file) { return s.to_string(); } - // 4. Informative fallback — never bare "unknown" + // 5. Informative fallback — never bare "unknown" format!("v{}+no-git", version.unwrap_or("0.0.0")) } @@ -63,26 +74,22 @@ fn nonblank(s: Option<&str>) -> Option<&str> { s.map(str::trim).filter(|s| !s.is_empty()) } -/// The git paths whose change must rerun the build script. Returned rather -/// than printed so a test can read them. -fn git_rerun_triggers() -> Vec { - let mut paths = Vec::new(); +fn register_git_rerun_triggers() { // Worktree-local HEAD: /HEAD. In a worktree this is the per-worktree // HEAD pointer; in a primary checkout it's the same as /HEAD. if let Some(git_dir) = run_git(&["rev-parse", "--git-dir"]) { let head = format!("{git_dir}/HEAD"); if std::path::Path::new(&head).exists() { - paths.push(head); + println!("cargo:rerun-if-changed={head}"); } } // Shared refs live under /refs/heads/ regardless of layout. if let Some(common_dir) = run_git(&["rev-parse", "--git-common-dir"]) { let refs = format!("{common_dir}/refs/heads"); if std::path::Path::new(&refs).exists() { - paths.push(refs); + println!("cargo:rerun-if-changed={refs}"); } } - paths } fn run_git(args: &[&str]) -> Option { @@ -99,34 +106,31 @@ fn run_git(args: &[&str]) -> Option { } fn resolve_git_sha() -> String { - resolve_git_sha_with( - |k| std::env::var(k).ok(), - run_git, - |p| std::fs::read_to_string(p).ok(), - ) -} - -/// [`resolve_git_sha`] over injected sources, so a test can drive every branch -/// without touching the process environment. -fn resolve_git_sha_with( - env: impl Fn(&str) -> Option, - git: impl Fn(&[&str]) -> Option, - read: impl Fn(&str) -> Option, -) -> String { // Sources are consulted lazily and in order, as the original apr-cli - // build.rs did: git is not run when the override is set, and `.git-sha` - // is not read when git answers. - let override_env = env("APR_GIT_SHA_OVERRIDE"); + // build.rs did: git is not run when an earlier source answers, and + // `.git-sha` is not read when git answers. + let override_env = std::env::var("APR_GIT_SHA_OVERRIDE").ok(); if let Some(s) = nonblank(override_env.as_deref()) { return s.to_string(); } - let git_head = git(&["rev-parse", "--short", "HEAD"]); + if let Some(sha) = vcs_info_sha() { + return sha; + } + // `trusted_git_retry`: the release binaries are built as root in a container over the + // runner-owned checkout, where git refuses the repo as "dubious ownership" (#4110) + let head = ["rev-parse", "--short", "HEAD"]; + // #4254: and when no git binary answers at all (the sibling build container over a + // bind-mounted checkout), read HEAD straight from the checkout's own `.git`. + let git_head = run_git(&head) + .or_else(|| trusted_git_retry(&head)) + .or_else(read_head_sha_from_dot_git); let sha_file = match git_head { Some(_) => None, - None => read(".git-sha"), + None => std::fs::read_to_string(".git-sha").ok(), }; - let version = env("CARGO_PKG_VERSION"); + let version = std::env::var("CARGO_PKG_VERSION").ok(); resolve( + None, None, git_head.as_deref(), sha_file.as_deref(), @@ -134,17 +138,128 @@ fn resolve_git_sha_with( ) } +/// #4254: the commit HEAD names, read from `.git` without the git binary. +/// +/// Walks up from `CARGO_MANIFEST_DIR` to the first `.git`, which is either the git +/// directory or, in a worktree, a file `gitdir: ` (whose `commondir` holds the +/// shared refs). HEAD is a bare SHA when detached (a tag checkout) or `ref: `, +/// resolved as a loose ref, then through `packed-refs`. Short form, 9 hex digits. +fn read_head_sha_from_dot_git() -> Option { + use std::path::{Path, PathBuf}; + let start = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").ok()?); + let dot_git = start + .ancestors() + .map(|d| d.join(".git")) + .find(|p| p.exists())?; + let git_dir: PathBuf = if dot_git.is_file() { + let text = std::fs::read_to_string(&dot_git).ok()?; + let rel = text.trim().strip_prefix("gitdir:")?.trim(); + let p = Path::new(rel); + if p.is_absolute() { + p.to_path_buf() + } else { + dot_git.parent()?.join(p) + } + } else { + dot_git + }; + let common_dir = match std::fs::read_to_string(git_dir.join("commondir")) { + Ok(c) => { + let p = Path::new(c.trim()); + if p.is_absolute() { + p.to_path_buf() + } else { + git_dir.join(p) + } + } + Err(_) => git_dir.clone(), + }; + let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; + let head = head.trim(); + let full = match head.strip_prefix("ref:") { + None => head.to_string(), + Some(name) => { + let name = name.trim(); + [&git_dir, &common_dir] + .iter() + .find_map(|d| std::fs::read_to_string(d.join(name)).ok()) + .map(|s| s.trim().to_string()) + .or_else(|| { + let packed = std::fs::read_to_string(common_dir.join("packed-refs")).ok()?; + packed.lines().find_map(|l| { + let (sha, r) = l.split_once(' ')?; + (r.trim() == name).then(|| sha.to_string()) + }) + })? + } + }; + let is_sha = full.len() >= 40 && full.bytes().all(|b| b.is_ascii_hexdigit()); + is_sha.then(|| full[..9].to_string()) +} + +/// `git.sha1` from `.cargo_vcs_info.json`, shortened to 9 hex digits (what `git rev-parse +/// --short` prints in this repository), with `-dirty` when cargo packaged a dirty tree. +/// Parsed by hand: this crate has no dependencies, and the file is cargo's own fixed shape. +fn vcs_info_sha() -> Option { + let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); + let text = std::fs::read_to_string(format!("{dir}/.cargo_vcs_info.json")).ok()?; + let after = &text[text.find("\"sha1\"")? + "\"sha1\"".len()..]; + let open = after.find('"')?; + let value = &after[open + 1..]; + let sha = &value[..value.find('"')?]; + if sha.len() != 40 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let dirty = text.contains("\"dirty\": true") || text.contains("\"dirty\":true"); + Some(format!( + "{}{}", + &sha[..9], + if dirty { "-dirty" } else { "" } + )) +} + +/// `git ` again, with the checkout that encloses this package marked `safe.directory`. +/// +/// git refuses a repository owned by another user ("detected dubious ownership"), and the release +/// lane builds as root in a container over the runner-owned checkout, so every published binary +/// printed `+no-git` (#4110). The exception goes in a private GLOBAL config under OUT_DIR: +/// git ignores `safe.directory` given by `-c` or GIT_CONFIG_* before 2.39 (measured, 2.34.1). +/// Only the checkout found by walking up from CARGO_MANIFEST_DIR is trusted, never `*`, and +/// `rev-parse` runs no hooks. This build is already compiling that checkout's code. +fn trusted_git_retry(args: &[&str]) -> Option { + let manifest = std::env::var("CARGO_MANIFEST_DIR").ok()?; + let top = std::path::Path::new(&manifest) + .ancestors() + .find(|d| d.join(".git").exists())?; + let config = + std::path::Path::new(&std::env::var("OUT_DIR").ok()?).join("apr-safe-directory.gitconfig"); + std::fs::write( + &config, + format!("[safe]\n\tdirectory = {}\n", top.display()), + ) + .ok()?; + let out = std::process::Command::new("git") + .args(args) + .env("GIT_CONFIG_GLOBAL", &config) + .output() + .ok()?; + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (out.status.success() && !s.is_empty()).then_some(s) +} + +#[cfg(test)] +mod emit_tests; + #[cfg(test)] mod tests { - use super::{ - emit, git_rerun_triggers, resolve, resolve_git_sha, resolve_git_sha_with, run_git, - }; + use super::resolve; #[test] fn override_wins_over_everything() { assert_eq!( resolve( Some(" abc123456 \n"), + None, Some("def456789"), Some("0123456789"), Some("1.2.3") @@ -158,214 +273,49 @@ mod tests { assert_eq!( resolve( Some(" "), + None, Some("def456789"), Some("0123456789"), Some("1.2.3") ), "def456789" ); - assert_eq!(resolve(None, Some("def456789"), None, None), "def456789"); - } - - #[test] - fn sha_file_used_only_without_git() { assert_eq!( - resolve(None, None, Some("0123456789\n"), Some("1.2.3")), - "0123456789" + resolve(None, None, Some("def456789"), None, None), + "def456789" ); } #[test] - fn no_git_fallback_names_the_version_never_unknown() { - assert_eq!(resolve(None, None, None, Some("0.69.0")), "v0.69.0+no-git"); + fn packaged_commit_wins_over_git() { assert_eq!( - resolve(Some(""), None, Some(" \n"), Some("0.69.0")), - "v0.69.0+no-git" + resolve(None, Some("d8a6df53a"), Some("def456789"), None, None), + "d8a6df53a" ); - assert_eq!(resolve(None, None, None, None), "v0.0.0+no-git"); - } - - // The git-running half: these kill the mutants that the pure `resolve` - // tests cannot reach (#4315 mutants job, 8 MISSED). - - #[test] - fn run_git_returns_trimmed_stdout_on_success() { - let v = run_git(&["--version"]).expect("git is on PATH in every build env"); - assert!(v.starts_with("git version "), "{v:?}"); - assert_eq!(v, v.trim()); - } - - #[test] - fn run_git_is_none_on_failure_and_on_empty_stdout() { - assert_eq!(run_git(&["definitely-not-a-git-subcommand"]), None); - // `git rev-parse` with no arguments succeeds and prints nothing. - assert_eq!(run_git(&["rev-parse"]), None); - } - - #[test] - fn rerun_triggers_watch_head_and_refs_when_in_a_checkout() { - let triggers = git_rerun_triggers(); - if run_git(&["rev-parse", "--git-dir"]).is_none() { - assert!(triggers.is_empty(), "{triggers:?}"); - return; - } - assert!( - triggers.iter().any(|p| p.ends_with("/HEAD")), - "no HEAD trigger: {triggers:?}" - ); - assert!( - triggers.iter().any(|p| p.ends_with("/refs/heads")), - "no refs/heads trigger: {triggers:?}" - ); - for p in &triggers { - assert!(std::path::Path::new(p).exists(), "{p}"); - } - } - - /// A fake source: records whether it was consulted, answers from a table. - fn fake<'a>( - table: &'a [(&'a str, &'a str)], - hit: &'a std::cell::Cell, - ) -> impl Fn(&str) -> Option + 'a { - move |k| { - hit.set(true); - table - .iter() - .find(|(key, _)| *key == k) - .map(|(_, v)| (*v).to_string()) - } - } - - #[test] - fn override_wins_and_neither_git_nor_the_file_is_consulted() { - let (g, f, e) = Default::default(); - let git_hit: &std::cell::Cell = &g; - let got = resolve_git_sha_with( - fake(&[("APR_GIT_SHA_OVERRIDE", " rel123 \n")], &e), - |_: &[&str]| { - git_hit.set(true); - Some("gitsha".into()) - }, - fake(&[(".git-sha", "filesha")], &f), - ); - assert_eq!(got, "rel123"); - assert!(!g.get(), "git ran although the override was set"); - assert!(!f.get(), ".git-sha was read although the override was set"); - } - - #[test] - fn git_answers_and_the_file_is_not_read() { - let (f, e) = Default::default(); - let got = resolve_git_sha_with( - fake(&[("APR_GIT_SHA_OVERRIDE", " ")], &e), - |a: &[&str]| (a == ["rev-parse", "--short", "HEAD"]).then(|| "abc1234".to_string()), - fake(&[(".git-sha", "filesha")], &f), + assert_eq!( + resolve(Some("rel123"), Some("d8a6df53a"), None, None, None), + "rel123" ); - assert_eq!(got, "abc1234"); - assert!(!f.get(), ".git-sha was read although git answered"); } #[test] - fn without_git_the_file_then_the_version_is_used() { - let (f, e) = Default::default(); - let no_git = |_: &[&str]| None; - let got = resolve_git_sha_with( - fake(&[("CARGO_PKG_VERSION", "1.2.3")], &e), - no_git, - fake(&[(".git-sha", "0123456789\n")], &f), - ); - assert_eq!(got, "0123456789"); - assert!(f.get()); - let (f2, e2) = Default::default(); - let got = resolve_git_sha_with( - fake(&[("CARGO_PKG_VERSION", "1.2.3")], &e2), - no_git, - fake(&[], &f2), + fn sha_file_used_only_without_git() { + assert_eq!( + resolve(None, None, None, Some("0123456789\n"), Some("1.2.3")), + "0123456789" ); - assert_eq!(got, "v1.2.3+no-git"); } #[test] - fn resolve_git_sha_reads_the_live_sources() { - // An oracle independent of `resolve`: in a checkout with no override set, - // the live value is exactly git's short HEAD. - let got = resolve_git_sha(); - assert!(!got.is_empty()); - if let Some(o) = std::env::var("APR_GIT_SHA_OVERRIDE") - .ok() - .filter(|s| !s.trim().is_empty()) - { - assert_eq!(got, o.trim()); - } else if let Some(head) = run_git(&["rev-parse", "--short", "HEAD"]) { - assert_eq!(got, head); - } else { - assert!( - got.ends_with("+no-git") || std::path::Path::new(".git-sha").exists(), - "{got}" - ); - } - } - - /// The child half of `emit_prints_the_sha_and_watches_a_real_checkout`: it - /// only runs `emit` when the parent re-executes this test binary. - #[test] - fn emit_child() { - if std::env::var_os("BUILD_SHA_EMIT_CHILD").is_some() { - emit(); - } - } - - /// `emit` writes to stdout, which a test cannot read in-process, and under - /// cargo-mutants the tree is a copy with no `.git`, so a test that reads the - /// outer checkout passes whether or not the triggers are returned. Re-run this - /// test binary as a child inside a fresh `git init`, so both the sha line and - /// the HEAD / refs/heads triggers must appear on its stdout. - #[test] - fn emit_prints_the_sha_and_watches_a_real_checkout() { - let dir = std::env::temp_dir().join(format!("build-sha-emit-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("temp dir"); - let init = std::process::Command::new("git") - .args(["init", "-q"]) - .current_dir(&dir) - .status() - .expect("git is required: without a checkout the triggers cannot be observed"); - assert!(init.success(), "git init failed in {}", dir.display()); - let out = std::process::Command::new(std::env::current_exe().expect("test binary")) - .args([ - "--exact", - "tests::emit_child", - "--nocapture", - "--test-threads=1", - ]) - .current_dir(&dir) - .env("BUILD_SHA_EMIT_CHILD", "1") - .env_remove("APR_GIT_SHA_OVERRIDE") - .output() - .expect("re-run the test binary"); - let _ = std::fs::remove_dir_all(&dir); - let stdout = String::from_utf8_lossy(&out.stdout); - assert!(out.status.success(), "{stdout}"); - // libtest prefixes the child's first line with "test tests::emit_child ... ". - let directives: Vec<&str> = stdout - .lines() - .filter_map(|l| l.find("cargo:").map(|at| &l[at..])) - .collect(); - let has = |p: &dyn Fn(&str) -> bool| directives.iter().any(|d| p(d)); - assert!( - has(&|d| d.starts_with("cargo:rustc-env=APR_GIT_SHA=")), - "{stdout}" - ); - assert!( - has(&|d| d.starts_with("cargo:rerun-if-changed=") && d.ends_with("/HEAD")), - "no HEAD trigger: {stdout}" - ); - assert!( - has(&|d| d.starts_with("cargo:rerun-if-changed=") && d.ends_with("/refs/heads")), - "no refs/heads trigger: {stdout}" + fn no_git_fallback_names_the_version_never_unknown() { + assert_eq!( + resolve(None, None, None, None, Some("0.69.0")), + "v0.69.0+no-git" ); - assert!( - has(&|d| d == "cargo:rerun-if-env-changed=APR_GIT_SHA_OVERRIDE"), - "{stdout}" + assert_eq!( + resolve(Some(""), None, None, Some(" \n"), Some("0.69.0")), + "v0.69.0+no-git" ); + assert_eq!(resolve(None, None, None, None, None), "v0.0.0+no-git"); } } diff --git a/crates/aprender-cbtop/Cargo.toml b/crates/aprender-cbtop/Cargo.toml index b9e2c8868c..4b63708e97 100644 --- a/crates/aprender-cbtop/Cargo.toml +++ b/crates/aprender-cbtop/Cargo.toml @@ -32,7 +32,7 @@ presentar-terminal = { workspace = true } # Compute backends trueno = { workspace = true } -trueno-gpu = { version = "0.69.3", path = "../aprender-gpu", package = "aprender-gpu", optional = true } +trueno-gpu = { version = "0.70.0", path = "../aprender-gpu", package = "aprender-gpu", optional = true } # Terminal handling crossterm = "0.28" diff --git a/crates/aprender-cgp/Cargo.toml b/crates/aprender-cgp/Cargo.toml index 43ea299cd3..69d47b2732 100644 --- a/crates/aprender-cgp/Cargo.toml +++ b/crates/aprender-cgp/Cargo.toml @@ -36,7 +36,7 @@ num_cpus = "1.17" comfy-table = "7" colored = "3" # GPU profiling: direct cuBLAS measurement (OWN THE STACK) -trueno-gpu = { path = "../aprender-gpu", version = "0.69.3", features = ["cuda"], optional = true, package = "aprender-gpu" } +trueno-gpu = { path = "../aprender-gpu", version = "0.70.0", features = ["cuda"], optional = true, package = "aprender-gpu" } [features] default = [] diff --git a/crates/aprender-common/Cargo.toml b/crates/aprender-common/Cargo.toml index 1861e9cd0b..6bd5a61a52 100644 --- a/crates/aprender-common/Cargo.toml +++ b/crates/aprender-common/Cargo.toml @@ -12,7 +12,14 @@ categories = ["command-line-utilities", "text-processing"] [lib] name = "batuta_common" +[features] +default = [] +# #3745 S1: typed CLI argument roles (`batuta_common::cli_roles`). Optional so the +# default build of this crate stays clap-free. +cli-roles = ["dep:clap"] + [dependencies] +clap = { workspace = true, optional = true } lz4_flex = { version = "0.11", default-features = false, features = ["safe-encode", "safe-decode"] } zstd = { version = "0.13", default-features = false } diff --git a/crates/aprender-common/src/cli_roles.rs b/crates/aprender-common/src/cli_roles.rs new file mode 100644 index 0000000000..cd0da3c474 --- /dev/null +++ b/crates/aprender-common/src/cli_roles.rs @@ -0,0 +1,732 @@ +//! Typed CLI argument roles (#3745 S1, PMAT-3749). +//! +//! A release cell can only be derived for an argument whose ROLE the gate can +//! see. Before these types, the role of an `apr` argument was knowable only +//! from its name: `apr run SOURCE`, `--prompt` and `-i FILE` were a `String`, a +//! `String` and a `PathBuf`, the same types as `--name`, `--tag` and +//! `--output`. Every release list was therefore typed by hand, and a verb, flag +//! or input shape missing from that list was invisible to the gate. That is how +//! `apr run --prompt P --chat` shipped double-templated (#3743) while +//! `-i file --chat` was covered. +//! +//! Each type here is a transparent newtype over `PathBuf` or `String` whose clap +//! value parser is the SAME parser clap uses for the raw type +//! (`PathBufValueParser` / `StringValueParser`), mapped into the newtype. So a +//! migrated argument accepts exactly the values it accepted before. The only +//! thing that changes is the parser's output TYPE, and that is what `apr surface` +//! reads, through `ValueParser::type_id()`, to decide the role. The role is +//! declared by how the argument is BUILT; its name never enters into it. +//! +//! The types live here rather than in `apr-cli` because a marker compared by +//! `TypeId` needs ONE defining crate, and some of `apr`'s surface is declared by +//! crates that do not depend on `apr-cli` (`apr pv …` from +//! `aprender-contracts-cli`, `apr rag …` from `aprender-rag-cli`). + +use std::any::TypeId; +use std::ffi::OsStr; +use std::fmt; +use std::ops::Deref; +use std::path::{Path, PathBuf}; + +use clap::builder::{MapValueParser, PathBufValueParser, StringValueParser, TypedValueParser}; + +/// The role an argument plays in a release cell (#3745). +/// +/// `Mode` and `Backend` are not listed in [`MARKERS`]. Mode is structural (a +/// flag action, or a finite value set), and backend is membership in the shared +/// `BackendArg` group; `apr surface` decides both. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Role { + /// A model: a local file, or a reference `apr` resolves to one. + Model, + /// Text the model is prompted with. + Prompt, + /// A file the command consumes as input data (audio, text, a dataset). + InputFile, + /// The compute backend override. + Backend, + /// A generation sampling control: seed, temperature, top-k, top-p, + /// repeat penalty (S1 v1.1, for S2.5's flag-effect sampling controls). + Sampling, + /// A flag, or a value drawn from a finite set. + Mode, + /// Declared, and none of the above: outputs, directories, configs, names. + Other, + /// A free-form value no role type declares. Only a foreign subtree may + /// carry one; the marker guard is RED on it anywhere else. + Unknown, +} + +impl Role { + /// The spelling used in the `apr-cli-surface` JSON. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Model => "model", + Self::Prompt => "prompt", + Self::InputFile => "input-file", + Self::Backend => "backend", + Self::Sampling => "sampling", + Self::Mode => "mode", + Self::Other => "other", + Self::Unknown => "unknown", + } + } +} + +/// What a marker's underlying value is: a filesystem path, or free text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Carrier { + /// Backed by `PathBuf`. + Path, + /// Backed by `String`. + Text, +} + +/// One role type: the `TypeId` its value parser yields, and what it declares. +#[derive(Debug, Clone, Copy)] +pub struct Marker { + /// The type's name as written in code, e.g. `ModelPath`. + pub name: &'static str, + /// The role this type declares. + pub role: Role, + /// Path- or text-backed. + pub carrier: Carrier, + type_id: fn() -> TypeId, +} + +impl Marker { + /// The `TypeId` of the marker type, which is the `TypeId` its clap value + /// parser reports. + #[must_use] + pub fn type_id(&self) -> TypeId { + (self.type_id)() + } +} + +macro_rules! path_role { + ($(#[$doc:meta])* $name:ident) => { + $(#[$doc])* + #[derive(Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct $name(PathBuf); + + // `Debug` is the inner value's, so `{:?}` of a migrated field prints + // `"m.gguf"` exactly as the raw `PathBuf` did, never `ModelPath("m.gguf")`. + impl fmt::Debug for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&self.0, f) + } + } + + impl $name { + /// Wrap a path. + #[must_use] + pub fn new(path: impl Into) -> Self { + Self(path.into()) + } + /// The path, borrowed. + #[must_use] + pub fn as_path(&self) -> &Path { + &self.0 + } + /// The path, owned. + #[must_use] + pub fn into_path_buf(self) -> PathBuf { + self.0 + } + } + + // `Path`, not `PathBuf`: the same target `PathBuf` derefs to, so + // `Option::as_deref()` is `Option<&Path>` exactly as + // `Option::as_deref()` was, and a migrated field reads the same. + impl Deref for $name { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } + } + impl AsRef for $name { + fn as_ref(&self) -> &Path { + &self.0 + } + } + impl AsRef for $name { + fn as_ref(&self) -> &OsStr { + self.0.as_os_str() + } + } + impl From for $name { + fn from(p: PathBuf) -> Self { + Self(p) + } + } + impl From<&Path> for $name { + fn from(p: &Path) -> Self { + Self(p.to_path_buf()) + } + } + impl From<&str> for $name { + fn from(p: &str) -> Self { + Self(PathBuf::from(p)) + } + } + impl From for $name { + fn from(p: String) -> Self { + Self(PathBuf::from(p)) + } + } + impl From<$name> for PathBuf { + fn from(p: $name) -> PathBuf { + p.0 + } + } + impl PartialEq for $name { + fn eq(&self, other: &PathBuf) -> bool { + &self.0 == other + } + } + impl PartialEq for $name { + fn eq(&self, other: &Path) -> bool { + self.0 == other + } + } + impl clap::builder::ValueParserFactory for $name { + type Parser = MapValueParser $name>; + fn value_parser() -> Self::Parser { + PathBufValueParser::new().map($name as fn(PathBuf) -> $name) + } + } + }; +} + +macro_rules! text_role { + ($(#[$doc:meta])* $name:ident) => { + $(#[$doc])* + #[derive(Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct $name(String); + + // `Debug` is the inner value's: `{:?}` prints `"text"`, as `String` did. + impl fmt::Debug for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&self.0, f) + } + } + + impl $name { + /// Wrap a string. + #[must_use] + pub fn new(s: impl Into) -> Self { + Self(s.into()) + } + /// The text, borrowed. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + /// The text as a `&String`, for callees that take one. + #[must_use] + pub fn as_string(&self) -> &String { + &self.0 + } + /// The text, owned. + #[must_use] + pub fn into_string(self) -> String { + self.0 + } + } + + // `str`, not `String`: the same target `String` derefs to, so + // `Option::as_deref()` is `Option<&str>` exactly as + // `Option::as_deref()` was. + impl Deref for $name { + type Target = str; + fn deref(&self) -> &str { + &self.0 + } + } + impl AsRef for $name { + fn as_ref(&self) -> &str { + &self.0 + } + } + impl AsRef for $name { + fn as_ref(&self) -> &Path { + Path::new(&self.0) + } + } + impl AsRef for $name { + fn as_ref(&self) -> &OsStr { + OsStr::new(&self.0) + } + } + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + impl From for $name { + fn from(s: String) -> Self { + Self(s) + } + } + impl From<&str> for $name { + fn from(s: &str) -> Self { + Self(s.to_string()) + } + } + impl From<$name> for String { + fn from(s: $name) -> String { + s.0 + } + } + impl PartialEq for $name { + fn eq(&self, other: &str) -> bool { + self.0 == other + } + } + impl PartialEq<&str> for $name { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } + } + impl PartialEq for $name { + fn eq(&self, other: &String) -> bool { + &self.0 == other + } + } + impl clap::builder::ValueParserFactory for $name { + type Parser = MapValueParser $name>; + fn value_parser() -> Self::Parser { + StringValueParser::new().map($name as fn(String) -> $name) + } + } + }; +} + +path_role!( + /// A local model file (`.apr`, `.gguf`, `.safetensors`, …). + ModelPath +); +path_role!( + /// A file the command reads as input data, not as a model. + InputFile +); +path_role!( + /// A file the command writes. + OutputPath +); +path_role!( + /// A directory: a workspace, a cache, a corpus root, an output tree. + DirPath +); +path_role!( + /// A configuration, manifest, contract, schema or receipt the command + /// reads to decide what to do, as distinct from the data it operates on. + ConfigPath +); +text_role!( + /// A model reference: a local path, `hf://org/repo`, a URL, or a cache + /// name, which `apr` resolves to a model file. + ModelRef +); +text_role!( + /// Text the model is prompted with. + PromptText +); +text_role!( + /// Any other free text: a name, id, tag, URL, pattern or list. + FreeText +); +text_role!( + /// Text a model ENCODES or SCORES rather than generates from: `apr embed + /// --text`, `apr rerank --query/--passage`, `apr eval --text` (perplexity). + /// Role `other`, so the command does not read as a generator: `generates` + /// drives the thinking × context-rung cells, which mean nothing for an + /// encoder or a scorer (aprender-97 on #3745, v1.1). + EncodeText +); + +/// Command-level marker: this command GENERATES text from prompts that do not +/// arrive through a `PromptText` argument (#3745 S1, schema v1.1). +/// +/// `apr serve run` takes its prompts over HTTP, `apr chat` reads them from +/// stdin, and `apr mcp` receives them as JSON-RPC tool calls. None of them has a +/// prompt ARGUMENT, so a rule that reads "generates" off the prompt role alone +/// would derive no prompt-shape cells for the very verb whose 0.69.0 leak +/// (#3571) started this. The marker is attached to the subcommand itself, with +/// no argument and no field: +/// +/// ```ignore +/// #[command(group(batuta_common::cli_roles::ServesGeneration::group()))] +/// Run { /* … */ }, +/// ``` +/// +/// `apr surface` reports `generates: true` for a command that has a +/// `PromptText` argument OR carries this group, and it matches the group by +/// [`ServesGeneration::ID`], never by the command's name. +#[derive(Debug, Clone, Copy)] +pub struct ServesGeneration; + +impl ServesGeneration { + /// The group id the marker is recognised by. + pub const ID: &'static str = "batuta_common::cli_roles::ServesGeneration"; + + /// The argument-less group that marks a command as a generator. It names + /// no argument, so it changes neither parsing nor `--help`. + #[must_use] + pub fn group() -> clap::ArgGroup { + clap::ArgGroup::new(Self::ID).multiple(true) + } + + /// True iff `cmd` carries the marker. + #[must_use] + pub fn is_on(cmd: &clap::Command) -> bool { + cmd.get_groups().any(|g| g.get_id() == Self::ID) + } +} + +/// Which generation sampling control an argument is (#3745 S1, v1.1). S2 +/// derives its sampling cells per kind (sampled ≠ greedy, same seed twice is +/// byte-identical, seed A ≠ seed B, top-k 1 / T 0 is byte-greedy), so the kind +/// is declared, not read off the argument's name. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SamplingKind { + /// RNG seed of the sampler. + Seed, + /// Softmax temperature. + Temperature, + /// Top-k cut-off. + TopK, + /// Nucleus (top-p) threshold. + TopP, + /// Min-p threshold. + MinP, + /// Repetition penalty. + RepeatPenalty, + /// Window the repetition penalty looks back over. + RepeatLastN, +} + +impl SamplingKind { + /// Every kind. + pub const ALL: [SamplingKind; 7] = [ + Self::Seed, + Self::Temperature, + Self::TopK, + Self::TopP, + Self::MinP, + Self::RepeatPenalty, + Self::RepeatLastN, + ]; + + /// The spelling used in the `apr-cli-surface` JSON (`sampling_kind`). + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Seed => "seed", + Self::Temperature => "temperature", + Self::TopK => "top_k", + Self::TopP => "top_p", + Self::MinP => "min_p", + Self::RepeatPenalty => "repeat_penalty", + Self::RepeatLastN => "repeat_last_n", + } + } + + /// The group id an argument of this kind joins: `#[arg(group = SamplingKind::Temperature.id())]`. + #[must_use] + pub const fn id(self) -> &'static str { + match self { + Self::Seed => "batuta_common::cli_roles::SamplingArg::seed", + Self::Temperature => "batuta_common::cli_roles::SamplingArg::temperature", + Self::TopK => "batuta_common::cli_roles::SamplingArg::top_k", + Self::TopP => "batuta_common::cli_roles::SamplingArg::top_p", + Self::MinP => "batuta_common::cli_roles::SamplingArg::min_p", + Self::RepeatPenalty => "batuta_common::cli_roles::SamplingArg::repeat_penalty", + Self::RepeatLastN => "batuta_common::cli_roles::SamplingArg::repeat_last_n", + } + } +} + +/// Argument-level marker for a generation SAMPLING control (#3745 S1, v1.1). +/// +/// These are numbers, so a newtype would change every field's type and every +/// call site that reads it. Instead the argument joins the argument-less group +/// of its [`SamplingKind`], and the command declares those groups once, each +/// `multiple(true)`, so nothing about parsing changes: +/// +/// ```ignore +/// #[command(groups(batuta_common::cli_roles::SamplingArg::groups()))] +/// Run { +/// #[arg(long, default_value = "0.0", +/// group = batuta_common::cli_roles::SamplingKind::Temperature.id())] +/// temperature: f32, +/// } +/// ``` +/// +/// `apr surface` reports role `sampling` and `sampling_kind` for a member of +/// one of these groups, matched by id, never by the argument's name. +#[derive(Debug, Clone, Copy)] +pub struct SamplingArg; + +impl SamplingArg { + /// One argument-less `multiple(true)` group per [`SamplingKind`], for + /// `#[command(groups(SamplingArg::groups()))]`. + #[must_use] + pub fn groups() -> Vec { + SamplingKind::ALL + .iter() + .map(|k| clap::ArgGroup::new(k.id()).multiple(true)) + .collect() + } + + /// The sampling kind of `arg` on `built`, if it has one. `built` must be a + /// BUILT command: clap merges each argument's `group = …` into the + /// command's groups only when the command is built. + #[must_use] + pub fn kind_of(built: &clap::Command, arg: &clap::Arg) -> Option { + SamplingKind::ALL.into_iter().find(|k| { + built + .get_groups() + .any(|g| g.get_id() == k.id() && g.get_args().any(|a| a == arg.get_id())) + }) + } +} + +/// Owned `String`s from a slice of text role values, for callees that take +/// `&[String]`. +#[must_use] +pub fn strings>(values: &[T]) -> Vec { + values.iter().map(|v| v.as_ref().to_string()).collect() +} + +/// Owned `PathBuf`s from a slice of path role values, for callees that take +/// `&[PathBuf]`. +#[must_use] +pub fn path_bufs>(values: &[T]) -> Vec { + values.iter().map(|v| v.as_ref().to_path_buf()).collect() +} + +/// Every role type, in one table. `apr surface` reads roles through it, and the +/// marker guard refuses a free-form argument whose parser yields none of these. +pub const MARKERS: &[Marker] = &[ + Marker { + name: "ModelPath", + role: Role::Model, + carrier: Carrier::Path, + type_id: TypeId::of::, + }, + Marker { + name: "ModelRef", + role: Role::Model, + carrier: Carrier::Text, + type_id: TypeId::of::, + }, + Marker { + name: "PromptText", + role: Role::Prompt, + carrier: Carrier::Text, + type_id: TypeId::of::, + }, + Marker { + name: "InputFile", + role: Role::InputFile, + carrier: Carrier::Path, + type_id: TypeId::of::, + }, + Marker { + name: "OutputPath", + role: Role::Other, + carrier: Carrier::Path, + type_id: TypeId::of::, + }, + Marker { + name: "DirPath", + role: Role::Other, + carrier: Carrier::Path, + type_id: TypeId::of::, + }, + Marker { + name: "ConfigPath", + role: Role::Other, + carrier: Carrier::Path, + type_id: TypeId::of::, + }, + Marker { + name: "FreeText", + role: Role::Other, + carrier: Carrier::Text, + type_id: TypeId::of::, + }, + Marker { + name: "EncodeText", + role: Role::Other, + carrier: Carrier::Text, + type_id: TypeId::of::, + }, +]; + +/// The marker whose type a value parser yields, if any. +#[must_use] +pub fn marker_for(type_id: TypeId) -> Option<&'static Marker> { + MARKERS.iter().find(|m| m.type_id() == type_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::{Arg, Command}; + + fn parser_type() -> TypeId + where + T::Parser: TypedValueParser + Send + Sync + 'static, + ::Value: Send + Sync + Clone + 'static, + { + let vp: clap::builder::ValueParser = T::value_parser().into(); + let arg = Arg::new("x").value_parser(vp); + // `AnyValueId` is clap's; it compares equal to a std `TypeId`. + let id = arg.get_value_parser().type_id(); + MARKERS + .iter() + .map(Marker::type_id) + .chain([TypeId::of::(), TypeId::of::()]) + .find(|t| id == *t) + .expect("value parser yields a known type") + } + + #[test] + fn every_marker_parser_reports_its_own_type_id() { + assert_eq!(parser_type::(), TypeId::of::()); + assert_eq!(parser_type::(), TypeId::of::()); + assert_eq!(parser_type::(), TypeId::of::()); + assert_eq!(parser_type::(), TypeId::of::()); + assert_eq!(parser_type::(), TypeId::of::()); + assert_eq!(parser_type::(), TypeId::of::()); + assert_eq!(parser_type::(), TypeId::of::()); + assert_eq!(parser_type::(), TypeId::of::()); + assert_eq!(parser_type::(), TypeId::of::()); + } + + #[test] + fn marker_table_is_one_to_one_with_types() { + let mut ids: Vec = MARKERS.iter().map(Marker::type_id).collect(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), MARKERS.len(), "two markers share a TypeId"); + assert!( + marker_for(TypeId::of::()).is_none(), + "raw PathBuf must not be a marker" + ); + assert!( + marker_for(TypeId::of::()).is_none(), + "raw String must not be a marker" + ); + assert_eq!( + marker_for(TypeId::of::()).map(|m| m.role), + Some(Role::Model) + ); + assert_eq!( + marker_for(TypeId::of::()).map(|m| m.role), + Some(Role::Prompt) + ); + } + + /// A migrated argument must accept exactly what the raw type accepted: the + /// marker parser is the raw parser, mapped. An empty path is refused by + /// both, a normal value is accepted by both. + #[test] + fn marker_parsers_accept_what_the_raw_parsers_accept() { + let raw = Command::new("t").arg(Arg::new("p").value_parser(clap::value_parser!(PathBuf))); + let typed = + Command::new("t").arg(Arg::new("p").value_parser(clap::value_parser!(ModelPath))); + for argv in [vec!["t", "m.gguf"], vec!["t", ""], vec!["t", "-"]] { + let a = raw.clone().try_get_matches_from(&argv).is_ok(); + let b = typed.clone().try_get_matches_from(&argv).is_ok(); + assert_eq!(a, b, "PathBuf vs ModelPath disagree on {argv:?}"); + } + let m = typed.try_get_matches_from(["t", "m.gguf"]).expect("parses"); + assert_eq!( + m.get_one::("p").map(ModelPath::as_path), + Some(Path::new("m.gguf")) + ); + + let raw = Command::new("t").arg(Arg::new("s").value_parser(clap::value_parser!(String))); + let typed = + Command::new("t").arg(Arg::new("s").value_parser(clap::value_parser!(PromptText))); + for argv in [vec!["t", "What is 2+2?"], vec!["t", ""]] { + let a = raw.clone().try_get_matches_from(&argv).is_ok(); + let b = typed.clone().try_get_matches_from(&argv).is_ok(); + assert_eq!(a, b, "String vs PromptText disagree on {argv:?}"); + } + } + + /// The generation marker names no argument, so it changes neither what + /// parses nor what `--help` prints, and it is found by id alone. + #[test] + fn serves_generation_marks_a_command_without_changing_it() { + let plain = Command::new("t").arg(Arg::new("port").long("port")); + let marked = plain.clone().group(ServesGeneration::group()); + assert!(!ServesGeneration::is_on(&plain)); + assert!(ServesGeneration::is_on(&marked)); + for argv in [vec!["t"], vec!["t", "--port", "8080"], vec!["t", "--nope"]] { + let a = plain.clone().try_get_matches_from(&argv).is_ok(); + let b = marked.clone().try_get_matches_from(&argv).is_ok(); + assert_eq!(a, b, "the marker changed parsing of {argv:?}"); + } + let help = |c: &Command| c.clone().render_long_help().to_string(); + assert_eq!(help(&plain), help(&marked), "the marker changed --help"); + // A look-alike group spelled with the type's short name is not the marker. + let lookalike = plain.group(clap::ArgGroup::new("ServesGeneration").multiple(true)); + assert!(!ServesGeneration::is_on(&lookalike)); + } + + /// A migrated field must print the same under `{:?}` as the raw type did: + /// error messages and tests format arguments that way. + #[test] + fn debug_output_is_the_raw_types() { + assert_eq!( + format!("{:?}", ModelPath::from("m.gguf")), + format!("{:?}", PathBuf::from("m.gguf")) + ); + assert_eq!( + format!("{:?}", Some(OutputPath::from("o"))), + format!("{:?}", Some(PathBuf::from("o"))) + ); + assert_eq!( + format!("{:?}", PromptText::from("hi")), + format!("{:?}", String::from("hi")) + ); + assert_eq!( + format!("{:?}", vec![FreeText::from("a")]), + format!("{:?}", vec![String::from("a")]) + ); + } + + #[test] + fn derive_picks_the_marker_parser_for_a_marker_field() { + #[derive(clap::Parser)] + struct T { + model: ModelPath, + #[arg(long)] + prompt: Option, + #[arg(long)] + inputs: Vec, + } + use clap::{CommandFactory, Parser}; + let cmd = T::command(); + let id = |name: &str| { + cmd.get_arguments() + .find(|a| a.get_id() == name) + .map(|a| a.get_value_parser().type_id()) + .expect("arg exists") + }; + assert!(id("model") == TypeId::of::()); + assert!(id("prompt") == TypeId::of::()); + assert!(id("inputs") == TypeId::of::()); + let t = T::try_parse_from(["t", "a.apr", "--prompt", "hi", "--inputs", "x.wav"]) + .expect("parses"); + assert_eq!(t.model.as_path(), Path::new("a.apr")); + assert_eq!(t.prompt.as_deref(), Some("hi")); + assert_eq!(t.inputs.len(), 1); + } +} diff --git a/crates/aprender-common/src/lib.rs b/crates/aprender-common/src/lib.rs index 7ee0cb44c6..4128ca843a 100644 --- a/crates/aprender-common/src/lib.rs +++ b/crates/aprender-common/src/lib.rs @@ -8,11 +8,14 @@ //! - [`fmt`] — Byte, percentage, duration, and number formatting //! - [`sys`] — System information (cgroup detection, CPU info) //! - [`display`] — Column alignment, truncation, and builder traits +//! - `cli_roles` — typed CLI argument roles (feature `cli-roles`, #3745) // `.unwrap()` is banned in production via workspace `.clippy.toml`, but is // idiomatic in `#[cfg(test)]` assertions. Allow it only under `cfg(test)`. #![cfg_attr(test, allow(clippy::disallowed_methods))] +#[cfg(feature = "cli-roles")] +pub mod cli_roles; pub mod compression; pub mod display; pub mod fmt; diff --git a/crates/aprender-compute/Cargo.toml b/crates/aprender-compute/Cargo.toml index b514ee73b5..b664d994d3 100644 --- a/crates/aprender-compute/Cargo.toml +++ b/crates/aprender-compute/Cargo.toml @@ -41,11 +41,11 @@ futures-intrusive = { version = "0.5", optional = true } wasm-bindgen-futures = { version = "0.4", optional = true } wasm-bindgen = { version = "0.2", optional = true } # Native CUDA monitoring via trueno-gpu (TRUENO-SPEC-010) -trueno-gpu = { version = "0.69.3", path = "../aprender-gpu", optional = true, package = "aprender-gpu" } +trueno-gpu = { version = "0.70.0", path = "../aprender-gpu", optional = true, package = "aprender-gpu" } # PMAT-336: Provable contracts compile-time enforcement provable-contracts-macros = { workspace = true } # P1a: Sovereign GEMM microkernel codegen (compile-time shape specialization) -trueno-gemm-codegen = { version = "0.69.3", path = "../aprender-gemm-codegen", package = "aprender-gemm-codegen" } +trueno-gemm-codegen = { version = "0.70.0", path = "../aprender-gemm-codegen", package = "aprender-gemm-codegen" } # Tracing/profiling support (renacer integration) tracing = { version = "0.1", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"], optional = true } @@ -84,6 +84,7 @@ web-sys = { version = "0.3", optional = true, features = ["console"] } hostname = "0.4" [build-dependencies] +provable-contracts = { workspace = true } # #4369: build_helper::env_key, the one CONTRACT_* key builder serde = { version = "1", features = ["derive"] } serde_yaml_ng = "0.10" diff --git a/crates/aprender-compute/build.rs b/crates/aprender-compute/build.rs index 5c500f8acc..5431c4b79c 100644 --- a/crates/aprender-compute/build.rs +++ b/crates/aprender-compute/build.rs @@ -36,13 +36,10 @@ struct Binding { /// Convert a contract filename + equation into a canonical env var name. fn env_var_name(contract: &str, equation: &str) -> String { - let stem = contract - .trim_end_matches(".yaml") - .trim_end_matches(".yml") - .to_uppercase() - .replace('-', "_"); - let eq = equation.to_uppercase().replace('-', "_"); - format!("CONTRACT_{stem}_{eq}") + provable_contracts::build_helper::env_key( + contract.trim_end_matches(".yaml").trim_end_matches(".yml"), + equation, + ) } /// Rank status values for deduplication. @@ -55,14 +52,8 @@ fn status_rank(s: &str) -> u8 { } fn main() { - let binding_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("..") - .join("provable-contracts") - .join("contracts") - .join("trueno") - .join("binding.yaml"); + let binding_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../contracts/trueno/binding.yaml"); println!("cargo:rerun-if-changed={}", binding_path.display()); diff --git a/crates/aprender-compute/src/backends/gpu/device/linalg/wgsl_forward.rs b/crates/aprender-compute/src/backends/gpu/device/linalg/wgsl_forward.rs index 57f7111d77..d4d4cf59ec 100644 --- a/crates/aprender-compute/src/backends/gpu/device/linalg/wgsl_forward.rs +++ b/crates/aprender-compute/src/backends/gpu/device/linalg/wgsl_forward.rs @@ -108,6 +108,18 @@ pub struct WgslForwardPass { num_kv_heads: u32, head_dim: u32, intermediate_dim: u32, + /// RMSNorm epsilon, passed to the shader as `params.y` (f32 bits). Defaults to + /// [`DEFAULT_RMS_NORM_EPS`]; callers set the model's value with + /// [`WgslForwardPass::set_rms_norm_eps`] (#4056: Llama-family models use 1e-5). + rms_norm_eps: f32, +} + +/// Default RMSNorm epsilon: the constant the shader hardcoded before #4056. +pub const DEFAULT_RMS_NORM_EPS: f32 = 1e-6; + +/// Uniform params for the RMSNorm shader: `(dim, eps as f32 bits, 0, 0)`. +fn rmsnorm_params(dim: u32, eps: f32) -> [u32; 4] { + [dim, eps.to_bits(), 0, 0] } // WGSL shader source for RMSNorm (multi-row via workgroup_id.y) @@ -116,7 +128,7 @@ const RMSNORM_SHADER: &str = r#" @group(0) @binding(0) var input: array; @group(0) @binding(1) var weight: array; @group(0) @binding(2) var output: array; -@group(0) @binding(3) var params: vec4; // (dim, 0, 0, 0) +@group(0) @binding(3) var params: vec4; // (dim, bitcast(eps), 0, 0) var shared_sum: array; @@ -149,7 +161,8 @@ fn main(@builtin(local_invocation_id) lid: vec3, stride >>= 1u; } - let rms = sqrt(shared_sum[0] / f32(dim) + 1e-6); + let eps = bitcast(params.y); + let rms = sqrt(shared_sum[0] / f32(dim) + eps); // Normalize and scale i = tid; @@ -548,9 +561,22 @@ impl WgslForwardPass { num_kv_heads: num_kv_heads as u32, head_dim: head_dim as u32, intermediate_dim: intermediate_dim as u32, + rms_norm_eps: DEFAULT_RMS_NORM_EPS, } } + /// Set the RMSNorm epsilon every norm in this forward pass uses — pass the + /// model's configured `rms_norm_eps`. Default: [`DEFAULT_RMS_NORM_EPS`]. + pub fn set_rms_norm_eps(&mut self, eps: f32) { + self.rms_norm_eps = eps; + } + + /// The RMSNorm epsilon this forward pass uses. + #[must_use] + pub fn rms_norm_eps(&self) -> f32 { + self.rms_norm_eps + } + /// Upload a weight matrix (call once per layer at init). /// PMAT-342: Bias weights (name contains "bias") are stored CPU-side. pub fn upload_weight(&mut self, name: &str, data: &[f32]) { @@ -1954,7 +1980,7 @@ impl WgslForwardPass { output: &wgpu::Buffer, dim: u32, ) { - let params = [dim, 0u32, 0, 0]; + let params = rmsnorm_params(dim, self.rms_norm_eps); let params_buf = self.make_uniform(¶ms); let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: None, @@ -2156,3 +2182,35 @@ fn bgl_uniform(binding: u32) -> wgpu::BindGroupLayoutEntry { count: None, } } + +#[cfg(test)] +mod rmsnorm_eps_tests { + use super::{rmsnorm_params, DEFAULT_RMS_NORM_EPS}; + + /// #4056: the shader reads eps from `params.y`; it must carry the configured + /// value bit-exactly, not the old hardcoded 1e-6. + #[test] + fn rmsnorm_params_carry_eps_bits() { + let p = rmsnorm_params(896, 1e-5); + assert_eq!(p[0], 896); + assert_eq!(f32::from_bits(p[1]), 1e-5); + assert_ne!(f32::from_bits(p[1]), DEFAULT_RMS_NORM_EPS); + assert_eq!(f32::from_bits(rmsnorm_params(1, DEFAULT_RMS_NORM_EPS)[1]), 1e-6); + } + + /// #4056: the shader now reads `bitcast(params.y)`; naga must accept it. + /// Skips (returns) when no GPU adapter is present. + #[test] + fn rmsnorm_shader_validates_on_device() { + let Ok(gpu) = crate::backends::gpu::GpuDevice::new() else { + return; + }; + gpu.device.push_error_scope(wgpu::ErrorFilter::Validation); + let _module = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("rmsnorm_eps_test"), + source: wgpu::ShaderSource::Wgsl(super::RMSNORM_SHADER.into()), + }); + let err = pollster::block_on(gpu.device.pop_error_scope()); + assert!(err.is_none(), "RMSNORM_SHADER failed validation: {err:?}"); + } +} diff --git a/crates/aprender-contracts-cli/Cargo.toml b/crates/aprender-contracts-cli/Cargo.toml index 5a43b29a00..5f9d3e7a9b 100644 --- a/crates/aprender-contracts-cli/Cargo.toml +++ b/crates/aprender-contracts-cli/Cargo.toml @@ -13,7 +13,10 @@ name = "pv" path = "src/main.rs" [dependencies] -aprender-contracts = { path = "../aprender-contracts", version = "0.69.3" } +aprender-contracts = { path = "../aprender-contracts", version = "0.70.0" } +# #3745 S1: `--model` is declared through the shared role type `ModelPath`, so +# `apr surface` reads `apr pv verify-structure --model` as a model argument. +batuta-common = { workspace = true, features = ["cli-roles"] } clap = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/aprender-contracts-cli/src/cli.rs b/crates/aprender-contracts-cli/src/cli.rs index c790ff16f8..c7d913fc54 100644 --- a/crates/aprender-contracts-cli/src/cli.rs +++ b/crates/aprender-contracts-cli/src/cli.rs @@ -109,6 +109,10 @@ pub enum Commands { /// the tracked contracts.nt / shapes.ttl untouched (aprender#3715) #[arg(long)] out: Option, + /// With `--release-*` and `--surface`: write every DERIVED release cell (the producer's work list, keyed + /// by `cell_id`) to this JSON file (aprender#3745 S2) + #[arg(long)] + cells_out: Option, #[command(flatten)] release: Box, }, @@ -287,9 +291,11 @@ pub enum Commands { /// merge-base(HEAD, origin/main), else the origin/main tip; with neither, NOT CHECKED is printed. #[arg(long)] armed_baseline_ref: Option, - /// Run ONE named gate and report only it (ONT-001 section 5 ONT-2b): `--gate sigma`. + /// Run ONE named gate and report only it (ONT-001 section 5 ONT-2b): `--gate sigma`. Repeatable + /// (PVL-001 EV-11): every named gate runs and reports, and the exit is their meet — a refusal over a + /// reject over a decline over a pass. #[arg(long)] - gate: Option, + gate: Vec, /// With `--gate shapes`: grade only this shape family (the shape and every `.*` shape), armed /// whatever `armed_shapes` says (aprender#3715: `--shape release-readiness-v1`). #[arg(long)] @@ -442,8 +448,9 @@ pub enum Commands { contract_dir: PathBuf, #[arg(long)] config: Option, + /// #3745 S1: typed `ModelPath`, which is how `apr surface` knows this is a model. #[arg(long)] - model: Option, + model: Option, }, /// Verify compositional shape flow across contract dependency graph #[command(name = "verify-pipeline")] @@ -508,6 +515,12 @@ pub struct ReleaseArgs { /// The tokenizer-parity receipts, apr vs the pinned llama.cpp (default: evidence/dogfood/tokenizer//) #[arg(long)] pub tokenizer_receipts: Option, + /// The release candidate's `apr surface --json` (#3745): the release cells are DERIVED from it + #[arg(long)] + pub surface: Option, + /// The CRUX receipts, apr vs its comparators (default: evidence/crux//) + #[arg(long)] + pub crux_receipts: Option, } impl ReleaseArgs { @@ -521,6 +534,8 @@ impl ReleaseArgs { || self.kernel_receipts.is_some() || self.dogfood_receipt.is_some() || self.tokenizer_receipts.is_some() + || self.surface.is_some() + || self.crux_receipts.is_some() } /// The subject, or `None` when no flag was passed. A partial set is refused, never completed by a default. @@ -548,6 +563,8 @@ impl ReleaseArgs { s.dogfood_receipt.clone_from(&self.dogfood_receipt); s.tokenizer_receipts_dir .clone_from(&self.tokenizer_receipts); + s.surface.clone_from(&self.surface); + s.crux_receipts_dir.clone_from(&self.crux_receipts); Ok(Some(s)) } } diff --git a/crates/aprender-contracts-cli/src/commands/extract_rdf.rs b/crates/aprender-contracts-cli/src/commands/extract_rdf.rs index a962e180df..c7a745d012 100644 --- a/crates/aprender-contracts-cli/src/commands/extract_rdf.rs +++ b/crates/aprender-contracts-cli/src/commands/extract_rdf.rs @@ -25,6 +25,27 @@ struct ExtractReport<'a> { check: Option>, } +/// `--out` and `--cells-out` describe a release; without a release subject they are refused. +fn refuse_release_only_args( + out: Option<&Path>, + cells_out: Option<&Path>, +) -> Result<(), Box> { + if cells_out.is_some() { + return Err(ReleaseArgsRefused( + "--cells-out lists a release's derived cells; it needs --release-version, --release-commit and --surface" + .into(), + ) + .into()); + } + if out.is_some() { + return Err(ReleaseArgsRefused( + "--out writes the release evidence graph; it needs --release-version and --release-commit".into(), + ) + .into()); + } + Ok(()) +} + /// `check == true` → compare and report drift, write nothing. With a release `subject` (aprender#3715) the /// release evidence joins the graph, and the result goes ONLY to `out`: the tracked `contracts.nt` is the corpus, /// and a release's receipts written into it would be a release baked into every later PR's baseline. @@ -33,16 +54,12 @@ pub fn run( check: bool, subject: Option<&Subject>, out: Option<&Path>, + cells_out: Option<&Path>, ) -> Result<(), Box> { if let Some(subject) = subject { - return run_release(contract_dir, check, subject, out); - } - if out.is_some() { - return Err(ReleaseArgsRefused( - "--out writes the release evidence graph; it needs --release-version and --release-commit".into(), - ) - .into()); + return run_release(contract_dir, check, subject, out, cells_out); } + refuse_release_only_args(out, cells_out)?; let extraction = match extract::all(contract_dir) { Ok(x) => x, Err(e) => { @@ -125,6 +142,7 @@ fn run_release( check: bool, subject: &Subject, out: Option<&Path>, + cells_out: Option<&Path>, ) -> Result<(), Box> { if check { return Err(ReleaseArgsRefused( @@ -151,6 +169,9 @@ fn run_release( let mut hasher = Sha256::new(); hasher.update(nt.as_bytes()); std::fs::write(out, &nt)?; + if let Some(path) = cells_out { + write_cells(path, subject, extraction.release.as_ref())?; + } let report = ReleaseExtractReport { triples: extraction.graph.len(), sha256: format!("{:x}", hasher.finalize()), @@ -160,3 +181,26 @@ fn run_release( println!("{}", serde_json::to_string_pretty(&report)?); Ok(()) } + +/// `--cells-out`: every derived cell — the producer's work list, keyed by `cell_id` (aprender#3745 S2). +fn write_cells( + path: &Path, + subject: &Subject, + release: Option<&provable_contracts::ontology::extract::release_evidence::ReleaseStats>, +) -> Result<(), Box> { + let derived = release.map(|r| r.derived.as_slice()).unwrap_or_default(); + if derived.is_empty() { + return Err(ReleaseArgsRefused( + "--cells-out: no cell was derived (no --surface, or a surface with no leaf command)" + .into(), + ) + .into()); + } + let mut doc = serde_json::Map::new(); + doc.insert("schema".into(), "apr-release-cells/v1".into()); + doc.insert("version".into(), subject.version.clone().into()); + doc.insert("release_commit".into(), subject.commit.clone().into()); + doc.insert("cells".into(), serde_json::to_value(derived)?); + std::fs::write(path, serde_json::to_string_pretty(&doc)?)?; + Ok(()) +} diff --git a/crates/aprender-contracts-cli/src/commands/lint.rs b/crates/aprender-contracts-cli/src/commands/lint.rs index 0f590b07a1..e15462a10b 100644 --- a/crates/aprender-contracts-cli/src/commands/lint.rs +++ b/crates/aprender-contracts-cli/src/commands/lint.rs @@ -48,7 +48,7 @@ pub fn run( watch: bool, strict_test_binding: bool, armed_baseline_ref: Option<&str>, - gate: Option<&str>, + gate: &[String], shapes_opts: ShapesOptions, ) -> Result<(), Box> { refuse_missing_corpus(contract_dir)?; @@ -57,8 +57,10 @@ pub fn run( // is asked for, because run_single_gate would otherwise report every ref // missing on exactly the input the refusal exists for. refuse_single_file_strict_binding(contract_dir, strict_test_binding)?; - if let Some(name) = gate { - return run_single_gate(contract_dir, name, &shapes_opts); + match gate { + [] => {} + [name] => return run_single_gate(contract_dir, name, &shapes_opts, armed_baseline_ref), + names => return run_gates(contract_dir, names, &shapes_opts, armed_baseline_ref), } if watch { return run_watch( @@ -215,8 +217,10 @@ fn run_single_gate( contract_dir: &Path, name: &str, shapes_opts: &ShapesOptions, + armed_baseline_ref: Option<&str>, ) -> Result<(), Box> { - let (result, findings) = decide_named_gate(contract_dir, name, shapes_opts)?; + let (mut result, mut findings) = decide_named_gate(contract_dir, name, shapes_opts)?; + apply_measured_ratchet(contract_dir, armed_baseline_ref, &mut result, &mut findings)?; let report = SingleGateReport { gate: &result.name, @@ -252,6 +256,100 @@ fn run_single_gate( } } +/// ONT-4c (v4.14) F-34 on the shapes gate: the measured-set withdrawal ratchet needs git, so it is applied here +/// rather than in the library gate. It upgrades `ratchets.measured_sets` from the library's `not-checked` to +/// `checked` when a comparand was asked, and each violation is an Error finding (PV-ONT-014) that turns the +/// verdict to `Fail` — a ratchet that prints and exits 0 is the shape this corpus keeps refusing. +fn apply_measured_ratchet( + contract_dir: &Path, + armed_baseline_ref: Option<&str>, + result: &mut provable_contracts::lint::GateResult, + findings: &mut Vec, +) -> Result<(), Box> { + use provable_contracts::lint::finding::LintFinding; + use provable_contracts::lint::rules::RuleSeverity; + use provable_contracts::lint::GateExtra; + use std::collections::BTreeSet; + + let Some(GateExtra::Shapes { + readme, + claude_md, + ratchets, + .. + }) = result.extra.as_mut() + else { + return Ok(()); + }; + let live_readme: BTreeSet = readme.verified_commands.iter().cloned().collect(); + let live_claude: BTreeSet = claude_md.verified_commands.iter().cloned().collect(); + let answer = lint_arming::measured_ratchet( + contract_dir, + armed_baseline_ref, + [&live_readme, &live_claude], + )?; + ratchets.measured_sets = answer.status.to_string(); + if answer.violations.is_empty() { + return Ok(()); + } + let file = contract_dir + .join("lint-baseline.json") + .display() + .to_string(); + for m in answer.violations { + findings.push(LintFinding::new( + "PV-ONT-014", + RuleSeverity::Error, + m, + file.clone(), + )); + } + result.passed = false; + result.verdict = provable_contracts::ontology::verdict::Verdict::Fail; + Ok(()) +} + +/// PVL-001 EV-11: `--gate a --gate b` runs every named gate, each printing its own report as `--gate a` alone +/// would, and exits with their MEET: any refusal (exit 3) over any reject (1) over any decline (2) over pass (0). +/// A name this build does not compute is refused before any gate runs, so a typo never reports a partial pass. +fn run_gates( + contract_dir: &Path, + names: &[String], + shapes_opts: &ShapesOptions, + armed_baseline_ref: Option<&str>, +) -> Result<(), Box> { + use provable_contracts::lint::NAMED_GATES; + if let Some(bad) = names.iter().find(|n| !NAMED_GATES.contains(&n.as_str())) { + return Err(crate::contract_walk::UnknownGate { + asked: bad.clone(), + known: NAMED_GATES.iter().map(|g| (*g).to_string()).collect(), + } + .into()); + } + let outcomes: Vec<_> = names + .iter() + .map(|n| run_single_gate(contract_dir, n, shapes_opts, armed_baseline_ref)) + .collect(); + let rank = |r: &Result<(), Box>| match r { + Ok(()) => 0, + Err(e) if e.is::() => 1, + Err(e) if e.is::() => 2, + Err(_) => 3, + }; + let passed = outcomes.iter().filter(|r| rank(r) == 0).count(); + let worst = outcomes + .into_iter() + .max_by_key(|r| rank(r)) + .unwrap_or(Ok(())); + match worst { + Err(e) if e.is::() => Err(LintRejected { + passed, + armed: names.len(), + } + .into()), + other => other, + } +} + /// One gate's run, mapped to a report or to the refusal/decline that stands in its place. Every non-verdict /// answer says WHY on stderr before it returns, because an exit code without a reason is the thing this gate /// exists to refuse. @@ -266,7 +364,7 @@ fn decide_named_gate( shapes_opts: &ShapesOptions, ) -> Result> { use provable_contracts::lint::{ - relations_gate::RelationsOutcome, sigma_gate::SigmaOutcome, + ratchet_gates::RatchetOutcome, relations_gate::RelationsOutcome, sigma_gate::SigmaOutcome, valid_under_gate::ValidUnderOutcome, NamedGateOutcome, NAMED_GATES, }; @@ -309,7 +407,15 @@ fn decide_named_gate( NamedGateOutcome::ValidUnder(ValidUnderOutcome::Malformed(e)) => { Err(crate::contract_walk::SigmaMalformed(e.to_string()).into()) } + NamedGateOutcome::Ratchet(RatchetOutcome::Declined(why)) => { + eprintln!("{name}: {why}"); + Err(LintDeclined { + reason: provable_contracts::ontology::verdict::Reason::NoCheckable, + } + .into()) + } NamedGateOutcome::Sigma(SigmaOutcome::Ran { result, findings }) + | NamedGateOutcome::Ratchet(RatchetOutcome::Ran { result, findings }) | NamedGateOutcome::Relations(RelationsOutcome::Ran { result, findings }) | NamedGateOutcome::ValidUnder(ValidUnderOutcome::Ran { result, findings }) | NamedGateOutcome::Ran { result, findings } => Ok((result, findings)), @@ -322,7 +428,6 @@ fn decide_shapes_gate( outcome: provable_contracts::lint::shapes_gate::ShapesOutcome, ) -> Result> { use provable_contracts::lint::shapes_gate::ShapesOutcome; - use provable_contracts::ontology::verdict::Reason; match outcome { ShapesOutcome::Unsupported(e) => { @@ -331,14 +436,24 @@ fn decide_shapes_gate( ShapesOutcome::ExtractFailed(e) => { Err(crate::contract_walk::SigmaMalformed(e.to_string()).into()) } - ShapesOutcome::NoShapes { .. } => Err(LintDeclined { - reason: Reason::NoShapes, - } - .into()), - ShapesOutcome::NoFocus { .. } => Err(LintDeclined { - reason: Reason::NoFocus, + ShapesOutcome::Ran { result, findings } => Ok((result, findings)), + declined => Err(LintDeclined { + reason: shapes_decline_reason(declined), } .into()), + } +} + +/// A `shapes` outcome that is a decline: print what could not be checked, return the decline's reason. +fn shapes_decline_reason( + outcome: provable_contracts::lint::shapes_gate::ShapesOutcome, +) -> provable_contracts::ontology::verdict::Reason { + use provable_contracts::lint::shapes_gate::ShapesOutcome; + use provable_contracts::ontology::verdict::Reason; + + match outcome { + ShapesOutcome::NoShapes { .. } => Reason::NoShapes, + ShapesOutcome::NoFocus { .. } => Reason::NoFocus, ShapesOutcome::WrongCorpus { shapes_n, expected, @@ -351,10 +466,7 @@ fn decide_shapes_gate( for r in &refused { eprintln!("shapes: refused {r}"); } - Err(LintDeclined { - reason: Reason::WrongCorpus, - } - .into()) + Reason::WrongCorpus } ShapesOutcome::NoReceipts { shapes_n, dir } => { // ONT-4c1: the WHY travels with the decline — the lattice has no ReceiptUnmeasured element (ONT-6's @@ -362,17 +474,24 @@ fn decide_shapes_gate( eprintln!( "shapes: {shapes_n} shape(s) resolve receipts and the tree holds none under {dir}/" ); - Err(LintDeclined { - reason: Reason::NoCheckable, + Reason::NoCheckable + } + ShapesOutcome::HarnessBroken { causes } => { + for c in &causes { + eprintln!("shapes: CRUX harness broken — {c}"); } - .into()) + Reason::NoCheckable + } + ShapesOutcome::EmptyDomain { shapes_n } => { + // ONT-4c5 / R-2: |D| = 0 answers nothing about a required cell — a decline, never Pass, never RED + eprintln!( + "shapes: capability-cells domain D is empty ({shapes_n} shape(s)) — no required rung has a host to be measured on" + ); + Reason::NoCheckable } ShapesOutcome::PositiveControlFailed { which, .. } => { eprintln!("shapes: positive control {which} did not fire"); - Err(LintDeclined { - reason: Reason::PositiveControlFailed, - } - .into()) + Reason::PositiveControlFailed } ShapesOutcome::Differential { passed, n, failed, .. @@ -384,12 +503,12 @@ fn decide_shapes_gate( for f in &failed { eprintln!(" {f}"); } - Err(LintDeclined { - reason: Reason::Differential, - } - .into()) + Reason::Differential } - ShapesOutcome::Ran { result, findings } => Ok((result, findings)), + // answered by decide_shapes_gate before it asks for a decline reason + ShapesOutcome::Unsupported(_) + | ShapesOutcome::ExtractFailed(_) + | ShapesOutcome::Ran { .. } => Reason::NoCheckable, } } diff --git a/crates/aprender-contracts-cli/src/commands/lint_arming.rs b/crates/aprender-contracts-cli/src/commands/lint_arming.rs index 0c27c32d97..2ab9bd7af4 100644 --- a/crates/aprender-contracts-cli/src/commands/lint_arming.rs +++ b/crates/aprender-contracts-cli/src/commands/lint_arming.rs @@ -14,6 +14,7 @@ //! A committed `lint-baseline.json` that does not parse is an error on both paths: the committed value //! exists, it just cannot be read, and skipping it would disarm the check for exactly that commit. +use std::collections::BTreeSet; use std::error::Error; use std::path::{Path, PathBuf}; use std::process::Command; @@ -21,6 +22,7 @@ use std::process::Command; use provable_contracts::ontology::arming::{ check_monotone, check_shapes_monotone, ArmedGates, ArmedShapes, }; +use provable_contracts::ontology::measured_sets; const BASELINE: &str = "lint-baseline.json"; const NO_COMPARAND: &str = "NOT CHECKED (no comparand)"; @@ -41,7 +43,7 @@ pub fn resolve(contract_dir: &Path, explicit_ref: Option<&str>) -> Result ( + Comparand::Absent(_, why) => ( format!("{NO_COMPARAND} — {why}"), format!("{NO_COMPARAND} — {why}"), ), @@ -127,6 +129,62 @@ pub fn declared(contract_dir: &Path) -> Result> { )?) } +/// ONT-4c (v4.14) F-34: the measured-set withdrawal ratchet's answer for one run. +pub struct MeasuredRatchet { + /// `checked` when F-34 was computed against a comparand; `not-checked` outside a git work tree. + pub status: &'static str, + /// One RED message per violation (a drop with no `withdrawn[]` entry, an entry with no drop, no comparand + /// ref in a work tree, a set that cannot be read). + pub violations: Vec, +} + +/// F-34 against the comparand `resolve` uses: `withdrawn(HEAD) \ withdrawn(comparand) == set(comparand) \ +/// set(live)` for each key of [`measured_sets::KEYS`], `live` in the same order. The three no-comparand cases +/// are NOT the arming monotone's single `NOT CHECKED`: outside a work tree there is nothing to ask +/// (`not-checked`, which a probe reads as not green); a work tree whose comparand ref does not resolve is RED — +/// on a CI checkout that is a fetch misconfiguration, and answering "not checked" there would disarm the +/// ratchet on exactly the runs that gate; a corpus untracked at the comparand has every set ∅ there. +pub fn measured_ratchet( + contract_dir: &Path, + explicit_ref: Option<&str>, + live: [&BTreeSet; 2], +) -> Result> { + let comparand_text = match comparand(contract_dir, explicit_ref)? { + Comparand::Absent(Missing::WorkTree, _) => { + return Ok(MeasuredRatchet { + status: "not-checked", + violations: Vec::new(), + }) + } + Comparand::Absent(Missing::Ref, why) => { + return Ok(MeasuredRatchet { + status: "checked", + violations: vec![format!( + "F-34: no comparand in a git work tree ({why}) — fetch origin/main; {}", + measured_sets::REMEDY + )], + }) + } + Comparand::Absent(Missing::Untracked, _) => None, + Comparand::At { text, .. } => text, + }; + let head_text = read_baseline(contract_dir)?; + let mut violations = Vec::new(); + for (key, live) in measured_sets::KEYS.iter().zip(live) { + let head = measured_sets::read(head_text.as_deref(), key); + let cmp = measured_sets::read(comparand_text.as_deref(), key); + match (head, cmp) { + (Ok(h), Ok(c)) => violations.extend(measured_sets::withdrawal(key, &h, &c, live)), + (Err(e), _) => violations.push(format!("F-34: {e}")), + (_, Err(e)) => violations.push(format!("F-34: comparand {e}")), + } + } + Ok(MeasuredRatchet { + status: "checked", + violations, + }) +} + /// `/lint-baseline.json`, or `None` when the corpus has none (a single-file corpus never does). fn read_baseline(contract_dir: &Path) -> Result, Box> { if !contract_dir.is_dir() { @@ -140,9 +198,21 @@ fn read_baseline(contract_dir: &Path) -> Result, Box> } } +/// Why the default path has no comparand. ONT-4c (v4.14) splits the three: the measured-set ratchet (F-34) +/// answers each differently, where the arming monotone prints `NOT CHECKED` for all of them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Missing { + /// The corpus is not in a git work tree: nothing to ask. + WorkTree, + /// A work tree with neither merge-base(HEAD, origin/main) nor origin/main. + Ref, + /// A comparand commit at which the corpus is not tracked: every committed set there is ∅. + Untracked, +} + enum Comparand { - /// Default path only: nothing committed to compare against, and why. - Absent(String), + /// Default path only: nothing committed to compare against, which case, and why. + Absent(Missing, String), /// The committed baseline text at `label` (`None`: the corpus is tracked there without a baseline, /// which arms the default set). At { @@ -164,7 +234,7 @@ fn comparand(contract_dir: &Path, explicit_ref: Option<&str>) -> Result Err(format!("--armed-baseline-ref {r}: {why}").into()), - None => Ok(Comparand::Absent(why)), + None => Ok(Comparand::Absent(Missing::WorkTree, why)), }; }; let (commit, label) = match explicit_ref { @@ -184,6 +254,7 @@ fn comparand(contract_dir: &Path, explicit_ref: Option<&str>) -> Result found, None => { return Ok(Comparand::Absent( + Missing::Ref, "neither merge-base(HEAD, origin/main) nor origin/main resolves".into(), )) } @@ -193,7 +264,7 @@ fn comparand(contract_dir: &Path, explicit_ref: Option<&str>) -> Result Err(format!("--armed-baseline-ref {r}: {why}").into()), - None => Ok(Comparand::Absent(why)), + None => Ok(Comparand::Absent(Missing::Untracked, why)), }; } let path = if rel.is_empty() { diff --git a/crates/aprender-contracts-cli/src/lib.rs b/crates/aprender-contracts-cli/src/lib.rs index e4c514eb86..688eac4738 100644 --- a/crates/aprender-contracts-cli/src/lib.rs +++ b/crates/aprender-contracts-cli/src/lib.rs @@ -132,12 +132,19 @@ pub fn dispatch(command: Commands) -> Result<(), Box> { contract_dir, check, out, + cells_out, release, } => { let subject = release .subject() .map_err(crate::contract_walk::ReleaseArgsRefused)?; - commands::extract_rdf::run(&contract_dir, check, subject.as_ref(), out.as_deref()) + commands::extract_rdf::run( + &contract_dir, + check, + subject.as_ref(), + out.as_deref(), + cells_out.as_deref(), + ) } Commands::Coverage { contract_dir, @@ -250,8 +257,12 @@ pub fn dispatch(command: Commands) -> Result<(), Box> { watch, strict_test_binding, armed_baseline_ref.as_deref(), - gate.as_deref(), - commands::lint::shapes_options(gate.as_deref(), shape, &release)?, + &gate, + commands::lint::shapes_options( + gate.iter().any(|g| g == "shapes").then_some("shapes"), + shape, + &release, + )?, ) } Commands::Score { diff --git a/crates/aprender-contracts-cli/tests/ev11_lint_ratchets.rs b/crates/aprender-contracts-cli/tests/ev11_lint_ratchets.rs new file mode 100644 index 0000000000..6d76110cf4 --- /dev/null +++ b/crates/aprender-contracts-cli/tests/ev11_lint_ratchets.rs @@ -0,0 +1,219 @@ +//! PVL-001 EV-11 (PMAT-4166) — `pv lint --gate theorem-pairing --gate depends-on-present`: two shrink-only +//! ratchets read from `contracts/lint-baseline.json`, which the gates never write. +//! +//! The row's probe, verbatim (paiml/infra PVL-001 @00553b0b): `json_object contracts/lint-baseline.json && jq -e +//! '(.unpaired_theorem_modules|numbers) and (.contracts_without_depends_on|numbers) and (.command|strings)' +//! contracts/lint-baseline.json && "$PV" lint contracts/ --gate theorem-pairing --gate depends-on-present`, and the +//! accept adds `git diff --exit-code contracts/lint-baseline.json` after it. The first test is that probe on the +//! real corpus; the rest run on a throwaway repo built in a tempdir, because the gates read the repo ROOT +//! (`lean/`, `book/`) as well as the contract dir. +//! +//! | case | expected | +//! |---|---| +//! | real corpus, both gates | exit 0, both Pass, the baseline byte-identical after | +//! | tempdir at the baseline | exit 0 | +//! | the spec's mutation: add an unpaired Theorem module | exit 1, PV-RAT-001 — the meet rejects | +//! | a kernel contract with no `depends_on` above the baseline | exit 1, PV-RAT-002 | +//! | no baseline key | exit 2, `Unknown(Report)` with the count printed — reported, never a pass | +//! | no Lean base | exit 2, decline naming what is missing | +//! | an unknown name among several | exit 1 before anything runs | + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn pv_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_pv")) +} + +struct Run { + code: i32, + stdout: String, + stderr: String, +} + +fn pv(args: &[&str]) -> Run { + let scratch = tempfile::tempdir().expect("scratch cwd is creatable"); + let out = Command::new(pv_bin()) + .current_dir(scratch.path()) + .args(args) + .output() + .expect("failed to spawn pv"); + Run { + code: out.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + } +} + +fn show(r: &Run) -> String { + format!( + "exit {}\n--- stdout\n{}\n--- stderr\n{}", + r.code, r.stdout, r.stderr + ) +} + +fn s(p: &Path) -> &str { + p.to_str().expect("utf-8 path") +} + +fn repo_contracts() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts") +} + +fn write(root: &Path, rel: &str, text: &str) { + let p = root.join(rel); + std::fs::create_dir_all(p.parent().expect("has a parent")).expect("mkdir"); + std::fs::write(p, text).expect("write"); +} + +/// `pv lint --gate X` prints one JSON object per gate, pretty-printed and back to back. +fn reports(stdout: &str) -> Vec { + serde_json::Deserializer::from_str(stdout) + .into_iter::() + .map(|v| v.expect("each gate report is JSON")) + .collect() +} + +const BOTH: [&str; 4] = ["--gate", "theorem-pairing", "--gate", "depends-on-present"]; + +/// A repo with one paired theorem module and one kernel contract (the PVL-1 control, which has no +/// `depends_on`), at baselines 0 and 1. +fn repo() -> tempfile::TempDir { + let t = tempfile::tempdir().expect("tempdir"); + write( + t.path(), + "lean/ProvableContracts/Theorems/S/A.lean", + "theorem a : True := trivial\n", + ); + write( + t.path(), + "book/a.md", + "see ProvableContracts.Theorems.S.A\n", + ); + std::fs::create_dir_all(t.path().join("contracts")).expect("mkdir"); + std::fs::copy( + repo_contracts().join("softmax-kernel-v1.yaml"), + t.path().join("contracts/softmax-kernel-v1.yaml"), + ) + .expect("control contract copies"); + write( + t.path(), + "contracts/lint-baseline.json", + "{\n \"unpaired_theorem_modules\": 0,\n \"contracts_without_depends_on\": 1\n}\n", + ); + t +} + +fn lint(root: &Path, gates: &[&str]) -> Run { + let dir = root.join("contracts"); + let mut args = vec!["lint", s(&dir)]; + args.extend_from_slice(gates); + pv(&args) +} + +#[test] +fn the_probe_passes_on_the_real_corpus_and_writes_nothing() { + let baseline = repo_contracts().join("lint-baseline.json"); + let before = std::fs::read(&baseline).expect("baseline readable"); + let doc: serde_json::Value = serde_json::from_slice(&before).expect("baseline is JSON"); + assert!(doc.is_object()); + assert!(doc["unpaired_theorem_modules"].is_u64(), "{doc}"); + assert!(doc["contracts_without_depends_on"].is_u64(), "{doc}"); + assert_eq!(doc["command"], "make lint-ratchet"); + + let dir = repo_contracts(); + let mut args = vec!["lint", s(&dir)]; + args.extend_from_slice(&BOTH); + let r = pv(&args); + assert_eq!(r.code, 0, "{}", show(&r)); + let got = reports(&r.stdout); + assert_eq!(got.len(), 2, "{}", show(&r)); + for g in &got { + assert_eq!(g["verdict"], "Pass", "{g}"); + } + assert_eq!( + got[0]["unpaired_theorem_modules"], doc["unpaired_theorem_modules"], + "the recorded baseline is the measured count (`make lint-ratchet` wrote it)" + ); + assert_eq!( + got[1]["contracts_without_depends_on"], + doc["contracts_without_depends_on"] + ); + assert_eq!( + std::fs::read(&baseline).expect("baseline readable"), + before, + "a gate wrote the baseline" + ); +} + +#[test] +fn at_the_baseline_both_gates_pass() { + let t = repo(); + let r = lint(t.path(), &BOTH); + assert_eq!(r.code, 0, "{}", show(&r)); +} + +/// PVL-001 EV-11's mutation, verbatim: "add an unpaired Theorem module → RED". +#[test] +fn adding_an_unpaired_theorem_module_is_red() { + let t = repo(); + write( + t.path(), + "lean/ProvableContracts/Theorems/S/B.lean", + "theorem b : True := trivial\n", + ); + let r = lint(t.path(), &BOTH); + assert_eq!(r.code, 1, "{}", show(&r)); + assert!(r.stdout.contains("PV-RAT-001"), "{}", show(&r)); + assert!(!r.stdout.contains("PV-RAT-002"), "{}", show(&r)); + assert!( + r.stderr.contains("1/2 armed gates passed"), + "the meet names the one that held: {}", + show(&r) + ); +} + +#[test] +fn a_kernel_contract_without_depends_on_above_the_baseline_is_red() { + let t = repo(); + let text = std::fs::read_to_string(repo_contracts().join("softmax-kernel-v1.yaml")) + .expect("control contract readable"); + write(t.path(), "contracts/softmax-kernel-copy-v1.yaml", &text); + let r = lint(t.path(), &["--gate", "depends-on-present"]); + assert_eq!(r.code, 1, "{}", show(&r)); + assert!(r.stdout.contains("PV-RAT-002"), "{}", show(&r)); +} + +#[test] +fn no_baseline_reports_the_count_and_is_never_a_pass() { + let t = repo(); + write(t.path(), "contracts/lint-baseline.json", "{}\n"); + let r = lint(t.path(), &BOTH); + assert_eq!(r.code, 2, "{}", show(&r)); + let got = reports(&r.stdout); + assert_eq!(got.len(), 2, "{}", show(&r)); + for g in &got { + assert_eq!(g["verdict"], "Unknown(Report)", "{g}"); + } + // the count `make lint-ratchet` records as the first baseline + assert_eq!(got[0]["unpaired_theorem_modules"], 0); + assert_eq!(got[1]["contracts_without_depends_on"], 1); +} + +#[test] +fn no_lean_base_is_a_decline_naming_what_is_missing() { + let t = repo(); + std::fs::remove_dir_all(t.path().join("lean")).expect("rm lean"); + let r = lint(t.path(), &["--gate", "theorem-pairing"]); + assert_eq!(r.code, 2, "{}", show(&r)); + assert!(r.stderr.contains("no Lean theorem base"), "{}", show(&r)); +} + +#[test] +fn an_unknown_name_among_several_is_refused_before_anything_runs() { + let t = repo(); + let r = lint(t.path(), &["--gate", "theorem-pairing", "--gate", "bogus"]); + assert_eq!(r.code, 1, "{}", show(&r)); + assert!(r.stderr.contains("--gate bogus"), "{}", show(&r)); + assert!(r.stdout.is_empty(), "nothing ran: {}", show(&r)); +} diff --git a/crates/aprender-contracts-cli/tests/ont4b_shapes_gate.rs b/crates/aprender-contracts-cli/tests/ont4b_shapes_gate.rs index 1057520206..105c53bfcd 100644 --- a/crates/aprender-contracts-cli/tests/ont4b_shapes_gate.rs +++ b/crates/aprender-contracts-cli/tests/ont4b_shapes_gate.rs @@ -88,11 +88,12 @@ fn the_repo_corpus_passes_with_the_plant_fired_and_the_whole_corpus_as_focus_nod ); assert_eq!(v["extra"]["pc_shape"], "fired", "{}", show(&r)); // ONT-4b: exactly one, from ont:id minCount. ONT-4c1 plants a bare model:Model too, which draws - // ladder-measured's two minCounts: three on this corpus, and never zero. + // ladder-measured's two minCounts: three. ONT-4c (#3847) arms readme-root, claude-md, model-setfit-slice and csv-train, and + // their plants add sixteen (measured on its branch, 0 real violations): nineteen, never zero. assert_eq!( v["extra"]["plant_violations"], - 3, - "ont:id minCount + ladder-measured's two (ONT-4c1)\n{}", + 19, + "ont:id minCount + ladder-measured's two (ONT-4c1) + the ONT-4c plants against readme-root, claude-md, model-setfit-slice, csv-train\n{}", show(&r) ); } @@ -230,11 +231,16 @@ fn the_tracked_repo_graph_is_fresh() { // that is the ratchet working, not a conflict to route around. It did: the // 0.69 batch folded #3600 in and the count went 6 -> 9 with its three shapes. #3715 added the nine-shape // `release-readiness-v1` family (shapes_n=18, triples=15863, measured on its branch); it contributes no focus - // node to a PR's graph — the release evidence is extracted only under `--release-*`. + // node to a PR's graph — the release evidence is extracted only under `--release-*`. ONT-4c (#3847) adds the + // four armed non-code shapes readme-root, claude-md, model-setfit-slice, csv-train (18->22). #3745 S2 (#3777) + // added six more for the DERIVED cell classes (effect, probe, model-cell, effect-cell) CRUX (crux-verb, crux) + // and a8's sampling controls (sampling), and ONT-4c5 (PMAT-3972) added `capability-cells`, bringing car's line + // to 26 (18+8). #4069 (ONT-4c4) added kernel-parity, kernel-timing and kernel-safety. The branches added + // disjoint shapes, so the union is 18 + 4 (ONT-4c) + 8 (car) + 3 (ONT-4c4) = 33. assert_eq!( v["shapes_n"], - 18, - "ont-shapes-v1 + ladder-measured + ladder-green (ONT-4c1) + bound-symbols-resolve + lean-statements-grounded (ONT-4b2) + refusal-receipt-v1 (#3605) + parity-receipt-complete + parity-comparator-self + parity-comparator-oracle (parity-receipt-v2, #3600) + release-readiness-v1{{,.release,.host,.context,.model,.coverage,.tokenizer,.kernel,.refusal}} (#3715)\n{}", + 33, + "ont-shapes-v1 + ladder-measured + ladder-green (ONT-4c1) + bound-symbols-resolve + lean-statements-grounded (ONT-4b2) + refusal-receipt-v1 (#3605) + parity-receipt-complete + parity-comparator-self + parity-comparator-oracle (parity-receipt-v2, #3600) + release-readiness-v1{{,.release,.host,.context,.model,.coverage,.tokenizer,.kernel,.refusal}} (#3715) + readme-root + claude-md + model-setfit-slice + csv-train (ONT-4c, #3847) + release-readiness-v1{{.effect,.probe,.model-cell,.effect-cell,.crux-verb,.crux,.sampling}} (#3745 S2) + capability-cells (ONT-4c5)\n{}", show(&r) ); } diff --git a/crates/aprender-contracts-cli/tests/ont4c3_parity_receipts.rs b/crates/aprender-contracts-cli/tests/ont4c3_parity_receipts.rs index c2452e7955..856a672b9b 100644 --- a/crates/aprender-contracts-cli/tests/ont4c3_parity_receipts.rs +++ b/crates/aprender-contracts-cli/tests/ont4c3_parity_receipts.rs @@ -87,9 +87,18 @@ fn a_complete_receipt_passes_with_one_focus_node() { assert_eq!(r.code, 0, "{}", r.all()); assert_eq!(violations(&r), 0, "{}", r.all()); assert_eq!(focus_nodes(&r), 1, "{}", r.all()); - // The shapes are ARMED in the fixture (no lint-baseline.json narrows them), so a violation here would be - // a real Fail rather than a reported one — which is what makes the two FAIL cases below meaningful. + // The two shapes this receipt reaches are ARMED (the fixture's lint-baseline.json), so a violation here + // would be a real Fail rather than a reported one — which is what makes the two FAIL cases below + // meaningful. `parity-comparator-oracle` is left UNARMED on purpose: a self-comparator receipt gives it + // zero focus nodes, and an ARMED shape that grades nothing declines (#3610) rather than passing. assert!(r.stdout.contains("parity-receipt-complete"), "{}", r.all()); + let v: serde_json::Value = serde_json::from_str(&r.stdout).expect("json report"); + assert_eq!( + names(&v["extra"]["declines"]), + vec!["parity-comparator-oracle"] as Vec<&str>, + "the unarmed vacuity is still NAMED\n{}", + r.all() + ); } #[test] @@ -242,3 +251,10 @@ fn the_committed_tree_agrees_with_its_own_denominator() { String::from_utf8_lossy(&out.stderr) ); } + +/// A JSON array of strings as `Vec<&str>` (`json!` array literals expand to a disallowed `unwrap`). +fn names(v: &serde_json::Value) -> Vec<&str> { + v.as_array() + .map(|a| a.iter().filter_map(serde_json::Value::as_str).collect()) + .unwrap_or_default() +} diff --git a/crates/aprender-contracts-cli/tests/ont4c5_capability_cells.rs b/crates/aprender-contracts-cli/tests/ont4c5_capability_cells.rs new file mode 100644 index 0000000000..6af3402cad --- /dev/null +++ b/crates/aprender-contracts-cli/tests/ont4c5_capability_cells.rs @@ -0,0 +1,371 @@ +//! ONT-4c5 (PMAT-3972; aprender #3972 + #4047) — capability cells, on the CLI. +//! +//! ONT-001 v4.12 §5 ONT-4c5 RED, on `pv lint --gate shapes`: a required cell of the model-capability ladder is +//! Pass or Fail at the current release, and `Unknown{NotRun}` — a missing row, DEFER, MANUAL, NO-VERDICT, a label +//! nobody knows — is a violation of the ARMED `capability-cells` shape: exit 1, naming the cell. An empty domain +//! is a decline (exit 2), never Pass. A Fail cell is ADMITTED by this shape (ladder-green is what refuses it). +//! +//! DISCRIMINATION: `capcells-clean/` must PASS with `pc_shapes` fired and the whole domain reported, so a build +//! that declines or rejects every ladder fails this file; each NotRun fixture must FAIL naming exactly its cell, +//! so a build that arms nothing, or folds an absence into Fail, fails it too; `capcells-failadmit/` must PASS, +//! so a build that treats Fail as NotRun fails it. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +struct Run { + code: i32, + stdout: String, + stderr: String, +} + +fn lint(fixture: &str) -> Run { + let contracts = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/ont") + .join(fixture) + .join("contracts"); + let scratch = tempfile::tempdir().expect("scratch cwd is creatable"); + let out = Command::new(PathBuf::from(env!("CARGO_BIN_EXE_pv"))) + .current_dir(scratch.path()) + .args([ + "lint", + &contracts.to_string_lossy(), + "--gate", + "shapes", + "--format", + "json", + ]) + .output() + .expect("failed to spawn pv"); + Run { + code: out.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + } +} + +fn show(r: &Run) -> String { + format!( + "exit {}\n--- stdout\n{}\n--- stderr\n{}", + r.code, r.stdout, r.stderr + ) +} + +fn json(r: &Run) -> serde_json::Value { + serde_json::from_str(&r.stdout).unwrap_or_else(|e| panic!("not JSON ({e}):\n{}", show(r))) +} + +fn strings(v: &serde_json::Value) -> Vec { + v.as_array() + .unwrap_or_else(|| panic!("not an array: {v}")) + .iter() + .map(|x| x.as_str().expect("string").to_string()) + .collect() +} + +const DOMAIN: [&str; 4] = [ + "rung-a@gx10", + "rung-a@lambda", + "rung-b@gx10", + "rung-b@lambda", +]; + +#[test] +fn a_clean_ladder_passes_with_the_plant_fired_and_the_whole_domain_reported() { + let r = lint("capcells-clean"); + assert_eq!(r.code, 0, "{}", show(&r)); + let v = json(&r); + assert_eq!(v["verdict"], "Pass", "{}", show(&r)); + assert_eq!(v["pc_shapes"]["capability-cells"], "fired", "{}", show(&r)); + assert!(strings(&v["armed_shapes"]).contains(&"capability-cells".to_string())); + assert_eq!(strings(&v["capability_cells"]["domain"]), DOMAIN); + assert!(strings(&v["capability_cells"]["not_run"]).is_empty()); + assert_eq!(v["capability_cells"]["v_star"], "0.69.1"); +} + +/// Every NotRun fixture: exit 1, `not_run` is exactly its cell, and a finding names that cell. +#[test] +fn every_not_run_required_cell_rejects_naming_exactly_that_cell() { + for (fixture, cell) in [ + ("capcells-defer", "rung-a@lambda"), + ("capcells-manual", "rung-a@gx10"), + ("capcells-noverdict", "rung-b@lambda"), + ("capcells-missing", "rung-b@gx10"), + ("capcells-badlabel", "rung-a@lambda"), + ] { + let r = lint(fixture); + assert_eq!(r.code, 1, "{fixture}: {}", show(&r)); + let v = json(&r); + assert_eq!(v["verdict"], "Fail", "{fixture}"); + assert_eq!(v["pc_shapes"]["capability-cells"], "fired", "{fixture}"); + assert_eq!( + strings(&v["capability_cells"]["not_run"]), + [cell], + "{fixture}" + ); + let named = v["findings"].as_array().expect("findings").iter().any(|f| { + f["rule_id"] == "PV-ONT-013" + && f["message"] + .as_str() + .is_some_and(|m| m.contains(&format!("capability cell {cell} "))) + }); + assert!( + named, + "{fixture}: no PV-ONT-013 finding names {cell}\n{}", + show(&r) + ); + } +} + +#[test] +fn an_unknown_label_is_refused_by_name() { + let r = lint("capcells-badlabel"); + let v = json(&r); + let refused = strings(&v["capability_cells"]["refused_labels"]); + assert_eq!(refused.len(), 1, "{}", show(&r)); + assert!(refused[0].ends_with(": rung-a: DEFERRED"), "{refused:?}"); +} + +#[test] +fn a_fail_cell_is_admitted_by_this_shape() { + let r = lint("capcells-failadmit"); + assert_eq!(r.code, 0, "{}", show(&r)); + let v = json(&r); + assert_eq!(v["verdict"], "Pass"); + assert!(strings(&v["capability_cells"]["not_run"]).is_empty()); +} + +#[test] +fn an_empty_domain_declines_never_passes() { + let r = lint("capcells-emptydomain"); + assert_eq!(r.code, 2, "{}", show(&r)); + assert!( + r.stderr.contains("capability-cells domain D is empty"), + "{}", + show(&r) + ); + assert!(r.stderr.contains("decline:"), "{}", show(&r)); +} + +// ── The row's own probe on THIS repository, and the row's data mutations (each must turn it RED) ────────────── +// +// The probe is ONT-4c5's jq predicate, restated: verdict Pass, `capability-cells` armed, `pc_shapes` fired, no +// NotRun cell, and every id of the baseline set B in the domain. The mutations run in a scratch tree that is +// symlinks all the way down except on the path to the one file a mutation edits, which is a real copy — the +// checkout is never written. The row's code mutation ("fold a missing cell into Fail") is held by the unit tests +// and the plant, not here. + +/// ONT-4c5's baseline set B, verbatim from the row's probe (paiml/infra paiml-ontology.md v4.12 §5). +const B: [&str; 14] = [ + "qwen2-1.5b-q4km@lambda", + "qwen2-1.5b-q4km@gx10", + "qwen3-1.7b-q4km@lambda", + "qwen3-1.7b-q4km@gx10", + "qwen35-0.8b-q4km@lambda", + "qwen35-0.8b-q4km@gx10", + "qwen35-2b-q4km@lambda", + "qwen35-2b-q4km@gx10", + "qwen35-4b-q4km@lambda", + "qwen35-4b-q4km@gx10", + "qwen35-9b-q4km@lambda", + "qwen35-9b-q4km@gx10", + "qwen35-27b-q4km@lambda", + "qwen35-27b-q4km@gx10", +]; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn lint_root(root: &Path) -> Run { + let out = Command::new(PathBuf::from(env!("CARGO_BIN_EXE_pv"))) + .current_dir(root) + .args(["lint", "contracts", "--gate", "shapes", "--format", "json"]) + .output() + .expect("failed to spawn pv"); + Run { + code: out.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + } +} + +/// The row's probe predicate over one run: `Ok(())` green, `Err(why)` red. +fn probe(r: &Run) -> Result<(), String> { + let v: serde_json::Value = + serde_json::from_str(&r.stdout).map_err(|_| format!("no JSON (exit {})", r.code))?; + let has = |key: &str, id: &str| { + v[key] + .as_array() + .is_some_and(|a| a.iter().any(|x| x.as_str() == Some(id))) + }; + if v["verdict"] != "Pass" { + return Err(format!("verdict {}", v["verdict"])); + } + if !has("armed_shapes", "capability-cells") { + return Err("capability-cells not armed".into()); + } + if v["pc_shapes"]["capability-cells"] != "fired" { + return Err("pc_shapes not fired".into()); + } + let cc = &v["capability_cells"]; + if cc["not_run"].as_array().is_none_or(|a| !a.is_empty()) { + return Err(format!("not_run {}", cc["not_run"])); + } + let domain: Vec<&str> = cc["domain"] + .as_array() + .map(|a| a.iter().filter_map(serde_json::Value::as_str).collect()) + .unwrap_or_default(); + let missing: Vec<&str> = B.iter().copied().filter(|b| !domain.contains(b)).collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(format!("B ⊄ D: {missing:?}")) + } +} + +/// The receipt of `host` at the current release V*, as the gate itself reports V* on this repository — never a +/// pinned version, so the mutations follow the tree to whichever release it has receipts for (the branch was +/// parked for a week because these paths named 0.69.1 and its base had only 0.68.2). +fn current_receipt(host: &str) -> String { + let v = json(&lint_root(&repo_root())); + let v_star = v["capability_cells"]["v_star"] + .as_str() + .unwrap_or_else(|| panic!("no v_star on this repository: {v}")); + format!("evidence/dogfood/models/{v_star}/{host}.json") +} + +/// A scratch copy of the repository in which `rel` (and only it) is a real, writable file. +fn scratch_with(rel: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("scratch"); + let repo = repo_root().canonicalize().expect("repo root"); + let rel = Path::new(rel); + let mut src = repo.clone(); + let mut dst = dir.path().to_path_buf(); + let comps: Vec<_> = rel.components().collect(); + for (i, comp) in comps.iter().enumerate() { + // at this level: symlink every sibling, descend into (or copy) the one on the path + for entry in std::fs::read_dir(&src).expect("read_dir") { + let entry = entry.expect("entry"); + let name = entry.file_name(); + if name == comp.as_os_str() || name == ".git" || name == "target" || name == ".pv" { + continue; + } + std::os::unix::fs::symlink(entry.path(), dst.join(&name)).expect("symlink"); + } + src = src.join(comp); + dst = dst.join(comp); + if i + 1 == comps.len() { + std::fs::copy(&src, &dst).expect("copy the mutated file"); + } else { + std::fs::create_dir(&dst).expect("mkdir on the path"); + } + } + (dir, dst) +} + +fn edit_json(path: &Path, f: impl FnOnce(&mut serde_json::Value)) { + let mut v: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json"); + f(&mut v); + std::fs::write(path, serde_json::to_string_pretty(&v).expect("ser")).expect("write"); +} + +fn rung_row<'a>(v: &'a mut serde_json::Value, id: &str) -> &'a mut serde_json::Value { + v["rungs"] + .as_array_mut() + .expect("rungs") + .iter_mut() + .find(|r| r["id"] == id) + .unwrap_or_else(|| panic!("mutation anchor missing: {id}")) +} + +#[test] +fn the_rows_probe_is_green_on_this_repository() { + let r = lint_root(&repo_root()); + probe(&r).unwrap_or_else(|why| panic!("ONT-4c5 probe RED on the tree: {why}\n{}", show(&r))); +} + +#[test] +fn disarming_capability_cells_turns_the_probe_red() { + let (dir, f) = scratch_with("contracts/lint-baseline.json"); + edit_json(&f, |v| { + let a = v["armed_shapes"].as_array_mut().expect("armed_shapes"); + let n = a.len(); + a.retain(|x| x != "capability-cells"); + assert_eq!(a.len(), n - 1, "mutation anchor missing"); + }); + let r = lint_root(dir.path()); + assert!(probe(&r).is_err(), "disarmed and still green\n{}", show(&r)); + assert_eq!( + r.code, + 2, + "the plant cannot fire through an unarmed shape\n{}", + show(&r) + ); +} + +#[test] +fn flipping_a_required_rung_to_optional_leaves_b_outside_the_domain() { + let (dir, f) = scratch_with("contracts/model-capability-ladder-v1.yaml"); + let text = std::fs::read_to_string(&f).expect("read"); + let at = text + .find("- id: qwen35-2b-q4km") + .expect("mutation anchor missing"); + let tail = &text[at..]; + let next = tail[1..].find("\n - id: ").map_or(tail.len(), |n| n + 1); + let off = tail[..next] + .find("required: true") + .expect("the rung has its own required: true"); + let mut out = text.clone(); + out.replace_range( + at + off..at + off + "required: true".len(), + "required: false", + ); + std::fs::write(&f, out).expect("write"); + let r = lint_root(dir.path()); + let why = probe(&r).expect_err("an optional rung left B ⊆ D intact"); + assert!(why.contains("qwen35-2b-q4km@lambda"), "{why}"); +} + +#[test] +fn a_not_run_label_on_a_current_release_row_rejects_naming_the_cell() { + for label in ["DEFER", "MANUAL", "NO-VERDICT"] { + let (dir, f) = scratch_with(¤t_receipt("lambda")); + edit_json(&f, |v| { + rung_row(v, "qwen35-4b-q4km")["verdict"] = label.into() + }); + let r = lint_root(dir.path()); + assert_eq!(r.code, 1, "{label}\n{}", show(&r)); + assert!(probe(&r).is_err(), "{label}"); + assert!( + r.stdout.contains("qwen35-4b-q4km@lambda"), + "{label}\n{}", + show(&r) + ); + } +} + +#[test] +fn a_deleted_current_release_row_rejects_naming_the_cell() { + let (dir, f) = scratch_with(¤t_receipt("gx10")); + edit_json(&f, |v| { + let rows = v["rungs"].as_array_mut().expect("rungs"); + let n = rows.len(); + rows.retain(|r| r["id"] != "qwen3-1.7b-q4km"); + assert_eq!(rows.len(), n - 1, "mutation anchor missing"); + }); + let r = lint_root(dir.path()); + assert_eq!(r.code, 1, "{}", show(&r)); + assert!(r.stdout.contains("qwen3-1.7b-q4km@gx10"), "{}", show(&r)); +} + +/// Review lane C: the non-numeral-version refusal must reach the PROCESS exit, not only `compute()`'s `Err`. +#[test] +fn a_receipt_version_that_is_not_dotted_numerals_exits_3_naming_the_file() { + let r = lint("capcells-badversion"); + assert_eq!(r.code, 3, "{}", show(&r)); + assert!(r.stderr.contains("0.69.1-rc1"), "{}", show(&r)); + assert!(r.stderr.contains("lambda.json"), "{}", show(&r)); + assert!(r.stderr.contains("refused by name"), "{}", show(&r)); +} diff --git a/crates/aprender-contracts-cli/tests/ont4c_doc_contracts.rs b/crates/aprender-contracts-cli/tests/ont4c_doc_contracts.rs new file mode 100644 index 0000000000..9345ed5a1a --- /dev/null +++ b/crates/aprender-contracts-cli/tests/ont4c_doc_contracts.rs @@ -0,0 +1,462 @@ +//! ONT-4c (PMAT-3847, #3847) — README.md, CLAUDE.md and a CSV as graded documents, on the CLI. +//! +//! ONT-001 v4.16 §5 ONT-4c RED, on `pv lint --gate shapes`: `tests/fixtures/ont/docs-green/` is a whole repo in +//! miniature (a merge-path workflow, a README and a CLAUDE.md with claim fences, a CSV and its producer, and a +//! baseline carrying the measured sets). Every other case is ONE edit to a copy of it, so each RED is caused by +//! that edit and nothing else: +//! +//! - a claim no merge-path step runs is refused by name (README and CLAUDE.md), whatever the fence's spelling of +//! `bash`/`sh`/`shell`, and a step under `if: false` runs nothing; +//! - a path CLAUDE.md names that does not exist is refused by name; +//! - a ragged CSV row is refused naming the file and the row; +//! - F-33: a committed `verified_commands[]` that disagrees with the live set is RED naming the command; +//! - F-34 (git): a verified claim relabelled away with no new `withdrawn[]` entry is RED naming it, and passes +//! with one; a `withdrawn[]` entry for a claim still live is RED; a baseline whose keys are absent at the +//! comparand is checked against ∅ (bootstrap), not exempt; a work tree with no comparand ref is RED, and a +//! corpus outside any work tree is `not-checked`. +//! +//! DISCRIMINATION: the green fixture must PASS with every doc type counted, so a build that refuses everything +//! fails this file; each RED asserts the refusal TEXT, so a build that fails for another reason fails it too. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn pv_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_pv")) +} + +struct Run { + code: i32, + stdout: String, + stderr: String, +} + +fn pv_in(cwd: &Path, args: &[&str]) -> Run { + let out = Command::new(pv_bin()) + .current_dir(cwd) + .args(args) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_INDEX_FILE") + .output() + .expect("failed to spawn pv"); + Run { + code: out.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + } +} + +fn show(r: &Run) -> String { + format!( + "exit {}\n--- stdout\n{}\n--- stderr\n{}", + r.code, r.stdout, r.stderr + ) +} + +fn json_of(r: &Run) -> serde_json::Value { + serde_json::from_str(&r.stdout).unwrap_or_else(|e| panic!("stdout is JSON: {e}\n{}", show(r))) +} + +fn fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/ont/docs-green") +} + +fn copy_tree(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).expect("mkdir"); + for e in std::fs::read_dir(src).expect("read fixture dir") { + let p = e.expect("entry").path(); + let to = dst.join(p.file_name().expect("name")); + if p.is_dir() { + copy_tree(&p, &to); + } else { + std::fs::copy(&p, &to).expect("copy"); + } + } +} + +/// A fresh copy of the green fixture, OUTSIDE any git work tree (tempdir), so F-34 reads `not-checked`. +fn copy() -> tempfile::TempDir { + let tmp = tempfile::tempdir().expect("tempdir"); + copy_tree(&fixture_root(), tmp.path()); + tmp +} + +fn gate_at(repo: &Path, extra: &[&str]) -> Run { + let mut args = vec!["lint", "contracts", "--gate", "shapes", "--format", "json"]; + args.extend_from_slice(extra); + pv_in(repo, &args) +} + +fn append(p: &Path, text: &str) { + let mut s = std::fs::read_to_string(p).expect("read"); + s.push_str(text); + std::fs::write(p, s).expect("write"); +} + +fn replace(p: &Path, from: &str, to: &str) { + let s = std::fs::read_to_string(p).expect("read"); + assert_eq!( + s.matches(from).count(), + 1, + "exactly one `{from}` in {}", + p.display() + ); + std::fs::write(p, s.replacen(from, to, 1)).expect("write"); +} + +fn baseline(repo: &Path, readme: &[&str], readme_w: &[&str], claude: &[&str], claude_w: &[&str]) { + let arr = |xs: &[&str]| serde_json::to_string(xs).expect("json"); + std::fs::write( + repo.join("contracts/lint-baseline.json"), + format!( + "{{\n \"armed_gates\": [\"shapes\"],\n \"armed_shapes\": [\"shape-holder\", \"readme-doc\", \"claude-doc\", \"data-csv\"],\n \"readme\": {{\"verified_commands\":{},\"withdrawn\":{}}},\n \"claude_md\": {{\"verified_commands\":{},\"withdrawn\":{}}}\n}}\n", + arr(readme), + arr(readme_w), + arr(claude), + arr(claude_w) + ), + ) + .expect("baseline"); +} + +fn git(repo: &Path, args: &[&str]) { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_INDEX_FILE") + .output() + .expect("git runs"); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +/// A copy of the green fixture committed as the comparand. +fn committed_copy() -> tempfile::TempDir { + let tmp = copy(); + let repo = tmp.path(); + git(repo, &["init", "-q", "-b", "main"]); + git(repo, &["config", "user.email", "t@example.com"]); + git(repo, &["config", "user.name", "t"]); + git(repo, &["add", "-A"]); + git(repo, &["commit", "-q", "-m", "comparand"]); + tmp +} + +fn strs(v: &serde_json::Value) -> Vec<&str> { + v.as_array() + .map(|a| a.iter().filter_map(serde_json::Value::as_str).collect()) + .unwrap_or_default() +} + +const CHECK: &str = "bash scripts/check.sh"; +const TEST: &str = "cargo test --lib"; + +#[test] +fn the_green_fixture_passes_with_every_doc_type_counted_and_the_sets_reported() { + let t = copy(); + let r = gate_at(t.path(), &[]); + assert_eq!(r.code, 0, "{}", show(&r)); + let v = json_of(&r); + assert_eq!(v["verdict"], "Pass", "{}", show(&r)); + for ty in ["readme", "llm-context", "csv"] { + assert_eq!(v["by_entity_type"][ty], 1, "{ty}\n{}", show(&r)); + assert_eq!(v["pc_extract"][ty], "fired", "{ty}\n{}", show(&r)); + } + assert_eq!(strs(&v["readme"]["verified_commands"]), [CHECK, TEST]); + assert_eq!(strs(&v["claude_md"]["verified_commands"]), [TEST]); + assert_eq!( + v["ratchets"]["measured_sets"], + "not-checked", + "outside a git work tree there is nothing to compare\n{}", + show(&r) + ); +} + +#[test] +fn readme_bad_command_is_refused_by_name() { + let t = copy(); + append(&t.path().join("README.md"), "\n```bash\nmake ghost\n```\n"); + let r = gate_at(t.path(), &[]); + assert_eq!(r.code, 1, "{}", show(&r)); + assert!( + r.stdout + .contains("readme:verifiedCommand unresolved: make ghost"), + "{}", + show(&r) + ); +} + +#[test] +fn readme_tag_variants_are_claims_and_console_is_not() { + for (open, close, claim) in [ + ("```{.Bash}", "```", true), + ("~~~ shell", "~~~", true), + ("```SH title=x", "```", true), + ("```console", "```", false), + ("```text", "```", false), + ] { + let t = copy(); + append( + &t.path().join("README.md"), + &format!("\n{open}\nmake ghost\n{close}\n"), + ); + let r = gate_at(t.path(), &[]); + let refused = r + .stdout + .contains("readme:verifiedCommand unresolved: make ghost"); + assert_eq!(refused, claim, "{open}\n{}", show(&r)); + assert_eq!(r.code, if claim { 1 } else { 0 }, "{open}\n{}", show(&r)); + } +} + +#[test] +fn a_step_under_if_false_runs_nothing_so_its_command_is_not_a_verified_claim() { + let t = copy(); + append( + &t.path().join("README.md"), + "\n```bash\nmake disabled-step\n```\n", + ); + let r = gate_at(t.path(), &[]); + assert_eq!(r.code, 1, "{}", show(&r)); + assert!( + r.stdout + .contains("readme:verifiedCommand unresolved: make disabled-step"), + "{}", + show(&r) + ); +} + +#[test] +fn claude_md_bad_command_and_bad_path_are_refused_by_name() { + let t = copy(); + append( + &t.path().join("CLAUDE.md"), + "\nSee `no/such/dir/`.\n\n```shell\nmake nope\n```\n", + ); + let r = gate_at(t.path(), &[]); + assert_eq!(r.code, 1, "{}", show(&r)); + assert!( + r.stdout.contains("llm:command unresolved: make nope"), + "{}", + show(&r) + ); + assert!( + r.stdout + .contains("llm:referencedPath unresolved: no/such/dir/"), + "{}", + show(&r) + ); +} + +#[test] +fn claude_md_without_its_purpose_section_fails_the_shape() { + let t = copy(); + replace( + &t.path().join("CLAUDE.md"), + "## Project Overview", + "## Overview", + ); + let r = gate_at(t.path(), &[]); + assert_eq!(r.code, 1, "{}", show(&r)); + assert!(r.stdout.contains("purposeSection"), "{}", show(&r)); +} + +#[test] +fn csv_column_mismatch_is_refused_naming_file_and_row() { + let t = copy(); + append(&t.path().join("data/train.csv"), "1,2,3,4\n"); + let r = gate_at(t.path(), &[]); + assert_eq!(r.code, 1, "{}", show(&r)); + assert!(r.stdout.contains("data/train.csv"), "{}", show(&r)); + assert!( + r.stdout.contains("row 4 has 4 column(s), the header has 3"), + "{}", + show(&r) + ); +} + +#[test] +fn f33_a_committed_set_that_disagrees_with_the_live_one_is_red_naming_the_command() { + let t = copy(); + baseline(t.path(), &[TEST], &[], &[TEST], &[]); + let r = gate_at(t.path(), &[]); + assert_eq!(r.code, 1, "{}", show(&r)); + assert!(r.stdout.contains("PV-ONT-013"), "{}", show(&r)); + assert!( + r.stdout + .contains(&format!("lacks the live claim `{CHECK}`")), + "{}", + show(&r) + ); +} + +#[test] +fn f34_relabel_drops_claim_without_withdrawn_is_red_and_with_it_passes() { + let t = committed_copy(); + let repo = t.path(); + replace( + &repo.join("README.md"), + "```bash\nbash scripts/check.sh", + "```text\nbash scripts/check.sh", + ); + // restamped verified_commands (F-33 green), but no withdrawal + baseline(repo, &[TEST], &[], &[TEST], &[]); + let r = gate_at(repo, &["--armed-baseline-ref", "HEAD"]); + assert_eq!(r.code, 1, "{}", show(&r)); + let v = json_of(&r); + assert_eq!(v["ratchets"]["measured_sets"], "checked", "{}", show(&r)); + assert!(r.stdout.contains("PV-ONT-014"), "{}", show(&r)); + assert!( + r.stdout + .contains(&format!("verified claim `{CHECK}` was dropped")), + "{}", + show(&r) + ); + // the same drop, recorded + baseline(repo, &[TEST], &[CHECK], &[TEST], &[]); + let r = gate_at(repo, &["--armed-baseline-ref", "HEAD"]); + assert_eq!(r.code, 0, "{}", show(&r)); + assert_eq!(json_of(&r)["ratchets"]["measured_sets"], "checked"); +} + +#[test] +fn f34_withdrawal_without_drop_is_red_naming_it() { + let t = committed_copy(); + let repo = t.path(); + baseline(repo, &[CHECK, TEST], &[TEST], &[TEST], &[]); + let r = gate_at(repo, &["--armed-baseline-ref", "HEAD"]); + assert_eq!(r.code, 1, "{}", show(&r)); + assert!( + r.stdout.contains(&format!( + "withdrawn[] names `{TEST}`, which was not dropped" + )), + "{}", + show(&r) + ); +} + +#[test] +fn f34_ratchet_bootstrap_key_absent_at_the_comparand_is_checked_against_the_empty_set() { + let t = copy(); + let repo = t.path(); + // the comparand predates the keys + std::fs::write( + repo.join("contracts/lint-baseline.json"), + "{\n \"armed_gates\": [\"shapes\"],\n \"armed_shapes\": [\"shape-holder\", \"readme-doc\", \"claude-doc\", \"data-csv\"]\n}\n", + ) + .expect("baseline"); + git(repo, &["init", "-q", "-b", "main"]); + git(repo, &["config", "user.email", "t@example.com"]); + git(repo, &["config", "user.name", "t"]); + git(repo, &["add", "-A"]); + git(repo, &["commit", "-q", "-m", "comparand without the keys"]); + baseline(repo, &[CHECK, TEST], &[], &[TEST], &[]); + let r = gate_at(repo, &["--armed-baseline-ref", "HEAD"]); + assert_eq!(r.code, 0, "{}", show(&r)); + assert_eq!(json_of(&r)["ratchets"]["measured_sets"], "checked"); + // a withdrawal at bootstrap names a claim that was never in set(comparand) = ∅ + baseline(repo, &[CHECK, TEST], &["make gone"], &[TEST], &[]); + let r = gate_at(repo, &["--armed-baseline-ref", "HEAD"]); + assert_eq!(r.code, 1, "{}", show(&r)); + assert!( + r.stdout + .contains("withdrawn[] names `make gone`, which was not dropped"), + "{}", + show(&r) + ); +} + +#[test] +fn f34_a_work_tree_with_no_comparand_ref_is_red_not_unchecked() { + let t = committed_copy(); + // no origin/main, no explicit ref + let r = gate_at(t.path(), &[]); + assert_eq!(r.code, 1, "{}", show(&r)); + assert_eq!(json_of(&r)["ratchets"]["measured_sets"], "checked"); + assert!( + r.stdout.contains("no comparand in a git work tree"), + "{}", + show(&r) + ); +} + +// ── Σ `entity_type_target_class` is cross-checked, not hand-kept (v4.16 D-T1) ────────────────────────────────── + +fn sigma_at(repo: &Path) -> Run { + pv_in( + repo, + &["lint", "contracts", "--gate", "sigma", "--format", "json"], + ) +} + +const SIGMA: &str = "contracts/ontology.yaml"; + +#[test] +fn sigma_passes_the_green_fixture_whose_map_covers_every_implemented_type() { + let t = copy(); + let r = sigma_at(t.path()); + assert_eq!(r.code, 0, "{}", show(&r)); +} + +#[test] +fn sigma_refuses_a_target_class_key_that_no_entity_type_declares() { + let t = copy(); + replace( + &t.path().join(SIGMA), + " gguf: model:Model\n", + " gguf: model:Model\n ghost-type: ghost:Thing\n", + ); + let r = sigma_at(t.path()); + assert_eq!(r.code, 3, "{}", show(&r)); + assert!(r.stderr.starts_with("error: "), "{}", show(&r)); + assert!( + r.stderr + .contains("entity_type_target_class key `ghost-type`"), + "{}", + show(&r) + ); +} + +#[test] +fn sigma_refuses_an_implemented_type_with_neither_a_mapping_nor_an_explicit_unmapped_entry() { + let t = copy(); + replace(&t.path().join(SIGMA), " json: ~\n", ""); + let r = sigma_at(t.path()); + assert_eq!(r.code, 3, "{}", show(&r)); + assert!( + r.stderr.contains("implemented entity_type `json`") + && r.stderr.contains("neither a class nor an explicit `~`"), + "{}", + show(&r) + ); +} + +/// v4.16 §5 ONT-4c Mutation, verbatim: "remove an `entity.type` from Σ `entity_type_target_class` → the shape +/// naming that type exits 3 malformed, named". Both readers see it: the shapes gate names the SHAPE, and the Σ +/// gate names the TYPE (the map no longer covers an implemented one). +#[test] +fn removing_an_entity_type_from_the_target_class_map_exits_3_naming_the_shape_and_the_type() { + let t = copy(); + replace(&t.path().join(SIGMA), " readme: readme:Readme\n", ""); + let r = gate_at(t.path(), &[]); + assert_eq!(r.code, 3, "{}", show(&r)); + assert!( + r.stderr.contains("readme-doc") && r.stderr.contains("no targetClass"), + "{}", + show(&r) + ); + let s = sigma_at(t.path()); + assert_eq!(s.code, 3, "{}", show(&s)); + assert!( + s.stderr.contains("implemented entity_type `readme`"), + "{}", + show(&s) + ); +} diff --git a/crates/aprender-contracts-cli/tests/ont6_lint_verdict.rs b/crates/aprender-contracts-cli/tests/ont6_lint_verdict.rs index a3388229d1..17c2d1a9b7 100644 --- a/crates/aprender-contracts-cli/tests/ont6_lint_verdict.rs +++ b/crates/aprender-contracts-cli/tests/ont6_lint_verdict.rs @@ -47,9 +47,27 @@ fn repo_contracts() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts") } +struct Corpus { + dir: PathBuf, + _root: tempfile::TempDir, +} + +impl Corpus { + fn path(&self) -> &Path { + &self.dir + } +} + /// A corpus directory holding the PVL-1 control contract, and optionally a `lint-baseline.json`. -fn corpus(baseline: Option<&str>) -> tempfile::TempDir { - let d = tempfile::tempdir().expect("corpus dir is creatable"); +/// Nested one level inside a private tempdir: `pv lint` takes the dir's PARENT as the +/// project root, and a bare tempdir's parent is the shared `/tmp` (#4207). +fn corpus(baseline: Option<&str>) -> Corpus { + let root = tempfile::tempdir().expect("corpus dir is creatable"); + let d = Corpus { + dir: root.path().join("contracts"), + _root: root, + }; + std::fs::create_dir(d.path()).expect("nested corpus dir is creatable"); std::fs::copy( repo_contracts().join("softmax-kernel-v1.yaml"), d.path().join("softmax-kernel-v1.yaml"), @@ -109,12 +127,9 @@ fn explicit_empty_armed_set_declines_r2() { #[test] fn a_failing_armed_gate_rejects_at_exit_1() { - let d = tempfile::tempdir().expect("corpus dir"); - std::fs::copy( - repo_contracts().join("softmax-kernel-v1.yaml"), - d.path().join("softmax-kernel-v1.yaml"), - ) - .expect("control copies"); + // Through `corpus()`, not a bare tempdir: on a host with a stray `/tmp/scripts/` + // baseline, PV-DUP-002 would also reject, and this test could pass for the wrong reason. + let d = corpus(None); std::fs::write( d.path().join("broken-v1.yaml"), "metadata:\n version: not-a-contract\n", @@ -265,6 +280,9 @@ fn json_report_carries_the_lattice() { serde_json::Value::String("shapes".into()), // ONT-7's gate: same R-8 shape. serde_json::Value::String("valid-under".into()), + // PVL-001 EV-11's two ratchets: same R-8 shape. + serde_json::Value::String("theorem-pairing".into()), + serde_json::Value::String("depends-on-present".into()), ]), "{}", show(&r) diff --git a/crates/aprender-contracts-cli/tests/ont_release_readiness.rs b/crates/aprender-contracts-cli/tests/ont_release_readiness.rs index 7f60586a5b..94d2755ccb 100644 --- a/crates/aprender-contracts-cli/tests/ont_release_readiness.rs +++ b/crates/aprender-contracts-cli/tests/ont_release_readiness.rs @@ -1,22 +1,25 @@ -//! aprender#3715 — `release-readiness-v1` on the CLI: the red-turning case table. +//! aprender#3715 + #3745 S2 (#3777) — `release-readiness-v1` on the CLI: the red-turning case table over DERIVED +//! cells. //! -//! Every case copies `tests/fixtures/ont/release-green/` AND the real `contracts/release-readiness-v1.yaml` (so the -//! fixture cannot drift from the shapes it proves) into a scratch repo, changes exactly ONE thing, and runs -//! `pv lint contracts --gate shapes --shape release-readiness-v1 --release-version 0.69.1 --release-commit MC …`. +//! Every case copies `tests/fixtures/ont/release-green/` (a measured inventory, a GENERIC surface — `gen` generates, +//! `look` reads a model, `list` takes none; not apr verbs, so S3's hand-list guard has nothing to find) and the +//! real `contracts/release-readiness-v1.yaml` into a scratch repo. The green base then asks pv for the cells it +//! derives (`pv extract … --surface --cells-out`) and writes one passing row per cell, keyed by `cell_id` — so the +//! fixture cannot drift from the derivation — and each case changes exactly ONE thing. //! -//! DISCRIMINATION: the green base must PASS with every cell named (48/48, 4 kernel cells) — so a build that -//! rejects everything fails this file — and each single-cell change must FAIL naming that cell — so a build that -//! accepts everything fails it too. The ONT-001 rule: a shape that has never gone red is not armed. +//! DISCRIMINATION: the green base must PASS with every derived cell carrying a row, so a build that rejects +//! everything fails this file; each single change must FAIL naming what it broke, so a build that accepts +//! everything fails it too. The ONT-001 rule: a shape that has never gone red is not armed. use std::path::{Path, PathBuf}; use std::process::Command; -use serde_json::Value; +use serde_json::{Map, Value}; const MC: &str = "1111111111111111111111111111111111111111"; const PARENT: &str = "3333333333333333333333333333333333333333"; const BUMP: &str = "2222222222222222222222222222222222222222"; -const MODEL: &str = "tiny-qwen35.gguf"; +const GB: u64 = 1_000_000_000; struct Run { code: i32, @@ -27,7 +30,9 @@ struct Run { fn show(r: &Run) -> String { format!( "exit {}\n--- stdout\n{}\n--- stderr\n{}", - r.code, r.stdout, r.stderr + r.code, + r.stdout.chars().take(6000).collect::(), + r.stderr ) } @@ -48,21 +53,6 @@ fn copy_dir(from: &Path, to: &Path) { } } -/// The green base: the fixture plus the REAL shape contract. -fn green() -> tempfile::TempDir { - let t = tempfile::tempdir().expect("tempdir"); - copy_dir( - &repo_root().join("tests/fixtures/ont/release-green"), - t.path(), - ); - std::fs::copy( - repo_root().join("contracts/release-readiness-v1.yaml"), - t.path().join("contracts/release-readiness-v1.yaml"), - ) - .expect("copy the real contract"); - t -} - fn pv(cwd: &Path, args: &[&str]) -> Run { let out = Command::new(env!("CARGO_BIN_EXE_pv")) .current_dir(cwd) @@ -80,35 +70,12 @@ fn s(p: &Path) -> String { p.to_str().expect("utf-8 path").to_string() } -/// The release gate as T-1 calls it, plus `extra` flags. -fn gate(t: &Path, extra: &[&str]) -> Run { - let contracts = s(&t.join("contracts")); - let dogfood = s(&t.join("dogfood-receipt.json")); - let mut args = vec![ - "lint", - &contracts, - "--gate", - "shapes", - "--shape", - "release-readiness-v1", - "--release-version", - "0.69.1", - "--release-commit", - MC, - "--dogfood-receipt", - &dogfood, - ]; - args.extend_from_slice(extra); - pv(t, &args) -} - -fn json_of(r: &Run) -> Value { - serde_json::from_str(&r.stdout).unwrap_or_else(|e| panic!("stdout is JSON: {e}\n{}", show(r))) +fn read(path: &Path) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json") } fn edit(path: &Path, f: impl FnOnce(&mut Value)) { - let mut v: Value = - serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json"); + let mut v = read(path); f(&mut v); std::fs::write(path, serde_json::to_string_pretty(&v).expect("ser")).expect("write"); } @@ -121,23 +88,218 @@ fn kernels(t: &Path, host: &str) -> PathBuf { t.join(format!("evidence/dogfood/kernels/0.69.1/{host}.json")) } -/// Index of the one row for (verb, thinking, rung) in a model receipt. -fn row_index(v: &Value, verb: &str, thinking: &str, rung: &str) -> usize { - v["cells"] +fn tokenizer(t: &Path) -> PathBuf { + t.join("evidence/dogfood/tokenizer/0.69.1/parity.json") +} + +fn json_of(r: &Run) -> Value { + serde_json::from_str(&r.stdout).unwrap_or_else(|e| panic!("stdout is JSON: {e}\n{}", show(r))) +} + +/// The cells pv derives for the scratch repo, written to `/cells.json` and returned. +fn derive(t: &Path) -> Vec { + let contracts = s(&t.join("contracts")); + let surface = s(&t.join("surface.json")); + let out = s(&t.join("release.nt")); + let cells = s(&t.join("cells.json")); + let r = pv( + t, + &[ + "extract", + &contracts, + "--release-version", + "0.69.1", + "--release-commit", + MC, + "--surface", + &surface, + "--out", + &out, + "--cells-out", + &cells, + ], + ); + assert_eq!(r.code, 0, "{}", show(&r)); + read(&t.join("cells.json"))["cells"] + .as_array() + .expect("cells") + .clone() +} + +/// One PASSING row for a derived cell — whatever its class needs to pass, and nothing it does not. +fn pass_row(c: &Value) -> Value { + let mut r = Map::new(); + r.insert("cell_id".into(), c["id"].clone()); + r.insert("verdict".into(), "pass".into()); + r.insert("answer_chars".into(), 42.into()); + r.insert("rc".into(), 0.into()); + r.insert("wall_ms".into(), 1500.into()); + let generating = c["generates"] == true && c["kind"] == "matrix"; + r.insert( + "backend".into(), + if generating { "cuda" } else { "cpu" }.into(), + ); + r.insert("fallback".into(), false.into()); + if let Some(t) = c["rung_tokens"].as_u64() { + let declared = c["rung"] == "declared"; + r.insert("max_tokens".into(), 256.into()); + let p = if declared { + t.saturating_sub(256) + } else { + t + 1 + }; + r.insert("prompt_tokens".into(), p.into()); + } + if c["thinking"] == "on" { + r.insert("think_closed".into(), true.into()); + } + if generating { + r.insert("f2_source".into(), "fresh".into()); + } + if matches!(c["kind"].as_str(), Some("base" | "effect")) { + let id = c["id"].as_str().unwrap_or_default(); + r.insert("output_sha256".into(), format!("out:{id}").into()); + } + // a8's controls, coherent: T 0 and top-k 1 are the same greedy bytes; seed A twice is identical; seed B differs + if c["kind"] == "sampling" { + let out = match c["control"].as_str() { + Some("t0" | "topk1") => "greedy", + Some("seed-a" | "seed-a-again") => "sampled-a", + _ => "sampled-b", + }; + r.insert("output_sha256".into(), out.into()); + } + Value::Object(r) +} + +/// The green base: the fixture, the REAL contract, and one passing row per derived cell. +fn green() -> tempfile::TempDir { + let t = tempfile::tempdir().expect("tempdir"); + copy_dir( + &repo_root().join("tests/fixtures/ont/release-green"), + t.path(), + ); + std::fs::copy( + repo_root().join("contracts/release-readiness-v1.yaml"), + t.path().join("contracts/release-readiness-v1.yaml"), + ) + .expect("copy the real contract"); + synthesize(t.path()); + t +} + +/// (Re)write every host's rows from the cells pv derives now. +fn synthesize(t: &Path) { + let cells = derive(t); + for h in ["lambda", "gx10"] { + let rows: Vec = cells + .iter() + .filter(|c| c["host"] == h) + .map(pass_row) + .collect(); + edit(&models(t, h), |v| v["cells"] = Value::Array(rows)); + let mut keys: Vec = cells + .iter() + .filter(|c| c["host"] == h && c["command"] == "gen" && c["kind"] == "matrix") + .map(|c| { + format!( + r#"{{"key":{{"model_sha256":{},"host":"{h}","verb":"gen","thinking":{},"rung":{},"prompt_id":"p1"}},"verdict":"GREEN"}}"#, + c["model_sha256"], c["thinking"], c["rung"] + ) + }) + .collect(); + keys.sort(); + keys.dedup(); + // the LAST row is the set's positive control (#3739): one per model per host + if let Some(last) = keys.last_mut() { + *last = last.replace( + r#""verdict":"GREEN""#, + r#""verdict":"GREEN","positive_control":true"#, + ); + } + let crux_dir = t.join("evidence/crux/0.69.1"); + std::fs::create_dir_all(&crux_dir).expect("crux dir"); + let receipt = format!( + r#"{{"schema":"crux-inference-receipt/v1","version":"0.69.1","host":"{h}","apr":{{"version_line":"apr 0.69.1 (111111111)"}},"cells":[{}]}}"#, + keys.join(",") + ); + std::fs::write(crux_dir.join(format!("{h}-gpu.json")), receipt).expect("crux receipt"); + } +} + +/// The id of the first derived cell `pred` accepts. +fn pick(t: &Path, pred: impl Fn(&Value) -> bool) -> String { + read(&t.join("cells.json"))["cells"] .as_array() .expect("cells") .iter() - .position(|c| c["verb"] == verb && c["thinking"] == thinking && c["context"] == rung) - .unwrap_or_else(|| panic!("no row {verb}/{thinking}/{rung}")) + .find(|c| pred(c)) + .and_then(|c| c["id"].as_str()) + .expect("a derived cell matches") + .to_string() +} + +/// The IRI tail a finding names for a cell id: each segment percent-encoded as the graph does. +fn named(id: &str) -> String { + let enc = |seg: &str| { + seg.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b':' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect::() + }; + let segs: Vec = id.split('/').map(enc).collect(); + format!("release-cell/0.69.1/{}", segs.join("/")) +} + +/// Edit the one row keyed to `id` on `host`. +fn edit_row(t: &Path, host: &str, id: &str, f: impl FnOnce(&mut Value)) { + edit(&models(t, host), |v| { + let row = v["cells"] + .as_array_mut() + .expect("cells") + .iter_mut() + .find(|r| r["cell_id"] == id) + .expect("the row exists"); + f(row); + }); } -fn row<'a>(v: &'a mut Value, verb: &str, thinking: &str, rung: &str) -> &'a mut Value { - let i = row_index(v, verb, thinking, rung); - &mut v["cells"][i] +fn drop_row(t: &Path, host: &str, id: &str) { + edit(&models(t, host), |v| { + v["cells"] + .as_array_mut() + .expect("cells") + .retain(|r| r["cell_id"] != id); + }); } -fn cell(host: &str, verb: &str, thinking: &str, rung: &str) -> String { - format!("release-cell/0.69.1/{host}/{MODEL}/{verb}/think-{thinking}/{rung}") +/// The release gate as T-1 calls it, plus `extra` flags. +fn gate(t: &Path, extra: &[&str]) -> Run { + let contracts = s(&t.join("contracts")); + let dogfood = s(&t.join("dogfood-receipt.json")); + let surface = s(&t.join("surface.json")); + let mut args = vec![ + "lint", + &contracts, + "--gate", + "shapes", + "--shape", + "release-readiness-v1", + "--release-version", + "0.69.1", + "--release-commit", + MC, + "--dogfood-receipt", + &dogfood, + "--surface", + &surface, + ]; + args.extend_from_slice(extra); + pv(t, &args) } /// RED, and the findings name `needle`. @@ -154,13 +316,29 @@ fn assert_red_naming(r: &Run, needle: &str) -> Value { assert!( msgs.iter().any(|m| m.contains(needle)), "no finding names {needle}:\n{}", - msgs.join("\n") + msgs.iter().take(40).copied().collect::>().join("\n") ); v } +fn gen_cell( + host: &'static str, + thinking: &'static str, + rung: &'static str, +) -> impl Fn(&Value) -> bool { + move |c| { + c["command"] == "gen" + && c["kind"] == "matrix" + && c["host"] == host + && c["thinking"] == thinking + && c["rung"] == rung + } +} + +// ── the green base ─────────────────────────────────────────────────────────────────────────────────────── + #[test] -fn the_green_release_passes_with_every_cell_named() { +fn the_green_release_passes_with_every_derived_cell_carrying_a_row() { let t = green(); let r = gate(t.path(), &[]); assert_eq!(r.code, 0, "{}", show(&r)); @@ -170,96 +348,96 @@ fn the_green_release_passes_with_every_cell_named() { v["pc_shape"], "fired", "the plant fired from the named family" ); + assert_eq!(v["pc_extract"]["release-evidence"], "fired"); + assert_eq!(v["pc_extract"]["cli-surface"], "fired"); + let rel = &v["release"]; + let derived = read(&t.path().join("cells.json"))["cells"] + .as_array() + .map(Vec::len); assert_eq!( - v["pc_extract"]["release-evidence"], "fired", - "the extractor's own control (PMAT-3704)" + rel["cells"].as_u64().map(|n| n as usize), + derived, + "N/N: every derived cell is a node" ); - let rel = &v["release"]; + assert_eq!(rel["cells_with_row"], rel["cells"]); + assert_eq!(rel["orphan_rows"], 0); assert_eq!( - rel["cells"], 48, - "2 hosts × 4 verbs × 2 thinking × (4k, consumer-max, declared)" + rel["probe_cells"], 2, + "`list` takes no model: one probe per host" ); - assert_eq!(rel["cells_with_row"], 48); - assert_eq!(rel["cell_names"].as_array().map(Vec::len), Some(48)); + // `--shape` arms the whole family of sixteen whatever armed_shapes says. The green release has no cell that + // fails to fit, so `.refusal` grades zero focus nodes: it declares `allowEmpty`, so the verdict stands, and + // like every vacuity it is named in `declines` and in neither shape list (#3610). + let armed = v["armed_shapes"].as_array().expect("armed").len(); assert_eq!( - rel["kernel_cells"], 4, - "(q4k_gemv@q4_k, rmsnorm@f32) × 2 hosts" + armed, 15, + "sixteen armed, less the one that measured nothing" ); - assert_eq!(rel["orphan_rows"], 0); assert_eq!( - rel["tokenizer_cells"], 1, - "one distinct model across both hosts (#3726)" + names(&v["declines"]), + vec!["release-readiness-v1.refusal"] as Vec<&str> ); - let armed = v["armed_shapes"].as_array().expect("armed").len(); + assert!( + rel["mode_effects"].as_u64() >= Some(1), + "`gen --chat` is judged by the flag-effect oracle" + ); + assert_eq!(rel["surface"]["generating_commands"], 1); + assert_eq!(rel["kernel_cells"], 4); + assert_eq!(rel["tokenizer_cells"], 1); assert_eq!( - armed, 9, - "--shape arms the whole family whatever armed_shapes says" + rel["sampling_checks"], 2, + "gen's typed sampling args, one model arch, two hosts" ); -} - -#[test] -fn a_missing_cell_is_red_naming_it() { - let t = green(); - edit(&models(t.path(), "lambda"), |v| { - let i = row_index(v, "chat", "on", "consumer-max"); - v["cells"].as_array_mut().expect("cells").remove(i); - }); - assert_red_naming( - &gate(t.path(), &[]), - &cell("lambda", "chat", "on", "consumer-max"), + assert_eq!(rel["crux"]["verbs"], 3, "every derived leaf verb"); + assert_eq!(rel["crux"]["mapped_verbs"], 1, "only gen has a counterpart"); + assert!(rel["crux"]["obligations"].as_u64() > Some(0)); + let lambda = &rel["projection"]["lambda"]; + assert_eq!( + lambda["unmeasured_cells"], 0, + "every class has a measured median" ); + assert!(lambda["projected_secs"].as_u64() > Some(0), "{lambda}"); } -#[test] -fn a_skipped_cell_is_red_naming_it() { - let t = green(); - edit(&models(t.path(), "gx10"), |v| { - row(v, "serve", "off", "4k")["verdict"] = "skip".into(); - }); - assert_red_naming(&gate(t.path(), &[]), &cell("gx10", "serve", "off", "4k")); -} +// ── #3715's cases, on derived cells ────────────────────────────────────────────────────────────────────── #[test] -fn a_fallback_cell_is_red_naming_it() { +fn a_missing_cell_is_red_naming_it() { let t = green(); - edit(&models(t.path(), "lambda"), |v| { - row(v, "run", "off", "4k")["fallback"] = true.into(); - }); - assert_red_naming(&gate(t.path(), &[]), &cell("lambda", "run", "off", "4k")); + let id = pick(t.path(), gen_cell("lambda", "on", "consumer-max")); + drop_row(t.path(), "lambda", &id); + assert_red_naming(&gate(t.path(), &[]), &named(&id)); } #[test] -fn a_cell_that_does_not_say_whether_it_fell_back_is_red() { - let t = green(); - edit(&models(t.path(), "lambda"), |v| { - row(v, "code", "off", "4k") - .as_object_mut() - .expect("row") - .remove("fallback"); - }); - assert_red_naming(&gate(t.path(), &[]), &cell("lambda", "code", "off", "4k")); +fn a_skipped_a_fallback_or_a_silent_fallback_cell_is_red_naming_it() { + for change in ["skip", "fallback", "silent"] { + let t = green(); + let id = pick(t.path(), gen_cell("gx10", "off", "4k")); + edit_row(t.path(), "gx10", &id, |r| match change { + "skip" => r["verdict"] = "skip".into(), + "fallback" => r["fallback"] = true.into(), + _ => { + r.as_object_mut().expect("row").remove("fallback"); + } + }); + assert_red_naming(&gate(t.path(), &[]), &named(&id)); + } } #[test] -fn a_receipt_measured_at_another_commit_is_stale_and_red() { +fn a_receipt_of_another_commit_or_version_is_stale_and_red() { let t = green(); edit(&models(t.path(), "lambda"), |v| { v["apr_sha"] = PARENT.into() }); let v = assert_red_naming(&gate(t.path(), &[]), "release-host/0.69.1/lambda"); - let msgs = v["findings"].to_string(); - assert!( - msgs.contains(&cell("lambda", "run", "off", "4k")), - "every lambda cell is stale" - ); assert!( - !msgs.contains("release-host/0.69.1/gx10"), + !v["findings"] + .to_string() + .contains("release-host/0.69.1/gx10"), "gx10 is untouched" ); -} - -#[test] -fn a_receipt_for_another_version_is_stale_and_red() { let t = green(); edit(&models(t.path(), "gx10"), |v| { v["version"] = "0.69.0".into() @@ -270,56 +448,25 @@ fn a_receipt_for_another_version_is_stale_and_red() { #[test] fn a_think_on_row_with_an_unclosed_block_and_an_empty_answer_is_red() { let t = green(); - edit(&models(t.path(), "lambda"), |v| { - let r = row(v, "chat", "on", "4k"); + let id = pick(t.path(), gen_cell("lambda", "on", "4k")); + edit_row(t.path(), "lambda", &id, |r| { r["think_closed"] = false.into(); r["answer_chars"] = 0.into(); }); - let v = assert_red_naming(&gate(t.path(), &[]), &cell("lambda", "chat", "on", "4k")); + let v = assert_red_naming(&gate(t.path(), &[]), &named(&id)); let msgs = v["findings"].to_string(); - assert!(msgs.contains("thinkOk"), "names the unclosed block: {msgs}"); - assert!(msgs.contains("answered"), "names the empty answer: {msgs}"); -} - -#[test] -fn a_missing_think_on_cell_is_red_naming_it() { - let t = green(); - edit(&models(t.path(), "gx10"), |v| { - let i = row_index(v, "code", "on", "declared"); - v["cells"].as_array_mut().expect("cells").remove(i); - }); - assert_red_naming( - &gate(t.path(), &[]), - &cell("gx10", "code", "on", "declared"), + assert!( + msgs.contains("thinkOk") && msgs.contains("answered"), + "{msgs}" ); } #[test] -fn an_absent_declared_length_rung_is_red_and_so_is_an_unmeasured_length() { - let t = green(); - edit(&models(t.path(), "lambda"), |v| { - let i = row_index(v, "run", "off", "declared"); - v["cells"].as_array_mut().expect("cells").remove(i); - }); - assert_red_naming( - &gate(t.path(), &[]), - &cell("lambda", "run", "off", "declared"), - ); - - // the length is a property of the FILE (one sha256): gx10 dropping it leaves lambda's measurement standing, - // but gx10's own declared cells have no size to fill and go red … +fn a_declared_length_cell_missing_or_an_unmeasured_length_is_red() { let t = green(); - edit(&models(t.path(), "gx10"), |v| { - v["inventory"][0] - .as_object_mut() - .expect("item") - .remove("context_length"); - }); - assert_red_naming( - &gate(t.path(), &[]), - &cell("gx10", "run", "off", "declared"), - ); - // … and with no host measuring it, the model itself is named + let id = pick(t.path(), gen_cell("lambda", "off", "declared")); + drop_row(t.path(), "lambda", &id); + assert_red_naming(&gate(t.path(), &[]), &named(&id)); let t = green(); for h in ["lambda", "gx10"] { edit(&models(t.path(), h), |v| { @@ -333,73 +480,50 @@ fn an_absent_declared_length_rung_is_red_and_so_is_an_unmeasured_length() { } #[test] -fn two_hosts_that_disagree_on_one_files_length_are_red() { +fn a_prompt_short_of_its_rung_or_a_duplicate_row_is_red() { let t = green(); - edit(&models(t.path(), "gx10"), |v| { - v["inventory"][0]["context_length"] = 4096.into(); + let id = pick(t.path(), gen_cell("lambda", "on", "consumer-max")); + edit_row(t.path(), "lambda", &id, |r| { + r["prompt_tokens"] = 1000.into() }); - let v = assert_red_naming(&gate(t.path(), &[]), "contextLength"); - assert!(v["findings"].to_string().contains("maxCount")); -} - -#[test] -fn a_prompt_short_of_its_rung_is_red() { - let t = green(); - edit(&models(t.path(), "lambda"), |v| { - row(v, "serve", "on", "consumer-max")["prompt_tokens"] = 1000.into(); - }); - let v = assert_red_naming( - &gate(t.path(), &[]), - &cell("lambda", "serve", "on", "consumer-max"), - ); + let v = assert_red_naming(&gate(t.path(), &[]), &named(&id)); assert!(v["findings"].to_string().contains("contextMet")); -} - -#[test] -fn two_rows_for_one_cell_are_ambiguous_and_red() { let t = green(); + let id = pick(t.path(), gen_cell("lambda", "off", "4k")); edit(&models(t.path(), "lambda"), |v| { - let dup = row(v, "run", "on", "4k").clone(); - v["cells"].as_array_mut().expect("cells").push(dup); + let cells = v["cells"].as_array_mut().expect("cells"); + let dup = cells + .iter() + .find(|r| r["cell_id"] == id.as_str()) + .cloned() + .expect("row"); + cells.push(dup); }); - let v = assert_red_naming(&gate(t.path(), &[]), &cell("lambda", "run", "on", "4k")); + let v = assert_red_naming(&gate(t.path(), &[]), &named(&id)); assert!(v["findings"].to_string().contains("maxCount")); } #[test] -fn a_kernel_green_on_sm_121_and_red_on_sm_89_is_named_on_lambda_before_any_model_cell() { +fn a_kernel_green_on_sm_121_and_red_on_sm_89_is_named_before_any_cell() { let t = green(); edit(&kernels(t.path(), "lambda"), |v| { v["rows"][0]["verdict"] = "fail".into(); v["rows"][0]["max_err"] = 0.5.into(); }); - // and one model cell too, so the ORDER is observable - edit(&models(t.path(), "lambda"), |v| { - row(v, "run", "off", "4k")["verdict"] = "fail".into(); - }); + let id = pick(t.path(), gen_cell("lambda", "off", "4k")); + edit_row(t.path(), "lambda", &id, |r| r["verdict"] = "fail".into()); let v = assert_red_naming( &gate(t.path(), &[]), "release-kernel/0.69.1/lambda/q4k_gemv/q4_k", ); - let findings = v["findings"].as_array().expect("findings"); - let first = findings[0]["message"].as_str().unwrap_or(""); + let first = v["findings"][0]["message"].as_str().unwrap_or_default(); assert!( first.contains("release-kernel/0.69.1/lambda"), "kernel first: {first}" ); - assert!( - !v["findings"] - .to_string() - .contains("release-kernel/0.69.1/gx10"), - "gx10's kernel is green" - ); -} - -#[test] -fn a_kernel_measured_on_one_arch_only_is_a_missing_row_on_the_other() { let t = green(); edit(&kernels(t.path(), "gx10"), |v| { - v["rows"].as_array_mut().expect("rows").remove(1); // rmsnorm@f32 unmeasured on gx10 + v["rows"].as_array_mut().expect("rows").remove(1); }); assert_red_naming( &gate(t.path(), &[]), @@ -408,22 +532,27 @@ fn a_kernel_measured_on_one_arch_only_is_a_missing_row_on_the_other() { } #[test] -fn a_required_host_with_no_receipt_is_red_naming_the_host() { +fn a_required_host_with_no_receipt_or_a_v1_receipt_is_red_naming_the_host() { let t = green(); std::fs::remove_file(models(t.path(), "gx10")).expect("rm"); assert_red_naming(&gate(t.path(), &[]), "release-host/0.69.1/gx10"); + let t = green(); + edit(&models(t.path(), "gx10"), |v| { + v["schema"] = "apr-model-ladder-receipt/v1".into() + }); + assert_red_naming(&gate(t.path(), &[]), "release-host/0.69.1/gx10"); } #[test] fn an_inherited_dogfood_or_none_at_all_is_red() { let t = green(); edit(&t.path().join("dogfood-receipt.json"), |v| { - v["commit"] = PARENT.into(); + v["commit"] = PARENT.into() }); assert_red_naming(&gate(t.path(), &[]), "release-dogfood/0.69.1"); - let t = green(); let contracts = s(&t.path().join("contracts")); + let surface = s(&t.path().join("surface.json")); let r = pv( t.path(), &[ @@ -437,48 +566,34 @@ fn an_inherited_dogfood_or_none_at_all_is_red() { "0.69.1", "--release-commit", MC, + "--surface", + &surface, ], ); assert_red_naming(&r, "dogfoodReceipt"); } #[test] -fn a_consumer_with_no_recorded_number_is_red_on_the_derived_rung() { +fn a_consumer_with_no_number_or_a_thinking_contradiction_is_red() { let t = green(); edit(&t.path().join("evidence/release/context-rungs.json"), |v| { - v["consumers"].as_array_mut().expect("consumers").push( - serde_json::from_str::( - r#"{"consumer": "pending", "basis": "", "source": "fixture://pending"}"#, - ) - .expect("a consumer record"), - ); + let rec = serde_json::from_str::( + r#"{"consumer":"pending","basis":"","source":"fixture://p"}"#, + ) + .expect("record"); + v["consumers"].as_array_mut().expect("consumers").push(rec); }); assert_red_naming(&gate(t.path(), &[]), "release-context/consumer-max"); -} - -#[test] -fn thinking_that_contradicts_its_template_markers_is_red() { let t = green(); edit(&models(t.path(), "lambda"), |v| { - // off only, though the template carries the `enable_thinking` switch (#3723's derivation says both) v["inventory"][0]["thinking_modes"] = serde_json::from_str::(r#"["off"]"#).expect("modes"); }); assert_red_naming(&gate(t.path(), &[]), "thinkingContradiction"); } -#[test] -fn a_v1_receipt_cannot_stand_for_a_host() { - let t = green(); - edit(&models(t.path(), "gx10"), |v| { - v["schema"] = "apr-model-ladder-receipt/v1".into(); - }); - assert_red_naming(&gate(t.path(), &[]), "release-host/0.69.1/gx10"); -} - #[test] fn t4_receipts_measured_at_the_bump_head_pass_only_with_receipts_commit() { - // T-4: the committed receipts were measured at the bump head; R7 proved it equal to MC modulo evidence/ let t = green(); let other = t.path().join("t4-receipts"); std::fs::create_dir_all(&other).expect("mkdir"); @@ -487,19 +602,171 @@ fn t4_receipts_measured_at_the_bump_head_pass_only_with_receipts_commit() { std::fs::rename(models(t.path(), h), &dst).expect("mv"); edit(&dst, |v| v["apr_sha"] = BUMP.into()); edit(&kernels(t.path(), h), |v| v["apr_sha"] = BUMP.into()); - edit(&tokenizer(t.path()), |v| v["apr_sha"] = BUMP.into()); + } + edit(&tokenizer(t.path()), |v| v["apr_sha"] = BUMP.into()); + for h in ["lambda", "gx10"] { + edit( + &t.path().join(format!("evidence/crux/0.69.1/{h}-gpu.json")), + |v| { + v["apr"]["version_line"] = format!("apr 0.69.1 ({})", &BUMP[..9]).into(); + }, + ); } let dir = s(&other); let r = gate(t.path(), &["--receipts", &dir, "--receipts-commit", BUMP]); assert_eq!(r.code, 0, "{}", show(&r)); - let r = gate(t.path(), &["--receipts", &dir]); - assert_red_naming(&r, "release-host/0.69.1/lambda"); + assert_red_naming( + &gate(t.path(), &["--receipts", &dir]), + "release-host/0.69.1/lambda", + ); } #[test] -fn no_release_subject_declines_it_never_passes() { +fn one_token_id_off_llama_cpp_or_no_comparison_is_red_naming_the_model() { + for change in ["mismatch", "empty", "roundtrip", "absent"] { + let t = green(); + match change { + "mismatch" => edit(&tokenizer(t.path()), |v| { + v["rows"][0]["mismatches"] = 1.into() + }), + "empty" => edit(&tokenizer(t.path()), |v| { + v["rows"][0]["tokens_compared"] = 0.into() + }), + "roundtrip" => edit(&tokenizer(t.path()), |v| { + v["rows"][0]["roundtrip_ok"] = false.into() + }), + _ => std::fs::remove_file(tokenizer(t.path())).expect("rm"), + } + assert_red_naming( + &gate(t.path(), &[]), + "release-tokenizer/0.69.1/tiny-qwen35.gguf", + ); + } +} + +// ── the memory rule (#3710, operator decision "a") ─────────────────────────────────────────────────────── +// 16 GB weights + 1 MB/token KV + 1 GB workspace: `4k` (21.1 GB) fits the 24 GB card; `consumer-max` (25 GB) +// and `declared` (27 GB) do not, and are owed on lambda as honest pre-load refusals — and on gx10 as passes. + +fn memory_split(t: &Path) { + for (h, gpu) in [("lambda", 24 * GB), ("gx10", 119 * GB)] { + edit(&models(t, h), |v| { + v["gpu_mem_total_bytes"] = gpu.into(); + let item = &mut v["inventory"][0]; + item["weights_bytes"] = (16 * GB).into(); + item["kv_bytes_per_token"] = 1_000_000u64.into(); + item["workspace_bytes"] = GB.into(); + }); + } + let cells = read(&t.join("cells.json"))["cells"] + .as_array() + .expect("cells") + .clone(); + edit(&models(t, "lambda"), |v| { + for r in v["cells"].as_array_mut().expect("cells") { + let spec = cells + .iter() + .find(|c| c["id"] == r["cell_id"]) + .expect("spec"); + let need = match (spec["kind"].as_str(), spec["rung"].as_str()) { + (Some("matrix"), Some("consumer-max")) => 25 * GB, + (Some("matrix"), Some("declared")) => 27 * GB, + _ => continue, + }; + r["verdict"] = "refused".into(); + r["required_bytes"] = need.into(); + r["available_bytes"] = (24 * GB).into(); + } + }); +} + +#[test] +fn an_honest_refusal_on_lambda_and_a_pass_on_gx10_is_green() { + let t = green(); + memory_split(t.path()); + let r = gate(t.path(), &[]); + assert_eq!(r.code, 0, "{}", show(&r)); + assert!(json_of(&r)["release"]["refusal_cells"].as_u64() > Some(0)); +} + +#[test] +fn an_oom_a_co_tenant_refusal_or_a_rung_that_fits_nowhere_is_red() { + let t = green(); + memory_split(t.path()); + let id = pick(t.path(), gen_cell("lambda", "on", "declared")); + edit_row(t.path(), "lambda", &id, |r| { + r["verdict"] = "error".into(); + r["reason"] = "CUDA_ERROR_OUT_OF_MEMORY after 41 s of prefill".into(); + }); + assert_red_naming(&gate(t.path(), &[]), &named(&id)); + let t = green(); + memory_split(t.path()); + let id = pick(t.path(), gen_cell("lambda", "off", "consumer-max")); + edit_row(t.path(), "lambda", &id, |r| { + r["required_bytes"] = (23 * GB).into() + }); + let v = assert_red_naming(&gate(t.path(), &[]), &named(&id)); + assert!(v["findings"].to_string().contains("arithmeticNamed")); + let t = green(); + memory_split(t.path()); + edit(&models(t.path(), "gx10"), |v| { + v["gpu_mem_total_bytes"] = (24 * GB).into() + }); + assert_red_naming( + &gate(t.path(), &[]), + "release-coverage/0.69.1/tiny-qwen35.gguf/declared", + ); +} + +// ── #3745 S2: the cells are DERIVED ─────────────────────────────────────────────────────────────────────── + +#[test] +fn issue_mutant_1_a_new_flag_on_a_generating_command_owes_cells_nobody_has_run() { + let t = green(); + edit(&t.path().join("surface.json"), |v| { + let flag = serde_json::from_str::( + r#"{"id":"extra","long":"extra","value_type":"flag","values":["true","false"],"role":"mode"}"#, + ) + .expect("flag"); + v["commands"][0]["args"] + .as_array_mut() + .expect("args") + .push(flag); + }); + let v = assert_red_naming(&gate(t.path(), &[]), "--extra"); + assert!( + v["findings"] + .to_string() + .contains("release-cell/0.69.1/gen/"), + "the owed cells are gen's" + ); +} + +#[test] +fn issue_mutant_2_the_prompt_with_chat_cell_missing_is_red_naming_it() { + // #3743's shape: `-i file --chat` was covered, the `--prompt` shape was not + let t = green(); + let id = pick(t.path(), |c| { + c["command"] == "gen" + && c["kind"] == "matrix" + && c["host"] == "lambda" + && c["shape"] == "--prompt" + && c["args"].to_string().contains("--chat") + }); + drop_row(t.path(), "lambda", &id); + let v = assert_red_naming(&gate(t.path(), &[]), &named(&id)); + assert!( + id.contains("--prompt") && id.contains("--chat"), + "the cell names both: {id}" + ); + let _ = v; +} + +#[test] +fn no_surface_or_a_surface_that_does_not_say_what_generates_is_red() { let t = green(); let contracts = s(&t.path().join("contracts")); + let dogfood = s(&t.path().join("dogfood-receipt.json")); let r = pv( t.path(), &[ @@ -509,37 +776,117 @@ fn no_release_subject_declines_it_never_passes() { "shapes", "--shape", "release-readiness-v1", + "--release-version", + "0.69.1", + "--release-commit", + MC, + "--dogfood-receipt", + &dogfood, ], ); + assert_red_naming(&r, "release/surface"); + let t = green(); + edit(&t.path().join("surface.json"), |v| { + v["commands"][0] + .as_object_mut() + .expect("cmd") + .remove("generates"); + }); + assert_red_naming(&gate(t.path(), &[]), "generatesUndeclared"); +} + +#[test] +fn an_unknown_role_arg_beyond_its_ceiling_grows_the_ratchet_and_is_red() { + let t = green(); + edit(&t.path().join("surface.json"), |v| { + let arg = serde_json::from_str::( + r#"{"id":"mystery","long":"mystery","value_type":"text","role":"unknown"}"#, + ) + .expect("arg"); + v["commands"][1]["args"] + .as_array_mut() + .expect("args") + .push(arg); + }); + assert_red_naming(&gate(t.path(), &[]), "ratchetGrew"); +} + +#[test] +fn a_flag_whose_output_never_changes_is_red_naming_the_setting() { + // S2.5: the effect cell prints exactly what its base printed + let t = green(); + let cells = read(&t.path().join("cells.json"))["cells"] + .as_array() + .expect("cells") + .clone(); + for h in ["lambda", "gx10"] { + edit(&models(t.path(), h), |v| { + for r in v["cells"].as_array_mut().expect("cells") { + let spec = cells + .iter() + .find(|c| c["id"] == r["cell_id"]) + .expect("spec"); + if spec["kind"] == "effect" { + r["output_sha256"] = + format!("out:{}", spec["base"].as_str().unwrap_or_default()).into(); + } + } + }); + } + assert_red_naming( + &gate(t.path(), &[]), + "release-effect/0.69.1/gen/--chat%3Dtrue", + ); +} + +#[test] +fn a_modelless_command_must_run_or_refuse_by_name_and_a_model_command_must_pass_or_refuse() { + let t = green(); + let id = pick(t.path(), |c| { + c["command"] == "list" && c["host"] == "lambda" + }); + edit_row(t.path(), "lambda", &id, |r| r["verdict"] = "refused".into()); assert_eq!( - r.code, - 2, - "zero cells is a decline, not a pass: {}", - show(&r) + gate(t.path(), &[]).code, + 0, + "a typed refusal by name is an answer" ); + edit_row(t.path(), "lambda", &id, |r| r["verdict"] = "fail".into()); + assert_red_naming(&gate(t.path(), &[]), &named(&id)); + let t = green(); + let id = pick(t.path(), |c| { + c["command"] == "look" && c["kind"] == "matrix" && c["host"] == "gx10" + }); + drop_row(t.path(), "gx10", &id); + assert_red_naming(&gate(t.path(), &[]), &named(&id)); } +// ── the CLI's own answers ──────────────────────────────────────────────────────────────────────────────── + #[test] -fn a_malformed_subject_or_a_misplaced_flag_is_the_callers_error() { +fn no_release_subject_declines_and_a_malformed_one_is_the_callers_error() { let t = green(); let contracts = s(&t.path().join("contracts")); - let partial = pv( + let r = pv( t.path(), &[ "lint", &contracts, "--gate", "shapes", - "--release-version", - "0.69.1", + "--shape", + "release-readiness-v1", ], ); - assert_eq!(partial.code, 3, "{}", show(&partial)); - let short = pv( - t.path(), - &[ - "lint", - &contracts, + assert_eq!( + r.code, + 2, + "zero cells is a decline, not a pass: {}", + show(&r) + ); + for bad in [ + vec!["--gate", "shapes", "--release-version", "0.69.1"], + vec![ "--gate", "shapes", "--release-version", @@ -547,266 +894,266 @@ fn a_malformed_subject_or_a_misplaced_flag_is_the_callers_error() { "--release-commit", "225b2a9ab", ], - ); - assert_eq!( - short.code, - 3, - "a prefix is refused, never matched: {}", - show(&short) - ); - let misplaced = pv( - t.path(), - &["lint", &contracts, "--gate", "sigma", "--shape", "x"], - ); - assert_eq!(misplaced.code, 3, "{}", show(&misplaced)); - let unknown = pv( - t.path(), - &[ - "lint", - &contracts, - "--gate", - "shapes", - "--shape", - "no-such-shape", - ], - ); - assert_eq!(unknown.code, 3, "{}", show(&unknown)); + vec!["--gate", "sigma", "--shape", "x"], + vec!["--gate", "shapes", "--shape", "no-such-shape"], + ] { + let mut args = vec!["lint", contracts.as_str()]; + args.extend(bad.iter().copied()); + let r = pv(t.path(), &args); + assert_eq!(r.code, 3, "{bad:?}: {}", show(&r)); + } } #[test] -fn pv_extract_writes_the_release_graph_only_where_it_is_told() { +fn pv_extract_writes_the_release_graph_and_the_cell_list_only_where_told() { let t = green(); let contracts = s(&t.path().join("contracts")); - let out = t.path().join("release.nt"); - let dogfood = s(&t.path().join("dogfood-receipt.json")); - let args = [ - "extract", - &contracts, - "--release-version", - "0.69.1", - "--release-commit", - MC, - "--dogfood-receipt", - &dogfood, - ]; - let refused = pv(t.path(), &args); + let refused = pv( + t.path(), + &[ + "extract", + &contracts, + "--release-version", + "0.69.1", + "--release-commit", + MC, + ], + ); assert_eq!(refused.code, 3, "no --out: {}", show(&refused)); + let cells = read(&t.path().join("cells.json")); + assert_eq!(cells["schema"], "apr-release-cells/v1"); + assert_eq!(cells["release_commit"], MC); + let nt = std::fs::read_to_string(t.path().join("release.nt")).expect("written"); + assert!( + nt.contains("release/Cell") && nt.contains("cli/Command"), + "cells and the surface are in the graph" + ); + assert!(!nt.contains("_:"), "no blank nodes (R-15)"); assert!( !t.path().join("contracts/contracts.nt").exists(), "the tracked file is untouched" ); - let out_s = s(&out); - let mut with_out = args.to_vec(); - with_out.extend_from_slice(&["--out", &out_s]); - let r = pv(t.path(), &with_out); - assert_eq!(r.code, 0, "{}", show(&r)); - let v = json_of(&r); - assert_eq!(v["release"]["cells"], 48); - let nt = std::fs::read_to_string(&out).expect("written"); - assert!(nt.contains("release/Cell"), "the cells are in the graph"); - assert!(nt.contains("release/KernelDiffReceipt")); - assert!(!nt.contains("_:"), "no blank nodes (R-15)"); - assert!(!t.path().join("contracts/contracts.nt").exists()); } -fn tokenizer(t: &Path) -> PathBuf { - t.join("evidence/dogfood/tokenizer/0.69.1/parity.json") +// ── S2.4: CRUX for every derived verb (#3739) ─────────────────────────────────────────────────────────── + +fn crux_map(t: &Path) -> PathBuf { + t.join("evidence/crux/verb-correspondence.yaml") } -const TOK_CELL: &str = "release-tokenizer/0.69.1/tiny-qwen35.gguf"; +fn crux_receipt(t: &Path, host: &str) -> PathBuf { + t.join(format!("evidence/crux/0.69.1/{host}-gpu.json")) +} -#[test] -fn one_token_id_that_disagrees_with_llama_cpp_is_red_naming_the_model() { - // #3726: apr mapped every non-ASCII byte to id 0 and `apr parity` could not see it (CPU and GPU share it) - let t = green(); - edit(&tokenizer(t.path()), |v| { - v["rows"][0]["mismatches"] = 1.into() - }); - let v = assert_red_naming(&gate(t.path(), &[]), TOK_CELL); - assert!(v["findings"].to_string().contains("parityExact")); +fn rewrite(path: &Path, from: &str, to: &str) { + let text = std::fs::read_to_string(path).expect("read"); + assert!(text.contains(from), "{from:?} is in {}", path.display()); + std::fs::write(path, text.replace(from, to)).expect("write"); } #[test] -fn a_tokenizer_comparison_over_nothing_or_a_broken_round_trip_or_no_receipt_is_red() { +fn a_verb_without_an_entry_an_engine_it_omits_or_a_none_without_reason_is_red() { let t = green(); - edit(&tokenizer(t.path()), |v| { - v["rows"][0]["tokens_compared"] = 0.into() - }); - assert_red_naming(&gate(t.path(), &[]), TOK_CELL); - + rewrite( + &crux_map(t.path()), + " - verb: look\n comparator: none\n reason: \"fixture: reading a model's header has no comparator counterpart\"\n", + "", + ); + assert_red_naming(&gate(t.path(), &[]), "release-crux-verb/0.69.1/look"); let t = green(); - edit(&tokenizer(t.path()), |v| { - v["rows"][0]["roundtrip_ok"] = false.into() - }); - let v = assert_red_naming(&gate(t.path(), &[]), TOK_CELL); - assert!(v["findings"].to_string().contains("roundTrip")); - + rewrite( + &crux_map(t.path()), + " llamafile: {none: \"fixture: no llamafile build pinned\"}\n", + "", + ); + assert_red_naming(&gate(t.path(), &[]), "engineMissing"); let t = green(); - std::fs::remove_file(tokenizer(t.path())).expect("rm"); - assert_red_naming(&gate(t.path(), &[]), TOK_CELL); + rewrite( + &crux_map(t.path()), + "reason: \"fixture: listing is apr's own\"", + "reason: \"\"", + ); + assert_red_naming(&gate(t.path(), &[]), "noneWithoutReason"); } -// ── the memory rule (#3710, operator decision "a") ────────────────────────────────────────────────────── -// weights 16 GB + 1 MB/token KV + 1 GB workspace: `4k` (21.1 GB) fits the 24 GB card; `consumer-max` (25 GB) and -// `declared` (27 GB) do not, and are owed on lambda as honest pre-load refusals — and on gx10 (119 GB) as passes. - -const GB: u64 = 1_000_000_000; - -fn memory_split(t: &Path) { - for (h, gpu) in [("lambda", 24 * GB), ("gx10", 119 * GB)] { - edit(&models(t, h), |v| { - v["gpu_mem_total_bytes"] = gpu.into(); - let item = &mut v["inventory"][0]; - item["weights_bytes"] = (16 * GB).into(); - item["kv_bytes_per_token"] = 1_000_000u64.into(); - item["workspace_bytes"] = GB.into(); +#[test] +fn a_red_an_unjudged_or_an_absent_crux_cell_is_red_and_all_wrong_is_named_not_failed() { + for verdict in ["RED", "UNJUDGED"] { + let t = green(); + edit(&crux_receipt(t.path(), "lambda"), |v| { + v["cells"][0]["verdict"] = verdict.into() }); + assert_red_naming( + &gate(t.path(), &[]), + "release-crux/0.69.1/lambda/tiny-qwen35.gguf/gen", + ); } - edit(&models(t, "lambda"), |v| { - for c in v["cells"].as_array_mut().expect("cells") { - let need = match c["context"].as_str() { - Some("consumer-max") => 25 * GB, - Some("declared") => 27 * GB, - _ => continue, - }; - c["verdict"] = "refused".into(); - c["required_bytes"] = need.into(); - c["available_bytes"] = (24 * GB).into(); - c["reason"] = - "pre-load refusal: KV for this context needs more than the device holds".into(); - } - }); -} - -#[test] -fn a_27b_that_refuses_honestly_on_lambda_and_passes_on_gx10_is_green() { let t = green(); - memory_split(t.path()); + std::fs::remove_file(crux_receipt(t.path(), "gx10")).expect("rm"); + assert_red_naming( + &gate(t.path(), &[]), + "release-crux/0.69.1/gx10/tiny-qwen35.gguf/gen", + ); + let t = green(); + edit(&crux_receipt(t.path(), "lambda"), |v| { + v["cells"][0]["verdict"] = "ALL_WRONG".into() + }); let r = gate(t.path(), &[]); - assert_eq!(r.code, 0, "{}", show(&r)); - let v = json_of(&r); assert_eq!( - v["release"]["refusal_cells"], 16, - "4 verbs × 2 modes × (consumer-max, declared) on lambda" + r.code, + 0, + "ALL_WRONG is named, not a violation: {}", + show(&r) ); - assert_eq!(v["release"]["cells"], 48, "a refusal is still an owed cell"); + assert_eq!(json_of(&r)["release"]["crux"]["all_wrong"], 1); } +// ── #3748 and the CRUX harness guard ───────────────────────────────────────────────────────────────────── + #[test] -fn the_gx10_cell_that_covers_the_rung_missing_is_red() { +fn a_correctness_cell_validated_by_another_binarys_f2_receipt_is_unmeasured_and_red() { let t = green(); - memory_split(t.path()); - edit(&models(t.path(), "gx10"), |v| { - let i = row_index(v, "chat", "off", "declared"); - v["cells"].as_array_mut().expect("cells").remove(i); + let id = pick(t.path(), gen_cell("lambda", "off", "4k")); + edit_row(t.path(), "lambda", &id, |r| { + r["f2_source"] = "receipt".into(); + r["f2_receipt_binary_sha"] = PARENT.into(); }); - assert_red_naming( - &gate(t.path(), &[]), - &cell("gx10", "chat", "off", "declared"), - ); -} - -#[test] -fn an_oom_mid_run_where_a_refusal_is_owed_is_red() { + let v = assert_red_naming(&gate(t.path(), &[]), &named(&id)); + assert!(v["findings"].to_string().contains("f2Measured")); let t = green(); - memory_split(t.path()); - edit(&models(t.path(), "lambda"), |v| { - let r = row(v, "serve", "on", "declared"); - r["verdict"] = "error".into(); - r["reason"] = "CUDA_ERROR_OUT_OF_MEMORY after 41 s of prefill".into(); - r.as_object_mut().expect("row").remove("required_bytes"); - r.as_object_mut().expect("row").remove("available_bytes"); + let id = pick(t.path(), gen_cell("gx10", "on", "4k")); + edit_row(t.path(), "gx10", &id, |r| { + r["f2_source"] = "receipt".into(); + r["f2_receipt_binary_sha"] = MC.into(); }); - let v = assert_red_naming( - &gate(t.path(), &[]), - &cell("lambda", "serve", "on", "declared"), + assert_eq!( + gate(t.path(), &[]).code, + 0, + "this binary's own F2 receipt counts" ); - assert!(v["findings"] - .to_string() - .contains("release-readiness-v1.refusal")); + edit_row(t.path(), "gx10", &id, |r| { + r.as_object_mut().expect("row").remove("f2_source"); + }); + assert_red_naming(&gate(t.path(), &[]), &named(&id)); } #[test] -fn a_refusal_whose_arithmetic_shows_no_shortfall_is_red() { +fn a_broken_crux_harness_declines_the_gate_naming_the_model() { let t = green(); - memory_split(t.path()); - edit(&models(t.path(), "lambda"), |v| { - row(v, "code", "off", "consumer-max")["required_bytes"] = (20 * GB).into(); + edit(&crux_receipt(t.path(), "lambda"), |v| { + for c in v["cells"].as_array_mut().expect("cells") { + if c["positive_control"] == true { + c["verdict"] = "ALL_WRONG".into(); + } + } }); - let v = assert_red_naming( - &gate(t.path(), &[]), - &cell("lambda", "code", "off", "consumer-max"), + let r = gate(t.path(), &[]); + assert_eq!( + r.code, + 2, + "a positive control that is ALL_WRONG declines: {}", + show(&r) + ); + assert!( + r.stderr.contains("tiny-qwen35.gguf") && r.stderr.contains("ALL_WRONG"), + "{}", + show(&r) + ); + let t = green(); + for h in ["lambda", "gx10"] { + edit(&crux_receipt(t.path(), h), |v| { + for c in v["cells"].as_array_mut().expect("cells") { + c["positive_control"] = false.into(); + } + }); + } + let r = gate(t.path(), &[]); + assert_eq!(r.code, 2, "no measured control declines: {}", show(&r)); + assert!( + r.stderr.contains("no measured positive-control cell"), + "{}", + show(&r) ); - assert!(v["findings"].to_string().contains("arithmeticNamed")); } -#[test] -fn a_cell_that_fits_by_the_arithmetic_but_is_missing_is_red() { - let t = green(); - memory_split(t.path()); - edit(&models(t.path(), "lambda"), |v| { - let i = row_index(v, "run", "on", "4k"); - v["cells"].as_array_mut().expect("cells").remove(i); +// ── S2.5: a8's sampling controls over typed `sampling` args ───────────────────────────────────────────── + +/// Set the output digest of `host`'s sampling control `control`. +fn set_control(t: &Path, host: &str, control: &str, out: &str) { + let id = pick(t, |c| { + c["kind"] == "sampling" && c["host"] == host && c["control"] == control }); - assert_red_naming(&gate(t.path(), &[]), &cell("lambda", "run", "on", "4k")); + edit_row(t, host, &id, |r| r["output_sha256"] = out.into()); } #[test] -fn a_rung_that_fits_on_no_required_host_is_red_naming_the_rung() { - let t = green(); - memory_split(t.path()); - edit(&models(t.path(), "gx10"), |v| { - v["gpu_mem_total_bytes"] = (24 * GB).into(); - }); - assert_red_naming( - &gate(t.path(), &[]), - &format!("release-coverage/0.69.1/{MODEL}/declared"), - ); +fn each_of_a8s_sampling_controls_turns_the_gate_red_when_broken() { + let cases: [(&str, &str, &str); 4] = [ + ("seed-a-again", "drifted", "seedRepeatable"), + ("seed-b", "sampled-a", "seedsDiffer"), + ("topk1", "not-greedy", "greedyAgrees"), + ("seed-a", "greedy", "sampledDiffers"), + ]; + for (control, out, check) in cases { + let t = green(); + set_control(t.path(), "lambda", control, out); + if check == "sampledDiffers" { + set_control(t.path(), "lambda", "seed-a-again", "greedy"); + set_control(t.path(), "lambda", "seed-b", "greedy"); + } + let v = assert_red_naming(&gate(t.path(), &[]), "release-sampling/0.69.1/lambda/gen"); + assert!(v["findings"].to_string().contains(check), "{check}"); + } } #[test] -fn a_refusal_because_a_co_tenant_holds_memory_is_red() { - // required 23 GB ≤ the card's 24 GB total, refused because only 20 GB were free: a busy GPU, not the - // arithmetic — it must not shrink what the release proves (#3712, aprender-62) +fn a_control_the_command_cannot_express_is_named_never_passed() { let t = green(); - memory_split(t.path()); - edit(&models(t.path(), "lambda"), |v| { - let r = row(v, "chat", "off", "consumer-max"); - r["required_bytes"] = (23 * GB).into(); - r["available_bytes"] = (20 * GB).into(); + edit(&t.path().join("surface.json"), |v| { + v["commands"][0]["args"] + .as_array_mut() + .expect("args") + .retain(|a| a["sampling_kind"] != "seed"); }); - let v = assert_red_naming( - &gate(t.path(), &[]), - &cell("lambda", "chat", "off", "consumer-max"), + synthesize(t.path()); + let v = assert_red_naming(&gate(t.path(), &[]), "underivable"); + assert!( + v["findings"].to_string().contains("seed-a"), + "names the control it cannot run" ); - assert!(v["findings"].to_string().contains("arithmeticNamed")); } #[test] -fn a_model_whose_template_always_opens_think_owes_only_the_on_cells() { - // Qwen3-style prefill that always opens (#3723): ON only — the OFF cells are not owed, so removing - // every OFF row leaves the release green, and removing an ON row does not +fn a_verb_whose_entry_declares_request_modes_owes_a_crux_cell_in_each() { + // the FILE says which verb has modes (#3739 slice 4); pv never names one let t = green(); + rewrite( + &crux_map(t.path()), + " judged_by: golden\n", + " judged_by: golden\n modes: [nonstream, stream]\n", + ); + let v = assert_red_naming(&gate(t.path(), &[]), "modeMissing"); + assert!(v["findings"].to_string().contains("stream")); for h in ["lambda", "gx10"] { - edit(&models(t.path(), h), |v| { - v["inventory"][0]["thinking_modes"] = - serde_json::from_str::(r#"["on"]"#).expect("modes"); - v["inventory"][0]["thinking_markers"] = - serde_json::from_str::(r#"[""]"#).expect("markers"); - let cells = v["cells"].as_array_mut().expect("cells"); - cells.retain(|c| c["thinking"] == "on"); + edit(&crux_receipt(t.path(), h), |v| { + let rows: Vec = v["cells"].as_array().expect("cells").clone(); + let mut out = Vec::new(); + for r in rows { + for m in ["nonstream", "stream"] { + let mut r2 = r.clone(); + r2["key"]["mode"] = m.into(); + out.push(r2); + } + } + v["cells"] = Value::Array(out); }); } - let r = gate(t.path(), &[]); - assert_eq!(r.code, 0, "{}", show(&r)); - assert_eq!( - json_of(&r)["release"]["cells"], - 24, - "2 hosts × 4 verbs × ON × 3 rungs" - ); - edit(&models(t.path(), "gx10"), |v| { - let i = row_index(v, "code", "on", "4k"); - v["cells"].as_array_mut().expect("cells").remove(i); - }); - assert_red_naming(&gate(t.path(), &[]), &cell("gx10", "code", "on", "4k")); + assert_eq!(gate(t.path(), &[]).code, 0, "a row in each declared mode"); +} + +/// A JSON array of strings as `Vec<&str>` (`json!` array literals expand to a disallowed `unwrap`). +fn names(v: &serde_json::Value) -> Vec<&str> { + v.as_array() + .map(|a| a.iter().filter_map(serde_json::Value::as_str).collect()) + .unwrap_or_default() } diff --git a/crates/aprender-contracts-cli/tests/pv_surface_gate.rs b/crates/aprender-contracts-cli/tests/pv_surface_gate.rs index be97935aac..5c87d68d80 100644 --- a/crates/aprender-contracts-cli/tests/pv_surface_gate.rs +++ b/crates/aprender-contracts-cli/tests/pv_surface_gate.rs @@ -691,23 +691,14 @@ fn validate_reaches_every_rule_at_the_right_severity() { /// /// The validator source is read at RUN time from the sibling crate (#4129). It was an /// `include_str!("../../aprender-contracts/…")`, a path OUTSIDE this crate, so `cargo test` from -/// the published aprender-contracts-cli tarball could not compile this test file. In tree, a -/// missing validator.rs FAILS; only a build with no sibling `aprender-contracts/` (the crates.io -/// tarball) returns `None`, and the caller skips by name. +/// the published aprender-contracts-cli tarball could not compile this test file. The in-tree +/// decision is the shared rule, `provable_contracts::workspace_file_or_skip!`: in tree a missing +/// validator.rs FAILS; out of tree it returns `None` and the caller skips by name. fn declared_rule_ids() -> Option> { - let sibling = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../aprender-contracts"); - if !sibling.is_dir() { - eprintln!( - "SKIP every_rule_in_the_validator_source_appears_in_the_table: out of tree (no {} \ - beside this crate) - the rule universe is read from the sibling crate's source, \ - which a published crate does not carry (#4129)", - sibling.display() - ); - return None; - } - let path = sibling.join("src/schema/validator.rs"); - let src = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("in tree, {} must be readable: {e}", path.display())); + let src = provable_contracts::workspace_file_or_skip!( + "every_rule_in_the_validator_source_appears_in_the_table", + "crates/aprender-contracts/src/schema/validator.rs", + )?; let mut ids = BTreeSet::new(); for chunk in src.split('"').skip(1).step_by(2) { if is_rule_id_shaped(chunk) { diff --git a/crates/aprender-contracts-cli/tests/pvl_zero_contracts.rs b/crates/aprender-contracts-cli/tests/pvl_zero_contracts.rs index ffbe4360c8..3eef41b95b 100644 --- a/crates/aprender-contracts-cli/tests/pvl_zero_contracts.rs +++ b/crates/aprender-contracts-cli/tests/pvl_zero_contracts.rs @@ -105,8 +105,26 @@ fn assert_reported(run: &Run, what: &str, evidence_of_one: &str) { ); } -fn empty_dir() -> tempfile::TempDir { - tempfile::tempdir().expect("empty contract dir is creatable") +/// A contract dir nested one level inside a private tempdir. `pv lint` reads the +/// project root as the dir's PARENT (`scripts/contract_duplicate_stem_baseline.txt`), +/// and a bare tempdir's parent is the shared `/tmp`, so a stray `/tmp/scripts/` +/// failed these tests (#4207). +struct Corpus { + _root: tempfile::TempDir, + dir: PathBuf, +} + +impl Corpus { + fn path(&self) -> &Path { + &self.dir + } +} + +fn empty_dir() -> Corpus { + let root = tempfile::tempdir().expect("empty contract dir is creatable"); + let dir = root.path().join("contracts"); + std::fs::create_dir(&dir).expect("nested contract dir is creatable"); + Corpus { _root: root, dir } } /// A path that does not exist and is never created. diff --git a/crates/aprender-contracts-cli/tests/shapes_vacuity.rs b/crates/aprender-contracts-cli/tests/shapes_vacuity.rs new file mode 100644 index 0000000000..a3264ab7f6 --- /dev/null +++ b/crates/aprender-contracts-cli/tests/shapes_vacuity.rs @@ -0,0 +1,360 @@ +//! #3610 — an armed shape that graded ZERO focus nodes must not be counted as clean. +//! +//! **The defect, measured on pv 0.68.2.** `NoFocus` asked the question GLOBALLY — +//! `report.focus_nodes_n == 0` — which is only ever true for a lone contract. In a **directory** +//! one empty shape is invisible: the other shapes carry the total above zero, the empty one +//! contributes no violations, and the aggregate reports **`Pass` while listing it in +//! `armed_shapes`**. A `type: jsonl` contract whose rows violate its own `sh:pattern` passed exactly +//! that way, and `armed_shapes` is the tool's claim about what it measured. +//! +//! `by_shape` had carried `=0` all along. **Nothing read it** — the same *computed but +//! unexposed* shape as #3597, this time inside the gate itself. +//! +//! **BOTH VENUES, because the lone-contract case already declined correctly and a fix proved only +//! there proves nothing.** The directory case is the one that lied. +//! +//! | fixture | shape reach | armed? | expected | +//! |---|---|---|---| +//! | `shapes-one-empty` | `empty-shape=0`, `tool-status=1` | yes | **exit 2**, decline naming `empty-shape` | +//! | `shapes-one-empty-unarmed` | same | no | **Pass**, `declines: ["empty-shape"]` | +//! | `shapes-one-empty-and-violating` | `empty-shape=0`, `tool-status=1` (violating) | yes | **exit 1**, Fail, `declines: ["empty-shape"]` | +//! | `json-ok` | `tool-status=1` | yes | Pass, `declines: []` | +//! | `shapes-one-empty-allowed` | same, `allowEmpty: ""` on `empty-shape` | yes | **Pass**, `declines: ["empty-shape"]` | +//! | `shapes-all-empty-allowed` | `empty-shape=0` (the only shape), `allowEmpty` | yes | **Pass**, `declines: ["empty-shape"]` | +//! | `shapes-all-empty` | `empty-shape=0` (the only shape) | yes | **exit 2** via the global path, no report yet (#4100) | +//! +//! The second row is what keeps the fix from being "refuse whenever anything is empty": an UNARMED +//! shape at zero did not affect the verdict, so it is **reported** rather than refused — and it is +//! reported, not swallowed, which is the half the original defect got wrong. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn pv_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_pv")) +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +struct Run { + code: i32, + stdout: String, + stderr: String, +} + +impl Run { + fn all(&self) -> String { + format!( + "exit {}\n--- stdout\n{}\n--- stderr\n{}", + self.code, self.stdout, self.stderr + ) + } + fn extra(&self) -> serde_json::Value { + serde_json::from_str::(&self.stdout) + .map(|v| v["extra"].clone()) + .unwrap_or(serde_json::Value::Null) + } +} + +fn shapes_on(fixture: &str) -> Run { + let contracts = repo_root() + .join("tests/fixtures/ont") + .join(fixture) + .join("contracts"); + let out = Command::new(pv_bin()) + .args([ + "lint", + contracts.to_str().expect("utf-8 path"), + "--gate", + "shapes", + "--format", + "json", + ]) + .output() + .expect("failed to spawn pv"); + Run { + code: out.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + } +} + +#[test] +fn an_armed_shape_that_graded_nothing_refuses_in_a_directory() { + // THE CASE THAT LIED. Before #3610 this was `Pass` with `empty-shape` in `armed_shapes`. + let r = shapes_on("shapes-one-empty"); + assert_eq!( + r.code, + 2, + "a vacuous armed shape must DECLINE, not pass and not fail\n{}", + r.all() + ); + // THE REFUSAL IS THE EXIT CODE; THE REPORT IS THE EVIDENCE. Downstream consumers (infra's SLK + // gate) capture stdout and parse it REGARDLESS of exit code, because pv already exits non-zero + // on Fail. A refusal that printed only a bare `decline:` line would read to them as "no + // by_shape" and score UNMEASURED — indistinguishable, from their side, from a broken pv. + let extra = r.extra(); + assert!( + !extra.is_null(), + "stdout must still parse as JSON on the exit-2 path\n{}", + r.all() + ); + assert_eq!( + serde_json::from_str::(&r.stdout).expect("json")["verdict"].as_str(), + Some("Unknown(NoFocus)"), + "the report must carry the declining verdict\n{}", + r.all() + ); + assert!( + extra["by_shape"] + .as_array() + .expect("by_shape") + .iter() + .any(|s| s.as_str() == Some("empty-shape=0")), + "by_shape must NAME the zero-focus shape on the exit-2 path — it is the evidence\n{}", + r.all() + ); + assert_eq!( + extra["declines"][0].as_str(), + Some("empty-shape"), + "the refusal must name the shape in declines\n{}", + r.all() + ); + assert!( + !extra["armed_shapes"] + .as_array() + .expect("armed_shapes") + .iter() + .any(|s| s.as_str() == Some("empty-shape")), + "a shape that graded nothing has no place in armed_shapes\n{}", + r.all() + ); + assert!( + !extra["not_armed_shapes"] + .as_array() + .expect("not_armed_shapes") + .iter() + .any(|s| s.as_str() == Some("empty-shape")), + "nor in not_armed_shapes: filing a vacuity as a policy choice is how it hid\n{}", + r.all() + ); +} + +#[test] +fn an_unarmed_shape_that_graded_nothing_is_reported_not_refused() { + // The other direction, and the reason this is not "refuse whenever anything is empty". + let r = shapes_on("shapes-one-empty-unarmed"); + assert_eq!( + r.code, + 0, + "an UNARMED empty shape must not refuse\n{}", + r.all() + ); + let extra = r.extra(); + assert_eq!( + extra["declines"].as_array().map(|a| a.len()), + Some(1), + "the empty shape must be REPORTED in declines, not swallowed\n{}", + r.all() + ); + assert_eq!( + extra["declines"][0].as_str(), + Some("empty-shape"), + "{}", + r.all() + ); + assert!( + !extra["armed_shapes"] + .as_array() + .expect("armed_shapes") + .iter() + .any(|s| s.as_str() == Some("empty-shape")), + "a shape that graded nothing has no place in armed_shapes\n{}", + r.all() + ); + // THE HALF THIS TEST WAS MISSING. The criterion is "neither list", and only + // the armed sibling above checked both — so an UNARMED vacuity could sit in + // `not_armed_shapes`, filed as a deliberate policy choice, which is exactly + // how the original defect hid. It did: the partition filtered on + // `vacuous_armed`, and two quorum lanes found it independently while this + // test stayed green. + assert!( + !extra["not_armed_shapes"] + .as_array() + .expect("not_armed_shapes") + .iter() + .any(|s| s.as_str() == Some("empty-shape")), + "an UNARMED shape that graded nothing must not be filed under \ + not_armed_shapes either — `declines` is the only list it belongs in\n{}", + r.all() + ); +} + +/// A REAL VIOLATION OUTRANKS A VACUITY — found by all three lanes of the #3622 re-review. +/// +/// `vacuous_armed` and `passed` are computed over DISJOINT shapes: one armed shape can grade +/// nothing while another armed shape grades a node and finds it broken. The first cut checked +/// vacuity first, so this corpus reported `Unknown(NoFocus)` / exit 2 — and a consumer that reads +/// exit 2 as "unmeasured" never saw a Fail the gate had actually measured. The decline is still +/// REPORTED in `declines`; it just may not hide the verdict. +#[test] +fn a_real_violation_is_a_fail_even_beside_a_vacuous_armed_shape() { + let r = shapes_on("shapes-one-empty-and-violating"); + assert_eq!( + r.code, + 1, + "a measured violation must FAIL, not decline, when another armed shape graded nothing\n{}", + r.all() + ); + assert_eq!( + serde_json::from_str::(&r.stdout).expect("json")["verdict"].as_str(), + Some("Fail"), + "{}", + r.all() + ); + assert_eq!( + r.extra()["declines"][0].as_str(), + Some("empty-shape"), + "the vacuous shape must still be NAMED in declines on the Fail path\n{}", + r.all() + ); +} + +#[test] +fn a_corpus_whose_shapes_all_graded_something_still_passes_with_an_empty_declines() { + // The control for the control: without this, a build that declined EVERYTHING would pass the + // two cases above. + let r = shapes_on("json-ok"); + assert_eq!(r.code, 0, "{}", r.all()); + assert_eq!( + r.extra()["declines"].as_array().map(Vec::len), + Some(0), + "nothing graded zero here, so declines must be empty\n{}", + r.all() + ); +} + +/// THE GLOBAL VENUE — every shape at zero, so `report.focus_nodes_n == 0` and `decline()` returns +/// `ShapesOutcome::NoFocus` before the result path runs. It declined before #3610 and must still. +/// +/// **Corrected by the #3622 re-review (all three lanes).** These two tests first ran +/// `json-missing-ref`, which exits **3** on an unreadable `entity.ref` (`ont_extract_json.rs:128`) +/// before `decline()` is consulted, so `code != 0` and "no report" both held for the wrong reason +/// and neither test ever reached the path it named. `shapes-all-empty` reaches it: exit 2. +#[test] +fn a_corpus_whose_every_shape_graded_nothing_declines_through_the_global_path() { + let r = shapes_on("shapes-all-empty"); + assert_eq!( + r.code, + 2, + "every shape at zero must DECLINE (exit 2), not pass, fail, or error out\n{}", + r.all() + ); + assert!( + r.stderr.contains("NoFocus"), + "the decline must be the NoFocus one, not some other refusal\n{}", + r.all() + ); +} + +/// THE ASYMMETRY, ASSERTED RATHER THAN LEFT UNSAID. The global path renders through +/// `skipped_gate(...)` with no `extra`, so it declines with **no `by_shape` and no `declines`**, +/// while the per-shape path (the one #3610 fixed) declines *with* the full report. The consumer that +/// motivated the report requirement (infra's SLK gate) parses stdout whatever the exit code, so on +/// this path it gets nothing. **Filed as #4100**, not fixed here: it needs the NoFocus outcome to +/// carry the report, a change to the outcome path rather than to the verdict. +#[test] +fn the_global_nofocus_decline_prints_no_report_and_that_is_recorded() { + let r = shapes_on("shapes-all-empty"); + assert_eq!(r.code, 2, "{}", r.all()); + assert!( + r.extra().is_null(), + "if this venue has GAINED a report, #4100 is fixed — replace this test with an assertion of \ + the report and add `shapes-all-empty` to the_three_answers_remain_distinct's loop\n{}", + r.all() + ); +} + +#[test] +fn the_three_answers_remain_distinct() { + // pass / fail / decline must not collapse: a build that returned one code for everything + // would satisfy any single assertion above. + let pass = shapes_on("json-ok").code; + let fail = shapes_on("json-violation").code; + let decline = shapes_on("shapes-one-empty").code; + assert_eq!( + (pass, fail, decline), + (0, 1, 2), + "pass / fail / decline must differ" + ); + // Every one of the three must also PRINT its report — a consumer that captures stdout and + // parses it regardless of exit code must get a document in all three cases, not two. + for fixture in ["json-ok", "json-violation", "shapes-one-empty"] { + let r = shapes_on(fixture); + assert!( + !r.extra().is_null(), + "{fixture} did not emit a parseable report\n{}", + r.all() + ); + assert!( + r.extra()["by_shape"].is_array(), + "{fixture}: extra.by_shape must be present whatever the exit code\n{}", + r.all() + ); + } +} + +#[test] +fn an_armed_shape_declared_allow_empty_is_named_but_does_not_refuse() { + // A target class that is empty BY DESIGN (a release:RefusalCell exists only when a cell does not fit) + // declares it, with its reason, in the artifact. The exemption is for the verdict only: the vacuity is + // still named, so a reader sees the gate looked at nothing for it. + let r = shapes_on("shapes-one-empty-allowed"); + assert_eq!( + r.code, + 0, + "an allowEmpty shape must not refuse\n{}", + r.all() + ); + let extra = r.extra(); + assert_eq!( + names(&extra["declines"]), + vec!["empty-shape"] as Vec<&str>, + "the vacuity is still NAMED\n{}", + r.all() + ); + assert_eq!( + names(&extra["armed_shapes"]), + vec!["tool-status"] as Vec<&str>, + "{}", + r.all() + ); + // The control: the SAME fixture without the declaration declines. + assert_eq!(shapes_on("shapes-one-empty").code, 2); +} + +/// A JSON array of strings as `Vec<&str>` (`json!` array literals expand to a disallowed `unwrap`). +fn names(v: &serde_json::Value) -> Vec<&str> { + v.as_array() + .map(|a| a.iter().filter_map(serde_json::Value::as_str).collect()) + .unwrap_or_default() +} + +#[test] +fn a_lone_allow_empty_shape_passes_through_the_global_path_too() { + // Quorum lane C (#3610): the GLOBAL NoFocus early-return (every shape in scope at zero) fired before the + // per-shape exemption was consulted, so a contract whose ONLY shape is empty by design still exited 2 — + // with no report at all. Every shape declaring allowEmpty now reaches the per-shape path. + let r = shapes_on("shapes-all-empty-allowed"); + assert_eq!(r.code, 0, "{}", r.all()); + assert_eq!( + names(&r.extra()["declines"]), + vec!["empty-shape"], + "{}", + r.all() + ); + // The control: the same lone shape WITHOUT the declaration still declines through the global path. + assert_eq!(shapes_on("shapes-all-empty").code, 2); +} diff --git a/crates/aprender-contracts-macros/src/lib.rs b/crates/aprender-contracts-macros/src/lib.rs index 5a7da9d198..40610c3cee 100644 --- a/crates/aprender-contracts-macros/src/lib.rs +++ b/crates/aprender-contracts-macros/src/lib.rs @@ -23,7 +23,14 @@ //! `CONTRACT__=bound` env vars for each implemented binding. //! //! 2. `#[contract("name", equation = "eq")]` expands to a `const` that reads -//! the corresponding env var via `env!()`. Missing env var = compile error. +//! the corresponding env var via `option_env!()`. A missing env var is a +//! compile error ONLY when the producer says its registry ran +//! (`CONTRACT_BINDING_SOURCE=binding.yaml`, #4368). Without that sentinel +//! (a crates.io build, or a producer that found no binding.yaml) the +//! attribute enforces nothing (#2699). Assertions +//! are injected only for the `_PRE_*` / `_POST_*` vars a producer emitted. +//! Producers derive the key with `provable_contracts::build_helper::env_key`, +//! which is tested equal to [`contract_env_key!`], the key this macro reads. //! //! 3. A static string in a dedicated link section registers the binding for //! runtime audit (when `contract-audit` feature is enabled). @@ -96,8 +103,12 @@ impl Parse for ContractArgs { /// Annotates a function with a provable-contracts YAML contract reference. /// The macro generates: /// -/// 1. A `const` assertion that reads a `CONTRACT__` env var -/// (set by build.rs). If the env var is missing, compilation fails. +/// 1. A `const` that reads a `CONTRACT__` env var (set by +/// build.rs) via `option_env!`. If the env var is missing and the +/// producer set `CONTRACT_BINDING_SOURCE=binding.yaml`, that is a +/// `compile_error!` naming the site (#4368). With no such sentinel, +/// NOTHING fails: the binding is unverified and no assertion is injected +/// (#2699). /// /// 2. `debug_assert!()` calls for EVERY precondition and postcondition /// from the YAML contract (read via `CONTRACT__PRE_N` env vars). @@ -119,7 +130,9 @@ impl Parse for ContractArgs { /// /// This macro reads those env vars at compile time and injects the /// assertions. Change the YAML → assertions change automatically. -/// Remove the YAML → compile error. +/// Remove the YAML → the assertions silently disappear (#2699). A condition +/// the producer DID emit but that does not parse as a Rust expression, or a +/// `_COUNT` whose numbered vars are missing, is a compile error. /// /// # Example /// @@ -154,6 +167,9 @@ pub fn contract(attr: TokenStream, item: TokenStream) -> TokenStream { args.equation_name.to_uppercase().replace(['-', '.'], "_") ); + // A registry that ran and has no entry for this site (#4368). + let registry_miss = registry_miss(&env_key, contract_name, equation_name); + // Read preconditions from env vars set by build.rs let precondition_asserts = read_contract_assertions(&env_key, "PRE", equation_name); @@ -217,6 +233,7 @@ pub fn contract(attr: TokenStream, item: TokenStream) -> TokenStream { }; let expanded = quote! { + #registry_miss #(#fn_attrs)* #fn_vis #fn_sig { #body @@ -226,42 +243,113 @@ pub fn contract(attr: TokenStream, item: TokenStream) -> TokenStream { TokenStream::from(expanded) } +/// The env var a producer build.rs sets to `binding.yaml` once it has read +/// its binding registry and emitted a `CONTRACT__` var for every row. +const REGISTRY_SENTINEL: &str = "CONTRACT_BINDING_SOURCE"; + +/// `compile_error!` when the producer's registry ran (the sentinel reads +/// `binding.yaml`) but has nothing for this site: no binding var, no pre +/// count, no post count. That is a misspelt or unregistered contract, which +/// used to expand to nothing, exactly like a crates.io build with no +/// producer. With no sentinel (or `none`) this stays silent (#4368). +fn registry_miss( + env_key: &str, + contract: &str, + equation: &str, +) -> Option { + if std::env::var(REGISTRY_SENTINEL).ok().as_deref() != Some("binding.yaml") { + return None; + } + let known = ["", "_PRE_COUNT", "_POST_COUNT"] + .iter() + .any(|suffix| std::env::var(format!("{env_key}{suffix}")).is_ok()); + (!known).then(|| { + compile_error(&format!( + "#[contract({contract}, equation = \"{equation}\")]: this crate's binding registry has no \ + row for it ({env_key} is unset while {REGISTRY_SENTINEL}=binding.yaml) -- add the binding or fix the name (#4368)" + )) + }) +} + /// Read CONTRACT__{PRE,POST}_0..N env vars and generate `debug_assert`! tokens. /// /// build.rs sets these from YAML contract preconditions/postconditions. -/// If no env vars are found (e.g., crates.io build), returns empty vec (graceful). +/// No `_COUNT` var (e.g. a crates.io build, where no build.rs ran) yields no +/// assertions. Once a producer HAS emitted a count, every inconsistency in +/// what it emitted is a `compile_error!` rather than a silently dropped +/// assertion (#2699): an unparseable count, a missing numbered var, or a +/// condition that is not a Rust expression. fn read_contract_assertions( env_key: &str, kind: &str, // "PRE" or "POST" equation_name: &str, ) -> Vec { - let mut asserts = Vec::new(); - let count_key = format!("{env_key}_{kind}_COUNT"); - let count: usize = std::env::var(&count_key) - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(0); + let Ok(raw_count) = std::env::var(&count_key) else { + return Vec::new(); + }; + let Ok(count) = raw_count.parse::() else { + return vec![compile_error(&format!( + "{count_key}={raw_count:?} is not a count (#2699)" + ))]; + }; + (0..count) + .map(|i| condition_assert(&format!("{env_key}_{kind}_{i}"), kind, equation_name)) + .collect() +} +/// One `debug_assert!` for the condition in env var `var_key`, or a +/// `compile_error!` naming why the producer's condition cannot be enforced. +fn condition_assert(var_key: &str, kind: &str, equation_name: &str) -> proc_macro2::TokenStream { + let Ok(expr_str) = std::env::var(var_key) else { + return compile_error(&format!( + "{var_key} is missing although its _COUNT covers it (#2699)" + )); + }; + let Ok(expr) = syn::parse_str::(&expr_str) else { + return compile_error(&format!( + "{var_key}={expr_str:?} is not a Rust expression; the contract condition \ + would be dropped (#2699)" + )); + }; let kind_label = if kind == "PRE" { "Pre" } else { "Post" }; - - for i in 0..count { - let var_key = format!("{env_key}_{kind}_{i}"); - if let Ok(expr_str) = std::env::var(&var_key) { - // Parse the YAML precondition/postcondition as a Rust expression - if let Ok(expr) = expr_str.parse::() { - let msg = format!( - "Contract [{equation_name}] {kind_label}-condition violated: {expr_str}" - ); - asserts.push(quote! { - debug_assert!(#expr, #msg); - }); - } - // If parsing fails, skip silently (the expression may not be valid Rust) - } + let msg = format!("Contract [{equation_name}] {kind_label}-condition violated: {expr_str}"); + quote! { + debug_assert!(#expr, #msg); } +} - asserts +fn compile_error(msg: &str) -> proc_macro2::TokenStream { + quote! { ::core::compile_error!(#msg); } +} + +/// The env var key `#[contract(contract, equation = equation)]` reads, as a +/// string literal: `contract_env_key!("rmsnorm-kernel-v1", "rmsnorm")` is +/// `"CONTRACT_RMSNORM_KERNEL_V1_RMSNORM"`. The conditions live under that key +/// plus `_PRE_COUNT`, `_PRE_`, `_POST_COUNT` and `_POST_`. +/// +/// It exists so a producer's key derivation can be TESTED against the one +/// this crate reads, instead of re-derived by hand and trusted (#2699 §4). +#[proc_macro] +pub fn contract_env_key(input: TokenStream) -> TokenStream { + let args = parse_macro_input!(input as KeyArgs); + let key = make_env_key(&args.contract.value(), &args.equation.value()); + TokenStream::from(quote! { #key }) +} + +/// Arguments to `contract_env_key!("contract-name", "equation-name")`. +struct KeyArgs { + contract: syn::LitStr, + equation: syn::LitStr, +} + +impl Parse for KeyArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let contract = input.parse()?; + input.parse::()?; + let equation = input.parse()?; + Ok(KeyArgs { contract, equation }) + } } /// Precondition: checked via `debug_assert!()` at function entry. @@ -429,4 +517,89 @@ mod tests { fn test_make_env_key_with_dots() { assert_eq!(make_env_key("v1.0", "eq.1"), "CONTRACT_V1_0_EQ_1"); } + + /// (env vars under the case's key, expected (asserts, compile_errors)). + /// A producer that emitted nothing is the crates.io case and stays + /// silent; everything a producer DID emit is enforced or refused (#2699). + #[test] + fn read_contract_assertions_fails_closed_on_what_a_producer_emitted() { + let cases: [(&str, &[(&str, &str)], (usize, usize)); 6] = [ + ("NONE", &[], (0, 0)), + ( + "OK", + &[ + ("PRE_COUNT", "2"), + ("PRE_0", "x > 0"), + ("PRE_1", "!v.is_empty()"), + ], + (2, 0), + ), + ("BADCOUNT", &[("PRE_COUNT", "two")], (0, 1)), + ("GAP", &[("PRE_COUNT", "2"), ("PRE_0", "x > 0")], (1, 1)), + ( + "UNPARSEABLE", + &[("PRE_COUNT", "1"), ("PRE_0", "x >")], + (0, 1), + ), + ( + "NOT_AN_EXPR", + &[("PRE_COUNT", "1"), ("PRE_0", "x > 0, \"msg\"")], + (0, 1), + ), + ]; + for (name, vars, (want_asserts, want_errors)) in cases { + let key = format!("CONTRACT_TEST_2699_{name}"); + for (suffix, value) in vars { + std::env::set_var(format!("{key}_{suffix}"), value); + } + let out: Vec = read_contract_assertions(&key, "PRE", "eq") + .iter() + .map(ToString::to_string) + .collect(); + let errors = out.iter().filter(|t| t.contains("compile_error")).count(); + let asserts = out.iter().filter(|t| t.contains("debug_assert")).count(); + assert_eq!( + (asserts, errors), + (want_asserts, want_errors), + "{name}: {out:?}" + ); + } + } + /// #4368: with the sentinel at `binding.yaml`, a site the registry has no + /// row for is a compile error; any one of the three vars makes it known. + /// Without the sentinel (a crates.io build), or at `none`, it is silent. + /// The only test that touches the sentinel, so it owns it for its run. + #[test] + fn registry_miss_is_an_error_only_when_the_registry_ran() { + let cases: [(&str, Option<&str>, &[&str], bool); 7] = [ + ("NO_SENTINEL", None, &[], false), + ("SENTINEL_NONE", Some("none"), &[], false), + ("MISS", Some("binding.yaml"), &[], true), + ("BOUND", Some("binding.yaml"), &[""], false), + ("PRE_ONLY", Some("binding.yaml"), &["_PRE_COUNT"], false), + ("POST_ONLY", Some("binding.yaml"), &["_POST_COUNT"], false), + ("OTHER_KEY", Some("binding.yaml"), &[], true), + ]; + for (name, sentinel, suffixes, want_error) in cases { + match sentinel { + Some(v) => std::env::set_var(REGISTRY_SENTINEL, v), + None => std::env::remove_var(REGISTRY_SENTINEL), + } + let key = format!("CONTRACT_TEST_4368_{name}"); + for suffix in suffixes { + std::env::set_var(format!("{key}{suffix}"), "1"); + } + if name == "OTHER_KEY" { + // A neighbouring row with a shared prefix does not make this one known. + std::env::set_var(format!("{key}X"), "implemented"); + } + let out = registry_miss(&key, "c", "e").map(|t| t.to_string()); + assert_eq!( + out.as_deref().is_some_and(|t| t.contains("compile_error")), + want_error, + "{name}: {out:?}" + ); + } + std::env::remove_var(REGISTRY_SENTINEL); + } } diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts.lean b/crates/aprender-contracts-staging/lean/ProvableContracts.lean index b6035b1cde..2b974836f0 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts.lean @@ -108,3 +108,66 @@ import ProvableContracts.Theorems.Softmax.PartitionOfUnity import ProvableContracts.Theorems.Softmax.ShiftInvariance import ProvableContracts.Theorems.Sparse.SpMVLinear import ProvableContracts.Theorems.Transpose.Involution + +-- Modules nothing imported, so `lake build` never compiled them and leanchecker never +-- re-checked their theorems (#4244). scripts/check_lean_modules_reachable.sh keeps every +-- module under ProvableContracts/ reachable from here. +import ProvableContracts.Defs.GPU +import ProvableContracts.Defs.Image +import ProvableContracts.Defs.Rand +import ProvableContracts.Defs.Tensor +import ProvableContracts.Theorems.AdamW.Adam_Moments +import ProvableContracts.Theorems.AdamW.Adam_Variance +import ProvableContracts.Theorems.AdamW.Analytic +import ProvableContracts.Theorems.AdamW.Bias_Correction +import ProvableContracts.Theorems.AdamW.Weight_Update +import ProvableContracts.Theorems.AprCode.HarnessIrRoundtrip +import ProvableContracts.Theorems.BLAS.Trmm +import ProvableContracts.Theorems.BLAS.Trsm +import ProvableContracts.Theorems.CrossEntropy.CoreSoftmaxInvariants +import ProvableContracts.Theorems.DPO +import ProvableContracts.Theorems.F16.Conversion +import ProvableContracts.Theorems.FFT.Bluestein +import ProvableContracts.Theorems.FFT.Fft2d +import ProvableContracts.Theorems.FFT.Fft3d +import ProvableContracts.Theorems.FFT.FftBatched +import ProvableContracts.Theorems.Fusion.Correctness +import ProvableContracts.Theorems.GPU.DimensionIndependence +import ProvableContracts.Theorems.GatedDeltaNet.Recurrence +import ProvableContracts.Theorems.GgufExportSymmetry.Roundtrip +import ProvableContracts.Theorems.Image.Canny +import ProvableContracts.Theorems.Image.ConnectedComponents +import ProvableContracts.Theorems.Image.Conv2d +import ProvableContracts.Theorems.Image.Histogram +import ProvableContracts.Theorems.Image.HsvRoundtrip +import ProvableContracts.Theorems.Image.Morphology +import ProvableContracts.Theorems.Image.Resize +import ProvableContracts.Theorems.Image.RgbToGray +import ProvableContracts.Theorems.Image.Sobel +import ProvableContracts.Theorems.LoRA.Dare_Unbiased +import ProvableContracts.Theorems.LoRA.Lora_Shape +import ProvableContracts.Theorems.LoRA.Shape_Preservation +import ProvableContracts.Theorems.LoRA.Task_Vector +import ProvableContracts.Theorems.Lora.MergeForwardEquiv +import ProvableContracts.Theorems.MatMul.CooperativeTiling +import ProvableContracts.Theorems.MatMul.Distributivity +import ProvableContracts.Theorems.MatMul.MatVecLinearity +import ProvableContracts.Theorems.MatMul.Shape +import ProvableContracts.Theorems.MatMul.TransposeMul +import ProvableContracts.Theorems.Metrics.Regression +import ProvableContracts.Theorems.Metrics.RegressionAnalytic +import ProvableContracts.Theorems.Quantization.NF4Dequant +import ProvableContracts.Theorems.RMSNorm.NormalizedRMS +import ProvableContracts.Theorems.Rand.Philox +import ProvableContracts.Theorems.Rand.Threefry +import ProvableContracts.Theorems.Rope.Rope +import ProvableContracts.Theorems.Sigmoid.SiluAsymptotic +import ProvableContracts.Theorems.Sigmoid.SiluSign +import ProvableContracts.Theorems.Sparse.BsrSpmv +import ProvableContracts.Theorems.Sparse.SellSpmv +import ProvableContracts.Theorems.Sparse.Spgemm +import ProvableContracts.Theorems.Sparse.Spmm +import ProvableContracts.Theorems.Tensor.Einsum +import ProvableContracts.Theorems.TensorLayout.IndexAlgebra +import ProvableContracts.Theorems.TensorTranspose.Roundtrip +import ProvableContracts.Theorems.Tokenizer diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Defs/GPU.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Defs/GPU.lean index 0096a4ca95..c0fb8b540b 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Defs/GPU.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Defs/GPU.lean @@ -11,7 +11,7 @@ namespace ProvableContracts.GPU /-- A dimension-independent kernel produces the same PTX regardless of input dimensions. We model this as: the kernel function is parametric in dimensions (they are runtime params, not compile-time). -/ -def dimension_independent (kernel : ℕ → ℕ → ℕ → α) : Prop := +def dimension_independent {α : Type*} (kernel : ℕ → ℕ → ℕ → α) : Prop := ∀ m₁ k₁ n₁ m₂ k₂ n₂, kernel m₁ k₁ n₁ = kernel m₂ k₂ n₂ end ProvableContracts.GPU diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Defs/Rand.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Defs/Rand.lean index 83c97f822b..11a8280a15 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Defs/Rand.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Defs/Rand.lean @@ -15,11 +15,11 @@ namespace ProvableContracts.Rand /-- A counter-based RNG is a pure function from (key, counter) to output. Determinism is the core property: same inputs → same output. -/ -def deterministic (f : α → β → γ) : Prop := +def deterministic {α β γ : Type*} (f : α → β → γ) : Prop := ∀ k c, f k c = f k c /-- Counter increment preserves key. -/ -def counter_independent (f : α → ℕ → γ) : Prop := - ∀ k c₁ c₂, c₁ ≠ c₂ → True -- We can't prove outputs differ without the actual function +def counter_independent {α γ : Type*} (f : α → ℕ → γ) : Prop := + ∀ (k : α) (c₁ c₂ : ℕ), c₁ ≠ c₂ → True -- We can't prove outputs differ without the actual function end ProvableContracts.Rand diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Alibi/Slopes.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Alibi/Slopes.lean index e2dd4c1532..c3d629ee7f 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Alibi/Slopes.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Alibi/Slopes.lean @@ -56,7 +56,7 @@ theorem alibi_head_zero_eight : alibiSlope 8 0 = 1 / 2 := by -- Status: proved /-- Every ALiBi slope is strictly positive (base `2 > 0`). -/ -theorem alibi_slope_pos (n h : ℝ) : 0 < alibiSlope n h := by +theorem alibi_slope_pos_real (n h : ℝ) : 0 < alibiSlope n h := by unfold alibiSlope exact Real.rpow_pos_of_pos (by norm_num) _ @@ -97,13 +97,13 @@ theorem alibi_slope_strict_anti {n h₁ h₂ : ℝ} (hn : 0 < n) (h : h₁ < h -- Tests #check @alibi_head_zero #check @alibi_head_zero_eight -#check @alibi_slope_pos +#check @alibi_slope_pos_real #check @alibi_slope_lt_one #check @alibi_slope_ggml #check @alibi_slope_strict_anti example : alibiSlope 8 0 = 1 / 2 := alibi_head_zero_eight -example : (0 : ℝ) < alibiSlope 8 3 := alibi_slope_pos 8 3 +example : (0 : ℝ) < alibiSlope 8 3 := alibi_slope_pos_real 8 3 example : alibiSlope 8 0 = alibiM0 8 := alibi_head_zero 8 end ProvableContracts.Alibi diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/AprCode/HarnessIrRoundtrip.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/AprCode/HarnessIrRoundtrip.lean index 4c80fea001..17d44c008a 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/AprCode/HarnessIrRoundtrip.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/AprCode/HarnessIrRoundtrip.lean @@ -95,7 +95,7 @@ theorem list_roundtrip (bs : List Block) (h : ∀ b ∈ bs, anthNative b) : induction bs with | nil => rfl | cons b bs ih => - have hb : anthNative b := h b (List.mem_cons_self b bs) + have hb : anthNative b := h b (List.mem_cons_self) have hbs : ∀ b' ∈ bs, anthNative b' := fun b' hb' => h b' (List.mem_cons_of_mem b hb') simp only [List.map_cons, block_roundtrip b hb, ih hbs] @@ -151,7 +151,7 @@ theorem gem_list_roundtrip (bs : List Block) (h : ∀ b ∈ bs, gemNative b) : induction bs with | nil => rfl | cons b bs ih => - have hb : gemNative b := h b (List.mem_cons_self b bs) + have hb : gemNative b := h b (List.mem_cons_self) have hbs : ∀ b' ∈ bs, gemNative b' := fun b' hb' => h b' (List.mem_cons_of_mem b hb') simp only [List.map_cons, gem_block_roundtrip b hb, ih hbs] @@ -188,7 +188,7 @@ theorem cross_harness_list (bs : List Block) (h : ∀ b ∈ bs, sharedCore b) : induction bs with | nil => rfl | cons b bs ih => - have hb : sharedCore b := h b (List.mem_cons_self b bs) + have hb : sharedCore b := h b (List.mem_cons_self) have hbs : ∀ b' ∈ bs, sharedCore b' := fun b' hb' => h b' (List.mem_cons_of_mem b hb') simp only [List.map_cons, cross_harness_block b hb, ih hbs] diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/BLAS/Trmm.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/BLAS/Trmm.lean index f64a33b9b3..e3146789df 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/BLAS/Trmm.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/BLAS/Trmm.lean @@ -1,3 +1,4 @@ +import Mathlib.Data.Real.Basic import Mathlib.Data.Matrix.Basic /-! diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/BLAS/Trsm.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/BLAS/Trsm.lean index f9ae2067d0..bd91081e4b 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/BLAS/Trsm.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/BLAS/Trsm.lean @@ -1,3 +1,4 @@ +import Mathlib.Data.Real.Basic import Mathlib.Data.Matrix.Basic import Mathlib.LinearAlgebra.Matrix.NonsingularInverse diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/FFT/Fft2d.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/FFT/Fft2d.lean index 6b91b6d487..bf867b1749 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/FFT/Fft2d.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/FFT/Fft2d.lean @@ -1,3 +1,4 @@ +import Mathlib.Analysis.Complex.Exponential import Mathlib.Data.Complex.Basic import Mathlib.Algebra.BigOperators.Group.Finset.Basic diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/FFT/Fft3d.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/FFT/Fft3d.lean index 7c3369345b..8a50e1165e 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/FFT/Fft3d.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/FFT/Fft3d.lean @@ -1,3 +1,4 @@ +import Mathlib.Analysis.Complex.Exponential import Mathlib.Data.Complex.Basic /-! diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/FFT/FftBatched.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/FFT/FftBatched.lean index c92aa54650..38eab5c207 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/FFT/FftBatched.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/FFT/FftBatched.lean @@ -12,7 +12,7 @@ namespace ProvableContracts.FFT -- Status: proved /-- Batch independence: function applied to i-th element doesn't depend on j-th. -/ -theorem batch_independence {n : ℕ} (f : Fin n → α) (i : Fin n) : +theorem batch_independence {n : ℕ} {α : Type*} (f : Fin n → α) (i : Fin n) : f i = f i := rfl #check @batch_independence diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/GPU/DimensionIndependence.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/GPU/DimensionIndependence.lean index 43604168da..1b865bd18d 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/GPU/DimensionIndependence.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/GPU/DimensionIndependence.lean @@ -12,7 +12,7 @@ namespace ProvableContracts.GPU -- Status: proved /-- A constant kernel is trivially dimension-independent. -/ -theorem const_is_dimension_independent (c : α) : +theorem const_is_dimension_independent {α : Type*} (c : α) : dimension_independent (fun (_ _ _ : ℕ) => c) := by intro m₁ k₁ n₁ m₂ k₂ n₂ rfl diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Canny.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Canny.lean index 9771577e17..08b2d5e9de 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Canny.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Canny.lean @@ -1,3 +1,4 @@ +import Mathlib.Tactic.Linarith import Mathlib.Data.Real.Basic /-! diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Histogram.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Histogram.lean index 593a65f8c3..1bcce58404 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Histogram.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Histogram.lean @@ -1,3 +1,4 @@ +import Mathlib.Data.Fintype.Card import ProvableContracts.Defs.Image import Mathlib.Algebra.BigOperators.Group.Finset.Basic diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Morphology.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Morphology.lean index 60f9483dcd..d18906ff3f 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Morphology.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Morphology.lean @@ -1,4 +1,4 @@ -import Mathlib.Order.BooleanAlgebra +import Mathlib.Order.BooleanAlgebra.Basic /-! # Morphology — Opening is Anti-Extensive diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Resize.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Resize.lean index 7893fd3774..930302718c 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Resize.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/Resize.lean @@ -1,3 +1,4 @@ +import Mathlib.Tactic.Linarith import Mathlib.Data.Real.Basic /-! diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/RgbToGray.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/RgbToGray.lean index ee2b233c8c..163b284958 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/RgbToGray.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Image/RgbToGray.lean @@ -1,3 +1,4 @@ +import Mathlib.Tactic.NormNum import ProvableContracts.Defs.Image /-! diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Quantization/NF4Dequant.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Quantization/NF4Dequant.lean index f0f527e9ea..c252e4b5d8 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Quantization/NF4Dequant.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Quantization/NF4Dequant.lean @@ -28,11 +28,11 @@ namespace ProvableContracts.NF4 axiom nf4_lut : Fin 16 → ℝ /-- NF4 dequantization of a single nibble. -/ -def dequant_nibble (nibble : Fin 16) : ℝ := +noncomputable def dequant_nibble (nibble : Fin 16) : ℝ := nf4_lut nibble /-- Blockwise dequantization: x_i = LUT[nibble_i] * absmax[i / blocksize] -/ -def dequant_blockwise (nibbles : List (Fin 16)) (absmax : List ℝ) (blocksize : ℕ) +noncomputable def dequant_blockwise (nibbles : List (Fin 16)) (absmax : List ℝ) (blocksize : ℕ) (_hbs : blocksize > 0) : List ℝ := (nibbles.zip (List.range nibbles.length)).map fun ⟨n, i⟩ => dequant_nibble n * (absmax.getD (i / blocksize) 0) diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Rand/Philox.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Rand/Philox.lean index 8868ed1a0c..c7ac9b30ae 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Rand/Philox.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Rand/Philox.lean @@ -15,7 +15,7 @@ namespace ProvableContracts.Rand -- Status: proved /-- Any pure function is deterministic. -/ -theorem philox_deterministic (f : α → β → γ) : deterministic f := by +theorem philox_deterministic {α β γ : Type*} (f : α → β → γ) : deterministic f := by intro k c rfl diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Rand/Threefry.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Rand/Threefry.lean index e64f602ab0..81c84e4d54 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Rand/Threefry.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Rand/Threefry.lean @@ -15,7 +15,7 @@ namespace ProvableContracts.Rand -- Status: proved /-- ThreeFry is deterministic (same inputs → same output). -/ -theorem threefry_deterministic (f : α → β → γ) : deterministic f := by +theorem threefry_deterministic {α β γ : Type*} (f : α → β → γ) : deterministic f := by intro k c; rfl #check @threefry_deterministic diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Sigmoid/SiluAsymptotic.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Sigmoid/SiluAsymptotic.lean index cc6cfac79e..6c3cf6c86e 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Sigmoid/SiluAsymptotic.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Sigmoid/SiluAsymptotic.lean @@ -27,7 +27,7 @@ open Real -- Status: proved /-- Sigmoid is dominated by `exp`: `σ(x) < exp(x)` for all x, because `exp(x) · (1 + exp(-x)) = exp(x) + 1 > 1`. -/ -theorem sigmoid_lt_exp (x : ℝ) : sigmoid x < Real.exp x := by +private theorem sigmoid_lt_exp (x : ℝ) : sigmoid x < Real.exp x := by unfold sigmoid have hpos : (0:ℝ) < 1 + Real.exp (-x) := by linarith [Real.exp_pos (-x)] rw [div_lt_iff₀ hpos] diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Sparse/Spgemm.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Sparse/Spgemm.lean index 4df050d2ba..eb400f2592 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Sparse/Spgemm.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Sparse/Spgemm.lean @@ -1,3 +1,4 @@ +import Mathlib.Data.Real.Basic import Mathlib.Data.Matrix.Basic /-! diff --git a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Sparse/Spmm.lean b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Sparse/Spmm.lean index 264cdf1cb2..d2b8fb4346 100644 --- a/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Sparse/Spmm.lean +++ b/crates/aprender-contracts-staging/lean/ProvableContracts/Theorems/Sparse/Spmm.lean @@ -1,3 +1,4 @@ +import Mathlib.Data.Real.Basic import Mathlib.Data.Matrix.Basic /-! diff --git a/crates/aprender-contracts/Cargo.toml b/crates/aprender-contracts/Cargo.toml index eb7a20ae47..55ddcf61ef 100644 --- a/crates/aprender-contracts/Cargo.toml +++ b/crates/aprender-contracts/Cargo.toml @@ -11,6 +11,13 @@ keywords = ["verification", "formal-methods", "kani", "contracts", "ml-kernels"] categories = ["development-tools::testing", "mathematics"] exclude = ["/tests/", "benches/"] +[package.metadata.kani] +# kani_proofs_cd.rs uses #[kani::stub], which needs -Z stubbing. Kani reads this +# only from the crate directory (`cd crates/aprender-contracts && cargo kani +# --lib`); `cargo kani -p` from the workspace root still needs `-Z stubbing` +# (#3140). +unstable = { stubbing = true } + [features] default = [] kani = [] @@ -19,7 +26,7 @@ kani = [] name = "provable_contracts" [dependencies] -provable-contracts-macros = { path = "../aprender-contracts-macros", version = "0.69.3", package = "aprender-contracts-macros" } +provable-contracts-macros = { path = "../aprender-contracts-macros", version = "0.70.0", package = "aprender-contracts-macros" } regex = "1" sha2 = { workspace = true } syn = { workspace = true } diff --git a/crates/aprender-contracts/build.rs b/crates/aprender-contracts/build.rs index cae449f0f7..69a8e70a05 100644 --- a/crates/aprender-contracts/build.rs +++ b/crates/aprender-contracts/build.rs @@ -3,6 +3,9 @@ use serde::Deserialize; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +// The one key builder, shared with `build_helper::env_key` (#4369). +include!("src/env_key.rs"); + #[derive(Deserialize)] struct BindingFile { #[allow(dead_code)] @@ -78,13 +81,7 @@ fn load_binding_file(binding_path: &Path) -> Option { fn emit_binding_vars(bindings: &BindingFile, total: &mut u32, implemented: &mut u32) { for b in &bindings.bindings { - let stem = b - .contract - .trim_end_matches(".yaml") - .to_uppercase() - .replace('-', "_"); - let eq = b.equation.to_uppercase().replace('-', "_"); - let var = format!("CONTRACT_{stem}_{eq}"); + let var = env_key(b.contract.trim_end_matches(".yaml"), &b.equation); println!("cargo:rustc-env={var}={}", b.status); *total += 1; if b.status == "implemented" { @@ -134,11 +131,7 @@ fn emit_contract_file_pre_post(path: &PathBuf, total_pre: &mut usize, total_post return; }; for (eq_name, eq) in &parsed.equations { - let key = format!( - "CONTRACT_{}_{}", - stem, - eq_name.to_uppercase().replace('-', "_") - ); + let key = env_key(&stem, eq_name); emit_pre_post_lists(&key, eq, total_pre, total_post); } } diff --git a/crates/aprender-contracts/src/binding.rs b/crates/aprender-contracts/src/binding.rs index dea8fe20cc..b32769d112 100644 --- a/crates/aprender-contracts/src/binding.rs +++ b/crates/aprender-contracts/src/binding.rs @@ -49,6 +49,11 @@ pub struct KernelBinding { /// Free-form notes. #[serde(default)] pub notes: Option, + /// This entry binds a cuda-oxide `#[kernel]` beside the equation's CPU reference (ONT-4c4, #4069): the + /// ontology joins the two by the shared equation. BINDING-006 keys on it, so an equation holds at most one + /// reference and one kernel, and [`BindingRegistry::find_binding`] never returns the kernel entry. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub kernel: bool, } /// Implementation status of a binding. @@ -119,9 +124,9 @@ impl BindingRegistry { /// Find a specific binding by contract + equation (normalizes contract). pub fn find_binding(&self, contract_id: &str, equation: &str) -> Option<&KernelBinding> { let needle = normalize_contract_id(contract_id); - self.bindings - .iter() - .find(|b| normalize_contract_id(&b.contract) == needle && b.equation == equation) + self.bindings.iter().find(|b| { + !b.kernel && normalize_contract_id(&b.contract) == needle && b.equation == equation + }) } /// L5 verification: return a copy of this registry in which every binding @@ -285,7 +290,8 @@ pub fn validate_binding_registry(registry: &BindingRegistry) -> Vec { )); } - let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); + let mut seen: std::collections::HashSet<(String, String, bool)> = + std::collections::HashSet::new(); for (i, binding) in registry.bindings.iter().enumerate() { validate_one_binding(i, binding, &mut seen, &mut violations); } @@ -296,7 +302,7 @@ pub fn validate_binding_registry(registry: &BindingRegistry) -> Vec { fn validate_one_binding( index: usize, binding: &KernelBinding, - seen: &mut std::collections::HashSet<(String, String)>, + seen: &mut std::collections::HashSet<(String, String, bool)>, violations: &mut Vec, ) { let at = |field: &str| format!("bindings[{index}].{field}"); @@ -359,9 +365,12 @@ fn validate_one_binding( // BINDING-006: one equation, one binding. `find_binding` returns the FIRST // match, so a duplicate silently decides which implementation is audited. + // A `kernel: true` entry is keyed apart: it is the equation's #[kernel], graded + // by the kernel shapes against the reference, and `find_binding` skips it. let key = ( normalize_contract_id(&binding.contract).to_string(), binding.equation.clone(), + binding.kernel, ); if !seen.insert(key) { violations.push(err( @@ -499,6 +508,7 @@ bindings: signature: None, status: ImplStatus::Implemented, notes: None, + kernel: false, }; assert!(bound.function_defined_in(&names)); @@ -536,6 +546,7 @@ bindings: signature: None, status: ImplStatus::Implemented, notes: None, + kernel: false, }, KernelBinding { contract: "c-v1.yaml".into(), @@ -545,6 +556,7 @@ bindings: signature: None, status: ImplStatus::Implemented, notes: None, + kernel: false, }, ], }; diff --git a/crates/aprender-contracts/src/build_helper.rs b/crates/aprender-contracts/src/build_helper.rs index d42e75731d..80b053ac82 100644 --- a/crates/aprender-contracts/src/build_helper.rs +++ b/crates/aprender-contracts/src/build_helper.rs @@ -98,7 +98,7 @@ pub fn verify_bindings(binding_yaml_path: &str, policy: BindingPolicy) -> Verify }; for binding in ®istry.bindings { - let env_key = make_env_key(&binding.contract, &binding.equation); + let env_key = env_key(&binding.contract, &binding.equation); match binding.status { ImplStatus::Implemented => { @@ -321,14 +321,8 @@ fn scan_source_fns(dir: &Path, found: &mut std::collections::HashSet) { } } -/// Generate the env var key from contract name and equation name. -/// -/// Same convention as `provable-contracts-macros::make_env_key`. -fn make_env_key(contract: &str, equation: &str) -> String { - let contract_part = contract.to_uppercase().replace(['-', '.'], "_"); - let equation_part = equation.to_uppercase().replace(['-', '.'], "_"); - format!("CONTRACT_{contract_part}_{equation_part}") -} +// One definition, shared with this crate's own build.rs (#4369). +include!("env_key.rs"); #[cfg(test)] mod tests { @@ -448,14 +442,39 @@ bindings: // ── make_env_key tests ── + /// Parity with the key the macro READS, over a case table, so the + /// producer's derivation and the consumer's cannot drift (#2699 §4). + #[test] + fn env_key_equals_the_key_the_contract_macro_reads() { + use provable_contracts_macros::contract_env_key; + let table = [ + ( + env_key("rmsnorm-kernel-v1", "rmsnorm"), + contract_env_key!("rmsnorm-kernel-v1", "rmsnorm"), + ), + ( + env_key("attention-kernel-v1", "scaled_dot_product"), + contract_env_key!("attention-kernel-v1", "scaled_dot_product"), + ), + (env_key("v1.0", "eq.1"), contract_env_key!("v1.0", "eq.1")), + ( + env_key("mixed-Case.v2", "Eq-x.y"), + contract_env_key!("mixed-Case.v2", "Eq-x.y"), + ), + ]; + for (producer, consumer) in table { + assert_eq!(producer, consumer); + } + } + #[test] fn test_make_env_key_matches_macro_convention() { assert_eq!( - make_env_key("rmsnorm-kernel-v1", "rmsnorm"), + env_key("rmsnorm-kernel-v1", "rmsnorm"), "CONTRACT_RMSNORM_KERNEL_V1_RMSNORM" ); assert_eq!( - make_env_key("gated-delta-net-v1", "decay"), + env_key("gated-delta-net-v1", "decay"), "CONTRACT_GATED_DELTA_NET_V1_DECAY" ); } @@ -463,7 +482,7 @@ bindings: #[test] fn make_env_key_with_yaml_extension() { assert_eq!( - make_env_key("softmax-kernel-v1.yaml", "softmax"), + env_key("softmax-kernel-v1.yaml", "softmax"), "CONTRACT_SOFTMAX_KERNEL_V1_YAML_SOFTMAX" ); } @@ -471,7 +490,7 @@ bindings: #[test] fn make_env_key_dots_replaced() { assert_eq!( - make_env_key("my.contract.v1", "eq.1"), + env_key("my.contract.v1", "eq.1"), "CONTRACT_MY_CONTRACT_V1_EQ_1" ); } @@ -1226,4 +1245,142 @@ bindings: // A bare filename with no directory separators verify_bindings("nonexistent.yaml", BindingPolicy::WarnOnGaps); } + + // ── #4369: no build.rs hand-rolls the key ── + + /// A line of a build.rs that builds a `CONTRACT__` key itself + /// instead of calling `env_key`: a format string that interpolates + /// straight after `CONTRACT_`, or a `"CONTRACT_"` prefix to concatenate + /// onto. Fixed names (`CONTRACT_BINDING_SOURCE=none`) and comments pass. + fn hand_rolls_contract_key(line: &str) -> bool { + let code = line.trim_start(); + !code.starts_with("//") && (code.contains("CONTRACT_{") || code.contains("\"CONTRACT_\"")) + } + + #[test] + fn hand_rolled_key_detector_case_table() { + let must_match = [ + r#"let var = format!("CONTRACT_{stem}_{eq}");"#, + r#" "CONTRACT_{}_{}","#, + r#"let k = format!("CONTRACT_{su}_{eu}");"#, + r#"let k = "CONTRACT_".to_string() + &stem;"#, + r#"println!("cargo:rustc-env=CONTRACT_{stem}_{eq}={}", s);"#, + ]; + let must_not_match = [ + r#"println!("cargo:rustc-env=CONTRACT_BINDING_SOURCE=none");"#, + r#"println!("cargo:rustc-env=CONTRACT_TOTAL={total}");"#, + r#"let var = provable_contracts::build_helper::env_key(c, e);"#, + r#"println!("cargo:rustc-env={k}_PRE_COUNT={}", n);"#, + r#"// CONTRACT_{STEM}_{EQ}="#, + r#"/// `"x.yaml"` → `"CONTRACT_{}_{}"`"#, + ]; + for l in must_match { + assert!(hand_rolls_contract_key(l), "must match: {l}"); + } + for l in must_not_match { + assert!(!hand_rolls_contract_key(l), "must not match: {l}"); + } + } + + /// Every build.rs in the workspace builds its keys with `env_key` (#4369). + /// A hand-rolled copy that drifts from the macro's format binds nothing, + /// silently (#2699 §4). Outside the workspace (a packaged crate) there is + /// no tree to scan and the test says so rather than passing on zero files. + #[test] + fn no_build_rs_hand_rolls_the_contract_key() { + let crates = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let root = crates.join(".."); + if !root.join("Cargo.toml").exists() || !crates.join("aprender-core").exists() { + eprintln!("SKIP: not in the aprender workspace (packaged crate)"); + return; + } + let mut scanned = Vec::new(); + let mut offenders = Vec::new(); + let mut files = vec![root.join("build.rs")]; + for e in std::fs::read_dir(&crates).expect("read crates/") { + files.push(e.expect("dir entry").path().join("build.rs")); + } + for f in files.into_iter().filter(|f| f.is_file()) { + let src = std::fs::read_to_string(&f).expect("read build.rs"); + for (i, l) in src.lines().enumerate() { + if hand_rolls_contract_key(l) { + offenders.push(format!("{}:{}: {}", f.display(), i + 1, l.trim())); + } + } + scanned.push(f); + } + // 12 producers existed when this guard was written; a scan of fewer + // build.rs files than that is looking in the wrong place. + assert!( + scanned.len() >= 12, + "scanned only {} build.rs files", + scanned.len() + ); + assert!( + offenders.is_empty(), + "build.rs hand-rolls a CONTRACT_* key; call provable_contracts::build_helper::env_key \ + (after trimming \".yaml\"):\n{}", + offenders.join("\n") + ); + } + + /// Every producer reads its binding from THIS repo's `contracts//` + /// (#4369). The old `../../../provable-contracts/contracts//` path + /// resolved only on a checkout with the pre-monorepo sibling beside it, so + /// worktrees and CI built with `CONTRACT_BINDING_SOURCE=none` and nothing + /// was enforced, while the one main checkout enforced a stale copy. + #[test] + fn producer_bindings_are_in_tree() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + if !root.join("crates/aprender-core").exists() { + eprintln!("SKIP: not in the aprender workspace (packaged crate)"); + return; + } + let producers = [ + ("aprender-compute", "trueno"), + ("aprender-core", "aprender"), + ("aprender-db", "trueno-db"), + ("aprender-orchestrate", "batuta"), + ("aprender-present-cli", "presentar"), + ("aprender-present-core", "presentar"), + ("aprender-rag", "trueno-rag"), + ("aprender-serve", "realizar"), + ("aprender-simulate", "simular"), + ("aprender-train", "entrenar"), + ]; + let mut bad = Vec::new(); + for (krate, name) in producers { + let build = root.join("crates").join(krate).join("build.rs"); + let src = std::fs::read_to_string(&build).expect("read producer build.rs"); + let want = format!("\"../../contracts/{name}/binding.yaml\""); + if !src.contains(&want) { + bad.push(format!("{krate}/build.rs does not read {want}")); + } + // Per statement: a sibling path joined onto binding.yaml. Other + // sibling reads (serve's arch-requirements codegen) are out of scope. + let sibling_binding = |st: &str| { + (st.contains("join(\"provable-contracts\")") + || st.contains("provable-contracts/contracts/")) + && st.contains("binding.yaml") + }; + if src.split(';').any(sibling_binding) { + bad.push(format!( + "{krate}/build.rs still reads binding.yaml from the sibling provable-contracts" + )); + } + if !root + .join("contracts") + .join(name) + .join("binding.yaml") + .is_file() + { + bad.push(format!("contracts/{name}/binding.yaml is missing")); + } + } + assert!( + bad.is_empty(), + "producer bindings not in-tree:\n{}", + bad.join("\n") + ); + } } diff --git a/crates/aprender-contracts/src/env_key.rs b/crates/aprender-contracts/src/env_key.rs new file mode 100644 index 0000000000..bc38586f53 --- /dev/null +++ b/crates/aprender-contracts/src/env_key.rs @@ -0,0 +1,16 @@ +// `include!`d by `build_helper.rs` (which carries the equality test against the +// macro) and by this crate's own build.rs, which cannot build-depend on the +// crate it builds (#4369). +/// The env var key `#[contract(contract, equation = equation)]` reads. +/// +/// A producer (a consuming crate's `build.rs`) must emit its +/// `cargo:rustc-env` vars under exactly this key, plus `_PRE_COUNT`, +/// `_PRE_`, `_POST_COUNT` and `_POST_`. Call this instead of +/// re-deriving the format: a hand-rolled copy that drifts makes every +/// condition land under a name the macro never reads (#2699 §4). It is +/// tested equal to the macro's own `contract_env_key!`. +pub fn env_key(contract: &str, equation: &str) -> String { + let contract_part = contract.to_uppercase().replace(['-', '.'], "_"); + let equation_part = equation.to_uppercase().replace(['-', '.'], "_"); + format!("CONTRACT_{contract_part}_{equation_part}") +} diff --git a/crates/aprender-contracts/src/explain_tests.rs b/crates/aprender-contracts/src/explain_tests.rs index 19cf64e939..befb972682 100644 --- a/crates/aprender-contracts/src/explain_tests.rs +++ b/crates/aprender-contracts/src/explain_tests.rs @@ -999,6 +999,7 @@ falsification_tests: [] signature: None, status: crate::binding::ImplStatus::Implemented, notes: None, + kernel: false, }, crate::binding::KernelBinding { contract: "bind-md-v1.yaml".to_string(), @@ -1008,6 +1009,7 @@ falsification_tests: [] signature: None, status: crate::binding::ImplStatus::Partial, notes: None, + kernel: false, }, ], }; @@ -1045,6 +1047,7 @@ falsification_tests: [] signature: None, status: crate::binding::ImplStatus::Implemented, notes: None, + kernel: false, }], }; @@ -1142,6 +1145,7 @@ falsification_tests: [] signature: None, status: crate::binding::ImplStatus::Implemented, notes: None, + kernel: false, }], }; @@ -1235,6 +1239,7 @@ falsification_tests: [] signature: None, status: crate::binding::ImplStatus::Implemented, notes: None, + kernel: false, }, crate::binding::KernelBinding { contract: "bind-status-v1.yaml".to_string(), @@ -1244,6 +1249,7 @@ falsification_tests: [] signature: None, status: crate::binding::ImplStatus::NotImplemented, notes: None, + kernel: false, }, ], }; diff --git a/crates/aprender-contracts/src/kernels/kani_proofs.rs b/crates/aprender-contracts/src/kernels/kani_proofs.rs index ec2a087990..54599ccc46 100644 --- a/crates/aprender-contracts/src/kernels/kani_proofs.rs +++ b/crates/aprender-contracts/src/kernels/kani_proofs.rs @@ -32,6 +32,19 @@ use super::swiglu; // Float transcendental stubs // ════════════════════════════════════════════════════════════════════════════ +/// Magnitude bound for harness inputs that feed products, sums or exponents. +/// +/// "Finite for every finite input" is false for these kernels: Kani finds +/// inputs near `f32::MAX` whose dot products overflow to inf, and inf - inf +/// is NaN (#3140). Real activations sit far inside this bound, so a harness +/// states its precondition as `|x| <= ACT_BOUND` instead of `x.is_finite()`. +const ACT_BOUND: f32 = 1.0e4; + +/// `x` is finite and within [`ACT_BOUND`]. +fn act_bounded(x: f32) -> bool { + x.is_finite() && x.abs() <= ACT_BOUND +} + /// Stub for `f32::exp` — models exp(x) for softmax after max-subtraction. /// /// Softmax calls exp(x_i - max(x)), where x_i - max(x) ∈ (-∞, 0]. @@ -42,7 +55,13 @@ use super::swiglu; /// - Lean proves exp(x) > 0 for all x ∈ ℝ (Real.exp_pos) /// - Lean proves exp(0) = 1 (Real.exp_zero) /// - The upper bound 1.0 follows from x_i - max(x) ≤ 0 → exp(·) ≤ 1 -fn stub_exp(_x: f32) -> f32 { +fn stub_exp(x: f32) -> f32 { + // exp(0) = 1 exactly (the max element). Without this the stub let every + // element be tiny at once, so 1/sum overflowed and softmax/attention + // harnesses reported non-finite outputs no real exp can produce (#3140). + if x == 0.0 { + return 1.0; + } let r: f32 = kani::any(); // exp(x - max(x)) for x - max(x) ∈ [-88, 0] gives values in [exp(-88), 1]. // exp(-88) ≈ 6e-39 > f32::MIN_POSITIVE (1.175e-38 is normal). @@ -51,10 +70,28 @@ fn stub_exp(_x: f32) -> f32 { r } +/// Deterministic exp stand-in for EQUIVALENCE harnesses (PMAT-3140). +/// +/// `stub_exp` draws a fresh `kani::any()` per call, so two code paths that each +/// call `exp` on the same argument see two unrelated values and an equivalence +/// assertion between them is refuted by the stub, not by the code. This stub is a +/// pure function of `x` (positive, like exp), so both paths see the same value +/// and the harness judges only the arithmetic around the call. It has no branch +/// and no division: a stub with both timed out at 600 s on 4 elements. +fn stub_exp_det(x: f32) -> f32 { + 1.0 + x * x +} + /// Stub for `f32::sqrt` — returns an arbitrary non-negative finite value. -fn stub_sqrt(_x: f32) -> f32 { +fn stub_sqrt(x: f32) -> f32 { let r: f32 = kani::any(); kani::assume(r >= 0.0 && r.is_finite()); + // For x >= 0, sqrt(x) lies between x and 1 (both representable, so the + // bracket survives rounding). Without it the stub could return 0 for + // sqrt(d_k) and divide the attention scores by zero (#3140). + if x >= 0.0 { + kani::assume(r >= x.min(1.0) && r <= x.max(1.0)); + } r } diff --git a/crates/aprender-contracts/src/kernels/kani_proofs_ab.rs b/crates/aprender-contracts/src/kernels/kani_proofs_ab.rs index 0377fecad8..4bb3ce499d 100644 --- a/crates/aprender-contracts/src/kernels/kani_proofs_ab.rs +++ b/crates/aprender-contracts/src/kernels/kani_proofs_ab.rs @@ -190,18 +190,29 @@ fn verify_softmax_bounded() { } } -/// KANI-RN-001: RMSNorm output is finite when eps > 0. +/// KANI-RN-001: RMSNorm output is finite when eps > 0, |x| <= 1e18 and |gamma| <= 1e30. /// Obligation: RN-INV-001 /// Strategy: exhaustive /// Bound: 16 elements +/// +/// Bounds (PMAT-3140). Input: unbounded, Kani 0.67 reported +/// `rmsnorm_scalar.NaN.5 FAILURE "NaN on division"` at `1.0 / rms` (rmsnorm.rs:37) +/// on a trace with |x| ~ 1.9e36, where sum(x^2) overflows. IEEE gives +/// sqrt(inf) = inf and 1/inf = 0, so this is most likely CBMC's builtin sqrtf +/// model on inf, not a runtime NaN. The bound stays anyway: past it the real +/// output collapses to all zeros, which is finite but is not RMSNorm. Gamma: pure +/// arithmetic, not a Kani finding. The normalized value reaches sqrt(n) = 4, so +/// |gamma| > f32::MAX / 4 ~ 8.5e37 overflows, and 1e30 is conservative. #[kani::proof] #[kani::unwind(17)] fn verify_rmsnorm_finiteness() { const N: usize = 16; let input: [f32; N] = kani::any(); let gamma: [f32; N] = kani::any(); - kani::assume(input.iter().all(|x| x.is_finite())); - kani::assume(gamma.iter().all(|x| x.is_finite())); + // |x| <= 1e18 keeps sum(x^2) <= 1.6e37 < f32::MAX. Past that, sum_sq overflows, + // inv_rms becomes 0 and every output is 0: finite, but no longer RMSNorm. + kani::assume(input.iter().all(|x| x.is_finite() && x.abs() <= 1.0e18)); + kani::assume(gamma.iter().all(|x| x.is_finite() && x.abs() <= 1.0e30)); let eps: f32 = kani::any(); kani::assume(eps > 0.0 && eps.is_finite() && eps < 1.0); @@ -222,7 +233,12 @@ fn verify_rmsnorm_finiteness() { /// Obligation: RN-INV-002 /// Strategy: exhaustive /// Bound: 16 elements -/// Inlines the RMS computation to check the intermediate value. +/// Inlines the RMS computation to check the intermediate value, then binds the +/// property to the KERNEL: the inline copy alone never calls `rmsnorm_scalar`, +/// so a kernel that dropped or negated `+ eps` stayed green (#3140 finding). +/// The all-zero input is the worst case (sum_sq = 0, denominator = eps): there +/// the kernel's output is finite iff its denominator was positive, since a zero +/// denominator gives 0 * inf = NaN and a negative one gives sqrt(<0) = NaN. #[kani::proof] #[kani::unwind(17)] fn verify_rms_positive() { @@ -241,6 +257,20 @@ fn verify_rms_positive() { let denom = sum_sq / N as f32 + eps; assert!(denom > 0.0, "KANI-RN-002: denominator = {} <= 0", denom); + + let zeros = [0.0f32; N]; + let gamma: [f32; N] = kani::any(); + kani::assume(gamma.iter().all(|g| g.is_finite())); + let mut output = [1.0f32; N]; + rmsnorm::rmsnorm_scalar(&zeros, &gamma, eps, &mut output); + for i in 0..N { + assert!( + output[i] == 0.0, + "KANI-RN-002: kernel output[{}] = {} on zero input (denominator not positive)", + i, + output[i] + ); + } } /// KANI-LN-001: LayerNorm output has zero mean (with gamma=1, beta=0). diff --git a/crates/aprender-contracts/src/kernels/kani_proofs_cd.rs b/crates/aprender-contracts/src/kernels/kani_proofs_cd.rs index ff9106cfa0..78033c7f51 100644 --- a/crates/aprender-contracts/src/kernels/kani_proofs_cd.rs +++ b/crates/aprender-contracts/src/kernels/kani_proofs_cd.rs @@ -59,12 +59,15 @@ fn verify_swiglu_zero_preservation() { /// KANI-SG-002: Fused SwiGLU equivalence: swiglu(gate, value) = silu(gate) * value. /// Obligation: SG-INV-002 /// Strategy: stub_float -/// Bound: 4 elements +/// Bound: 2 elements (elements are independent; 4 timed out at 600 s bit-exact) +/// +/// Not in scripts/kani_parity_chain.sh: proving two symbolic f32 division circuits +/// bit-identical timed out at 600 s (2 and 4 elements). FALSIFY-SG-002 covers it. #[kani::proof] -#[kani::unwind(5)] -#[kani::stub(f32::exp, stub_exp)] +#[kani::unwind(3)] +#[kani::stub(f32::exp, stub_exp_det)] fn verify_swiglu_fused_equivalence() { - const N: usize = 4; + const N: usize = 2; let gate: [f32; N] = kani::any(); let value: [f32; N] = kani::any(); kani::assume(gate.iter().all(|x| x.is_finite())); @@ -84,8 +87,10 @@ fn verify_swiglu_fused_equivalence() { for i in 0..N { assert!( - (fused[i] - unfused[i]).abs() < 1e-5 - || (!fused[i].is_finite() && !unfused[i].is_finite()), + // Same formula, same exp value (deterministic stub): bitwise equal. + // An absolute 1e-5 tolerance is meaningless at |value| ~ 1e34. + fused[i].to_bits() == unfused[i].to_bits() + || (fused[i].is_nan() && unfused[i].is_nan()), "KANI-SG-002: fused[{}] = {} != unfused = {}", i, fused[i], @@ -156,7 +161,7 @@ fn verify_cross_entropy_non_negative() { fn verify_log_softmax_upper_bound() { const N: usize = 8; let logits: [f32; N] = kani::any(); - kani::assume(logits.iter().all(|x| x.is_finite())); + kani::assume(logits.iter().all(|x| act_bounded(*x))); let mut output = [0.0f32; N]; cross_entropy::log_softmax_scalar(&logits, &mut output); @@ -203,7 +208,7 @@ fn verify_cross_entropy_finite() { fn verify_rope_norm_preservation() { const D: usize = 4; let x: [f32; D] = kani::any(); - kani::assume(x.iter().all(|v| v.is_finite())); + kani::assume(x.iter().all(|v| act_bounded(*v))); let position: u32 = kani::any(); kani::assume(position < 1024); @@ -280,9 +285,9 @@ fn verify_attention_weights_normalize() { let q: [f32; N * DK] = kani::any(); let k: [f32; M * DK] = kani::any(); let v: [f32; M * DV] = kani::any(); - kani::assume(q.iter().all(|x| x.is_finite())); - kani::assume(k.iter().all(|x| x.is_finite())); - kani::assume(v.iter().all(|x| x.is_finite())); + kani::assume(q.iter().all(|x| act_bounded(*x))); + kani::assume(k.iter().all(|x| act_bounded(*x))); + kani::assume(v.iter().all(|x| act_bounded(*x))); let mut output = [0.0f32; N * DV]; attention::attention_scalar(&q, &k, &v, N, M, DK, DV, &mut output); @@ -301,7 +306,7 @@ fn verify_attention_weights_normalize() { /// Strategy: stub_float /// Bound: seq_len=2, d_k=2, d_v=2, 2 heads, 2 kv_heads #[kani::proof] -#[kani::unwind(5)] +#[kani::unwind(9)] #[kani::stub(f32::exp, stub_exp)] #[kani::stub(f32::sqrt, stub_sqrt)] fn verify_gqa_weight_normalization() { @@ -314,9 +319,9 @@ fn verify_gqa_weight_normalization() { let q: [f32; HEADS * SEQ * DK] = kani::any(); let k: [f32; KV_HEADS * SEQ * DK] = kani::any(); let v: [f32; KV_HEADS * SEQ * DV] = kani::any(); - kani::assume(q.iter().all(|x| x.is_finite())); - kani::assume(k.iter().all(|x| x.is_finite())); - kani::assume(v.iter().all(|x| x.is_finite())); + kani::assume(q.iter().all(|x| act_bounded(*x))); + kani::assume(k.iter().all(|x| act_bounded(*x))); + kani::assume(v.iter().all(|x| act_bounded(*x))); let mut output = [0.0f32; HEADS * SEQ * DV]; gqa::gqa_scalar(&q, &k, &v, SEQ, DK, DV, HEADS, KV_HEADS, &mut output); @@ -335,7 +340,7 @@ fn verify_gqa_weight_normalization() { /// Strategy: stub_float /// Bound: seq_len=2, d_k=2, d_v=2, 2 heads, 2 kv_heads #[kani::proof] -#[kani::unwind(5)] +#[kani::unwind(9)] #[kani::stub(f32::exp, stub_exp)] #[kani::stub(f32::sqrt, stub_sqrt)] fn verify_gqa_mha_equivalence() { @@ -347,9 +352,9 @@ fn verify_gqa_mha_equivalence() { let q: [f32; HEADS * SEQ * DK] = kani::any(); let k: [f32; HEADS * SEQ * DK] = kani::any(); let v: [f32; HEADS * SEQ * DV] = kani::any(); - kani::assume(q.iter().all(|x| x.is_finite())); - kani::assume(k.iter().all(|x| x.is_finite())); - kani::assume(v.iter().all(|x| x.is_finite())); + kani::assume(q.iter().all(|x| act_bounded(*x))); + kani::assume(k.iter().all(|x| act_bounded(*x))); + kani::assume(v.iter().all(|x| act_bounded(*x))); // GQA with kv_heads = num_heads is standard MHA let mut output_gqa = [0.0f32; HEADS * SEQ * DV]; @@ -372,7 +377,7 @@ fn verify_gqa_mha_equivalence() { /// Strategy: stub_float /// Bound: seq_len=2, d_k=2, d_v=2, 2 heads, 2 kv_heads #[kani::proof] -#[kani::unwind(5)] +#[kani::unwind(9)] #[kani::stub(f32::exp, stub_exp)] #[kani::stub(f32::sqrt, stub_sqrt)] fn verify_gqa_convex_bound() { @@ -385,9 +390,9 @@ fn verify_gqa_convex_bound() { let q: [f32; HEADS * SEQ * DK] = kani::any(); let k: [f32; KV_HEADS * SEQ * DK] = kani::any(); let v: [f32; KV_HEADS * SEQ * DV] = kani::any(); - kani::assume(q.iter().all(|x| x.is_finite())); - kani::assume(k.iter().all(|x| x.is_finite())); - kani::assume(v.iter().all(|x| x.is_finite())); + kani::assume(q.iter().all(|x| act_bounded(*x))); + kani::assume(k.iter().all(|x| act_bounded(*x))); + kani::assume(v.iter().all(|x| act_bounded(*x))); let mut output = [0.0f32; HEADS * SEQ * DV]; gqa::gqa_scalar(&q, &k, &v, SEQ, DK, DV, HEADS, KV_HEADS, &mut output); @@ -408,7 +413,8 @@ fn verify_gqa_convex_bound() { /// Strategy: stub_float /// Bound: n=4, d=2, tile_size=2 #[kani::proof] -#[kani::unwind(5)] +// 9, not 5: the `iter().all` finiteness assumptions walk N*D = 8 elements. +#[kani::unwind(9)] #[kani::stub(f32::exp, stub_exp)] fn verify_online_softmax_2tiles() { const N: usize = 4; @@ -417,9 +423,11 @@ fn verify_online_softmax_2tiles() { let q: [f32; N * D] = kani::any(); let k: [f32; N * D] = kani::any(); let v: [f32; N * D] = kani::any(); - kani::assume(q.iter().all(|x| x.is_finite())); - kani::assume(k.iter().all(|x| x.is_finite())); - kani::assume(v.iter().all(|x| x.is_finite())); + // |x| <= 1e15: a D=2 dot product stays <= 2e30. Unbounded finite q/k overflow the + // score to inf, and inf - inf in the running max is NaN (Kani found this, PMAT-3140). + kani::assume(q.iter().all(|x| x.is_finite() && x.abs() <= 1.0e15)); + kani::assume(k.iter().all(|x| x.is_finite() && x.abs() <= 1.0e15)); + kani::assume(v.iter().all(|x| x.is_finite() && x.abs() <= 1.0e15)); let mut output = [0.0f32; N * D]; flash_attention::flash_attention_scalar(&q, &k, &v, N, D, 2, &mut output); diff --git a/crates/aprender-contracts/src/lib.rs b/crates/aprender-contracts/src/lib.rs index 6ddd406061..5ecbe14749 100644 --- a/crates/aprender-contracts/src/lib.rs +++ b/crates/aprender-contracts/src/lib.rs @@ -68,3 +68,4 @@ pub mod schema; pub mod scoring; pub mod tla_gen; pub mod traits; +pub mod tree; diff --git a/crates/aprender-contracts/src/lint/capability_cells_gate.rs b/crates/aprender-contracts/src/lint/capability_cells_gate.rs new file mode 100644 index 0000000000..2d188b8f0f --- /dev/null +++ b/crates/aprender-contracts/src/lint/capability_cells_gate.rs @@ -0,0 +1,297 @@ +//! ONT-4c5 inside the `shapes` gate: the validator's half of `capability-cells`. +//! +//! The extractor half ([`crate::ontology::capability_cells::apply`]) writes what it finds and the set difference; +//! this module decides what the gate does with it: +//! +//! - **Corpus** — [`corpus`] runs `apply` on the gate's own copy of the graph, only when a contract declares the +//! shape. A receipt version that is not dotted numerals is the declaration's fault (exit 3); |D| = 0 is R-2's +//! decline ([`super::shapes_gate::ShapesOutcome::EmptyDomain`], exit 2), never Pass and never RED. +//! - **Positive control** — [`control`] runs the SAME `apply` over [`PLANT`] (a required rung with no receipt) and +//! then the ARMED `capability-cells` shape over the result. Both must refuse the plant's one cell, or +//! `pc_shapes["capability-cells"]` is `not-fired` and the gate declines: a plant that checked only the Rust set +//! difference would stay `fired` with the shape deleted or disarmed (plan grill round 1). +//! - **Wiring** — [`wiring_holds`]: the rungs the shape flagged on the corpus must be exactly the rungs that own a +//! `not_run` cell. A corpus that bypassed `apply`, or a shape that stopped reading `model:notRunCell`, disagrees +//! and the gate declines (`Differential`) — at run time, not only in a test (plan grill round 2). +//! - **Names** — [`findings`]: one finding per NotRun cell, naming it, because a SHACL `maxCount 0` result names +//! the rung, not which of its hosts is missing. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::ontology::arming::ArmedShapes; +use crate::ontology::capability_cells::{self, CapabilityCells, CellsError}; +use crate::ontology::extract::gguf::{self, Rung}; +use crate::ontology::rdf::{iri, Graph}; +use crate::ontology::receipts::Receipt; +use crate::ontology::shapes::{self, NodeShape, Report, Severity}; + +use super::finding::LintFinding; +use super::rules::RuleSeverity; + +/// The shape's id, as contracts/ont-capability-cells-v1.yaml declares it and `armed_shapes` names it. +pub const SHAPE: &str = "capability-cells"; + +/// The positive control. Byte-identical to the tracked `tests/fixtures/ont/capability-cells-plant.yaml` (a test +/// holds the two together): a crate published without the workspace's `tests/` still carries its plant. +pub const PLANT: &str = include_str!("capability_cells_plant.yaml"); + +/// The one cell the plant must be refused for. +pub const PLANT_CELL: &str = "capability-cells-plant@plant-host"; + +/// What `pv lint --gate shapes --format json` reports as `capability_cells`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct CapabilityCellsReport { + /// The release the cells were measured at. + pub v_star: Option, + /// D, sorted `@` ids. + pub domain: Vec, + /// `D \ {Pass | Fail}`, sorted. Non-empty ⇒ the armed shape fails. + pub not_run: Vec, + /// Row labels `Verdict::from_label` does not know, by file and rung. + pub refused_labels: Vec, +} + +impl From<&CapabilityCells> for CapabilityCellsReport { + fn from(cc: &CapabilityCells) -> Self { + Self { + v_star: cc.v_star.clone(), + domain: cc.domain.iter().cloned().collect(), + not_run: cc.not_run.iter().cloned().collect(), + refused_labels: cc.refused_labels.clone(), + } + } +} + +/// Does any contract declare the shape? Without it there is nothing to compute and nothing to control. +#[must_use] +pub fn declared(shapes: &[NodeShape]) -> bool { + shapes.iter().any(|s| s.id == SHAPE) +} + +/// Run `apply` on the corpus graph. `Ok(None)` when the shape is not declared. +pub fn corpus( + graph: &mut Graph, + shapes: &[NodeShape], + rungs: &[Rung], + receipts: &[Receipt], +) -> Result, CellsError> { + if !declared(shapes) { + return Ok(None); + } + capability_cells::apply(graph, rungs, receipts).map(Some) +} + +/// `fired` iff the plant's one cell is NotRun AND the ARMED `capability-cells` shape refuses the plant's node. +#[must_use] +pub fn control(shapes: &[NodeShape], arming: &ArmedShapes) -> String { + let fired = plant_refused(shapes, arming); + if fired { "fired" } else { "not-fired" }.to_string() +} + +fn plant_refused(shapes: &[NodeShape], arming: &ArmedShapes) -> bool { + if !arming.is_armed(SHAPE) { + return false; + } + let Some(shape) = shapes.iter().find(|s| s.id == SHAPE) else { + return false; + }; + let Ok(doc) = serde_yaml::from_str::(PLANT) else { + return false; + }; + let rungs = gguf::rungs_of("capability-cells-plant", &doc); + let mut g = Graph::new(); + for r in &rungs { + gguf::emit_rung(&mut g, r); + } + let Ok(cc) = capability_cells::apply(&mut g, &rungs, &[]) else { + return false; + }; + if cc.not_run.iter().map(String::as_str).ne([PLANT_CELL]) { + return false; + } + let report = shapes::validate(&g, std::slice::from_ref(shape)); + report + .results + .iter() + .any(|r| r.shape == SHAPE && r.severity == Severity::Violation) +} + +/// The NODES the shape flagged on the corpus are exactly the nodes of the rungs that own a `not_run` cell. +/// Compared by node, not by rung id: ONT-4c1 keys a rung's node by sha256, so two rungs sharing a sha share one +/// node, and an id-level comparison would read the healthy twin as flagged and decline an honest tree (review +/// lane C, round 2). +#[must_use] +pub fn wiring_holds(report: &Report, rungs: &[Rung], cc: &CapabilityCells) -> bool { + let flagged: BTreeSet<&str> = report + .results + .iter() + .filter(|r| r.shape == SHAPE && r.severity == Severity::Violation) + .map(|r| r.focus.as_str()) + .collect(); + // a refused label makes its row NotRun, so its rung already owns a not_run cell + let owning_ids: BTreeSet<&str> = cc + .not_run + .iter() + .filter_map(|id| id.rsplit_once('@').map(|(rung, _)| rung)) + .collect(); + let owning: BTreeSet = rungs + .iter() + .filter(|r| r.required && owning_ids.contains(r.id.as_str())) + .map(|r| iri("model", &r.sha256)) + .collect(); + flagged == owning.iter().map(String::as_str).collect() +} + +/// One finding per NotRun cell and per refused label, naming it. Error when the shape is armed, Warning when not. +#[must_use] +pub fn findings(cc: &CapabilityCells, arming: &ArmedShapes) -> Vec { + let severity = if arming.is_armed(SHAPE) { + RuleSeverity::Error + } else { + RuleSeverity::Warning + }; + let at = cc.v_star.as_deref().unwrap_or("none"); + let mut out: Vec = cc + .not_run + .iter() + .map(|id| { + let why = cc.cells.get(id).map_or_else( + || "no witness row in the V* receipt".to_string(), + |v| format!("its V* row says {v}"), + ); + LintFinding::new( + "PV-ONT-013", + severity, + format!("capability cell {id} is Unknown(NotRun) at V* {at} ({why}) — a required cell is Pass or Fail, never NotRun (ONT-4c5)"), + "contracts/ont-capability-cells-v1.yaml".to_string(), + ) + }) + .collect(); + out.extend(cc.refused_labels.iter().map(|l| { + LintFinding::new( + "PV-ONT-013", + severity, + format!("row label refused by name: {l} — not a fleet label (verdict.rs FLEET_LABELS)"), + "contracts/ont-capability-cells-v1.yaml".to_string(), + ) + })); + for f in &mut out { + f.contract_stem = Some(SHAPE.to_string()); + } + out +} + +/// `pc_shapes`: one entry per shape that carries its own positive control. Empty when none is declared. +#[must_use] +pub fn pc_shapes(shapes: &[NodeShape], arming: &ArmedShapes) -> BTreeMap { + let mut out = BTreeMap::new(); + if declared(shapes) { + out.insert(SHAPE.to_string(), control(shapes, arming)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_embedded_plant_is_the_tracked_fixture_byte_for_byte() { + let tracked = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/ont/capability-cells-plant.yaml"); + let text = std::fs::read_to_string(&tracked).expect("tracked plant fixture exists"); + assert_eq!( + PLANT, text, + "tests/fixtures/ont/capability-cells-plant.yaml drifted from the embedded plant" + ); + } + + /// Review lane C: the §5 probe reads `pc_shapes` and `capability_cells` at the TOP level of the single-gate + /// report, which flattens `extra`; so they must be DIRECT keys of the serialized `GateExtra::Shapes`, not nested + /// under `controls` (they sit in a boxed, flattened `ShapesControls`). + fn rung(id: &str, sha: &str) -> Rung { + Rung { + id: id.into(), + sha256: sha.into(), + arch: "qwen2".into(), + gguf: format!("{id}.gguf"), + backends: vec!["cpu".into()], + hosts: vec!["lambda".into()], + required: true, + contract: "ladder".into(), + } + } + + fn shape() -> NodeShape { + let doc: serde_yaml::Value = serde_yaml::from_str( + "shapes:\n - id: capability-cells\n targetClass: model:RequiredModel\n properties:\n - {path: model:notRunCell, maxCount: 0}\n - {path: model:refusedLabel, maxCount: 0}\n", + ) + .expect("yaml"); + shapes::parse_shapes("fixture", &doc) + .expect("in subset") + .into_iter() + .next() + .expect("one shape") + } + + /// Two required rungs with DIFFERENT ids and ONE sha share one node; only one of them is NotRun. The honest + /// wiring must still hold (compared by node), and a corpus that skipped `apply` must not. + #[test] + fn wiring_is_compared_by_node_so_a_shared_sha_does_not_decline_an_honest_tree() { + let sha = "c".repeat(64); + let rungs = [rung("twin-a", &sha), rung("twin-b", &sha)]; + let ok_row = r#"{"id":"twin-b","sha256":"SHA","green":true,"capability_match":{"passed":true,"skipped":false},"backends":{"cpu":{"ran":true,"fallback":false}}}"#.replace("SHA", &sha); + let rec = crate::ontology::receipts::parse( + "r.json", + &format!(r#"{{"schema":"apr-model-ladder-receipt/v2","host":"lambda","version":"0.69.1","sha":"x","cc":"8.9","gpu":"g","rungs":[{ok_row}]}}"#), + ) + .expect("parses"); + let mut g = Graph::new(); + for r in &rungs { + gguf::emit_rung(&mut g, r); + } + let cc = capability_cells::apply(&mut g, &rungs, &[rec]).expect("applies"); + assert_eq!( + cc.not_run.iter().map(String::as_str).collect::>(), + ["twin-a@lambda"] + ); + let report = shapes::validate(&g, &[shape()]); + assert!(wiring_holds(&report, &rungs, &cc), "{:?}", report.results); + // a corpus that bypassed apply: the same cells, but no edges in the validated graph → must NOT hold + let mut bare = Graph::new(); + for r in &rungs { + gguf::emit_rung(&mut bare, r); + } + let bypassed = shapes::validate(&bare, &[shape()]); + assert!(!wiring_holds(&bypassed, &rungs, &cc)); + } + + #[test] + fn pc_shapes_and_capability_cells_serialize_as_direct_keys_of_the_shapes_extra() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/ont/capcells-clean/contracts"); + let outcome = super::super::shapes_gate::run_shapes_gate(&dir); + let super::super::shapes_gate::ShapesOutcome::Ran { result, .. } = outcome else { + panic!("capcells-clean must run: {outcome:?}"); + }; + let v = serde_json::to_value(result.extra.as_ref().expect("extra")).expect("serializes"); + assert_eq!(v["type"], "shapes", "{v}"); + assert!( + v.get("controls").is_none(), + "the box leaked as a nested key: {v}" + ); + assert_eq!(v["pc_shapes"][SHAPE], "fired", "{v}"); + assert!(v["pc_extract"].is_object(), "{v}"); + assert_eq!(v["capability_cells"]["v_star"], "0.69.1", "{v}"); + assert_eq!( + v["capability_cells"]["domain"].as_array().map(Vec::len), + Some(4), + "{v}" + ); + assert_eq!( + v["capability_cells"]["not_run"], + serde_json::json!([]), + "{v}" + ); + } +} diff --git a/crates/aprender-contracts/src/lint/capability_cells_plant.yaml b/crates/aprender-contracts/src/lint/capability_cells_plant.yaml new file mode 100644 index 0000000000..46033e09bb --- /dev/null +++ b/crates/aprender-contracts/src/lint/capability_cells_plant.yaml @@ -0,0 +1,14 @@ +# ONT-4c5 positive control (paiml/infra paiml-ontology.md v4.12 §5 ONT-4c5): a REQUIRED rung with NO receipt. +# The shapes gate runs it every time through the same capability_cells::apply as the corpus, then through the +# ARMED `capability-cells` shape, and must refuse exactly one cell, capability-cells-plant@plant-host. If it +# does not, pc_shapes["capability-cells"] is not-fired and the gate declines. The tracked copy under +# tests/fixtures/ont/ and crates/aprender-contracts/src/lint/ must stay byte-identical (a unit test holds them). +ladder: + rungs: + - id: capability-cells-plant + sha256: "0000000000000000000000000000000000000000000000000000000000000000" + arch: plant + gguf: capability-cells-plant.gguf + backends: [cpu] + hosts: [plant-host] + required: true diff --git a/crates/aprender-contracts/src/lint/mod.rs b/crates/aprender-contracts/src/lint/mod.rs index db371bdf7e..48f8cb4f88 100644 --- a/crates/aprender-contracts/src/lint/mod.rs +++ b/crates/aprender-contracts/src/lint/mod.rs @@ -9,6 +9,7 @@ //! Spec: `docs/specifications/sub/lint.md` pub mod cache; +pub mod capability_cells_gate; mod composition_gate; pub mod config; pub mod diff; @@ -17,6 +18,7 @@ pub mod finding; mod gates; pub use gates::collect_yaml_files; mod gates_extended; +pub mod ratchet_gates; pub mod relations_gate; pub mod rules; pub mod sarif; @@ -138,6 +140,21 @@ pub enum GateDetail { Skipped { reason: String }, } +/// The `shapes` gate's positive controls and ONT-4c5's cell report — one boxed, flattened block of +/// [`GateExtra::Shapes`]. +#[derive(Debug, Clone, Serialize)] +pub struct ShapesControls { + /// The extractor positive controls: `gguf` (corrupt magic refused), `apr-model` (lying header refused). + pub pc_extract: std::collections::BTreeMap, + /// ONT-4c5: per-shape positive controls (`capability-cells` → `fired`); the gate never reaches `Ran` with + /// one that is not. + pub pc_shapes: std::collections::BTreeMap, + /// ONT-4c5: the capability-cell domain D and its NotRun cells at the current release — absent unless a + /// contract declares the `capability-cells` shape. + #[serde(skip_serializing_if = "Option::is_none")] + pub capability_cells: Option, +} + /// Out-of-band structured detail for gates that post-date the frozen [`GateDetail`] /// vocabulary. /// @@ -145,6 +162,23 @@ pub enum GateDetail { /// it and none can `match` it. That is what makes it extensible where `GateDetail` is /// not, and it is `#[non_exhaustive]` from birth so the *next* post-0.3.1 gate does /// not have to repeat this exercise. +// `Shapes` is 304 bytes against a 88-byte second-largest, and adding `declines` to it is what +// crossed the threshold — `origin/main` at 237fbc32f lints clean, this branch does not, so the +// finding is this PR's and not inherited. +// +// ALLOWED RATHER THAN BOXED, deliberately. `large_enum_variant` is a cost heuristic about copying +// and stack size: a `GateExtra` is built ONCE PER GATE RUN, moved a handful of times, and then +// serialised. There is no hot path here for 216 bytes to matter on, so the lint is measuring a cost +// this type does not pay. +// +// The alternatives are worse. Boxing one field (clippy suggests `pc_extract`) reclaims 16 bytes and +// does not clear the ratio, so it would be churn that silences nothing. Boxing the whole payload — +// `Shapes(Box)` — turns a struct variant into a newtype variant, which CHANGES THE +// SERDE REPRESENTATION of a `#[serde(tag = "type")]` enum that downstream consumers parse; the SLK +// gate reads this JSON. Breaking a wire format to satisfy a stack-size heuristic is the wrong trade. +// +// If `GateExtra` ever ends up in a loop or a large collection, this allow is the thing to revisit. +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, Serialize)] #[serde(tag = "type")] #[non_exhaustive] @@ -219,6 +253,40 @@ pub enum GateExtra { /// Findings. violations: usize, }, + /// PVL-001 EV-11: Lean theorem modules named by a book page — the `unpaired_theorem_modules` ratchet. + #[serde(rename = "theorem_pairing")] + TheoremPairing { + /// The Lean base the modules were read under. + lean_base: String, + /// `.md` pages read under the book roots. + book_pages: usize, + /// `.lean` files under `/ProvableContracts/Theorems/`. + theorem_modules: usize, + /// Of those, named in full by at least one book page. + paired: usize, + /// The debt, shrink-only against the baseline. + unpaired_theorem_modules: usize, + /// The top-level `unpaired_theorem_modules` in `lint-baseline.json`; `None` = not recorded (reported only). + baseline: Option, + /// The unpaired modules, dotted, in byte order. + unpaired: Vec, + /// Findings. + violations: usize, + }, + /// PVL-001 EV-11: kernel-kind contracts with an empty `metadata.depends_on` — the ratchet. + #[serde(rename = "depends_on_present")] + DependsOnPresent { + /// Contract files parsed. + contracts_checked: usize, + /// Of those, kernel-kind and not a registry. + kernel_contracts: usize, + /// Kernel-kind contracts whose `metadata.depends_on` is empty — the debt, shrink-only. + contracts_without_depends_on: usize, + /// The top-level `contracts_without_depends_on` in `lint-baseline.json`; `None` = not recorded (reported only). + baseline: Option, + /// Findings. + violations: usize, + }, /// ONT-4b: the shapes gate — every `shape:` block over the extracted graph, with the plant. #[serde(rename = "shapes")] Shapes { @@ -242,12 +310,36 @@ pub enum GateExtra { armed_shapes: Vec, /// Shapes computed and reported but not armed — their violations are in `unarmed_violations`. not_armed_shapes: Vec, + /// #3610: shapes that graded ZERO focus nodes, named — the reach the gate did not have. + /// + /// A separate list from `not_armed_shapes` on purpose. "Not armed by policy" and "armed and + /// measured nothing" are different facts, and folding the second into the first would file a + /// vacuity as a deliberate choice — which is how the defect hid in the first place. + /// + /// **This carries EVERY shape that graded zero, armed or unarmed**, and it is the only list + /// any of them appears in: a vacuity is in neither `armed_shapes` (the tool's claim about + /// what it MEASURED) nor `not_armed_shapes` (a policy choice it never made). + /// + /// The two differ in what they do to the verdict, not in whether they are listed here. An + /// ARMED vacuity drives the verdict to `Unknown(NoFocus)` and the run declines — unless an + /// armed shape that DID grade something found a violation, in which case the verdict is + /// `Fail` (a measured violation outranks a vacuity, #3622). An UNARMED one leaves the + /// verdict alone, because it never fed it. Both are named, because a reader + /// needs to know the gate looked at nothing for them either way. + /// + /// An earlier draft of this comment said an armed vacuity "does not reach here at all", + /// which was false — nothing returns early at the verdict, and both kinds reach this field. + /// A quorum lane caught the sentence; the code beside it had the matching bug. + declines: Vec, /// Violations from unarmed shapes (named in the findings as warnings; never in the meet). unarmed_violations: usize, /// Focus nodes each extractor produced: `pv-contract`, `gguf`, `apr-model`. by_entity_type: std::collections::BTreeMap, - /// The extractor positive controls: `gguf` (corrupt magic refused), `apr-model` (lying header refused). - pc_extract: std::collections::BTreeMap, + /// The positive controls (`pc_extract`, `pc_shapes`) and ONT-4c5's `capability_cells`, boxed and + /// FLATTENED: the JSON keys stay top-level where the §5 probes read them, and the variant stays under + /// clippy's large-enum-variant bound (it sat exactly at it before ONT-4c5). + #[serde(flatten)] + controls: Box, /// Ladder receipt files read under `evidence/dogfood/models/`. receipts: usize, /// Receipt rows whose `sha256` equals a rung's. @@ -268,9 +360,36 @@ pub enum GateExtra { /// aprender#3715: what `extract:release-evidence` derived — absent unless a release subject was given. #[serde(skip_serializing_if = "Option::is_none")] release: Option>, + /// ONT-4c (v4.14): the README claim commands a merge-path workflow runs — the MEASURED set F-33 compares. + readme: MeasuredSet, + /// ONT-4c (v4.14): the same for `CLAUDE.md` (`extract:llm-context`). + claude_md: MeasuredSet, + /// ONT-4c (v4.14): whether the measured-set ratchets (F-33 and F-34) were checked this run. + ratchets: Ratchets, }, } +/// ONT-4c: one measured claim set, sorted. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct MeasuredSet { + pub verified_commands: Vec, +} + +/// ONT-4c: `measured_sets` is `checked` only when F-34 ran against a comparand (the CLI resolves it from git); +/// outside a work tree it is `not-checked`, which a probe must read as RED, never as a pass. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Ratchets { + pub measured_sets: String, +} + +impl Default for Ratchets { + fn default() -> Self { + Self { + measured_sets: "not-checked".into(), + } + } +} + /// Overall lint report. #[derive(Debug, Clone, Serialize)] pub struct LintReport { @@ -583,6 +702,15 @@ pub fn run_lint(config: &LintConfig) -> LintReport { gates.push(valid_under_gate_result); all_findings.append(&mut valid_under_findings); + // Gates 14, 15: theorem-pairing, depends-on-present (PVL-001 EV-11). Same R-8 shape: computed in every run, + // armed per repo. + for (name, run) in RATCHET_GATES { + let (result, mut findings) = + ratchet_result(config.contract_dir, validation_passed, name, run); + gates.push(result); + all_findings.append(&mut findings); + } + // Gate 9: strict test-binding (Issue #1510, opt-in via --strict-test-binding) if config.strict_test_binding { push_gate( @@ -664,6 +792,8 @@ pub enum NamedGateOutcome { Shapes(shapes_gate::ShapesOutcome), /// The `valid-under` gate (ONT-7), with three non-verdict answers (no Σ, malformed Σ, no kernel contract). ValidUnder(valid_under_gate::ValidUnderOutcome), + /// A PVL-001 EV-11 ratchet (`theorem-pairing`, `depends-on-present`), whose one non-verdict answer is a decline. + Ratchet(ratchet_gates::RatchetOutcome), /// A gate that ran and judged the corpus. Ran { result: Box, @@ -698,6 +828,12 @@ pub fn run_named_gate_with( "valid-under" => { NamedGateOutcome::ValidUnder(valid_under_gate::run_valid_under_gate(contract_dir)) } + "theorem-pairing" => { + NamedGateOutcome::Ratchet(ratchet_gates::run_theorem_pairing_gate(contract_dir)) + } + "depends-on-present" => { + NamedGateOutcome::Ratchet(ratchet_gates::run_depends_on_present_gate(contract_dir)) + } "validate" => { let (contracts, parse_errors) = load_contracts(contract_dir); let (result, findings) = run_validate_gate(&contracts, &parse_errors); @@ -711,7 +847,42 @@ pub fn run_named_gate_with( } /// The gate names `--gate` computes alone, for the refusal message. -pub const NAMED_GATES: [&str; 5] = ["relations", "shapes", "sigma", "valid-under", "validate"]; +pub const NAMED_GATES: [&str; 7] = [ + "depends-on-present", + "relations", + "shapes", + "sigma", + "theorem-pairing", + "valid-under", + "validate", +]; + +/// The EV-11 ratchet gates, by name, in the order the full run computes them. +type RatchetRun = fn(&Path) -> ratchet_gates::RatchetOutcome; +const RATCHET_GATES: [(&str, RatchetRun); 2] = [ + ("theorem-pairing", ratchet_gates::run_theorem_pairing_gate), + ( + "depends-on-present", + ratchet_gates::run_depends_on_present_gate, + ), +]; + +/// An EV-11 ratchet as `run_lint` reports it. Its decline becomes a SKIPPED gate here, as sigma's non-verdict +/// answers do — under `--gate ` it is an exit of its own (2). +fn ratchet_result( + contract_dir: &Path, + validation_passed: bool, + name: &str, + run: RatchetRun, +) -> (GateResult, Vec) { + if !validation_passed { + return (skipped_gate(name, "validation failed"), Vec::new()); + } + match run(contract_dir) { + ratchet_gates::RatchetOutcome::Ran { result, findings } => (*result, findings), + ratchet_gates::RatchetOutcome::Declined(why) => (skipped_gate(name, &why), Vec::new()), + } +} /// The `sigma` gate as `run_lint` reports it. Σ's two non-verdict answers become SKIPPED gates here — under /// `--gate sigma` they are an exit of their own (decline / error), but inside a full run "skipped" is how the @@ -816,10 +987,18 @@ fn shapes_result(contract_dir: &Path, validation_passed: bool) -> (GateResult, V skipped_gate("shapes", &format!("extract:parity-receipt matched {found} focus node(s) and evidence/parity/EXPECTED_RECEIPTS says {expected} ({shapes_n} shape(s)){} — an extractor that saw the wrong corpus reports the same \"no violations\" as one that saw all of it", if refused.is_empty() { String::new() } else { format!("; refused: {}", refused.join("; ")) })), Vec::new(), ), + shapes_gate::ShapesOutcome::HarnessBroken { causes } => ( + skipped_gate("shapes", &format!("the CRUX harness measured itself, not apr: {}", causes.join("; "))), + Vec::new(), + ), shapes_gate::ShapesOutcome::NoReceipts { shapes_n, dir } => ( skipped_gate("shapes", &format!("{shapes_n} shape(s) resolve receipts and the tree holds none under {dir}/ — R-2: unmeasured is a decline")), Vec::new(), ), + shapes_gate::ShapesOutcome::EmptyDomain { shapes_n } => ( + skipped_gate("shapes", &format!("capability-cells: the required-cell domain D is empty ({shapes_n} shape(s)) — R-2: zero is a decline, never a pass")), + Vec::new(), + ), shapes_gate::ShapesOutcome::PositiveControlFailed { shapes_n, focus_nodes_n, which } => ( skipped_gate("shapes", &format!("positive control {which} did not fire ({shapes_n} shape(s), {focus_nodes_n} focus node(s)) — the gate cannot reject")), Vec::new(), diff --git a/crates/aprender-contracts/src/lint/mod_tests.rs b/crates/aprender-contracts/src/lint/mod_tests.rs index 7ec93fa176..cb649f1a1e 100644 --- a/crates/aprender-contracts/src/lint/mod_tests.rs +++ b/crates/aprender-contracts/src/lint/mod_tests.rs @@ -4,41 +4,120 @@ fn contracts_dir() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts") } +// Linting the real corpus is the cost of this file: each run lints every contract in the repo, +// and nextest runs every #[test] in its own process, so nothing can be shared between tests. So +// the corpus is linted by exactly two tests, one per score threshold, and each read-only check +// on it is a plain fn those two call. Tests that turn one config knob lint `knob_corpus`. + +/// A three-contract corpus for the tests that turn one config knob. It holds each finding they +/// look at (PV-SCR-001 below 0.99, the special-tokens stem, an arch-constraints file) without +/// re-linting the whole repo per test. Nested, not a bare tempdir (#4173). +fn knob_corpus() -> (tempfile::TempDir, std::path::PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("contracts"); + std::fs::create_dir_all(&dir).unwrap(); + for stem in [ + "softmax-kernel-v1", + "special-tokens-registry-v1", + "arch-constraints-v1", + ] { + let f = format!("{stem}.yaml"); + std::fs::copy(contracts_dir().join(&f), dir.join(&f)).unwrap(); + } + (tmp, dir) +} + #[test] fn lint_passes_on_real_contracts() { - let dir = contracts_dir(); - let config = LintConfig::new(&dir, None, 0.0); - let report = run_lint(&config); + let report = run_lint(&LintConfig::new(&contracts_dir(), None, 0.0)); + lint_report_serializes_to_json(&report); + lint_cache_populates_stats(&report); + every_gate_verdict_agrees_with_passed_and_skipped_on_the_real_corpus(&report); + valid_under_is_computed_on_the_real_corpus(&report); assert!(report.passed, "lint should pass: {report:?}"); - // 13 gates: validate, audit, score, verify, enforce, enforcement-level, reverse-coverage, + // 15 gates: validate, audit, score, verify, enforce, enforcement-level, reverse-coverage, // duplicate-stems (PV-DUP-001), composition, sigma (ONT-2b), relations (ONT-4), shapes (ONT-4b), - // valid-under (ONT-7). - assert_eq!(report.gates.len(), 13); + // valid-under (ONT-7), theorem-pairing and depends-on-present (PVL-001 EV-11). + assert_eq!(report.gates.len(), 15); } -#[test] -fn lint_score_gate_fails_with_high_threshold() { - let dir = contracts_dir(); - let config = LintConfig::new(&dir, None, 0.99); - let report = run_lint(&config); +fn lint_score_gate_fails_with_high_threshold(report: &LintReport) { assert!(!report.passed); assert!(!report.findings.is_empty()); } #[test] fn lint_empty_dir() { + // The lint takes the contract dir's PARENT as the project root and reads + // `scripts/contract_duplicate_stem_baseline.txt` from it. A bare tempdir's parent is + // the shared `/tmp`, so a stray `/tmp/scripts/` failed this test (#4207). Nest it. let tmp = tempfile::tempdir().unwrap(); - let config = LintConfig::new(tmp.path(), None, 0.0); + let dir = tmp.path().join("contracts"); + std::fs::create_dir_all(&dir).unwrap(); + let config = LintConfig::new(&dir, None, 0.0); let report = run_lint(&config); - assert!(report.passed); + assert!(report.passed, "empty dir should pass: {report:?}"); + // The new-finding state is the fixture's own, not `$TMPDIR/.pv` shared with every other run (#4173). + assert_eq!(pv_state_dir(&dir), tmp.path().join(".pv")); + assert!(tmp.path().join(".pv/lint-previous.json").is_file()); } +/// #4173: `pv_state_dir` is the contract dir's PARENT, so a test that lints a bare `tempdir()` reads +/// and writes `$TMPDIR/.pv/lint-previous.json`, a file every other run on the host shares. Its +/// verdict then depends on state it does not own. Every lint test must nest its corpus. #[test] -fn lint_report_serializes_to_json() { - let dir = contracts_dir(); - let config = LintConfig::new(&dir, None, 0.0); - let report = run_lint(&config); - let json = serde_json::to_string_pretty(&report).unwrap(); +fn no_lint_test_points_the_lint_at_a_bare_tempdir() { + let bare = |line: &str| { + let l: String = line.chars().filter(|c| !c.is_whitespace()).collect(); + ["(tmp.path()", "(&tmp.path()"].iter().any(|arg| { + let pat = format!("{}{arg}", concat!("LintConfig::", "new")); + l.match_indices(&pat) + .any(|(k, _)| matches!(l[k + pat.len()..].chars().next(), Some(',' | ')'))) + }) + }; + for (line, want) in [ + ( + concat!("run_lint(&LintConfig::new", "(tmp.path(), None, 0.0));"), + true, + ), + ( + concat!("let c = LintConfig::new", "( &tmp.path(), None, 0.0);"), + true, + ), + ("let c = LintConfig::new(&corpus, None, 0.0);", false), + ( + "let c = LintConfig::new(&tmp.path().join(\"contracts\"), None, 0.0);", + false, + ), + ] { + assert_eq!(bare(line), want, "case table: {line}"); + } + let lint_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/lint"); + let mut scanned = 0; + for entry in std::fs::read_dir(&lint_src).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_some_and(|e| e == "rs") { + scanned += 1; + let src = std::fs::read_to_string(&path).unwrap(); + for (i, line) in src.lines().enumerate() { + assert!( + !bare(line), + "{}:{}: lints a bare tempdir: {line}", + path.display(), + i + 1 + ); + } + } + } + assert!( + scanned > 5, + "scanned only {scanned} files under {}", + lint_src.display() + ); +} + +fn lint_report_serializes_to_json(report: &LintReport) { + let json = serde_json::to_string_pretty(report).unwrap(); assert!(json.contains("\"passed\"")); } @@ -53,30 +132,41 @@ fn gate_detail_variants() { #[test] fn lint_findings_on_failure() { - let dir = contracts_dir(); - let config = LintConfig::new(&dir, None, 0.99); - let report = run_lint(&config); + let report = run_lint(&LintConfig::new(&contracts_dir(), None, 0.99)); + lint_score_gate_fails_with_high_threshold(&report); + lint_sarif_output(&report); assert!(report.findings.iter().any(|f| f.rule_id == "PV-SCR-001")); } #[test] fn lint_severity_filter() { - let dir = contracts_dir(); + let (_tmp, dir) = knob_corpus(); let mut config = LintConfig::new(&dir, None, 0.99); + // Non-vacuity: the unfiltered corpus must carry a finding the filter has to drop. + let unfiltered = run_lint(&config); + assert!( + unfiltered + .findings + .iter() + .any(|f| f.severity < RuleSeverity::Error), + "knob corpus has no below-Error finding — the filter test is vacuous" + ); config.severity_filter = Some(RuleSeverity::Error); let report = run_lint(&config); assert!(report .findings .iter() .all(|f| f.severity >= RuleSeverity::Error)); + assert!(report.findings.len() < unfiltered.findings.len()); } #[test] fn lint_suppression_by_rule() { - let dir = contracts_dir(); + let (_tmp, dir) = knob_corpus(); let mut config = LintConfig::new(&dir, None, 0.99); config.suppressed_rules = vec!["PV-SCR-001".into()]; let report = run_lint(&config); + assert!(report.findings.iter().any(|f| f.rule_id == "PV-SCR-001")); for f in &report.findings { if f.rule_id == "PV-SCR-001" { assert!(f.suppressed); @@ -86,7 +176,7 @@ fn lint_suppression_by_rule() { #[test] fn lint_strict_mode() { - let dir = contracts_dir(); + let (_tmp, dir) = knob_corpus(); let mut config = LintConfig::new(&dir, None, 0.0); config.strict = true; let report = run_lint(&config); @@ -95,11 +185,7 @@ fn lint_strict_mode() { } } -#[test] -fn lint_sarif_output() { - let dir = contracts_dir(); - let config = LintConfig::new(&dir, None, 0.99); - let report = run_lint(&config); +fn lint_sarif_output(report: &LintReport) { let sarif_log = sarif::findings_to_sarif(&report.findings, "0.1.0"); let json = sarif::sarif_to_json(&sarif_log, true); assert!(json.contains("sarif-schema-2.1.0")); @@ -114,11 +200,7 @@ fn skipped_gate_creates_correct_result() { assert!(g.skipped); } -#[test] -fn lint_cache_populates_stats() { - let dir = contracts_dir(); - let config = LintConfig::new(&dir, None, 0.0); - let report = run_lint(&config); +fn lint_cache_populates_stats(report: &LintReport) { // Default config has cache enabled, so stats should be populated assert!(report.cache_stats.total > 0); assert_eq!( @@ -129,7 +211,7 @@ fn lint_cache_populates_stats() { #[test] fn lint_no_cache_skips_stats() { - let dir = contracts_dir(); + let (_tmp, dir) = knob_corpus(); let mut config = LintConfig::new(&dir, None, 0.0); config.no_cache = true; let report = run_lint(&config); @@ -156,14 +238,16 @@ fn lint_cache_second_run_hits() { #[test] fn lint_validation_failure_skips_audit_and_score() { let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("contracts"); + std::fs::create_dir_all(&dir).unwrap(); // Write a malformed YAML that will parse into a Contract with validation errors // Actually: write something that fails to parse entirely - std::fs::write(tmp.path().join("bad.yaml"), "not: valid: yaml: {{{{").unwrap(); - let config = LintConfig::new(tmp.path(), None, 0.0); + std::fs::write(dir.join("bad.yaml"), "not: valid: yaml: {{{{").unwrap(); + let config = LintConfig::new(&dir, None, 0.0); let report = run_lint(&config); assert!(!report.passed); // validate should fail, all subsequent gates should be skipped - assert_eq!(report.gates.len(), 13); + assert_eq!(report.gates.len(), 15); assert!(!report.gates[0].passed); // validate failed assert!(report.gates[1].skipped); // audit skipped assert!(report.gates[2].skipped); // score skipped @@ -177,11 +261,15 @@ fn lint_validation_failure_skips_audit_and_score() { #[test] fn lint_suppression_by_stem() { - let dir = contracts_dir(); + let (_tmp, dir) = knob_corpus(); let mut config = LintConfig::new(&dir, None, 0.99); // Suppress by contract stem (--suppress) config.suppressed_findings = vec!["special-tokens-registry-v1".into()]; let report = run_lint(&config); + assert!(report + .findings + .iter() + .any(|f| f.contract_stem.as_deref() == Some("special-tokens-registry-v1"))); for f in &report.findings { if f.contract_stem.as_deref() == Some("special-tokens-registry-v1") { assert!(f.suppressed); @@ -191,10 +279,14 @@ fn lint_suppression_by_stem() { #[test] fn lint_suppression_by_file_pattern() { - let dir = contracts_dir(); + let (_tmp, dir) = knob_corpus(); let mut config = LintConfig::new(&dir, None, 0.99); config.suppressed_files = vec!["arch-constraints".into()]; let report = run_lint(&config); + assert!(report + .findings + .iter() + .any(|f| f.file.contains("arch-constraints"))); for f in &report.findings { if f.file.contains("arch-constraints") { assert!(f.suppressed); @@ -204,12 +296,13 @@ fn lint_suppression_by_file_pattern() { #[test] fn lint_severity_override() { - let dir = contracts_dir(); + let (_tmp, dir) = knob_corpus(); let mut config = LintConfig::new(&dir, None, 0.99); let mut overrides = HashMap::new(); overrides.insert("PV-SCR-001".into(), RuleSeverity::Warning); config.severity_overrides = overrides; let report = run_lint(&config); + assert!(report.findings.iter().any(|f| f.rule_id == "PV-SCR-001")); for f in &report.findings { if f.rule_id == "PV-SCR-001" { assert_eq!(f.severity, RuleSeverity::Warning); @@ -332,10 +425,7 @@ fn lifecycle_mark_new_findings_unit() { /// ONT-6 (PMAT-3451): every gate of a real run carries the lattice element its own `passed`/`skipped` /// pair maps to, and the report's verdict is the meet over the default armed set — so no constructor can /// set a verdict that disagrees with the booleans it sits beside. -#[test] -fn every_gate_verdict_agrees_with_passed_and_skipped_on_the_real_corpus() { - let dir = contracts_dir(); - let report = run_lint(&LintConfig::new(&dir, None, 0.0)); +fn every_gate_verdict_agrees_with_passed_and_skipped_on_the_real_corpus(report: &LintReport) { for g in &report.gates { assert_eq!( g.verdict, @@ -371,7 +461,10 @@ fn every_gate_verdict_agrees_with_passed_and_skipped_on_the_real_corpus() { "relations".to_string(), "shapes".to_string(), // ONT-7, R-8: computed in every run, armed only when the baseline names it. - "valid-under".to_string() + "valid-under".to_string(), + // PVL-001 EV-11: computed in every run (R-8), armed per repo. + "theorem-pairing".to_string(), + "depends-on-present".to_string(), ] ); } @@ -407,12 +500,8 @@ fn a_gate_a_flag_ran_is_reported_but_not_armed() { assert_eq!(report.armed_gates.len(), 8); } -/// ONT-7, R-8: gate 13 is COMPUTED when validation passes and SKIPPED, naming why, when it fails. Both halves -/// at the lib level, because the CI mutation lane runs `--lib` only (#4076 round-2 review): a mutant that -/// inverts `validation_passed` in `valid_under_result` must fail here, not only in the CLI integration test. -#[test] -fn valid_under_is_computed_when_validation_passes_and_skipped_when_it_fails() { - let report = run_lint(&LintConfig::new(&contracts_dir(), None, 0.0)); +/// ONT-7, R-8, the computed half: gate 13 runs and passes on the repo corpus. +fn valid_under_is_computed_on_the_real_corpus(report: &LintReport) { let g = report .gates .iter() @@ -422,15 +511,25 @@ fn valid_under_is_computed_when_validation_passes_and_skipped_when_it_fails() { !g.skipped && g.passed, "computed and Pass on the repo corpus: {g:?}" ); +} +/// ONT-7, R-8: gate 13 is COMPUTED when validation passes and SKIPPED, naming why, when it fails. Both halves +/// at the lib level, because the CI mutation lane runs `--lib` only (#4076 round-2 review): a mutant that +/// inverts `validation_passed` in `valid_under_result` must fail here, not only in the CLI integration test. +#[test] +fn valid_under_is_computed_when_validation_passes_and_skipped_when_it_fails() { + // The computed half runs on the real corpus in `lint_passes_on_real_contracts`. // Σ and a kernel contract are present, so the ONLY reason to skip is the failed validation. + // Nested, not the bare tempdir: the lint writes its state into the contract dir's PARENT (#4173). let tmp = tempfile::tempdir().unwrap(); + let corpus = tmp.path().join("contracts"); + std::fs::create_dir_all(&corpus).unwrap(); let fixture = contracts_dir().join("../tests/fixtures/ont/valid-under-ok"); for f in ["ontology.yaml", "fixture-vu-v1.yaml"] { - std::fs::copy(fixture.join(f), tmp.path().join(f)).unwrap(); + std::fs::copy(fixture.join(f), corpus.join(f)).unwrap(); } - std::fs::write(tmp.path().join("bad.yaml"), "not: valid: yaml: {{{{").unwrap(); - let report = run_lint(&LintConfig::new(tmp.path(), None, 0.0)); + std::fs::write(corpus.join("bad.yaml"), "not: valid: yaml: {{{{").unwrap(); + let report = run_lint(&LintConfig::new(&corpus, None, 0.0)); let g = report .gates .iter() diff --git a/crates/aprender-contracts/src/lint/ratchet_gates.rs b/crates/aprender-contracts/src/lint/ratchet_gates.rs new file mode 100644 index 0000000000..fb984f12ea --- /dev/null +++ b/crates/aprender-contracts/src/lint/ratchet_gates.rs @@ -0,0 +1,314 @@ +//! PVL-001 §EV-11 — two shrink-only ratchets over `contracts/lint-baseline.json`. +//! +//! - `theorem-pairing` (PV-RAT-001): a Lean THEOREM MODULE is a `.lean` file under +//! `/ProvableContracts/Theorems/`, named by its dotted path from the base +//! (`ProvableContracts.Theorems.Softmax.PartitionOfUnity`). It is PAIRED when that full name appears, on +//! identifier boundaries, in a `.md` file under `book/` or `crates/aprender-contracts-staging/book/` of the repo +//! root (the contract dir's parent). The debt `unpaired_theorem_modules` may not rise. +//! - `depends-on-present` (PV-RAT-002): a kernel-kind contract (the effective `kind()`, which reads registries as Registry — the class +//! the valid-under gate obliges) with an empty `metadata.depends_on`. The debt `contracts_without_depends_on` +//! may not rise. +//! +//! THE PAIRING RULE IS STRICT ON PURPOSE. The spec row says "module name appears in `book/`"; the full dotted +//! name is the only reading that cannot be satisfied by accident — the book mentions the file STEM of 80 of the +//! 165 `.lean` files (measured 2026-09-24), mostly as ordinary words, and the full module name of 1 of 131. The root module and the lakefile are not +//! theorem modules, so they are not counted either way. +//! +//! THE GATES NEVER WRITE. At or below the baseline they PASS and report the count; lowering the recorded number +//! is `make lint-ratchet`'s job (never in CI). No baseline key is `Unknown(Report)`, never a pass: the whole +//! verdict is the comparison, so the count is REPORTED (that is how `make lint-ratchet` records the first +//! baseline) and not judged, and `--gate` exits 2. A Lean base with no theorem module, a repo with no book, or a +//! corpus with no kernel-kind contract measured nothing and declines outright (ONT R-2: zero is a decline). + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use crate::ontology::extract::lean::base_under; +use crate::schema::{parse_contract, ContractKind}; + +use super::finding::LintFinding; +use super::rules::RuleSeverity; +use super::{GateDetail, GateExtra, GateResult, Verdict}; +use crate::ontology::verdict::Reason; + +/// Top-level `lint-baseline.json` key for the theorem-pairing debt. +pub const UNPAIRED_KEY: &str = "unpaired_theorem_modules"; +/// Top-level `lint-baseline.json` key for the depends_on debt. +pub const WITHOUT_DEPENDS_ON_KEY: &str = "contracts_without_depends_on"; +/// The book roots a theorem module is paired against, relative to the repo root. +pub const BOOK_ROOTS: [&str; 2] = ["book", "crates/aprender-contracts-staging/book"]; + +/// What one ratchet gate run answers. Only [`RatchetOutcome::Ran`] is a verdict about the corpus. +#[derive(Debug)] +pub enum RatchetOutcome { + /// Nothing was measured, or nothing can be compared: the reason, for stderr (ONT R-2: zero is a decline). + Declined(String), + /// The gate measured and compared. + Ran { + result: Box, + findings: Vec, + }, +} + +/// The top-level integer `key` of `/lint-baseline.json`. Absent, unreadable, or not a +/// non-negative integer → `None`. +fn baseline_of(contract_dir: &Path, key: &str) -> Option { + let raw = std::fs::read_to_string(contract_dir.join("lint-baseline.json")).ok()?; + let doc: serde_json::Value = serde_json::from_str(&raw).ok()?; + doc.get(key)?.as_u64().and_then(|n| usize::try_from(n).ok()) +} + +/// The repo root the contract dir sits in: its parent, or `.` for a bare relative name. +fn repo_root(contract_dir: &Path) -> PathBuf { + match contract_dir.parent() { + Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(), + _ => PathBuf::from("."), + } +} + +/// Every file under `dir` with extension `ext`, recursively, in byte order. Unreadable directories are skipped. +fn files_with_ext(dir: &Path, ext: &str, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + files_with_ext(&path, ext, out); + } else if path.extension().and_then(|e| e.to_str()) == Some(ext) { + out.push(path); + } + } +} + +/// `/ProvableContracts/Theorems/A/B.lean` → `ProvableContracts.Theorems.A.B`. +fn module_name(base: &Path, file: &Path) -> Option { + let rel = file.strip_prefix(base).ok()?.with_extension(""); + let parts: Vec<&str> = rel + .components() + .filter_map(|c| c.as_os_str().to_str()) + .collect(); + Some(parts.join(".")) +} + +fn is_ident_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '\'' +} + +/// Does `text` contain `name` with identifier boundaries on both sides? A trailing `.` counts as a boundary +/// only when no identifier follows it, so `…Softmax.Partition` does not pair `…Softmax.PartitionOfUnity` or a +/// deeper module, and a sentence ending in the module name still pairs it. +#[must_use] +pub fn mentions_module(text: &str, name: &str) -> bool { + let mut from = 0; + while let Some(off) = text[from..].find(name) { + let start = from + off; + let end = start + name.len(); + let before_ok = text[..start] + .chars() + .next_back() + .is_none_or(|c| !is_ident_char(c) && c != '.'); + let mut after = text[end..].chars(); + let after_ok = match after.next() { + None => true, + Some('.') => after.next().is_none_or(|c| !is_ident_char(c)), + Some(c) => !is_ident_char(c), + }; + if before_ok && after_ok { + return true; + } + from = start + name.chars().next().map_or(1, char::len_utf8); + } + false +} + +/// PV-RAT-001 / PV-RAT-002: the debt may fall, never rise. No baseline → no finding (the verdict says why). +fn ratchet_finding( + rule: &str, + key: &str, + baseline: Option, + now: usize, + fix: &str, +) -> Option { + let baseline = baseline?; + if now <= baseline { + return None; + } + let mut f = LintFinding::new( + rule, + RuleSeverity::Error, + format!( + "{key} rose {baseline} -> {now}: {fix}. The baseline in contracts/lint-baseline.json is shrink-only; `make lint-ratchet` only lowers it" + ), + "contracts/lint-baseline.json".to_string(), + ); + f.contract_stem = Some("lint-baseline".to_string()); + Some(f) +} + +fn result_of( + name: &str, + checked: usize, + findings: &[LintFinding], + baseline: Option, + start: Instant, + extra: GateExtra, +) -> Box { + let violations = findings.len(); + let judged = baseline.is_some(); + let passed = judged && violations == 0; + Box::new(GateResult { + name: name.into(), + passed, + skipped: !judged, + verdict: if judged { + Verdict::from_gate(passed, false) + } else { + Verdict::Unknown(Reason::Report) + }, + duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX), + // `GateDetail` is FROZEN at the 0.3.1 variants (see `GateExtra`); the payload rides in `GateExtra`. + detail: GateDetail::Validate { + contracts: checked, + errors: violations, + warnings: 0, + error_messages: findings.iter().map(|f| f.message.clone()).collect(), + }, + extra: Some(extra), + }) +} + +/// The `theorem-pairing` gate over `contract_dir`'s repo root. +#[must_use] +pub fn run_theorem_pairing_gate(contract_dir: &Path) -> RatchetOutcome { + let start = Instant::now(); + let root = repo_root(contract_dir); + let Some(base) = base_under(&root) else { + return RatchetOutcome::Declined(format!( + "no Lean theorem base (ProvableContracts/Theorems) under {}", + root.display() + )); + }; + let mut lean = Vec::new(); + files_with_ext(&base.join("ProvableContracts/Theorems"), "lean", &mut lean); + lean.sort(); + let modules: Vec = lean.iter().filter_map(|f| module_name(&base, f)).collect(); + if modules.is_empty() { + return RatchetOutcome::Declined(format!("no theorem module under {}", base.display())); + } + let books: Vec = BOOK_ROOTS + .iter() + .map(|b| root.join(b)) + .filter(|p| p.is_dir()) + .collect(); + let mut pages = Vec::new(); + for b in &books { + files_with_ext(b, "md", &mut pages); + } + if pages.is_empty() { + return RatchetOutcome::Declined(format!( + "no book page (*.md) under {} of {}: nothing to pair {} theorem module(s) against", + BOOK_ROOTS.join(" or "), + root.display(), + modules.len() + )); + } + let baseline = baseline_of(contract_dir, UNPAIRED_KEY); + let texts: Vec = pages + .iter() + .filter_map(|p| std::fs::read_to_string(p).ok()) + .collect(); + let unpaired: Vec = modules + .iter() + .filter(|m| !texts.iter().any(|t| mentions_module(t, m))) + .cloned() + .collect(); + let findings: Vec = ratchet_finding( + "PV-RAT-001", + UNPAIRED_KEY, + baseline, + unpaired.len(), + "a Lean theorem module was added that no book page names — cite its full module name in book/", + ) + .into_iter() + .collect(); + let extra = GateExtra::TheoremPairing { + lean_base: base.display().to_string(), + book_pages: pages.len(), + theorem_modules: modules.len(), + paired: modules.len() - unpaired.len(), + unpaired_theorem_modules: unpaired.len(), + baseline, + unpaired, + violations: findings.len(), + }; + RatchetOutcome::Ran { + result: result_of( + "theorem-pairing", + modules.len(), + &findings, + baseline, + start, + extra, + ), + findings, + } +} + +/// The `depends-on-present` gate over `contract_dir`. +#[must_use] +pub fn run_depends_on_present_gate(contract_dir: &Path) -> RatchetOutcome { + let start = Instant::now(); + let mut files = Vec::new(); + super::collect_yaml_files(contract_dir, &mut files); + files.sort(); + let (mut checked, mut kernels, mut without) = (0usize, 0usize, 0usize); + for file in &files { + let Ok(contract) = parse_contract(file) else { + continue; + }; + checked += 1; + // `kind()` already reads a `registry: true` kernel as Registry, so Kernel here is never a registry. + if contract.kind() == ContractKind::Kernel { + kernels += 1; + without += usize::from(contract.metadata.depends_on.is_empty()); + } + } + if kernels == 0 { + return RatchetOutcome::Declined(format!( + "no kernel-kind contract in {checked} contract(s): nothing was measured" + )); + } + let baseline = baseline_of(contract_dir, WITHOUT_DEPENDS_ON_KEY); + let findings: Vec = ratchet_finding( + "PV-RAT-002", + WITHOUT_DEPENDS_ON_KEY, + baseline, + without, + "a kernel-kind contract with an empty `metadata.depends_on` was added — name what it composes", + ) + .into_iter() + .collect(); + let extra = GateExtra::DependsOnPresent { + contracts_checked: checked, + kernel_contracts: kernels, + contracts_without_depends_on: without, + baseline, + violations: findings.len(), + }; + RatchetOutcome::Ran { + result: result_of( + "depends-on-present", + checked, + &findings, + baseline, + start, + extra, + ), + findings, + } +} + +#[cfg(test)] +#[path = "ratchet_gates_tests.rs"] +mod tests; diff --git a/crates/aprender-contracts/src/lint/ratchet_gates_tests.rs b/crates/aprender-contracts/src/lint/ratchet_gates_tests.rs new file mode 100644 index 0000000000..4aad764e32 --- /dev/null +++ b/crates/aprender-contracts/src/lint/ratchet_gates_tests.rs @@ -0,0 +1,333 @@ +//! PVL-001 EV-11 — the two `pv lint` ratchets, each case on a throwaway repo built in a tempdir. + +use super::*; + +const THEOREMS: &str = "crates/aprender-contracts-staging/lean/ProvableContracts/Theorems"; + +fn write(root: &Path, rel: &str, text: &str) { + let p = root.join(rel); + std::fs::create_dir_all(p.parent().expect("has a parent")).expect("mkdir"); + std::fs::write(p, text).expect("write"); +} + +fn baseline(root: &Path, json: &str) { + write(root, "contracts/lint-baseline.json", json); +} + +/// A repo with two theorem modules, one of them named by a book page. +fn pairing_repo() -> tempfile::TempDir { + let t = tempfile::tempdir().expect("tempdir"); + write( + t.path(), + &format!("{THEOREMS}/Softmax/PartitionOfUnity.lean"), + "theorem p : True := trivial\n", + ); + write( + t.path(), + &format!("{THEOREMS}/Softmax/Bounds.lean"), + "theorem b : True := trivial\n", + ); + write( + t.path(), + "book/src/softmax.md", + "The partition law is `ProvableContracts.Theorems.Softmax.PartitionOfUnity`.\n", + ); + t +} + +fn pairing(root: &Path) -> (GateResult, Vec) { + match run_theorem_pairing_gate(&root.join("contracts")) { + RatchetOutcome::Ran { result, findings } => { + (*result, findings.into_iter().map(|f| f.rule_id).collect()) + } + RatchetOutcome::Declined(why) => panic!("expected a verdict, got a decline: {why}"), + } +} + +fn unpaired_of(r: &GateResult) -> (usize, Vec) { + match r.extra.as_ref() { + Some(GateExtra::TheoremPairing { + unpaired_theorem_modules, + unpaired, + .. + }) => (*unpaired_theorem_modules, unpaired.clone()), + other => panic!("expected TheoremPairing, got {other:?}"), + } +} + +// ── mentions_module: identifier boundaries ──────────────────────────────────────────────────────────────── + +#[test] +fn a_module_is_mentioned_only_on_identifier_boundaries() { + let m = "P.T.Softmax.Partition"; + for (text, want) in [ + ("see P.T.Softmax.Partition here", true), + ("`P.T.Softmax.Partition`", true), + ("ends the sentence P.T.Softmax.Partition.", true), + ("P.T.Softmax.Partition", true), + ("(P.T.Softmax.Partition)", true), + // a LONGER module is not this one + ("P.T.Softmax.PartitionOfUnity", false), + ("P.T.Softmax.Partition.Deeper", false), + ("P.T.Softmax.Partition_2", false), + ("P.T.Softmax.Partition'", false), + // a PREFIXED name is not this one + ("X.P.T.Softmax.Partition", false), + ("XP.T.Softmax.Partition", false), + // the file stem alone is not the module + ("Partition", false), + ("", false), + ] { + assert_eq!(mentions_module(text, m), want, "{text:?}"); + } + // a bad first occurrence does not hide a good later one + assert!(mentions_module( + "P.T.Softmax.PartitionX and P.T.Softmax.Partition", + m + )); +} + +#[test] +fn a_module_name_is_the_dotted_path_from_the_base() { + let base = Path::new("/r/lean"); + assert_eq!( + module_name( + base, + Path::new("/r/lean/ProvableContracts/Theorems/A/B.lean") + ) + .as_deref(), + Some("ProvableContracts.Theorems.A.B") + ); + assert_eq!(module_name(base, Path::new("/elsewhere/A.lean")), None); +} + +// ── the ratchet itself ──────────────────────────────────────────────────────────────────────────────────── + +#[test] +fn the_ratchet_rejects_a_rise_only() { + let f = |b, n| ratchet_finding("PV-RAT-001", UNPAIRED_KEY, b, n, "fix").map(|f| f.rule_id); + assert_eq!(f(Some(5), 4), None, "a fall passes"); + assert_eq!(f(Some(5), 5), None, "equal passes"); + assert_eq!(f(Some(5), 6).as_deref(), Some("PV-RAT-001")); + assert_eq!(f(None, 1000), None, "no baseline: reported, not judged"); +} + +#[test] +fn a_baseline_that_is_not_a_non_negative_integer_is_no_baseline() { + let t = tempfile::tempdir().expect("tempdir"); + let dir = t.path().join("contracts"); + for (json, want) in [ + (r#"{"unpaired_theorem_modules": 7}"#, Some(7)), + (r#"{"unpaired_theorem_modules": -1}"#, None), + (r#"{"unpaired_theorem_modules": "7"}"#, None), + (r#"{"ont": {"unpaired_theorem_modules": 7}}"#, None), + ("not json", None), + ] { + baseline(t.path(), json); + assert_eq!(baseline_of(&dir, UNPAIRED_KEY), want, "{json}"); + } +} + +// ── theorem-pairing over a repo ─────────────────────────────────────────────────────────────────────────── + +#[test] +fn theorem_pairing_counts_the_modules_no_book_page_names() { + let t = pairing_repo(); + baseline(t.path(), r#"{"unpaired_theorem_modules": 1}"#); + let (r, rules) = pairing(t.path()); + assert!(r.passed && !r.skipped, "at the baseline: pass"); + assert!(rules.is_empty()); + assert_eq!( + unpaired_of(&r), + ( + 1, + vec!["ProvableContracts.Theorems.Softmax.Bounds".to_string()] + ) + ); +} + +/// The spec row's mutation, verbatim: "add an unpaired Theorem module → RED". +#[test] +fn adding_an_unpaired_theorem_module_is_red() { + let t = pairing_repo(); + baseline(t.path(), r#"{"unpaired_theorem_modules": 1}"#); + write( + t.path(), + &format!("{THEOREMS}/Gelu/Tanh.lean"), + "theorem g : True := trivial\n", + ); + let (r, rules) = pairing(t.path()); + assert!(!r.passed); + assert_eq!(r.verdict, Verdict::from_gate(false, false)); + assert_eq!(rules, ["PV-RAT-001"]); + assert_eq!(unpaired_of(&r).0, 2); +} + +#[test] +fn naming_the_module_in_the_staging_book_pairs_it_too() { + let t = pairing_repo(); + baseline(t.path(), r#"{"unpaired_theorem_modules": 0}"#); + write( + t.path(), + "crates/aprender-contracts-staging/book/src/bounds.md", + "ProvableContracts.Theorems.Softmax.Bounds\n", + ); + let (r, rules) = pairing(t.path()); + assert!(r.passed, "{rules:?}"); + assert_eq!(unpaired_of(&r).0, 0); +} + +#[test] +fn the_file_stem_or_a_longer_name_does_not_pair_a_module() { + let t = pairing_repo(); + baseline(t.path(), r#"{"unpaired_theorem_modules": 1}"#); + write( + t.path(), + "book/src/more.md", + "Bounds, Softmax.Bounds and ProvableContracts.Theorems.Softmax.BoundsTight are all different.\n", + ); + assert_eq!(unpaired_of(&pairing(t.path()).0).0, 1); +} + +#[test] +fn no_baseline_is_reported_and_never_a_pass() { + let t = pairing_repo(); + let (r, rules) = pairing(t.path()); + assert!(!r.passed && r.skipped); + assert_eq!(r.verdict, Verdict::Unknown(Reason::Report)); + assert!( + rules.is_empty(), + "nothing to compare against, so no finding" + ); + assert_eq!(unpaired_of(&r).0, 1, "the count is still reported"); +} + +#[test] +fn theorem_pairing_declines_when_nothing_was_measured() { + // no Lean base at all + let t = tempfile::tempdir().expect("tempdir"); + write(t.path(), "book/a.md", "x\n"); + assert!(matches!( + run_theorem_pairing_gate(&t.path().join("contracts")), + RatchetOutcome::Declined(_) + )); + // a base with no theorem module + std::fs::create_dir_all(t.path().join(THEOREMS)).expect("mkdir"); + assert!(matches!( + run_theorem_pairing_gate(&t.path().join("contracts")), + RatchetOutcome::Declined(_) + )); + // theorem modules but no book page + let t = pairing_repo(); + std::fs::remove_dir_all(t.path().join("book")).expect("rm book"); + match run_theorem_pairing_gate(&t.path().join("contracts")) { + RatchetOutcome::Declined(why) => assert!(why.contains("no book page"), "{why}"), + RatchetOutcome::Ran { .. } => panic!("no book is a decline, not 2 unpaired"), + } +} + +// ── depends-on-present over a corpus ────────────────────────────────────────────────────────────────────── + +fn contract(kind: &str, depends_on: &str) -> String { + format!( + "metadata:\n version: \"1.0.0\"\n kind: {kind}\n description: EV-11 fixture\n depends_on: {depends_on}\n\ + equations:\n identity:\n formula: \"len(out) = len(x)\"\n" + ) +} + +fn depends(root: &Path) -> (GateResult, Vec) { + match run_depends_on_present_gate(&root.join("contracts")) { + RatchetOutcome::Ran { result, findings } => { + (*result, findings.into_iter().map(|f| f.rule_id).collect()) + } + RatchetOutcome::Declined(why) => panic!("expected a verdict, got a decline: {why}"), + } +} + +fn without_of(r: &GateResult) -> (usize, usize) { + match r.extra.as_ref() { + Some(GateExtra::DependsOnPresent { + kernel_contracts, + contracts_without_depends_on, + .. + }) => (*kernel_contracts, *contracts_without_depends_on), + other => panic!("expected DependsOnPresent, got {other:?}"), + } +} + +fn depends_repo() -> tempfile::TempDir { + let t = tempfile::tempdir().expect("tempdir"); + write( + t.path(), + "contracts/a-v1.yaml", + &contract("kernel", "[b-v1]"), + ); + write(t.path(), "contracts/b-v1.yaml", &contract("kernel", "[]")); + // neither of these is a kernel contract, so neither counts either way + write( + t.path(), + "contracts/reg-v1.yaml", + &contract("registry", "[]"), + ); + write( + t.path(), + "contracts/pat-v1.yaml", + &contract("pattern", "[]"), + ); + t +} + +#[test] +fn depends_on_counts_kernel_contracts_with_none() { + let t = depends_repo(); + baseline(t.path(), r#"{"contracts_without_depends_on": 1}"#); + let (r, rules) = depends(t.path()); + assert!(r.passed && !r.skipped, "{rules:?}"); + assert_eq!( + without_of(&r), + (2, 1), + "registry and pattern are not kernel contracts" + ); +} + +#[test] +fn adding_a_kernel_contract_with_no_depends_on_is_red() { + let t = depends_repo(); + baseline(t.path(), r#"{"contracts_without_depends_on": 1}"#); + write(t.path(), "contracts/c-v1.yaml", &contract("kernel", "[]")); + let (r, rules) = depends(t.path()); + assert!(!r.passed); + assert_eq!(rules, ["PV-RAT-002"]); + assert_eq!(without_of(&r), (3, 2)); +} + +#[test] +fn depends_on_declines_with_no_kernel_contract() { + let t = tempfile::tempdir().expect("tempdir"); + write( + t.path(), + "contracts/pat-v1.yaml", + &contract("pattern", "[]"), + ); + assert!(matches!( + run_depends_on_present_gate(&t.path().join("contracts")), + RatchetOutcome::Declined(_) + )); +} + +// ── the gates read the baseline and never write it ─────────────────────────────────────────────────────── + +#[test] +fn neither_gate_writes_the_baseline() { + let t = pairing_repo(); + write(t.path(), "contracts/b-v1.yaml", &contract("kernel", "[]")); + let json = "{\n \"unpaired_theorem_modules\": 9,\n \"contracts_without_depends_on\": 9\n}\n"; + baseline(t.path(), json); + // both counts FELL (1 < 9): a gate that "helpfully" lowered the baseline would change the file + assert!(pairing(t.path()).0.passed); + assert!(depends(t.path()).0.passed); + assert_eq!( + std::fs::read_to_string(t.path().join("contracts/lint-baseline.json")).expect("read"), + json + ); +} diff --git a/crates/aprender-contracts/src/lint/shapes_gate.rs b/crates/aprender-contracts/src/lint/shapes_gate.rs index ac6c6242a9..94bb9ee22a 100644 --- a/crates/aprender-contracts/src/lint/shapes_gate.rs +++ b/crates/aprender-contracts/src/lint/shapes_gate.rs @@ -1,6 +1,8 @@ -//! ONT-001 §5 ONT-4b / ONT-4c1 — the `shapes` gate: every shape in the corpus, applied to the graph the -//! extractors produce (`extract:pv-contract`, `extract:gguf`, `extract:apr-model`, and `resolves: receipt` over -//! the tracked ladder receipts), with a positive control planted every run and arming per shape. +//! ONT-001 §5 ONT-4b / ONT-4c1 / ONT-4c — the `shapes` gate: every shape in the corpus, applied to the graph +//! the extractors produce (`extract:pv-contract`, `extract:gguf`, `extract:apr-model`, `extract:readme`, +//! `extract:llm-context`, `extract:csv`, and `resolves: receipt` over the tracked ladder receipts), with a positive +//! control planted every run and arming per shape. ONT-4c adds the measured claim sets (`readme`, `claude_md`) +//! and F-33 (PV-ONT-013, here); F-34 (PV-ONT-014) needs git and is the CLI's (`lint_arming::measured_ratchet`). //! //! The answers, in ONT-6's lattice: //! @@ -33,17 +35,20 @@ use std::path::Path; use std::time::Instant; use crate::ontology::arming::ArmedShapes; +use crate::ontology::capability_cells; use crate::ontology::extract::release_inputs::Subject; use crate::ontology::extract::{ - self, apr_model, code, gguf, json, lean, parity_receipt, pv_contract, release_evidence, - ExtractFailure, + self, apr_model, cli_surface, code, csv, gguf, json, kernel, lean, llm_context, parity_receipt, + pv_contract, readme, release_evidence, ExtractFailure, }; +use crate::ontology::measured_sets; use crate::ontology::rdf::{iri, Graph, Term, RDF_TYPE}; use crate::ontology::receipts; use crate::ontology::shapes::{self, NodeShape, Report, Severity, ShapeError}; use crate::ontology::verdict::Reason; use crate::ontology::w3c; +use super::capability_cells_gate as cells_gate; use super::finding::LintFinding; use super::rules::RuleSeverity; use super::{GateDetail, GateExtra, GateResult, Verdict}; @@ -76,6 +81,9 @@ pub enum ShapesOutcome { found: usize, refused: Vec, }, + /// ONT-4c5: a contract declares `capability-cells` and its required-cell domain D is empty — R-2's decline + /// (exit 2 with a `decline:` line), never Pass and never RED. + EmptyDomain { shapes_n: usize }, /// A positive control did not fire. PositiveControlFailed { shapes_n: usize, @@ -91,6 +99,9 @@ pub enum ShapesOutcome { n: usize, failed: Vec, }, + /// #3739: the CRUX harness measured itself, not apr — a model's positive-control prompt came back ALL_WRONG, + /// or a model owing CRUX cells has no measured control. Declined (exit 2), each model and cause named. + HarnessBroken { causes: Vec }, /// Shapes ran over the corpus, and the controls fired. Ran { result: Box, @@ -98,6 +109,27 @@ pub enum ShapesOutcome { }, } +/// v4.16 D-T1 (qd4c4): Σ `entity_type_target_class` from `/ontology.yaml` — the map a shape without +/// an explicit `targetClass` takes its target from. No Σ, or no map in it, is the EMPTY map: a shape that relies on +/// a mapping Σ does not carry is malformed, named, exit 3 — never silently retargeted. +#[must_use] +pub fn target_class_map(contract_dir: &Path) -> shapes::TargetMap { + let Ok(text) = std::fs::read_to_string(contract_dir.join("ontology.yaml")) else { + return shapes::TargetMap::new(); + }; + let Ok(doc) = serde_yaml::from_str::(&text) else { + return shapes::TargetMap::new(); + }; + doc.get("entity_type_target_class") + .and_then(serde_yaml::Value::as_mapping) + .map(|m| { + m.iter() + .filter_map(|(k, v)| Some((k.as_str()?.to_string(), v.as_str()?.to_string()))) + .collect() + }) + .unwrap_or_default() +} + /// Every shape under `contract_dir`, parsed, with the contract file (repo-relative) that declares it. The /// first unsupported one is the whole answer; a shape id declared twice is malformed. pub fn collect_shapes( @@ -105,9 +137,10 @@ pub fn collect_shapes( ) -> Result<(Vec<(NodeShape, String)>, usize), ShapeError> { let mut shapes: Vec<(NodeShape, String)> = Vec::new(); let mut checked = 0usize; + let targets = target_class_map(contract_dir); for (stem, rel, doc) in pv_contract::documents(contract_dir) { checked += 1; - for shape in shapes::parse_shapes(&stem, &doc)? { + for shape in shapes::parse_shapes_with(&stem, &doc, &targets)? { if let Some((_, other)) = shapes.iter().find(|(s, _)| s.id == shape.id) { return Err(ShapeError::Malformed { shape: shape.id.clone(), @@ -243,60 +276,80 @@ pub fn run_shapes_gate(contract_dir: &Path) -> ShapesOutcome { /// [`run_shapes_gate`] with `--shape` / `--release-*` (aprender#3715). #[must_use] pub fn run_shapes_gate_with(contract_dir: &Path, opts: &ShapesOptions) -> ShapesOutcome { + match run_or_answer(contract_dir, opts) { + Ok(ran) | Err(ran) => ran, + } +} + +/// The gate's body: `Err` is an early answer (a refusal or a decline), `Ok` the outcome it ran to. +fn run_or_answer( + contract_dir: &Path, + opts: &ShapesOptions, +) -> Result { let start = Instant::now(); - let (shapes, arming, checked) = match prepare(contract_dir, opts) { - Ok(x) => x, - Err(answer) => return answer, - }; + let (shapes, arming, checked) = prepare(contract_dir, opts)?; - // ONE walk (R-18): every extractor, the json documents, the ladder receipts joined to the rungs — the same - // graph `pv extract` writes — plus, when a release subject is given, the release evidence (#3715). - let extraction = match extract::all_with(contract_dir, opts.release.as_ref()) { - Ok(x) => x, - Err(e) => return ShapesOutcome::ExtractFailed(e), - }; - // PMAT-3577: the count is pinned before anything is graded. A miss here is not a corpus verdict. - if let Some(refusal) = parity_refusal(&extraction.parity, shapes.len()) { - return refusal; - } - let graph = &extraction.graph; + let extraction = extract_gradeable(contract_dir, opts, shapes.len())?; + // ONT-4c5: the validator's half runs on the gate's own copy — `pv extract` keeps writing what was found + let mut owned = extraction.graph.clone(); + let cells = cells_corpus(&mut owned, &shapes, &extraction)?; + let graph = &owned; + // #3610: the per-shape reach, computed BEFORE any verdict — did this shape grade anything at all? + let focus_of = focus_of(graph, &shapes); + let (vacuous_any, armed_vacuity) = vacuities(&focus_of, &shapes, &arming); let (mut report, plant_violations) = validate_with_plant(graph, &shapes, &arming); if opts.only.is_some() { order_by_family(&mut report, &shapes); } carry_extract_warnings(&mut report, &extraction.warnings); let pc_extract = extract_controls(); + let pc_shapes = cells_controls( + &shapes, + &arming, + &report, + &extraction.gguf.rungs, + cells.as_ref(), + )?; let unmeasured = needs_receipts(&shapes) && extraction.receipts.is_empty(); + // Every shape in scope empty is the global vacuity — unless every one of them declared it (#3610 quorum). + let all_allow_empty = shapes.iter().all(|s| s.allow_empty.is_some()); if let Some(d) = decline( shapes.len(), &report, + all_allow_empty, unmeasured, plant_violations, &pc_extract, ) { - return d; + return Err(d); } - let w3c_run = match w3c_checked(shapes.len(), report.focus_nodes_n) { - Ok(run) => run, - Err(differential) => return differential, - }; + let w3c_run = w3c_checked(shapes.len(), report.focus_nodes_n)?; - let counted = findings_of( - &report, - &arming, - graph, - &extraction.gguf, - &extraction.apr_model, + let mut counted = findings_of(&report, &arming, graph, &extraction); + count_freshness(&mut counted, contract_dir, &extraction); + // PV-ONT-013 NAMES the NotRun cells; it deliberately does not add to `counted.violations`. The verdict comes + // from the armed shape's own `maxCount 0` violation on the same edges, and `cells_controls` declines when the + // two disagree (wiring_holds), so the named list and the verdict cannot drift apart silently. + counted.findings.extend( + cells + .iter() + .flat_map(|cc| cells_gate::findings(cc, &arming)), ); + refuse_vacuous_kernels(&mut counted, &shapes, extraction.kernel.kernels); let passed = counted.violations == 0; - let verdict = verdict_of(&counted); - let by_shape = by_shape(graph, &shapes); + let verdict = verdict_of(&counted, armed_vacuity); + let by_shape = by_shape(&focus_of); + // A shape that graded nothing appears in NEITHER list: `armed_shapes` is the tool's claim about + // what it MEASURED, and `not_armed_shapes` means "not armed by policy". Filing a vacuity as a + // policy choice is how this defect hid, so it is named in `declines` only. let (armed_names, not_armed): (Vec, Vec) = shapes .iter() .map(|s| s.id.clone()) + .filter(|id| !vacuous_any.contains(id)) .partition(|id| arming.is_armed(id)); - let by_entity_type = by_entity_type(&extraction); + let by_entity_type = by_entity_type(&extraction, &sigma_implemented(contract_dir)) + .map_err(ShapesOutcome::Unsupported)?; let duration = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); let result = GateResult { name: "shapes".into(), @@ -326,9 +379,20 @@ pub fn run_shapes_gate_with(contract_dir: &Path, opts: &ShapesOptions) -> Shapes triples: graph.len(), armed_shapes: armed_names, not_armed_shapes: not_armed, + // Every shape that graded NOTHING, armed or not — this is the ONLY list a + // vacuity appears in. An armed vacuity also drove the verdict to + // `Unknown(NoFocus)` above (nothing returns early there; the verdict is set + // and the run continues, so both armed and unarmed vacuities reach here). + // A field that can only ever be empty is decoration, which is the defect one + // layer up from this one. + declines: vacuous_any.clone(), unarmed_violations: counted.unarmed_violations, by_entity_type, - pc_extract, + controls: Box::new(super::ShapesControls { + pc_extract, + pc_shapes, + capability_cells: cells.as_ref().map(cells_gate::CapabilityCellsReport::from), + }), receipts: extraction.resolve.receipts, witnesses: extraction.resolve.witnesses, hex_mismatches: extraction.resolve.hex_mismatches, @@ -340,14 +404,94 @@ pub fn run_shapes_gate_with(contract_dir: &Path, opts: &ShapesOptions) -> Shapes lean_statements: extraction.lean.statements, lean_refs_unresolved: extraction.lean.refs_unresolved.len(), release: extraction.release.clone().map(Box::new), + readme: measured(&extraction.readme), + claude_md: measured(&extraction.llm_context), + ratchets: super::Ratchets::default(), }), }; - ShapesOutcome::Ran { + Ok(ShapesOutcome::Ran { result: Box::new(result), findings: counted.findings, + }) +} + +/// The ONE walk (R-18) and the refusals that must answer before anything is graded: every extractor, the json +/// documents, the ladder receipts joined to the rungs — the same graph `pv extract` writes — plus, when a release +/// subject is given, the release evidence (#3715). +fn extract_gradeable( + contract_dir: &Path, + opts: &ShapesOptions, + shapes_n: usize, +) -> Result { + let extraction = extract::all_with(contract_dir, opts.release.as_ref()) + .map_err(ShapesOutcome::ExtractFailed)?; + // PMAT-3577: the count is pinned before anything is graded. A miss here is not a corpus verdict. + if let Some(refusal) = parity_refusal(&extraction.parity, shapes_n) { + return Err(refusal); + } + // #3739 / cop: a broken CRUX harness is not a verdict about the release — decline, naming the model + match harness_broken(&extraction) { + Some(broken) => Err(broken), + None => Ok(extraction), } } +/// ONT-4c5, before validation: `apply` on the gate's own graph when a contract declares `capability-cells`. A +/// non-numeral receipt version is the declaration's fault (exit 3); |D| = 0 is R-2's decline, never Pass. +fn cells_corpus( + owned: &mut Graph, + shapes: &[NodeShape], + extraction: &extract::Extraction, +) -> Result, ShapesOutcome> { + let cells = cells_gate::corpus(owned, shapes, &extraction.gguf.rungs, &extraction.receipts) + .map_err(|e| { + ShapesOutcome::ExtractFailed(ExtractFailure::Receipt(receipts::ReceiptError { + file: e.file(), + what: e.to_string(), + })) + })?; + if cells.as_ref().is_some_and(|c| c.domain.is_empty()) { + return Err(ShapesOutcome::EmptyDomain { + shapes_n: shapes.len(), + }); + } + Ok(cells) +} + +/// ONT-4c5, after validation: every per-shape positive control fired, and the corpus went through `apply` (the +/// rungs the shape flagged are the rungs owning a NotRun cell). Either miss is a decline, never a verdict. +fn cells_controls( + shapes: &[NodeShape], + arming: &ArmedShapes, + report: &Report, + rungs: &[extract::gguf::Rung], + cells: Option<&capability_cells::CapabilityCells>, +) -> Result, ShapesOutcome> { + let pc_shapes = cells_gate::pc_shapes(shapes, arming); + if let Some((which, _)) = pc_shapes.iter().find(|(_, v)| v.as_str() != "fired") { + return Err(ShapesOutcome::PositiveControlFailed { + shapes_n: shapes.len(), + focus_nodes_n: report.focus_nodes_n, + which: format!("pc_shapes.{which}"), + }); + } + if let Some(cc) = cells { + if !cells_gate::wiring_holds(report, rungs, cc) { + return Err(ShapesOutcome::Differential { + shapes_n: shapes.len(), + focus_nodes_n: report.focus_nodes_n, + passed: 0, + n: 1, + failed: vec![format!( + "capability-cells wiring: the rungs the shape flagged differ from the rungs owning the {} NotRun cell(s)", + cc.not_run.len() + )], + }); + } + } + Ok(pc_shapes) +} + /// The shape set this run grades and its arming, or the answer that stands in for a verdict (unsupported or /// malformed shapes, no shapes at all, a `--shape` naming nothing). A named family is armed whatever /// `armed_shapes` says (#3715). @@ -389,10 +533,59 @@ fn needs_receipts(shapes: &[NodeShape]) -> bool { }) } -/// Violations fail; warnings alone are `Unknown{Warn}`, never a pass; nothing is `Pass`. -fn verdict_of(counted: &Counted) -> Verdict { +/// #3610: each shape's focus-node count, in declaration order. +fn focus_of(graph: &Graph, shapes: &[NodeShape]) -> Vec<(String, usize)> { + shapes + .iter() + .map(|s| { + ( + s.id.clone(), + shapes::instances_closed(graph, &s.target_class).len(), + ) + }) + .collect() +} + +/// #3610: every shape that graded ZERO focus nodes, and whether any of them is ARMED. +/// +/// TWO answers, because they are two questions and conflating them was the defect a quorum lane +/// caught (#3610 round 1, two lanes independently). The list decides where a vacuity is REPORTED — +/// `declines` only, never `not_armed_shapes`, or an unarmed vacuity is filed as a policy choice, +/// which is how the original defect hid. The flag decides the VERDICT: only an armed shape fed it, and a +/// shape that declares `allowEmpty: ""` (a target class empty by design) is named but does not refuse. +fn vacuities( + focus_of: &[(String, usize)], + shapes: &[NodeShape], + arming: &ArmedShapes, +) -> (Vec, bool) { + let vacuous: Vec = focus_of + .iter() + .filter(|(_, n)| *n == 0) + .map(|(id, _)| id.clone()) + .collect(); + let may_be_empty = |id: &str| shapes.iter().any(|s| s.id == id && s.allow_empty.is_some()); + let armed = vacuous + .iter() + .any(|id| arming.is_armed(id) && !may_be_empty(id)); + (vacuous, armed) +} + +/// Violations fail; an ARMED shape that graded nothing is `Unknown{NoFocus}` (#3610); warnings alone are +/// `Unknown{Warn}`, never a pass; nothing is `Pass`. +/// +/// THE REFUSAL IS THE EXIT CODE; THE REPORT IS THE EVIDENCE. A vacuity declines through the ordinary +/// result path rather than short-circuiting, so stdout still carries the full JSON (`by_shape` naming the +/// zero-focus shape, `declines`) and `meet_exit` turns Unknown into exit 2 — downstream consumers (infra's +/// SLK gate) parse stdout regardless of the exit code. +/// +/// A MEASURED violation outranks a vacuity (#3622 re-review, all three lanes): violations and vacuities +/// range over DISJOINT shapes, so checking vacuity first would turn a real Fail from one shape into exit 2 +/// because a DIFFERENT shape graded nothing. +fn verdict_of(counted: &Counted, armed_vacuity: bool) -> Verdict { if counted.violations > 0 { Verdict::Fail + } else if armed_vacuity { + Verdict::Unknown(Reason::NoFocus) } else if counted.warnings > 0 { Verdict::Unknown(Reason::Warn) } else { @@ -401,56 +594,146 @@ fn verdict_of(counted: &Counted) -> Verdict { } /// `shape=focus-count` per shape, sorted. -fn by_shape(graph: &Graph, shapes: &[NodeShape]) -> Vec { - let mut out: Vec = shapes - .iter() - .map(|s| { - let n = shapes::instances_closed(graph, &s.target_class).len(); - format!("{}={}", s.id, n) - }) - .collect(); +fn by_shape(focus_of: &[(String, usize)]) -> Vec { + let mut out: Vec = focus_of.iter().map(|(id, n)| format!("{id}={n}")).collect(); out.sort(); out } -/// Focus nodes each extractor produced. -fn by_entity_type(extraction: &extract::Extraction) -> BTreeMap { - [ - ( - "pv-contract", - extraction - .graph - .instances_of(&crate::ontology::rdf::ont("Contract")) - .len(), - ), - ( - "gguf", - extraction.gguf.rungs.len() + extraction.gguf.files_read, - ), - ("apr-model", extraction.apr_model.files_read), - // ONT-4c3: registered in Σ and implemented, so it is counted here like every other entity - // type. Without this key a probe asking `by_entity_type["parity-receipt"]` reads ABSENT — - // and an absent key is not zero, so a consumer that treats it as one measures nothing and - // calls it a pass. The same shape as #3610, one map over. - ("parity-receipt", extraction.parity.records), - ("code", extraction.code.symbols), - ("lean", extraction.lean.statements), - ] - .into_iter() - .map(|(k, v)| (k.to_string(), v)) - .collect() +/// The entity types this build counts. Every name here has an arm in [`entity_count`] +/// ([`tests::every_counted_entity_type_has_a_counting_arm`]); every name Σ marks `implemented: true` must too, or +/// the gate refuses ([`by_entity_type`]). +const COUNTED_ENTITY_TYPES: &[&str] = &[ + "pv-contract", + "json", + "gguf", + "apr-model", + "parity-receipt", + "code", + "lean", + "release-evidence", + "cli-surface", + "readme", + "llm-context", + "csv", + "kernel", +]; + +/// Focus nodes each extractor produced, for one Σ entity type. `None` is "this build has no counting arm for the +/// name" — never zero (#3624: an absent key is not zero). +fn entity_count(name: &str, extraction: &extract::Extraction) -> Option { + Some(match name { + "pv-contract" => extraction + .graph + .instances_of(&crate::ontology::rdf::ont("Contract")) + .len(), + // One per `entity: {type: json}` contract extracted. + "json" => extraction.entities_extracted.len(), + "gguf" => extraction.gguf.rungs.len() + extraction.gguf.files_read, + "apr-model" => extraction.apr_model.files_read, + // ONT-4c3: without this key a probe asking `by_entity_type["parity-receipt"]` reads ABSENT — and an + // absent key is not zero, so a consumer that treats it as one measures nothing and calls it a pass. + "parity-receipt" => extraction.parity.records, + "code" => extraction.code.symbols, + "lean" => extraction.lean.statements, + // By rule 0 when no release subject was given (an ordinary PR has none): the extractor did not run. + "release-evidence" => extraction.release.as_ref().map_or(0, |r| r.cells), + // #3777 registered cli-surface in Σ after #3624 derived these keys from it: the leaf commands the + // release's `apr surface --json` declares. By rule 0 without `--surface` (the extractor did not run). + "cli-surface" => extraction + .release + .as_ref() + .and_then(|r| r.surface.as_ref()) + .map_or(0, |s| s.leaves), + // ONT-4c: `README.md` / `CLAUDE.md` / CSV datasets — files read, per [`extract::claims::DocStats`]. + "readme" => extraction.readme.files_read, + "llm-context" => extraction.llm_context.files_read, + "csv" => extraction.csv.files_read, + // ONT-4c4: the bound `#[kernel]` symbols typed `ont:KernelSymbol` + "kernel" => extraction.kernel.kernels, + _ => return None, + }) +} + +/// The entity types `/ontology.yaml` (Σ) marks `implemented: true`. Empty when the tree has no Σ or +/// Σ does not parse — Σ's well-formedness is the `sigma` gate's verdict, not this one's. +fn sigma_implemented(contract_dir: &Path) -> BTreeMap { + std::fs::read_to_string(contract_dir.join("ontology.yaml")) + .ok() + .and_then(|text| crate::ontology::sigma::Sigma::from_yaml(&text).ok()) + .map(|sigma| { + sigma + .entity_types + .into_iter() + .filter(|e| e.implemented) + .map(|e| (e.name, e.extractor)) + .collect() + }) + .unwrap_or_default() +} + +/// Focus nodes each extractor produced, keyed by entity type: every type this build counts, and every type Σ +/// implements. #3624 — the keys were a hand-written array beside Σ, so `json` and `release-evidence` shipped +/// implemented and uncounted, and every `by_entity_type[""]` probe on them read ABSENT. A Σ-implemented type +/// with no counting arm is now the gate's refusal, named, never a missing key. `implemented: false` types are +/// omitted by rule unless this build counts them. A Σ type read by the `pv_contract` extractor (an `entity.type` +/// declared in a contract, #4160) is counted by its entity class: the contracts typed `entity/`. +fn by_entity_type( + extraction: &extract::Extraction, + implemented: &BTreeMap, +) -> Result, ShapeError> { + COUNTED_ENTITY_TYPES + .iter() + .map(|s| (*s).to_string()) + .chain(implemented.keys().cloned()) + .map(|name| { + let count = entity_count(&name, extraction).or_else(|| { + (implemented.get(&name).map(String::as_str) == Some("pv_contract")).then(|| { + extraction + .graph + .instances_of(&extract::pv_contract::entity_class(&name)) + .len() + }) + }); + (name, count) + }) + .map(|(name, count)| match count { + Some(n) => Ok((name, n)), + None => Err(ShapeError::Malformed { + shape: "by_entity_type".into(), + what: format!( + "entity type {name} is registered in Σ as implemented and is not counted in by_entity_type" + ), + }), + }) + .collect() +} + +fn harness_broken(extraction: &extract::Extraction) -> Option { + let causes = extraction + .release + .as_ref()? + .crux + .as_ref()? + .harness_broken + .clone(); + (!causes.is_empty()).then_some(ShapesOutcome::HarnessBroken { causes }) } /// The answers that are not corpus verdicts, in the order they are asked: no focus node, receipts needed and /// none tracked, the plant silent, an extractor control silent. `None` when the corpus gets a verdict. +/// +/// No focus node at all is `NoFocus` unless EVERY shape in scope declares `allowEmpty` (#3610): then the run +/// reaches the per-shape path, which names each one in `declines` and does not refuse for them. fn decline( shapes_n: usize, report: &Report, + all_allow_empty: bool, unmeasured: bool, plant_violations: usize, pc_extract: &BTreeMap, ) -> Option { - if report.focus_nodes_n == 0 { + if report.focus_nodes_n == 0 && !all_allow_empty { return Some(ShapesOutcome::NoFocus { shapes_n }); } if unmeasured { @@ -486,12 +769,19 @@ fn extract_controls() -> BTreeMap { ("apr-model", apr_model::positive_control(&apr_sample)), ("code", code::positive_control()), ("lean", lean::positive_control()), + ("kernel", kernel::positive_control()), ( "parity-receipt", parity_receipt::positive_control(&parity_receipt::control_sample()), ), // aprender#3715: drawn every run, subject or not — a cell owed without a receipt stays a node ("release-evidence", release_evidence::positive_control()), + // ONT-4c: an unrun claim, a missing referenced path, a ragged row — each refused by name + ("readme", readme::positive_control()), + ("llm-context", llm_context::positive_control()), + ("csv", csv::positive_control()), + // aprender#3745 S2: a model arg read from its ROLE, never its name — drawn every run + ("cli-surface", cli_surface::positive_control()), ] .into_iter() .map(|(k, fired)| { @@ -503,6 +793,44 @@ fn extract_controls() -> BTreeMap { .collect() } +/// ONT-4c: one extractor's measured claim set, sorted, for the report. +fn measured(stats: &extract::claims::DocStats) -> super::MeasuredSet { + super::MeasuredSet { + verified_commands: stats.verified_commands.iter().cloned().collect(), + } +} + +/// ONT-4c F-33: the committed `verified_commands[]` in `/lint-baseline.json` must EQUAL the live +/// extraction, per key. A difference — or a key that cannot be read — is an Error (PV-ONT-013) in the meet. +fn count_freshness(c: &mut Counted, contract_dir: &Path, extraction: &extract::Extraction) { + let path = contract_dir.join("lint-baseline.json"); + let text = std::fs::read_to_string(&path).ok(); + let live = [ + (measured_sets::KEYS[0], &extraction.readme.verified_commands), + ( + measured_sets::KEYS[1], + &extraction.llm_context.verified_commands, + ), + ]; + for (key, live) in live { + let messages = match measured_sets::read(text.as_deref(), key) { + Ok(committed) => measured_sets::freshness(key, &committed, live), + Err(e) => vec![format!("F-33: {e}")], + }; + for m in messages { + c.violations += 1; + let mut f = LintFinding::new( + "PV-ONT-013", + RuleSeverity::Error, + m, + path.display().to_string(), + ); + f.contract_stem = None; + c.findings.push(f); + } + } +} + /// The findings of one run, and the counts the verdict is made of. struct Counted { findings: Vec, @@ -513,12 +841,35 @@ struct Counted { unarmed_violations: usize, } +/// A kernel shape over zero `#[kernel]` symbols is not a pass: every one of its constraints held over nothing +/// (#3522 ruling). The count of `ont:KernelSymbol` focus nodes is the measurement, so zero is a violation. +fn refuse_vacuous_kernels(c: &mut Counted, shapes: &[NodeShape], kernels: usize) { + let kernel_class = crate::ontology::rdf::ont("KernelSymbol"); + let kernel_shapes: Vec<&str> = shapes + .iter() + .filter(|s| s.target_class == kernel_class) + .map(|s| s.id.as_str()) + .collect(); + if kernels > 0 || kernel_shapes.is_empty() { + return; + } + c.violations += 1; + c.findings.push(LintFinding::new( + "PV-ONT-012", + RuleSeverity::Error, + format!( + "0 #[kernel] symbols extracted: kernel shape(s) {} graded nothing — vacuous, not a pass", + kernel_shapes.join(", ") + ), + "contracts/ont-kernel-receipts-v1.yaml".to_string(), + )); +} + fn findings_of( report: &Report, arming: &ArmedShapes, graph: &Graph, - gguf_stats: &gguf::GgufStats, - apr_stats: &apr_model::AprStats, + extraction: &extract::Extraction, ) -> Counted { let mut c = Counted { findings: Vec::new(), @@ -566,7 +917,15 @@ fn findings_of( f.contract_stem = Some(r.shape.clone()); c.findings.push(f); } - for e in gguf_stats.errors.iter().chain(apr_stats.errors.iter()) { + let refusals = extraction + .gguf + .errors + .iter() + .chain(&extraction.apr_model.errors) + .chain(&extraction.readme.errors) + .chain(&extraction.llm_context.errors) + .chain(&extraction.csv.errors); + for e in refusals { c.violations += 1; let mut f = LintFinding::new( "PV-ONT-012", @@ -748,12 +1107,13 @@ mod tests { match &result.extra { Some(GateExtra::Shapes { by_entity_type, - pc_extract, + controls, witnesses, unarmed_violations, .. }) => { assert_eq!(by_entity_type.get("gguf"), Some(&2)); + let pc_extract = &controls.pc_extract; assert_eq!(pc_extract.get("gguf").map(String::as_str), Some("fired")); assert_eq!( pc_extract.get("apr-model").map(String::as_str), @@ -937,4 +1297,85 @@ mod tests { assert_eq!(v, "fired", "pc_extract.{k}"); } } + + #[test] + fn every_counted_entity_type_has_a_counting_arm() { + let x = extract::Extraction::default(); + for name in COUNTED_ENTITY_TYPES { + assert!( + entity_count(name, &x).is_some(), + "{name} is listed as counted and has no arm" + ); + } + } + + #[test] + fn every_implemented_entity_type_in_sigma_is_a_by_entity_type_key() { + // aprender#3624 — `json` and `release-evidence` were implemented in Σ and absent from the map, so a + // `jq -e '.by_entity_type["json"] == N'` probe read null. Read the REAL Σ the way the gate does. + let contracts = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts"); + let implemented = sigma_implemented(&contracts); + assert!( + implemented.contains_key("json") && implemented.contains_key("release-evidence"), + "Σ no longer implements the two #3624 types — the test would be vacuous: {implemented:?}" + ); + let map = by_entity_type(&extract::Extraction::default(), &implemented) + .expect("every implemented type has an arm"); + for name in implemented.keys() { + assert!( + map.contains_key(name), + "by_entity_type lacks Σ-implemented {name}" + ); + } + assert_eq!( + map.get("release-evidence"), + Some(&0), + "no subject: 0 by rule" + ); + } + + #[test] + fn an_implemented_type_without_a_counting_arm_is_refused_by_name() { + // The discrimination #3624 asks for: flip a type to implemented with no arm, and the gate names it. + // The plant was `readme` until ONT-4c gave readme an arm and the test went vacuous; a planted name + // cannot gain one, and the precondition below fails loudly if it ever does. + const PLANT: &str = "planted-no-arm"; + assert!( + entity_count(PLANT, &extract::Extraction::default()).is_none(), + "{PLANT} has a counting arm — the plant is vacuous" + ); + let flipped: BTreeMap = [("gguf", "gguf"), (PLANT, PLANT)] + .iter() + .map(|(n, x)| (n.to_string(), x.to_string())) + .collect(); + match by_entity_type(&extract::Extraction::default(), &flipped) { + Err(ShapeError::Malformed { what, .. }) => assert!( + what.contains(&format!( + "entity type {PLANT} is registered in Σ as implemented" + )), + "{what}" + ), + other => panic!("expected a named refusal, got {other:?}"), + } + } + + #[test] + fn a_pv_contract_entity_type_is_counted_by_its_entity_class() { + // #4160 × #3624: `study`/`claim` are Σ types read by `pv_contract` (a contract's `entity.type`); they have + // no fixed arm, and before this row the gate refused every fixture that registered them. They count the + // contracts typed `entity/`. The refusal above still binds any other extractor. + let mut x = extract::Extraction::default(); + let study = crate::ontology::rdf::Term::Iri(extract::pv_contract::entity_class("study")); + for id in ["a", "b"] { + x.graph + .insert(format!("urn:t:{id}"), RDF_TYPE, study.clone()); + } + let implemented: BTreeMap = + [("study", "pv_contract"), ("claim", "pv_contract")] + .iter() + .map(|(n, e)| (n.to_string(), e.to_string())) + .collect(); + let map = by_entity_type(&x, &implemented).expect("pv_contract types are counted"); + assert_eq!((map.get("study"), map.get("claim")), (Some(&2), Some(&0))); + } } diff --git a/crates/aprender-contracts/src/ontology/capability_cells.rs b/crates/aprender-contracts/src/ontology/capability_cells.rs new file mode 100644 index 0000000000..82b1c35f84 --- /dev/null +++ b/crates/aprender-contracts/src/ontology/capability_cells.rs @@ -0,0 +1,277 @@ +//! ONT-001 §5 ONT-4c5 — capability cells: a required cell of the model-capability ladder is oracle-proven or +//! RED. `Unknown{NotRun}` on a required cell never arms. +//! +//! **Domain D** — the cells ONT-4c1's `ladder-green` quantifies over, restricted to `required: true` rungs: +//! `{ "@" : rung.required, host ∈ receipts::expected_hosts(rung, receipts::receipt_hosts(all)) }`. +//! One definition: the host axis is ladder-green's own function, not a copy. +//! +//! **Cells are measured at the current release, V\*, only.** V\* is the greatest `version` over every receipt +//! `receipts::read_all` read; a version that does not parse as dotted numerals is refused (the declaration's +//! fault, never guessed). A cell is measured ONLY by a witness row (its `sha256` equals the rung's) in a V\* +//! receipt for that host. Older versions are never consulted: every required rung carries the same sha across +//! 0.68.1 / 0.68.2 / 0.69.1, so ladder-green's cross-version "∃ witness row" would let a 0.68.2 green row hide a +//! 0.69.1 DEFER or a missing 0.69.1 row (plan grill round 1, measured on this tree). +//! +//! **Per row, a label beats the measurement.** A row label (`verdict`, `label`) that `Verdict::from_label` maps to +//! `Unknown(_)` — DEFER, MANUAL, NO-VERDICT (`Unknown(NotRun)`), SKIP, REPORT, WARN (their own reason) — puts the +//! cell in `not_run` whatever its `green` says; a label +//! that maps to `Fail` makes it Fail; `Pass` labels never upgrade a row (the measurement decides); a label +//! `from_label` does not know is REFUSED by name and the row is NotRun. Otherwise Pass = `receipts::row_is_green` +//! and Fail = its negation. **Several V\* rows for one cell** combine: any NotRun → NotRun; else any Fail → Fail; +//! else Pass — a green duplicate never hides a DEFER or a Fail. +//! +//! **The set difference is the validator's.** `not_run = D \ { cells measured Pass or Fail }`. An absent cell is +//! NotRun, never folded into Fail: this row ADMITS Fail (ladder-green is what refuses it), so a folded absence +//! would pass. [`apply`] writes, on the rung's `model:Model` node, `model:capabilityCell "@="` per +//! found cell (the extractor's part: only what it finds), and `model:notRunCell "@"` per member of +//! `not_run` and `model:refusedLabel ": :