diff --git a/ci/explicit-test-commands.d/795-laya-train-rules-self-test.cmd b/ci/explicit-test-commands.d/795-laya-train-rules-self-test.cmd new file mode 100644 index 0000000000..97384dec4a --- /dev/null +++ b/ci/explicit-test-commands.d/795-laya-train-rules-self-test.cmd @@ -0,0 +1,2 @@ +# APR-LAYA-TRAIN-001 LT-0(g): the rule checker plants each cmd rule of §9 and must name it RED. +bash scripts/check_laya_train_rules.sh --self-test diff --git a/evidence/laya-train/lt-0/receipt.json b/evidence/laya-train/lt-0/receipt.json new file mode 100644 index 0000000000..0a5f27228e --- /dev/null +++ b/evidence/laya-train/lt-0/receipt.json @@ -0,0 +1,19 @@ +{ + "row": "LT-0", + "spec": "APR-LAYA-TRAIN-001", + "state": "measured", + "main_sha": "e39f91a65e", + "pr4941_head": "e7730a3a4c", + "pr4634_head": "dad7f6a5e0", + "host": "workstation, 48 threads, 125 GiB", + "measured_utc": "2026-10-10T11:00Z", + "report": "evidence/laya-train/lt-0/report.md", + "enforcing": ["scripts/check_laya_train_rules.sh --self-test"], + "ci_fragment": "ci/explicit-test-commands.d/795-laya-train-rules-self-test.cmd", + "command": "bash scripts/check_laya_train_rules.sh --base origin/main --head HEAD", + "self_test_cases": 35, + "py_added": 0, + "ops_absent_core": 9, + "carry_on_main": false, + "questions": ["Q1 surface", "Q2 CPU pool host", "Q3 E8 coupling", "Q4 dataset licence"] +} diff --git a/evidence/laya-train/lt-0/report.md b/evidence/laya-train/lt-0/report.md new file mode 100644 index 0000000000..b7fb303d46 --- /dev/null +++ b/evidence/laya-train/lt-0/report.md @@ -0,0 +1,177 @@ +# LT-0: first reads (APR-LAYA-TRAIN-001) + +Measured 2026-10-10, 10:50Z to 11:00Z, on a 48-thread, 125 GiB workstation, by the +worker `laya-train` (claude-opus-5-5). Nothing was built or run except the rule checker's +self-test. `main` = `e39f91a65e` (committed 2026-10-10T08:04:52Z). PR heads were read from +`refs/pull/4941/head` = `e7730a3a4c` and `refs/pull/4634/head` = `dad7f6a5e0`, read-only. + +## (a) Baseline facts re-measured on `main` + +| # | Spec said | Measured | Command | +|---|---|---|---| +| G1 | `6e7ea0133` | `e39f91a65e`, 2026-10-10T08:04:52Z. `main` moved; the spec's facts below still hold | `git rev-parse origin/main`; `git log -1 --format=%cI` | +| G2 | #4941 is a draft from a fork, one commit `e7730a3` | still OPEN, draft, head `e7730a3a4c`, not merged | `gh pr view 4941 --json state,isDraft,headRefOid,mergedAt` | +| G8 | gate contract 5.0.0, 13 equations, 6 binding rows, the seven unbound | holds on `e7730a3`: 5.0.0, 13 equations; `contracts/aprender/binding.yaml` has 6 rows (gate_pass, ece_top_label, split_disjointness, seed_selection_median, shift_probe_reported, synthetic_not_deployable), none of the seven. 55 lines of the gate contract and 6 of the parity contract name the `just laya-train-selftest` recipe or a `.py` file (command in the G8 note below the table) | `git show refs/laya/pr4941:` | +| G10 | 18 `deny_unknown_fields` in `pack.rs`; `torch_version` required; `device_is_cpu` check | holds: 18; `torch_version` is a required `String` with no value check (`pack.rs:340`); `verify.rs:1805` | same | +| G11 | doors public, take a policy | holds: `verify.rs:3397`, `:3429`; `VerifyPolicy` all 18 fields `pub`, no `Default`, one constructor `from_contract_views(&GateContractView, &ParityContractView)` (`:378`); `pack::load_checkpoint_for_scoring` `pack.rs:802`, and `scorer_from_parts` `:819` | same | +| G16, G17 | core and train engines; absent ops | holds, with detail in (c) | file reads on `main` | +| G18 | three encoders on `main`, modernbert absent | holds: `models/bert/` (7 files), `setfit/encoder.rs`, `aprender-train/src/transformer/encoder.rs`; `models/modernbert` absent | `git ls-tree` | +| G19 | `aprender-train` depends on `aprender-core` | holds: `crates/aprender-train/Cargo.toml:85`, a path dependency | file | +| G20 | `safetensors_is_read_only_by_pack` | holds: `lib.rs:341-398` scans every `.rs` under `src/` for `safetensors::` and `SafeTensors`; only `src/pack.rs` and `src/test_support.rs` allowed; `tests/` and `examples/` are not scanned | file | +| G22 | 170 `.py` | 170 | command in the G22 note below the table | +| G27 | no `benchmarks/tweeteval-stance/`, no CLI command | holds: 0 paths, 0 matches | `git ls-tree`; `grep -c tweet-eval-stance contracts/apr-cli-commands-v1.yaml` | + +G8 note: the 55 and 6 are `git show e7730a3a4c:contracts/laya-finetune-gate-v1.yaml | grep -cE 'laya-train-selftest|\.py\b'` and the same for `contracts/laya-parity-v1.yaml`. + +G22 note: the 170 is `git ls-tree -r --name-only origin/main | grep -c '\.py$'`. + +## (b) The carry of #4941 + +Not merged; no carry PR exists (`gh pr list --search 4941 --state all` returns #4941 only). +`verify.rs`, `pack.rs` and `models/modernbert/` are identical on `e7730a3` and `dad7f6a`. +The two heads differ in the contracts only: `laya-finetune-gate-v1.yaml` gains one line +(`- tweet-eval-stance-benchmark-v1`, a reference) on `dad7f6a`, and `laya-parity-v1.yaml` +differs by 8 lines. The seven stay in the contract on both heads. Rows LT-3 on wait (§5). + +## (c) Operation table + +P+B present with backward · P−B present without backward · A absent. `core/` = +`crates/aprender-core/src/`, `train/` = `crates/aprender-train/src/`. Each backward was +found where its grad struct is attached to the output. + +| Op | core | train | +|---|---|---| +| matmul 2D | P+B `core/autograd/ops/activation.rs:274`, `MatmulBackward` `gradient.rs:110` | P+B `train/autograd/ops/matmul.rs:453` | +| batched matmul | P+B, 4D only, `pub(crate)`: `nn/transformer/positional_encoding.rs:326`, `BatchedMatmul4dBackward` `grad_fn.rs:1553` | A | +| bias add (broadcast) | P+B `broadcast_add` `activation.rs:376` (`nn::functional::linear`'s `broadcast_add_1d` drops the gradient; use `nn::Linear`) | P−B `train/transformer/attention.rs:16`, `feedforward.rs:217` | +| elementwise mul | P+B `autograd/ops/mod.rs:86` | P+B `train/autograd/ops/basic.rs:54` | +| layer_norm (bias / no bias) | P+B `nn/functional.rs:369`, `LayerNormBackward` `grad_fn.rs:858` | P−B batched `train/transformer/norm.rs:247`; the autograd one normalises a whole 1-D tensor | +| softmax | P+B 2D `activation.rs:238`; last-dim N-D `positional_encoding.rs:499` (`pub(super)`) | P+B 1-D `train/autograd/ops/activations.rs:174` | +| log / exp / sqrt / div | P+B `ops/mod.rs:227`, `:205`, `:267`, `:117` | A (scalar `scale` only) | +| gelu exact | P+B `activation.rs:183` | A | +| gelu tanh | P+B `activation.rs:121` | P+B `activations.rs:52` | +| relu | P+B `activation.rs:9` | P+B `activations.rs:10` | +| embedding gather | P+B `autograd/ops/embedding.rs:42`, scatter-add `grad_fn.rs:1450` | P−B `train/transformer/embedding.rs:63` | +| slice / narrow | **A** | A | +| chunk / split (fused `Wqkv`) | **A** | A | +| concat | **A** | A | +| clamp | **A** | A | +| max | **A** | A | +| additive mask, padding | P+B `autograd/ops/masking.rs:47` + `add_mask` `positional_encoding.rs:480` | A (causal hard-coded) | +| additive mask, local window | **A**: a constant `[S,S]` mask can be built and combined with padding | A | +| MHA self, batched, masked | P+B `nn/transformer/mod.rs:390` (chain of backwards through `grad_fn.rs:1496-1647`) | P+B unbatched, no padding mask, `train/transformer/attention.rs:471` | +| rotate-half RoPE, two thetas | **P−B, wrong layout**: `nn/transformer/attention_helpers.rs:270` is interleaved and returns a tensor with no grad | P+B rotate-half, private, one theta: `train/transformer/attention.rs:79`, `RopeBackward` `:148` | +| seeded dropout | P+B `nn/dropout/mod.rs:77` | A | +| reshape / transpose | P+B `activation.rs:428`, `:328`, `positional_encoding.rs:221`, `:548` | P+B (flat tensors) | +| marker gather | **A**; `embedding_gather` over `[B·S,H]` with positions as ids is a P+B workaround | P−B CLS only, `train/transformer/encoder.rs:173` | +| sum / mean | P+B full reductions `ops/mod.rs:320`, `:342` | P+B sum only | +| cross_entropy | P+B `nn/loss.rs:259` | P+B, stops at the logits, `train/train/loss/cross_entropy.rs:38` | +| spherical score norm | P+B `autograd/ops/normalize.rs:60`, `similarity.rs:59` | A | +| grad clip, global norm | **A on tensors**; `metrics/grad_norm.rs:72` works on `&mut [f64]` | present `train/optim/clip.rs:23` | +| AdamW, two groups | **A**: one rate `nn/optim/mod.rs:391`; two instances are the workaround. `Optimizer::step` for AdamW does not update parameters; `step_with_params` (`rm_sprop.rs:87`) does | A, one rate `train/optim/adamw.rs:16` | +| AdamW decay on a parameter with no gradient | **skips** it: `rm_sprop.rs:46-48`. G5 decays every parameter, so LT-6 must give each parameter a gradient (zeros) or change the step | skips: `adamw.rs:120` | +| cosine schedule with floor | present `nn/scheduler/mod.rs:188`, `with_min_lr` `:215`; not clamped past `t_max` | present, clamps | +| F16 save | present `serialization/safetensors.rs:382` (round-to-nearest-even `:284`) | A (scalar converter only) | + +Counts: core absent 9 (slice, chunk, concat, clamp, max, local-window mask, marker gather, +tensor grad clipping, AdamW groups); core present-without 1 (RoPE, wrong layout). Train +absent 14, present-without 4. Core's `TransformerEncoderLayer` is pre-norm with a tanh GELU +and biased projections (`nn/transformer/mod.rs:589-613`, `:243-246`), as F6 says. +Provisional reading, for LT-1 to decide: core needs fewer additions; train's encoder parts +(norm, feed-forward, embedding, bias add) have no backward. + +## (d) The judge table + +Every item the trainer calls is public. All modules are `pub` (`lib.rs:45-50`). + +| Item the trainer calls | Where (on `e7730a3`) | +|---|---| +| `verify::pack_for_serving`, `verify::verify_path` | `verify.rs:3397`, `:3429` | +| `verify::VerifyPolicy::from_contract_views` + overwrite of the pub field `base` with `BasePins` (tiny pins); the views are `pub` + `Deserialize`, the caller parses YAML | `verify.rs:96-141`, `:146-163`, `:378`; the crate's own tests do this, `verify/tests.rs:58-77` | +| `pack::load_checkpoint_for_scoring`, `pack::scorer_from_parts` | `pack.rs:802`, `:819` | +| probabilities: `DecisionMethod::classify` on `Laya`; `Laya::classify_for_task`; raw logits `Laya::forward_row` | `lib.rs:159`, `:182`; `laya/mod.rs:478`, `:410` | +| `laya::temperature::{bucket_key, clamp_temperature, softmax_t, TEMP_MIN, TEMP_MAX}` | `laya/temperature.rs:12-64` | +| data reader `verify::read_data_dir` → `DataDir { task, train, eval, shift }` | `verify.rs:1254`, `:1196` | +| `verify::normalize_text`, `normalized_sha256` | `verify.rs:1638`, `:1645` | +| `artifact::*` manifest types (Serialize + Deserialize) | `artifact.rs:129-251` | + +Not public, and not needed (no stop 3): +- No function derives the calibration slice; `check_split` only validates it. The trainer + writes its own split with its own declared generator (F7). +- `pack::Recipe` and `pack::GateReport` derive `Deserialize` only. The trainer writes the + JSON with its own serde structs and proves each file by reading it back through the + judge's door, so a key drift is refused by `deny_unknown_fields`. +- The tiny fixture path is `pub(crate)` under `cfg(test)`; the trainer names + `crates/aprender-decide/tests/fixtures/laya_tiny` as a path. The fixture itself is + `synthetic-fixture` (its `recipe.json` has epochs 0, seed 20260925), so both doors refuse + it as `SyntheticNotDeployable`, as the spec's LT-7 tiny acceptance already expects; a + `production` tiny run must be written by the trainer. + +Appendix C re-read, differences from the spec only: +- `t_applied` is compared bit for bit with the **clamped** `temperature_by_options[bucket]` + (`artifact.rs:1617-1677`, `temperature.rs:44-53`); the report also needs + `t_applied == clamp(t_fitted, 0.5, 5.0)` and `clamp_hit` consistent (`verify.rs:1804-1846`). +- Of the files "copied byte for byte from the base", only `tokenizer.json` is compared + (`pack.rs:728-732`, `verify.rs:1569-1575`). The checkpoint's `encoder/config.json` is not + compared with the base; the base dir's four files are pinned (`verify.rs:1565-1568`). +- Probability rows: `text_sha256` is the hash of the **raw** eval text, not the normalised + one; values finite, in [0,1], sum to 1 within 1e-5 (`verify.rs:1852-1957`). +- `seeds.label` is checked only under `seed_selection` (`verify.rs:2630`). + +Which of the seven the judge re-checks: + +| Equation | Judge re-checks? | +|---|---| +| calibration_fit_bounded | partly: bound and clamp relations (`verify.rs:1804-1846`); it cannot re-fit `t_fitted` | +| declared_seed_ships | partly: `recipe.seed == 13` (`:1405`), `seeds.declared == recipe.seed` (`:2601`) | +| eval_set_in_distribution | no | +| recipe_before_scores | no (only `recipe_id == sha256(recipe.json)`, `pack.rs:678`) | +| device_recorded | yes, `verify.rs:1805-1815` | +| f16_reload_scoring | no directly; implied by the re-score of the packed bytes (`:1984`) | +| early_stopping_train_side | no (only the recipe block, `:1426-1444`) | + +So LT-7 feeds the judge the plants of `device_recorded`, and the partial ones of +`calibration_fit_bounded` and `declared_seed_ships`. + +## (e) How a command runs on a CPU pool host today + +Nothing fits. No document defines "CPU pool host" or names one. Every dispatchable +self-hosted workflow runs on a clean-room runner, a performance +runner or a GPU runner; no make target dispatches remote work (the nearest pattern is +`nightly-train-run`, a local `systemctl --user` unit). Per the spec, LT-1's PR adds one +workflow that starts on `workflow_dispatch` only, and its runner label is question Q2. + +## (f) Licence of the dataset files + +TweetEval on Hugging Face (`cardiffnlp/tweet_eval`): licence tag "unknown"; stance subsets +"Undefined"; Twitter terms apply. The original SemEval-2016 Task 6 data (NRC Canada, +saifmohammad.com/WebPages/StanceDataset.htm): "available free for research purposes", +cite the paper; commercial use by request; "Do not redistribute". Reading: research and +benchmark use by this open-source trainer is allowed; tweet text, labels and derived splits +never enter git (H12 already says so); prefer the NRC download over the third-party mirror. +This is not stop 8 by the worker's reading; it is Q4 for the cop. + +## (g) The rule checker + +`scripts/check_laya_train_rules.sh` prints one line per *cmd* rule of §9 (H1, H2, H4, H5, +H10, H11, H12, H15) for `...`, each with its anti-vacuity arm, and exits 1 on a +RED. `--self-test` plants 35 cases in a throwaway repository (each rule's mutant must be +named RED, each clean case PASS) and runs in `workspace-test` through +`ci/explicit-test-commands.d/795-laya-train-rules-self-test.cmd`. Two readings of the spec, +stated so they can be overruled: +- H4: a head that adds LT-1's own receipt may touch `crates/`. One path is exempt: + `crates/aprender-train/examples/laya_engine_probe.rs`, + the proxy probe, which must merge before its receipt can exist; the arm prints the exempt count. +- H15: the needles are plain substrings, so every form of an ssh call is a hit. Exactly one + path is skipped: `scripts/check_laya_train_rules.sh`, + which must name the needles to plant them; the arm prints the skipped path. +- H5: the chosen engine is read from `"engine"` in LT-1's receipt on the base. + +## (h) Questions to the cop + +- Q1 surface: default, the example binary stays and no `apr` verb is added. +- Q2 host for LT-1 and LT-10: which runner label or host is "a CPU pool host"? Default: none + is guessed; LT-1 is prepared on `laya-train/wip` and its PR adds one `workflow_dispatch` + workflow whose `runs-on` the cop names. +- Q3 E8's recipe-schema row: default, no coupling. +- Q4 dataset licence: default, research use, NRC source, no redistribution, proceed. diff --git a/scripts/check_laya_train_rules.sh b/scripts/check_laya_train_rules.sh new file mode 100755 index 0000000000..4806822771 --- /dev/null +++ b/scripts/check_laya_train_rules.sh @@ -0,0 +1,417 @@ +#!/usr/bin/env bash +# check_laya_train_rules.sh - the rule checker of APR-LAYA-TRAIN-001 (§9, row LT-0 g). +# +# Prints one line per hard rule of the spec that is a command (H1, H2, H4, H5, +# H10, H11, H12, H15), for the diff `...`. Each line carries the +# rule's verdict and its anti-vacuity arm, the fact that shows the rule looked +# at something. The cop runs it on every PR of this spec before the merge queue +# is armed and posts the output on the PR. +# +# scripts/check_laya_train_rules.sh [--repo DIR] [--base REF] [--head REF] +# scripts/check_laya_train_rules.sh --self-test +# +# Exit 0 = every rule PASS (or n/a). Exit 1 = at least one RED. Exit 2 = usage. +# +# --self-test builds a throwaway repository under mktemp, plants each rule's +# mutant on its own branch and requires the checker to name it, and requires a +# clean branch to pass. It never runs git in this checkout. +# +# Needles that would match this file's own text are assembled from pieces, so +# a PR that adds this file does not trip H1 or H15 on itself. + +set -euo pipefail + +PY_WORD="pyth""on" +PIP_WORD="pi""p " +UV_WORD="uv"" run" +DIRTY_FLAG="--allow""-dirty" +BUILD_WORD="car""go" # split so guard_tree files this checker with the no-cargo guards: it runs no build tool +SSH_WORD="ss""h " +PUBLISH_RE='(cargo|make)[[:space:]]+'"publi""sh" +# H15 skips exactly this file by path: it must name +# the forbidden needles to plant them. The needle itself stays a plain substring. +H15_SELF="scripts/check_laya_train_rules.sh" + +LT1_RECEIPT="evidence/laya-train/lt-1/receipt.json" +# The ONE path H4 exempts: the LT-1 +# proxy-layer probe is measurement, not model code, and must merge before its receipt. +LT1_PROBE="crates/aprender-train/examples/laya_engine_probe.rs" +LT9_DECL="evidence/laya-train/lt-9/declaration.json" +LT10_DIR="evidence/laya-train/lt-10" +NEW_CRATE="crates/aprender-decide-train" +SIZE_CAP=1048576 + +REPO="" +BASE="origin/main" +HEAD_REF="HEAD" +RANGE="" # set by run_rules: base...head, or base..head for a shallow merge head +RED=0 + +usage() { + printf 'usage: %s [--repo DIR] [--base REF] [--head REF] | --self-test\n' "$0" >&2 + exit 2 +} + +g() { git -C "$REPO" "$@"; } + +line() { + # line + printf '%-4s %-4s | %s | arm: %s\n' "$1" "$2" "$3" "$4" + if [ "$2" = "RED" ]; then RED=1; fi +} + +changed_names() { g diff --name-only "$RANGE"; } +added_names() { g diff --name-only --diff-filter=A "$RANGE"; } + +# Paths whose added lines count as "a Makefile, a justfile, a script or a workflow". +runner_paths() { + changed_names | awk '/(^|\/)Makefile[^\/]*$|\.mk$|(^|\/)justfile$|\.sh$|\.bash$|^scripts\/|^\.github\/workflows\//' +} + +# Added lines (without the leading '+') of runner paths, as "path:text". +added_runner_lines() { + local p + while IFS= read -r p; do + [ -n "$p" ] || continue + g diff -U0 "$RANGE" -- "$p" \ + | awk -v p="$p" '/^\+\+\+/ {next} /^\+/ {print p ":" substr($0, 2)}' + done < <(runner_paths) +} + +rule_h1() { + local files lines hits + files=$(added_names | awk '/\.py$|(^|\/)pyproject\.toml$|(^|\/)uv\.lock$/') + lines=$(added_runner_lines | awk -v a="$PY_WORD" -v b="$UV_WORD" -v c="$PIP_WORD" \ + 'index($0, a) || index($0, b) || index($0, c)') + hits=$(printf '%s\n%s\n' "$files" "$lines" | awk 'NF' | head -n 5 | paste -sd ';' -) + if [ -n "$hits" ]; then + line H1 RED "Python added: $hits" "files and runner lines scanned" + else + line H1 PASS "no .py/pyproject/uv.lock added, no runner line invokes it" \ + "$(runner_paths | awk 'NF' | wc -l) runner paths, $(added_names | awk 'NF' | wc -l) added files scanned" + fi +} + +rule_h2() { + local hits + hits=$(changed_names | awk '/^crates\/aprender-decide\/|^crates\/aprender-core\/src\/models\/modernbert\/|^scripts\/laya_train\/numeric_cases\.json$|\/fixtures\/(modernbert_tiny|laya_tiny)\//' \ + | head -n 5 | paste -sd ';' -) + if [ -n "$hits" ]; then + line H2 RED "judge path changed: $hits" "name-only diff" + else + line H2 PASS "no judge path changed" "$(changed_names | awk 'NF' | wc -l) changed paths scanned" + fi +} + +rule_h4() { + local on_base=absent on_head=absent touches + g cat-file -e "$BASE:$LT1_RECEIPT" 2>/dev/null && on_base=present + g cat-file -e "$HEAD_REF:$LT1_RECEIPT" 2>/dev/null && on_head=present + local exempt + touches=$(changed_names | awk -v p="$LT1_PROBE" '/^crates\// && $0 != p' | wc -l) + exempt=$(changed_names | awk -v p="$LT1_PROBE" '$0 == p' | wc -l) + local arm="$LT1_RECEIPT base=$on_base head=$on_head; crates/ paths=$touches; exempt=$exempt ($LT1_PROBE)" + if [ "$touches" -gt 0 ] && [ "$on_base" = absent ] && [ "$on_head" = absent ]; then + line H4 RED "touches crates/ before LT-1's receipt" "$arm" + else + line H4 PASS "measure-first holds" "$arm" + fi +} + +rule_h5() { + local engine other files imports_ok second_tensor + if ! g cat-file -e "$HEAD_REF:$NEW_CRATE/Cargo.toml" 2>/dev/null; then + line H5 n/a "$NEW_CRATE absent on head" "crate looked up at $HEAD_REF" + return + fi + engine=$(g show "$BASE:$LT1_RECEIPT" 2>/dev/null \ + | awk -F'"' '/"engine"[[:space:]]*:/ {print $4; exit}') + case "$engine" in + core) other='aprender_train::autograd|entrenar::autograd|^[[:space:]]*aprender-train[[:space:]]*=|package[[:space:]]*=[[:space:]]*"aprender-train"' ;; + train) other='aprender::autograd|aprender_core::autograd' ;; + *) + line H5 RED "no chosen engine in $BASE:$LT1_RECEIPT" "engine='${engine:-none}'" + return ;; + esac + files=$(g ls-tree -r --name-only "$HEAD_REF" -- "$NEW_CRATE" | awk '/\.rs$|Cargo\.toml$/') + local hits="" n_ok=0 f body + while IFS= read -r f; do + [ -n "$f" ] || continue + body=$(g show "$HEAD_REF:$f") + if grep -Eq -- "$other" <<<"$body"; then hits="$hits $f"; fi + if [ "$engine" = core ]; then + grep -Eq 'aprender::autograd' <<<"$body" && n_ok=$((n_ok + 1)) + else + grep -Eq 'aprender_train::autograd|entrenar::autograd' <<<"$body" && n_ok=$((n_ok + 1)) + fi + done <<<"$files" + second_tensor=$(g diff -U0 "$RANGE" -- '*.rs' | awk ' + /^\+\+\+ / {f=$2; next} + /^\+[[:space:]]*(pub(\([a-z]+\))?[[:space:]]+)?struct[[:space:]]+Tensor([^A-Za-z0-9_]|$)/ {t[f]=1} + /^\+.*fn[[:space:]]+backward[[:space:]]*\(/ {b[f]=1} + END {for (k in t) if (k in b) print k}' | paste -sd ';' -) + if [ -n "$hits" ] || [ -n "$second_tensor" ]; then + line H5 RED "other engine named:${hits:- none}; second Tensor with backward: ${second_tensor:-none}" \ + "engine=$engine" + else + line H5 PASS "only the chosen engine" "engine=$engine; its import found in $n_ok file(s)" + fi +} + +utc_commit_time() { + # utc_commit_time : commit time of the commit that added + TZ=UTC g log --diff-filter=A --date=format-local:%Y-%m-%dT%H:%M:%SZ --format='%cd %h' \ + "$1" -- "$2" | tail -n 1 +} + +rule_h10() { + local decl outcomes n bad="" f started + if ! g cat-file -e "$BASE:$LT9_DECL" 2>/dev/null; then + if g cat-file -e "$HEAD_REF:$LT10_DIR" 2>/dev/null \ + && [ -n "$(g ls-tree --name-only "$HEAD_REF" "$LT10_DIR/" | awk '/outcome/')" ]; then + line H10 RED "outcome present but no declaration on $BASE" "$LT9_DECL absent on base" + else + line H10 n/a "no declaration on $BASE" "$LT9_DECL absent on base" + fi + return + fi + decl=$(utc_commit_time "$BASE" "$LT9_DECL") + outcomes=$(g ls-tree -r --name-only "$HEAD_REF" -- "$LT10_DIR" | awk '/outcome[^\/]*\.json$/') + n=$(printf '%s\n' "$outcomes" | awk 'NF' | wc -l) + while IFS= read -r f; do + [ -n "$f" ] || continue + started=$(g show "$HEAD_REF:$f" | awk -F'"' '/"run_started_utc"[[:space:]]*:/ {print $4; exit}') + if [ -z "$started" ] || [[ "$started" < "${decl%% *}" ]]; then bad="$bad $f(${started:-no run_started_utc})"; fi + done <<<"$outcomes" + if [ "$n" -gt 1 ] || [ -n "$bad" ]; then + line H10 RED "outcomes=$n; before declaration:${bad:- none}" "declaration merged $decl" + else + line H10 PASS "outcomes=$n, none before the declaration" "declaration merged $decl" + fi +} + +rule_h11() { + local cli pub new_crates n_new wf bad_wf="" p + cli=$(changed_names | awk '$0 == "contracts/apr-cli-commands-v1.yaml"') + pub=$(changed_names | awk '/(^|\/)Cargo\.toml$/' | while IFS= read -r p; do + g diff -U0 "$RANGE" -- "$p" | awk -v p="$p" '/^[+-][[:space:]]*publish[[:space:]]*=/ {print p; exit}' + done | paste -sd ';' -) + new_crates=$(added_names | awk '/^crates\/[^\/]+\/Cargo\.toml$/' | paste -sd ';' -) + n_new=$(printf '%s' "$new_crates" | awk -F';' 'NF {print NF; exit} END {if (!NR) print 0}') + wf=$(changed_names | awk '/^\.github\/workflows\/.*\.ya?ml$/') + while IFS= read -r p; do + [ -n "$p" ] || continue + g cat-file -e "$HEAD_REF:$p" 2>/dev/null || continue + if g show "$HEAD_REF:$p" | awk ' + /^on:[[:space:]]*[^[:space:]]/ { if ($0 !~ /^on:[[:space:]]*\[?[[:space:]]*workflow_dispatch[[:space:]]*\]?[[:space:]]*$/) bad=1; next } + /^on:/ {inon=1; next} + inon && /^[^[:space:]#]/ {inon=0} + inon && /^ [A-Za-z_]+:?/ { k=$1; sub(/:$/, "", k); if (k != "workflow_dispatch") bad=1 } + END {exit (bad ? 0 : 1)}'; then + bad_wf="$bad_wf $p" + fi + done <<<"$wf" + if [ -n "$cli" ] || [ -n "$pub" ] || [ "$n_new" -gt 1 ] || [ -n "$bad_wf" ]; then + line H11 RED "cli-contract:${cli:-no} publish:${pub:-no} new crates:$n_new workflows:${bad_wf:- ok}" \ + "new crates: ${new_crates:-none}" + else + line H11 PASS "no public surface added" "new crates: ${new_crates:-none}" + fi +} + +rule_h12() { + local f s max=0 maxf="none" over="" + while IFS= read -r f; do + [ -n "$f" ] || continue + s=$(g cat-file -s "$HEAD_REF:$f" 2>/dev/null || echo 0) + if [ "$s" -gt "$max" ]; then max=$s; maxf=$f; fi + if [ "$s" -gt "$SIZE_CAP" ]; then over="$over $f($s)"; fi + done < <(added_names) + if [ -n "$over" ]; then + line H12 RED "added file above $SIZE_CAP bytes:$over" "largest added: $maxf $max" + else + line H12 PASS "no added file above $SIZE_CAP bytes" "largest added: $maxf $max" + fi +} + +rule_h15() { + local hits + hits=$(added_runner_lines | awk -v a="$DIRTY_FLAG" -v b="$SSH_WORD" -v re="$PUBLISH_RE" -v self="$H15_SELF:" \ + 'index($0, self) == 1 {next} { body = $0; sub(/^[^:]*:/, "", body) } index(body, a) || index(body, b) || body ~ re' | head -n 5 | paste -sd ';' -) + if [ -n "$hits" ]; then + line H15 RED "forbidden line added: $hits" "runner lines scanned; path skipped: $H15_SELF" + else + line H15 PASS "no dirty-publish, publish or ssh line added" \ + "$(added_runner_lines | wc -l) added runner lines scanned; path skipped: $H15_SELF" + fi +} + +run_rules() { + g rev-parse -q --verify "$BASE^{commit}" >/dev/null || { echo "base $BASE not found" >&2; exit 2; } + g rev-parse -q --verify "$HEAD_REF^{commit}" >/dev/null || { echo "head $HEAD_REF not found" >&2; exit 2; } + # A pull request's CI clone is shallow: HEAD is the merge commit (or, in the merge + # queue, the squashed commit) and the base is its first parent, fetched alone, so + # `base...head` has no merge base. Then, and only when the base IS head's first + # parent (read from the raw commit, which a shallow graft does not hide), the tree + # diff base..head is the pull request's diff. + # Anything else has no defined diff and stops (exit 2). + local how=merge-base p1 + RANGE="$BASE...$HEAD_REF" + if ! g merge-base "$BASE" "$HEAD_REF" >/dev/null 2>&1; then + p1=$(g cat-file -p "$(g rev-parse "$HEAD_REF")" | awk '/^parent /{n++; if (n == 1) p = $2} /^$/{exit} END{if (n >= 1) print p}') + if [ -n "$p1" ] && [ "$p1" = "$(g rev-parse "$BASE^{commit}")" ]; then + RANGE="$BASE..$HEAD_REF" + how=first-parent + else + echo "no merge base for $BASE...$HEAD_REF and $BASE is not the first parent of the head" >&2 + exit 2 + fi + fi + printf 'laya-train rules | base %s | head %s | diff %s\n' \ + "$(g rev-parse --short "$BASE")" "$(g rev-parse --short "$HEAD_REF")" "$how" + rule_h1; rule_h2; rule_h4; rule_h5; rule_h10; rule_h11; rule_h12; rule_h15 + if [ "$RED" -eq 0 ]; then echo "RESULT PASS"; else echo "RESULT RED"; fi + return "$RED" +} + +# ---------------------------------------------------------------- self-test + +st_commit() { git -C "$ST" -c core.hooksPath=/dev/null -c user.name=t -c user.email=t@t commit -qm "$1"; } + +st_case() { + # st_case : setup runs in $ST on a fresh branch. + local name=$1 expect=$2 rule=$3 out rc=0 + shift 3 + git -C "$ST" checkout -q -B "$name" base + (cd "$ST" && "$@") + git -C "$ST" add -A + st_commit "$name" + out=$("$SELF" --repo "$ST" --base base --head "$name") || rc=$? + local got + got=$(printf '%s\n' "$out" | awk -v r="$rule" '$1 == r {print $2; exit}') + if [ "$got" = "$expect" ] && { [ "$expect" = RED ] && [ "$rc" -eq 1 ] || [ "$expect" != RED ]; }; then + printf 'ok %-22s %s %s\n' "$name" "$rule" "$got" + else + printf 'FAIL %-22s %s want %s got %s rc=%s\n%s\n' "$name" "$rule" "$expect" "${got:-none}" "$rc" "$out" + ST_FAIL=1 + fi +} + +st_shallow() { + # st_shallow : main moves on (adding a .py the + # PR does not own), the PR runs and lands as a merge commit (a pull request + # run) or a single squashed commit (a merge queue run), and the check runs on a shallow + # clone of that head. + local name=$1 expect=$2 how=$3 setup=$4 out rc=0 got p1 sc + git -C "$ST" checkout -q -B "$name-pr" base + (cd "$ST" && sh -c "$setup") + git -C "$ST" add -A; st_commit "$name pr" + git -C "$ST" checkout -q -B "$name-main" base + echo "y = 1" >"$ST/main_only.py" + git -C "$ST" add -A; st_commit "$name main" + if [ "$how" = squash ]; then + git -C "$ST" merge -q --squash "$name-pr" >/dev/null + st_commit "$name squash" + else + git -C "$ST" -c user.name=t -c user.email=t@t merge -q --no-ff --no-edit "$name-pr" >/dev/null + fi + sc="$ST/shallow-$name" + git clone -q --no-local --depth 1 --branch "$name-main" "$ST" "$sc" 2>/dev/null + p1=$(git -C "$sc" cat-file -p HEAD | awk '/^parent /{print $2; exit}') + git -C "$sc" fetch -q --depth 1 origin "$p1" 2>/dev/null + out=$("$SELF" --repo "$sc" --base "$p1" --head HEAD) || rc=$? + got=$(printf '%s\n' "$out" | awk '$1 == "H1" {print $2; exit}') + if [ "$got" = "$expect" ] && printf '%s\n' "$out" | awk '/diff first-parent/ {f=1} END {exit !f}'; then + printf 'ok %-22s %s %s\n' "$name" H1 "$got" + else + printf 'FAIL %-22s %s want %s got %s rc=%s\n%s\n' "$name" H1 "$expect" "${got:-none}" "$rc" "$out" + ST_FAIL=1 + fi + git -C "$ST" checkout -q base +} + +self_test() { + SELF=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") + ST=$(mktemp -d) + case "$ST" in /tmp/*|"${TMPDIR:-/tmp}"/*) ;; *) echo "mktemp gave $ST" >&2; exit 2 ;; esac + trap 'rm -rf -- "${ST:?}"' EXIT + export GIT_CEILING_DIRECTORIES + GIT_CEILING_DIRECTORIES=$(dirname "$ST") + ST_FAIL=0 + git -C "$ST" init -q + mkdir -p "$ST/.git/info" && echo "shallow-*/" >>"$ST/.git/info/exclude" + mkdir -p "$ST/crates/aprender-decide/src" "$ST/scripts" "$ST/.github/workflows" + echo 'fn verify() {}' >"$ST/crates/aprender-decide/src/verify.rs" + echo 'echo hi' >"$ST/scripts/a.sh" + git -C "$ST" add -A + st_commit base + git -C "$ST" branch -q -M base + + st_case clean PASS H1 sh -c 'echo "# docs" > notes.md' + st_case plant-py-file RED H1 sh -c 'echo "x = 1" > x.py' + st_case plant-py-line RED H1 sh -c "echo '${PY_WORD}3 run.py' >> scripts/a.sh" + st_case plant-judge-edit RED H2 sh -c 'echo "// x" >> crates/aprender-decide/src/verify.rs' + st_case plant-crates-early RED H4 sh -c 'mkdir -p crates/x && echo "[package]" > crates/x/Cargo.toml' + st_case lt1-own-receipt PASS H4 sh -c "mkdir -p crates/x evidence/laya-train/lt-1 && echo '[package]' > crates/x/Cargo.toml && echo '{\"engine\": \"core\"}' > $LT1_RECEIPT" + st_case lt1-probe-only PASS H4 sh -c "mkdir -p crates/aprender-train/examples && echo 'fn main() {}' > $LT1_PROBE" + st_case plant-probe-plus-crate RED H4 sh -c "mkdir -p crates/aprender-train/examples crates/x && echo 'fn main() {}' > $LT1_PROBE && echo '[package]' > crates/x/Cargo.toml" + st_case plant-probe-lookalike RED H4 sh -c "mkdir -p crates/aprender-train/examples && echo 'fn main() {}' > crates/aprender-train/examples/laya_engine_probe2.rs" + st_case plant-cli-contract RED H11 sh -c 'mkdir -p contracts && echo "x: 1" > contracts/apr-cli-commands-v1.yaml' + st_case plant-two-crates RED H11 sh -c 'mkdir -p crates/a crates/b && echo "[package]" > crates/a/Cargo.toml && echo "[package]" > crates/b/Cargo.toml' + st_case plant-push-trigger RED H11 sh -c 'mkdir -p .github/workflows && printf "on:\n push:\n workflow_dispatch:\njobs: {}\n" > .github/workflows/w.yml' + st_case dispatch-only PASS H11 sh -c 'mkdir -p .github/workflows && printf "on:\n workflow_dispatch:\njobs: {}\n" > .github/workflows/w.yml' + st_case plant-big-file RED H12 sh -c 'head -c 1048577 /dev/zero > big.bin' + st_case plant-dirty RED H15 sh -c "echo '${BUILD_WORD} package ${DIRTY_FLAG}' >> scripts/a.sh" + st_case plant-ssh RED H15 sh -c "echo '${SSH_WORD}host true' >> scripts/a.sh" + st_case plant-ssh-chained RED H15 sh -c "echo 'true; sudo ${SSH_WORD}host true' >> scripts/a.sh" + st_case plant-ssh-yaml-run RED H15 sh -c "mkdir -p .github/workflows && echo ' run: ${SSH_WORD}host true' > .github/workflows/w.yml" + st_case plant-ssh-then RED H15 sh -c "echo 'if true; then ${SSH_WORD}host true; fi' >> scripts/a.sh" + st_case plant-ssh-do RED H15 sh -c "echo 'for h in a; do ${SSH_WORD}\$h true; done' >> scripts/a.sh" + st_case plant-ssh-else RED H15 sh -c "echo 'if false; then :; else ${SSH_WORD}host true; fi' >> scripts/a.sh" + st_case plant-ssh-brace RED H15 sh -c "echo '{ ${SSH_WORD}host true; }' >> scripts/a.sh" + st_case plant-ssh-env RED H15 sh -c "echo 'env A=1 ${SSH_WORD}host true' >> scripts/a.sh" + st_case plant-ssh-xargs RED H15 sh -c "echo 'echo h | xargs -I{} ${SSH_WORD}{} true' >> scripts/a.sh" + st_case plant-ssh-nohup RED H15 sh -c "echo 'nohup ${SSH_WORD}host true &' >> scripts/a.sh" + st_case plant-ssh-timeout RED H15 sh -c "echo 'timeout 5 ${SSH_WORD}host true' >> scripts/a.sh" + st_case plant-ssh-abspath RED H15 sh -c "echo '/usr/bin/${SSH_WORD}host true' >> scripts/a.sh" + st_case h15-self-path PASS H15 sh -c "echo '# ${SSH_WORD}is a needle here' >> $H15_SELF" + st_case plant-ssh-self-lookalike RED H15 sh -c "echo '${SSH_WORD}host true' >> scripts/check_laya_train_rules2.sh" + + # A pull request's CI clone: a depth-1 merge head plus its first parent fetched + # alone, so there is no merge base. Only the pull request's side is judged. + st_shallow plant-py-shallow RED merge 'echo "x = 1" > x.py' + st_shallow clean-shallow PASS merge 'echo "# docs" > notes.md' + st_shallow plant-py-squash RED squash 'echo "x = 1" > x.py' + st_shallow clean-squash PASS squash 'echo "# docs" > notes.md' + + # H5 and H10 need LT-1's receipt and LT-9's declaration on the base. + git -C "$ST" checkout -q base + mkdir -p "$ST/evidence/laya-train/lt-1" "$ST/evidence/laya-train/lt-9" + echo '{"engine": "core"}' >"$ST/$LT1_RECEIPT" + echo '{"seeds": [13, 17, 23]}' >"$ST/$LT9_DECL" + git -C "$ST" add -A + st_commit "receipts" + local nc="$NEW_CRATE" + st_case crate-core-ok PASS H5 sh -c "mkdir -p $nc/src && echo '[package]' > $nc/Cargo.toml && echo 'use aprender::autograd::Tensor;' > $nc/src/lib.rs" + st_case plant-other-engine RED H5 sh -c "mkdir -p $nc/src && echo '[package]' > $nc/Cargo.toml && echo 'use aprender_train::autograd::Tensor;' > $nc/src/lib.rs" + st_case plant-second-tensor RED H5 sh -c "mkdir -p $nc/src && echo '[package]' > $nc/Cargo.toml && printf 'use aprender::autograd::Tensor as T;\npub struct Tensor;\nimpl Tensor { fn backward(&self) {} }\n' > $nc/src/lib.rs" + st_case outcome-after PASS H10 sh -c "mkdir -p $LT10_DIR && echo '{\"run_started_utc\": \"2999-01-01T00:00:00Z\"}' > $LT10_DIR/outcome.json" + st_case plant-outcome-before RED H10 sh -c "mkdir -p $LT10_DIR && echo '{\"run_started_utc\": \"2000-01-01T00:00:00Z\"}' > $LT10_DIR/outcome.json" + st_case plant-two-outcomes RED H10 sh -c "mkdir -p $LT10_DIR && echo '{\"run_started_utc\": \"2999-01-01T00:00:00Z\"}' > $LT10_DIR/outcome.json && cp $LT10_DIR/outcome.json $LT10_DIR/outcome-2.json" + + if [ "$ST_FAIL" -eq 0 ]; then echo "SELF-TEST PASS"; else echo "SELF-TEST FAIL"; exit 1; fi +} + +# ---------------------------------------------------------------- main + +while [ $# -gt 0 ]; do + case "$1" in + --self-test) self_test; exit 0 ;; + --repo) [ $# -ge 2 ] || usage; REPO=$2; shift 2 ;; + --base) [ $# -ge 2 ] || usage; BASE=$2; shift 2 ;; + --head) [ $# -ge 2 ] || usage; HEAD_REF=$2; shift 2 ;; + *) usage ;; + esac +done +if [ -z "$REPO" ]; then REPO=$(cd "$(dirname "$0")/.." && pwd); fi +run_rules