#!/usr/bin/env bash
# _gate-cache-run: content-keyed result cache for gate/test invocations.
#
# Usage:
#   bin/_gate-cache-run --domain DOMAIN --id ID [--watch DIR]... -- CMD ARG...
#
# A green result is a cacheable artifact keyed on the SHA-256 of everything
# the run read. The input closure is DERIVED, never hand-maintained: the run
# executes under `strace -f -e trace=%file`, and every file the process tree
# opened, statted, executed, listed, or probed-and-missed becomes part of the
# key. A later invocation whose entire recorded closure is byte-identical is
# served the stored result as the SAME proof (marked cached-green on stdout,
# distinct from ran-green). Any doubt is resolved toward re-running: an input
# that cannot be revalidated, a read of a file that has since vanished, or a
# missing tracer all mean the command runs for real.
#
#   - Only exit-0 results are ever stored; red always re-runs.
#   - BEAGLE_GATE_NO_CACHE=1 bypasses entirely (full run, nothing stored).
#   - BEAGLE_GATE_CACHE overrides the cache root
#     (default ${XDG_CACHE_HOME:-~/.cache}/beagle/gate-results).
#   - Nested invocations run direct: ptrace forbids tracing a process another
#     tracer (the outer strace -f) already owns, so the wrapper exports
#     BEAGLE_GATE_CACHE_INNER=1 to its child and steps aside when it sees it.
#
# Closure record kinds, and what revalidates each:
#   files.sha256    content hash per file (post-run state)  -> re-hash equal
#   links.list      symlink -> fully-resolved target        -> same resolution
#   dirs.digest     one digest over every listed directory  -> same listings
#   absent.list     paths probed and not found (as spelled) -> still absent
#   nixpaths.list   /nix/store inputs (immutable by path)   -> still present
#
# The pinned Racket install is stated by its store root alone, not by the
# hundreds of paths a module load probes inside it; `racket_prefix` below
# carries the invariant that makes the two statements equivalent.
#
# A file whose BYTES name the checkout is hashed over its NORMALIZED content
# (see `normalize_content`), so the same file in two checkouts hashes alike.
# It stays a full member of the closure; only the location spelling is dropped.
#
# An access the trace cannot place at a definite absolute path is never keyed
# on a guess: it makes the run uncacheable. See the awk header below.
#
# Paths are stored repo-relative (%R%) and home-relative (%H%), so an entry
# survives its checkout being moved or renamed AND is replayable in a sibling
# checkout: main and every worktree share proofs. Sharing is sound because the
# rewrite is total — the bare repo root is a %R% too, in the identity (cwd,
# argv, --watch, env) and in every stored record kind alike — so a lookup
# re-anchors the WHOLE closure onto the checkout doing the lookup and then
# revalidates it there. A relative input that resolved against checkout A's
# launch cwd is revalidated against checkout B's file of the same name: same
# bytes, same proof; different bytes, cache miss. An access that cannot be
# placed at all is still class U and still makes the run uncacheable — never
# guessed, never shared. The replayed stdout is the proving run's verbatim, so
# absolute paths inside it name the checkout that earned the proof.
# Scratch (the run's private TMPDIR, /tmp, /proc, /sys, /dev, caches) is
# excluded; an explicit --watch root is exempt from those exclusions so tests
# can sandbox the mechanism under /tmp.
#
# Stat-identity memo: .hashmemo caches dev:ino:size:mtime:ctime -> sha256 so a
# no-op revalidation is stats, not re-hashing. A stat-identity change falls
# back to a real hash; the memo can only cost an extra hash, never skip one.
set -uo pipefail

# Storage vocabulary version: what a stored path MEANS. Bumped whenever that
# meaning changes, and load-bearing in two places at once — it namespaces every
# identity (so entries written under an older vocabulary are never looked up)
# and it is stamped into each entry's meta and required by validate_entry (so
# one that is looked up anyway, by a stale directory or a hand-copied entry,
# fails validation instead of being read with the wrong dictionary).
#   1: %R%/ and %H%/ prefixes only; a bare repo root stayed absolute, so
#      identities pinned to one checkout.
#   2: bare roots rewritten too — identities and closures are checkout-relative.
#   3: a .dep is hashed over normalized CONTENT, so its stored hash means
#      something different from the file's own sha256.
vocab=3

die() { echo "_gate-cache-run: $*" >&2; exit 2; }
cache_status() {
    [[ "${BEAGLE_GATE_CACHE_QUIET_STATUS:-0}" == "1" ]] || echo "beagle-gate-cache: $*" >&2
}
cache_status_stdout() {
    [[ "${BEAGLE_GATE_CACHE_QUIET_STATUS:-0}" == "1" ]] || echo "beagle-gate-cache: $*"
}

domain=""
id=""
watch_roots=()
cmd=()
while [[ $# -gt 0 ]]; do
    case "$1" in
        --domain) domain="$2"; shift 2 ;;
        --id)     id="$2"; shift 2 ;;
        --watch)  watch_roots+=("$(realpath -m "$2")"); shift 2 ;;
        --)       shift; cmd=("$@"); break ;;
        *)        die "unknown argument: $1" ;;
    esac
done
[[ -n "$domain" && -n "$id" && ${#cmd[@]} -gt 0 ]] ||
    die "need --domain, --id, and -- CMD"

# Bypass paths: each still marks the child as inner so a self-wrapping gate
# script does not re-exec the wrapper forever.
if [[ "${BEAGLE_GATE_NO_CACHE:-0}" == "1" || -n "${BEAGLE_GATE_CACHE_INNER:-}" ]] ||
   ! command -v strace >/dev/null 2>&1; then
    export BEAGLE_GATE_CACHE_INNER=1
    exec "${cmd[@]}"
fi

self="$(readlink -f "${BASH_SOURCE[0]}")"
repo="$(cd "$(dirname "$self")/.." && pwd)"
cache_root="${BEAGLE_GATE_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/beagle/gate-results}"
memo="$cache_root/.hashmemo"
launch_cwd="$(pwd)"

scratch="$(mktemp -d "${TMPDIR:-/tmp}/gate-cache.XXXXXX")"
trap 'rm -rf "${scratch:?}"' EXIT

# --- the pinned Racket install, collapsed to its store root -------------------
# The interpreter accounts for most of what a Racket gate touches, and nearly
# all of it is a miss: Racket derives a compiled-file probe by appending an
# absolute source path to each collects root, so one module load fans out into
# hundreds of ENOENTs inside the install. On a small tier file that is 2640 of
# 3251 stored records, every one of them re-probed on every lookup.
#
# All of them collapse into ONE record — the install's store root — WITHOUT
# weakening the key, because every record under that root is already keyed on
# presence alone and presence of the root already implies all of them:
#
#   1. A /nix/store path never enters files.sha256. Class R/W/L goes to
#      nixpaths.list, revalidated by `[[ -e ]]`; class A goes to absent.list,
#      revalidated by `[[ ! -e ]]`. The closure therefore asserts nothing about
#      the BYTES of anything under the install, only about which paths exist.
#   2. /nix/store is a read-only mount and a store path is realized whole and
#      never mutated in place; the only transition available to it is whole-path
#      deletion by the collector. So while the root exists, every path under it
#      has exactly the existence it had during the proving run — the present
#      ones present, the probed-absent ones still absent.
#   3. Which install is in play is keyed independently of the closure:
#      _BEAGLE_RACKET is part of env_material and the interpreter's store path
#      is spelled in argv, so a different Racket is a different IDENTITY and
#      cannot reach this entry's directory at all.
#
# (1) and (2) make the root's presence equivalent to the whole subtree's
# recorded state, so the collapsed key admits exactly the same runs as the
# expanded one; (3) closes the remaining door, an upgraded interpreter reusing
# a proof earned under the old one. A collapse is only ever as sound as its
# invariant, so it is taken only for a path that is under /nix/store — never
# for a mutable prefix — and only for the install actually selected.
racket_prefix=""
if [[ "${_BEAGLE_RACKET:-}" == /nix/store/*/* ]]; then
    _rk="${_BEAGLE_RACKET#/nix/store/}"
    _rk="/nix/store/${_rk%%/*}"
    [[ -d "$_rk" ]] && racket_prefix="$_rk"
    unset _rk
fi

# --- path normalization ------------------------------------------------------
# Global (not line-anchored): env values and argv lines embed paths mid-line.
# %R% before %H%: the repo lives under $HOME.
#
# The BARE root is rewritten as well as the prefixed one, so a record naming the
# repo root itself — the launch cwd, a --watch root, a listed or existence-
# checked directory, a symlink target — is stored re-anchorable like every other
# path. It is rewritten only where the match ends at a path boundary: `$repo`
# followed by `-` or a digit names a SIBLING checkout, and re-anchoring that
# into this one would assert something about a directory the run never read.
# Non-boundary matches are left absolute: a missed rewrite costs a cache hit,
# a wrong one buys a false green.
#
# Substitution is literal (index/substr over ENVIRON), never regex: a checkout
# path holding `.` or `[` must match itself and nothing else, and a replacement
# holding `&` or `\` must arrive intact.

_gc_bound=$'/ \t:,;=|<>()[]{}"\''

normalize() {
    _GC_REPO="$repo" _GC_HOME="$HOME" _GC_BOUND="$_gc_bound" awk '
        function bound(s, i) {
            return (i > length(s)) || index(bnd, substr(s, i, 1)) > 0
        }
        function retoken(s, root, tok,   out, i, n) {
            n = length(root)
            if (n == 0) return s
            out = ""
            while ((i = index(s, root)) > 0) {
                out = out substr(s, 1, i - 1) (bound(s, i + n) ? tok : root)
                s = substr(s, i + n)
            }
            return out s
        }
        BEGIN { repo = ENVIRON["_GC_REPO"]; home = ENVIRON["_GC_HOME"]
                bnd = ENVIRON["_GC_BOUND"] }
        { print retoken(retoken($0, repo, "%R%"), home, "%H%") }
    '
}
denormalize() {
    _GC_REPO="$repo" _GC_HOME="$HOME" awk '
        function expand(s, tok, val,   out, i, n) {
            n = length(tok); out = ""
            while ((i = index(s, tok)) > 0) {
                out = out substr(s, 1, i - 1) val
                s = substr(s, i + n)
            }
            return out s
        }
        BEGIN { repo = ENVIRON["_GC_REPO"]; home = ENVIRON["_GC_HOME"] }
        { print expand(expand($0, "%R%", repo), "%H%", home) }
    '
}
n_denorm()    { local s="$1"; s=${s//"%R%"/"$repo"}; s=${s//"%H%"/"$HOME"}; printf '%s' "$s"; }

# --- content normalization ---------------------------------------------------
# Some inputs spell the checkout inside their own bytes. A Racket
# `compiled/*.dep` records the absolute path of every dependency that is not
# collection-relative, so two checkouts with byte-identical sources hold
# .dep files that differ — and a closure that reads one can never replay in
# the other. Such a file is hashed over its content with the SAME %R%/%H%
# vocabulary a path record gets: that drops the checkout-location spelling and
# keeps every other bit, so a .dep naming a genuinely different dependency
# still hashes differently and still busts the key. Dropping .dep from the
# closure would have bought the same hits by giving up the dependency
# information; re-anchoring keeps it. An input that cannot be placed at all is
# still class U and still uncacheable — unchanged.
#
# Unlike the path rewrite, this one is INJECTIVE by construction. A path record
# is denormalized and re-read at lookup, so a token that fails to round-trip
# costs a cache miss; a content hash is never re-anchored, so two different
# files hashing alike would be a false green. `%` is therefore doubled before
# the tokens are introduced: a file that literally spells `%R%` can never hash
# equal to one that spells the checkout root.
#
# LC_ALL=C: the bytes are hashed as bytes, never re-encoded.
#
# The set is named by suffix — `*.dep` — in hash_paths, in the shell and in the
# awk alike. Normalizing a file that needs no normalizing is harmless (the
# rewrite is injective), so the test is deliberately a spelling rule and not a
# probe of the contents.

normalize_content() {
    LC_ALL=C _GC_REPO="$repo" _GC_HOME="$HOME" _GC_BOUND="$_gc_bound" awk '
        function bound(s, i) {
            return (i > length(s)) || index(bnd, substr(s, i, 1)) > 0
        }
        function retoken(s, root, tok,   out, i, n) {
            n = length(root)
            if (n == 0) return s
            out = ""
            while ((i = index(s, root)) > 0) {
                out = out substr(s, 1, i - 1) (bound(s, i + n) ? tok : root)
                s = substr(s, i + n)
            }
            return out s
        }
        function escape(s,   out, i) {
            out = ""
            while ((i = index(s, "%")) > 0) {
                out = out substr(s, 1, i - 1) "%%"
                s = substr(s, i + 1)
            }
            return out s
        }
        BEGIN { repo = ENVIRON["_GC_REPO"]; home = ENVIRON["_GC_HOME"]
                bnd = ENVIRON["_GC_BOUND"] }
        { print retoken(retoken(escape($0), repo, "%R%"), home, "%H%") }
    '
}

# --- identity: which invocation is this? -------------------------------------
# Filtered env: every var that plausibly steers a gate or the toolchain.
# BEAGLE_GATE_* stays out (the cache's own controls are not the run's inputs).
# _BEAGLE_RACKET is load-bearing: it selects the interpreter without reading
# the flake, so it must key the result itself.
#
# BEAGLE_BOUNDED_COMPLETION_RECEIPT keys on PRESENCE, never on its value. The
# value names the file run-bounded.rkt writes its own `subtree-reaped-v0`
# outcome to — an OUTPUT of the supervisor ABOVE the traced command, minted by
# beagle-test under a per-run `mktemp -d` and never read by the command itself
# (it is scratch under /tmp, so the closure excludes it too). Keyed by value it
# handed every unit a path that had never existed before, so every identity was
# single-use and no stored result was ever reachable again.
#
# Presence stays in the key because presence is READ: wasm-materializer.rkt's
# `run-owned/bounded` branches on whether the variable is set, prefixing the
# command under test with `env BEAGLE_BOUNDED_COMPLETION_RECEIPT=…` and taking
# a fresh receipt for its own supervisor. Set and unset therefore run different
# argv, and must not share a proof.

env_material() {
    env | LC_ALL=C sort | grep -E \
        '^(BEAGLE_[A-Z0-9_]+|_BEAGLE_RACKET|PLT[A-Z0-9_]*|NATIVE_[A-Z0-9_]+|BEAGLE_STORE_[A-Z0-9_]+|WASI[A-Z0-9_]*|WASMTIME|QBE[A-Z0-9_]*|CI|NIXOS_[A-Z0-9_]+|LANG|LC_[A-Z]+)=' |
        grep -v '^BEAGLE_GATE_' |
        sed 's|^BEAGLE_BOUNDED_COMPLETION_RECEIPT=.*|BEAGLE_BOUNDED_COMPLETION_RECEIPT=<set>|' |
        normalize
    return 0
}

identity_material() {
    echo "beagle-gate-cache/$vocab"
    echo "domain=$domain"
    echo "id=$id"
    printf '%s\n' "$launch_cwd" | normalize | sed 's/^/cwd=/'
    printf '%s\n' "${cmd[@]}" | normalize | sed 's/^/argv=/'
    echo "wrapper=$(sha256sum "$self" | cut -d' ' -f1)"
    local w
    for w in "${watch_roots[@]:-}"; do
        [[ -n "$w" ]] && printf '%s\n' "$w" | normalize | sed 's/^/watch=/'
    done
    env_material
}

identity_sha="$(identity_material | sha256sum | cut -d' ' -f1)"
domain_dir="$cache_root/$domain/$identity_sha"

# Sanitizer-bearing gates cannot run traced: LeakSanitizer's stop-the-world
# needs ptrace on its own process, and one tracee admits one tracer. Such an
# identity is flagged on first discovery and runs direct — full run, never
# cached, never under-keyed. The flag expires so the identity is re-probed
# once the toolchain or gate might have changed.
untraceable_flag="$domain_dir/.untraceable"
if [[ -f "$untraceable_flag" && -n "$(find "$untraceable_flag" -mtime -14 2>/dev/null)" ]]; then
    rm -rf "${scratch:?}"
    trap - EXIT
    export BEAGLE_GATE_CACHE_INNER=1
    exec "${cmd[@]}"
fi

# --- stat-identity memo helpers ---------------------------------------------

stat_batch() {  # stdin: abs paths; stdout: "key<TAB>path" (key has no tabs)
    xargs -r -d '\n' stat -c $'%d:%i:%s|%y|%z\t%n' -- 2>/dev/null
}

# hash_paths: stdin abs paths -> stdout "sha256  path", memo-accelerated.
# Paths that cannot be statted or hashed are dropped; callers compare counts.
# A `*.dep` path (see normalize_content) is digested over its normalized
# bytes instead of its own; its memo key carries an `n:` prefix, because the
# stat identity is the same but the hash it stands for is not, and serving a
# raw hash where a normalized one is meant is a wrong answer, not a slow one.
hash_paths() {
    local stats="$scratch/hp.stats" need="$scratch/hp.need" out="$scratch/hp.out"
    : > "$need"
    stat_batch > "$stats" || true
    [[ -f "$memo" ]] || { mkdir -p "$cache_root" 2>/dev/null; touch "$memo" 2>/dev/null; } || true
    awk -F'\t' -v memofile="$memo" -v need="$need" '
        function memokey(stat, path) { return (path ~ /\.dep$/) ? "n:" stat : stat }
        BEGIN {
            while ((getline l < memofile) > 0) {
                t = index(l, "\t"); if (t) m[substr(l, 1, t-1)] = substr(l, t+1)
            }
            close(memofile)
        }
        { k = memokey($1, $2); if (k in m) print m[k] "  " $2; else print $2 > need }
    ' "$stats" > "$out"
    if [[ -s "$need" ]]; then
        local hashed="$scratch/hp.hashed" raw="$scratch/hp.raw" norm="$scratch/hp.norm"
        local p h
        : > "$hashed"
        grep -v '\.dep$' "$need" > "$raw" || true
        grep    '\.dep$' "$need" > "$norm" || true
        [[ -s "$raw" ]] &&
            { xargs -r -d '\n' sha256sum -- < "$raw" 2>/dev/null >> "$hashed" || true; }
        while IFS= read -r p; do
            # A regular readable file is required BEFORE the redirect: a failed
            # `< "$p"` (gone, unreadable, now a directory) would still let
            # sha256sum digest an empty stream and store that as the file's
            # hash. Dropping the path instead trips the caller's count check,
            # which refuses to cache — the same outcome sha256sum gives on the
            # raw side.
            [[ -f "$p" && -r "$p" ]] || continue
            h="$(normalize_content < "$p" | sha256sum | cut -d' ' -f1)"
            [[ -n "$h" ]] && printf '%s  %s\n' "$h" "$p" >> "$hashed"
        done < "$norm"
        awk -F'\t' 'NR==FNR { key[$2] = $1; next }
             { p = substr($0, 67)
               if (p in key)
                   print ((p ~ /\.dep$/) ? "n:" : "") key[p] "\t" substr($0, 1, 64) }
        ' "$stats" "$hashed" >> "$memo" 2>/dev/null || true
        cat "$hashed" >> "$out"
    fi
    cat "$out"
}

# --- directory listing digest ------------------------------------------------
# Canonical stream: normalized dir paths on stdin, LC_ALL=C sorted by caller.

dirs_stream() {
    local nd d
    while IFS= read -r nd; do
        d="$(n_denorm "$nd")"
        printf 'D %s\n' "$nd"
        # "no entries" and "not a directory at all" are different facts: a run
        # that listed an empty dir must not revalidate where the dir is gone or
        # is now a file (O_DIRECTORY would fail there), so absence is stated.
        if [[ -d "$d" ]]; then
            find "$d" -mindepth 1 -maxdepth 1 -printf '%f\n' 2>/dev/null |
                LC_ALL=C sort | sed 's/^/E /'
        else
            printf 'M\n'
        fi
    done
    return 0
}

dirs_digest() {  # stdin: normalized LIST dir paths
    LC_ALL=C sort -u | dirs_stream | sha256sum | cut -d' ' -f1
}

# --- validation --------------------------------------------------------------

validate_entry() {
    local e="$1" p t resolved
    [[ -f "$e/meta" && -f "$e/stdout" ]] || return 1
    # An entry is readable only under the vocabulary it was written in: its
    # paths mean nothing without the dictionary that stored them, and an older
    # one spells a bare repo root as this checkout's neighbour.
    grep -qxF "vocab=$vocab" "$e/meta" || return 1
    # nix store inputs: immutable by construction; presence is identity.
    if [[ -s "$e/nixpaths.list" ]]; then
        while IFS= read -r p; do [[ -e "$p" ]] || return 1; done < "$e/nixpaths.list"
    fi
    # probed-absent paths must still be absent.
    if [[ -s "$e/absent.list" ]]; then
        denormalize < "$e/absent.list" > "$scratch/v.absent"
        while IFS= read -r p; do [[ ! -e "$p" ]] || return 1; done < "$scratch/v.absent"
    fi
    # symlinks must resolve to the same final target.
    if [[ -s "$e/links.list" ]]; then
        while IFS=$'\t' read -r p t; do
            resolved="$(readlink -f -- "$(n_denorm "$p")" 2>/dev/null || true)"
            [[ "$resolved" == "$(n_denorm "$t")" ]] || return 1
        done < "$e/links.list"
    fi
    # listed directories must list identically (one digest over the stream).
    if [[ -f "$e/dirs.digest" ]]; then
        [[ "$(sed -n 's/^LIST //p' "$e/dirs.list" | dirs_digest)" == "$(cat "$e/dirs.digest")" ]] ||
            return 1
    fi
    if [[ -s "$e/dirs.list" ]]; then
        sed -n 's/^EXIST //p' "$e/dirs.list" | denormalize > "$scratch/v.dirs"
        while IFS= read -r p; do [[ -d "$p" ]] || return 1; done < "$scratch/v.dirs"
    fi
    # file contents: every recorded hash must still hold.
    if [[ -s "$e/files.sha256" ]]; then
        denormalize < "$e/files.sha256" | LC_ALL=C sort > "$scratch/v.want"
        cut -c67- "$scratch/v.want" > "$scratch/v.paths"
        hash_paths < "$scratch/v.paths" | LC_ALL=C sort > "$scratch/v.have"
        cmp -s "$scratch/v.want" "$scratch/v.have" || return 1
    fi
    return 0
}

# --- lookup ------------------------------------------------------------------

if [[ -d "$domain_dir" ]]; then
    for entry in $(ls -1t "$domain_dir" 2>/dev/null); do
        e="$domain_dir/$entry"
        [[ -d "$e" ]] || continue
        if validate_entry "$e"; then
            proof="$(sed -n 's/^created=//p' "$e/meta" | head -1)"
            cache_status_stdout "cached-green $domain/$id entry=${entry:0:12} proof=$proof"
            cat "$e/stdout"
            [[ -f "$e/stderr" ]] && cat "$e/stderr" >&2
            touch -c "$e" 2>/dev/null || true
            exit 0
        fi
    done
fi

# --- the classifier: a trace line in, one closure record out -----------------
# awk emits "CLASS<TAB>abs-path": W written, L dir-listed, R read, A absent,
# plus U<TAB>relative-path for an access that cannot be placed at all. One
# record per path, aggregated in flight with precedence U > W > L > R > A: U
# outranks everything, because an access nobody could place must not be masked
# by one that could. (U paths are the only relative ones, so they can never
# collide with a class path and are carried in their own array.)
#
# It runs AS A STREAM, off the critical path: strace pipes the trace straight
# into it (`-o |CMD`) instead of materializing a file the wrapper then re-reads.
# The heavy tier file traced 2.4M lines / 584 MB, and classifying it afterwards
# was >=150s of single-threaded work AFTER the test had already finished — time
# no meta duration_s ever counted. Streaming spends it inside the run instead,
# and nothing lands on disk.
#
# strace's own `-o |CMD` is the mechanism, not a process substitution: strace
# creates the consumer itself, and closes and REAPS it before exiting, so the
# records are whole by the time the wrapper looks. The shell-side alternative
# deadlocks, and the reason is worth keeping — a consumer forked by the shell
# before the exec becomes strace's own child, strace waits for all its children
# at exit, and the consumer waits for EOF on the fd strace still holds.
#
# --decode-fds=path is load-bearing and stays. Without it strace prints a bare
# `AT_FDCWD` with no path, which is exactly the kernel-truth cwd this
# classifier resolves every *at syscall against; dropping it would make every
# relative access class U and every traced run uncacheable.
#
# AT_FDCWD</cwd> decorations carry the kernel-truth cwd per call, so every *at
# syscall resolves against the kernel's own answer. Success return values may
# be decorated too ("= 3</path>").
#
# A path with no such decoration — execve above all, which takes no dirfd — can
# only be resolved against the calling pid's cwd, and a forked child has none
# of its own until it chdirs or issues a decorated call. Resolving those against
# the LAUNCH cwd is a guess, and a guess that lands on a real file is the worst
# outcome available: `( cd "$build"; ./probe )` would key the entry on whatever
# ./probe names at the launch cwd while the file actually executed went
# unrecorded — a green replayed from the wrong proof. So an unplaceable access
# is not resolved and not dropped: it is class U, and it makes the whole run
# uncacheable. The first pid IS launch_cwd (strace's direct child, and the
# wrapper never chdirs), which is knowledge, not a guess, so it is seeded.

classifier="$scratch/classify.awk"
records="$scratch/records.agg"
classifier_log="$scratch/classify.err"

cat > "$classifier" <<'CLASSIFY'
    function resolve(p, base) {
        if (p ~ /^\//) return p
        if (base == "") { unplaceable[p] = 1; return "" }
        return base "/" p
    }
    # fd decorations for pipes/sockets/anon inodes are not filesystem paths;
    # one leaking into path position must never key (or de-key) a result.
    function emit(c, p,   rank) {
        if (p == "") return
        if (p ~ /(pipe|socket|anon_inode|memfd):/) return
        # Every access inside the pinned Racket install is stated once, by the
        # install root: see the racket_prefix derivation for why that is the
        # same assertion and not a weaker one.
        if (rkpfx != "" && (p == rkpfx || index(p, rkslash) == 1)) {
            rkseen = 1; return
        }
        rank = (c == "W") ? 4 : (c == "L") ? 3 : (c == "R") ? 2 : 1
        if (rank > best[p]) { best[p] = rank; cls[p] = c }
    }
    BEGIN { if (rkpfx != "") rkslash = rkpfx "/" }
    {
        pid = $1
        if (!seeded) { cwd[pid] = launch_cwd; seeded = 1 }
        line = $0; sub(/^[0-9]+[ \t]+/, "", line)
        if (line ~ /<unfinished \.\.\.>$/) {
            sub(/[ \t]*<unfinished \.\.\.>$/, "", line); pend[pid] = line; next
        }
        if (line ~ /^<\.\.\. [a-z0-9_]+ resumed>/) {
            sub(/^<\.\.\. [a-z0-9_]+ resumed>[ ]?/, "", line)
            line = pend[pid] line; delete pend[pid]
        }
        if (match(line, /^[a-z0-9_]+\(/) == 0) next
        sc = substr(line, RSTART, RLENGTH - 1)

        # kernel-truth cwd from decorated AT_FDCWD
        if (match(line, /AT_FDCWD<[^>]+>/))
            cwd[pid] = substr(line, RSTART + 9, RLENGTH - 10)
        base = (pid in cwd) ? cwd[pid] : ""

        # decorated dirfd bases, in arg order
        nb = 0; rest = line
        while (match(rest, /(AT_FDCWD|[0-9]+)<[^>]+>/)) {
            tok = substr(rest, RSTART, RLENGTH)
            sub(/^[^<]*</, "", tok); sub(/>$/, "", tok)
            bases[++nb] = tok
            rest = substr(rest, RSTART + RLENGTH)
        }
        # quoted path args, in order
        nq = 0; rest = line
        while (match(rest, /"[^"]*"/) && nq < 4) {
            quotes[++nq] = substr(rest, RSTART + 1, RLENGTH - 2)
            rest = substr(rest, RSTART + RLENGTH)
        }

        enoent = (line ~ / = -1 ENOENT /) ? 1 : 0
        failed = (line ~ / = -1 /) ? 1 : 0
        ok = (!failed && line ~ / = [0-9]+(<[^>]*>)?$/) ? 1 : 0

        if (nq == 0) {
            if (sc == "fchdir" && nb >= 1 && ok) cwd[pid] = bases[1]
            next
        }
        b1 = (nb >= 1) ? bases[1] : base
        b2 = (nb >= 2) ? bases[2] : base

        if (sc ~ /^(open|openat|openat2|creat)$/) {
            p = resolve(quotes[1], b1)
            wr = (line ~ /O_WRONLY|O_RDWR|O_CREAT|O_TRUNC|O_APPEND/) ? 1 : 0
            if (ok && wr)           emit("W", p)
            else if (ok)            emit((line ~ /O_DIRECTORY/) ? "L" : "R", p)
            else if (enoent && !wr) emit("A", p)
        } else if (sc ~ /^(stat|lstat|fstatat64|newfstatat|statx|access|faccessat|faccessat2|readlink|readlinkat|getxattr|lgetxattr|execve|execveat)$/) {
            p = resolve(quotes[1], b1)
            if (ok) emit("R", p); else if (enoent) emit("A", p)
        } else if (sc == "chdir") {
            p = resolve(quotes[1], base)
            if (ok && p != "") { cwd[pid] = p; emit("R", p) }
        } else if (sc ~ /^(mkdir|mkdirat|rmdir|unlink|unlinkat|truncate|chmod|fchmodat|utimensat|futimesat|setxattr|removexattr)$/) {
            if (ok) emit("W", resolve(quotes[1], b1))
        } else if (sc ~ /^(rename|renameat|renameat2)$/) {
            if (ok && nq >= 2) { emit("W", resolve(quotes[1], b1)); emit("W", resolve(quotes[2], b2)) }
        } else if (sc ~ /^(symlink|symlinkat)$/) {
            if (ok && nq >= 2) emit("W", resolve(quotes[2], (sc == "symlinkat") ? b1 : base))
        } else if (sc ~ /^(link|linkat)$/) {
            if (ok && nq >= 2) { emit("W", resolve(quotes[1], b1)); emit("W", resolve(quotes[2], b2)) }
        }
    }
    END {
        if (rkseen) print "R\t" rkpfx
        for (p in cls) print cls[p] "\t" p
        for (p in unplaceable) print "U\t" p
    }
CLASSIFY

# --- run for real, traced ----------------------------------------------------

child_tmp="$scratch/tmp"
mkdir -p "$child_tmp"
out_f="$scratch/stdout"
err_f="$scratch/stderr"

# stdout streams live through tee; stderr goes to a file and is replayed after
# the run. The classifier's arguments travel as environment: strace hands the
# `-o |CMD` string to `sh -c` with its own environment, so `sh` expands them and
# no path has to survive being spelled inside a nested quoting level.
#
# --seccomp-bpf makes the KERNEL decide which syscalls stop the tracee, instead
# of stopping on every syscall and discarding the uninteresting ones in
# userspace. The cost this removes is ptrace stop overhead, which is the whole
# cost: a tier file traces ~468k events to yield ~4k closure records, and
# splitting tracer from classifier showed the classifier was not the bind.
#
# It REQUIRES -f, and it is incompatible with -b/--detach-on and
# --syscall-limit, so neither may join this command line. A filter that cannot
# be installed is not an error — strace falls back to stopping on everything —
# so the failure mode is the old cost, never a thinner trace. The one thing that
# would thin a trace is a tracee carrying its own seccomp filter outranking
# SECCOMP_RET_TRACE, whose syscalls strace never sees; a closure derived from an
# unseen read would be under-keyed. Nothing under test installs one.
start_ts=$(date +%s)
set +e
TMPDIR="$child_tmp" TMP="$child_tmp" TEMP="$child_tmp" \
BEAGLE_GATE_CACHE_INNER=1 \
_GC_LAUNCH_CWD="$launch_cwd" _GC_RACKET_PREFIX="$racket_prefix" \
_GC_CLASSIFIER="$classifier" _GC_RECORDS="$records" _GC_CLASSIFY_LOG="$classifier_log" \
strace -f --seccomp-bpf -qq -s 500 --decode-fds=path -e trace=%file,%process,fchdir \
    -o '|exec awk -v launch_cwd="$_GC_LAUNCH_CWD" -v rkpfx="$_GC_RACKET_PREFIX" -f "$_GC_CLASSIFIER" >"$_GC_RECORDS" 2>"$_GC_CLASSIFY_LOG"' \
    -- "${cmd[@]}" 2> "$err_f" | tee "$out_f"
status=${PIPESTATUS[0]}
set -e
cat "$err_f" >&2
duration=$(( $(date +%s) - start_ts ))

if [[ "$status" -ne 0 ]] &&
   grep -q "does not work under ptrace" "$out_f" "$err_f" 2>/dev/null; then
    mkdir -p "$domain_dir" 2>/dev/null || true
    touch "$untraceable_flag" 2>/dev/null || true
    cache_status "$domain/$id is untraceable (sanitizer refuses ptrace); rerunning direct"
    rm -rf "${scratch:?}"
    trap - EXIT
    export BEAGLE_GATE_CACHE_INNER=1
    exec "${cmd[@]}"
fi

[[ "$status" -eq 0 ]] || exit "$status"

# --- derive the input closure from the trace ---------------------------------
# The classifier is finished here — strace reaps it before exiting — so a
# missing, empty, or complaining classifier means the closure was never
# derived. An empty record set would revalidate against nothing and replay
# green forever, so it is refused as loudly as a crash: doubt re-runs.
if [[ ! -s "$records" || -s "$classifier_log" ]]; then
    [[ -s "$classifier_log" ]] && sed -n '1,5p' "$classifier_log" >&2
    cache_status "ran-green $domain/$id (not cached: trace classifier produced no closure)"
    exit 0
fi

# Lexical cleanup only (realpath -s never follows symlinks: a final-component
# symlink must keep its own identity so a repoint invalidates), and never for
# class A. A `..` component is resolvable only by the kernel: gcc probes
# `/lib/../lib64`, which is ENOENT here because `/lib` is missing, and
# collapsing it to `/lib64` — which exists — would store an assertion the run
# never made and no revalidation could ever satisfy. An absent record therefore
# keeps the spelling that was actually probed; `[[ -e ]]` re-probes it the same
# way the run did, so a newly appearing intermediate component still
# invalidates. On any count mismatch fall back to the uncleaned paths.
# Class U is held out for the same reason and one more: it is not absolute, so
# realpath would resolve it against the WRAPPER's cwd and manufacture exactly
# the guess the class exists to refuse.
: > "$scratch/records.verbatim"
awk -F'\t' -v keep="$scratch/records.verbatim" \
    '$1 == "A" || $1 == "U" { print > keep; next } { print }' \
    "$records" > "$scratch/records.present"
cut -f2 "$scratch/records.present" > "$scratch/records.p"
if xargs -r -d '\n' realpath -smq -- < "$scratch/records.p" > "$scratch/records.norm" 2>/dev/null &&
   [[ "$(wc -l < "$scratch/records.norm")" == "$(wc -l < "$scratch/records.p")" ]]; then
    paste "$scratch/records.present" "$scratch/records.norm" | cut -f1,3 > "$scratch/records"
else
    cp "$scratch/records.present" "$scratch/records"
fi
cat "$scratch/records.verbatim" >> "$scratch/records"

# ~/.clojure/.cpcache is the Clojure CLI's derived classpath cache: its file
# names hash the classpath STRING, which embeds this run's temp dirs, so the
# probed names differ every run and an entry recording them never revalidates.
# Its real sources (deps.edn, classpath contents) are traced in their own
# right, so excluding the cache itself drops churn, not coverage.
ignore_prefixes=("$scratch" "${TMPDIR:-/tmp}" /tmp /var/tmp /proc /sys /dev
                 /run/user /nix/var "$HOME/.cache" "$HOME/.local/state"
                 "$HOME/.clojure/.cpcache" "$cache_root")

# Racket derives compiled-file probe paths by APPENDING an absolute source
# path to a collects root ("/nix/store/.../compiled/var/tmp/<run-tmp>/x.zo"),
# so a run-unique temp dir can appear mid-path, not just as a prefix. Such a
# path can never recur, so recording it only churns the entry hash. Substring-
# match is safe exactly when the needle is run-unique (mktemp suffixes); a
# generic TMPDIR like /tmp or /var/tmp must never be substring-dropped or it
# would swallow real inputs.
tmp_sub=""
case "${TMPDIR:-}" in
    "" | /tmp | /tmp/ | /var/tmp | /var/tmp/) ;;
    *) tmp_sub="${TMPDIR%/}" ;;
esac

is_watched() {
    local p="$1" w
    for w in "${watch_roots[@]:-}"; do
        [[ -n "$w" && ( "$p" == "$w" || "$p" == "$w"/* ) ]] && return 0
    done
    return 1
}
is_ignored() {
    local p="$1" i
    is_watched "$p" && return 1
    for i in "${ignore_prefixes[@]}"; do
        [[ "$p" == "$i" || "$p" == "$i"/* ]] && return 0
    done
    return 1
}

: > "$scratch/hash.paths"; : > "$scratch/links.list"; : > "$scratch/dirs.list"
: > "$scratch/absent.list"; : > "$scratch/nixpaths.list"; : > "$scratch/listdirs"
uncacheable=""

while IFS=$'\t' read -r cls p; do
    # Checked before the absolute-path filter and before every exclusion: a
    # path we could not place is a path we cannot decide is scratch either.
    if [[ "$cls" == "U" ]]; then
        uncacheable="unplaceable relative access: $p"
        continue
    fi
    [[ "$p" == /* ]] || continue
    case "$p" in *pipe:\[* | *socket:\[* | *anon_inode:*) continue ;; esac
    [[ "$p" == *"$scratch"* ]] && continue
    [[ -n "$tmp_sub" && "$p" == *"$tmp_sub"* ]] && continue
    is_ignored "$p" && continue
    if [[ "$p" == /nix/store/* ]]; then
        case "$cls" in
            A) printf '%s\n' "$p" >> "$scratch/absent.list" ;;
            *) printf '%s\n' "$p" >> "$scratch/nixpaths.list" ;;
        esac
        continue
    fi
    case "$cls" in
        A) printf '%s\n' "$p" >> "$scratch/absent.list" ;;
        L)
            if   [[ -d "$p" ]]; then printf '%s\n' "$p" >> "$scratch/listdirs"
            elif [[ -f "$p" ]]; then printf '%s\n' "$p" >> "$scratch/hash.paths"
            fi
            ;;
        R|W)
            if [[ -L "$p" ]]; then
                tgt="$(readlink -f -- "$p" 2>/dev/null || true)"
                printf '%s\t%s\n' "$p" "$tgt" >> "$scratch/links.list"
                [[ -f "$p" && "$tgt" != /nix/store/* ]] &&
                    printf '%s\n' "$p" >> "$scratch/hash.paths"
            elif [[ -f "$p" ]]; then
                printf '%s\n' "$p" >> "$scratch/hash.paths"
            elif [[ -d "$p" ]]; then
                printf '%s\n' "$p" >> "$scratch/existdirs.tmp"
            elif [[ "$cls" == "R" ]]; then
                # an input existed during the run and is gone now: unprovable
                uncacheable="read input vanished: $p"
            fi
            ;;
    esac
done < "$scratch/records"

if [[ -n "$uncacheable" ]]; then
    cache_status "ran-green $domain/$id (not cached: $uncacheable)"
    exit 0
fi

mkdir -p "$cache_root" 2>/dev/null || exit 0
staging="$(mktemp -d "$cache_root/.staging.XXXXXX" 2>/dev/null)" || exit 0

LC_ALL=C sort -u "$scratch/hash.paths" > "$scratch/hash.uniq"
hash_paths < "$scratch/hash.uniq" | LC_ALL=C sort | normalize > "$staging/files.sha256"
if [[ "$(wc -l < "$staging/files.sha256")" != "$(wc -l < "$scratch/hash.uniq")" ]]; then
    rm -rf -- "${staging:?}"
    cache_status "ran-green $domain/$id (not cached: unreadable input)"
    exit 0
fi

normalize < "$scratch/listdirs" | LC_ALL=C sort -u > "$scratch/listdirs.norm"
dirs_digest < "$scratch/listdirs.norm" > "$staging/dirs.digest"
{
    sed 's/^/LIST /' "$scratch/listdirs.norm"
    [[ -f "$scratch/existdirs.tmp" ]] &&
        normalize < "$scratch/existdirs.tmp" | sed 's/^/EXIST /'
} | LC_ALL=C sort -u > "$staging/dirs.list"

normalize < "$scratch/absent.list"   | LC_ALL=C sort -u > "$staging/absent.list"
LC_ALL=C sort -u "$scratch/nixpaths.list" > "$staging/nixpaths.list"
normalize < "$scratch/links.list"    | LC_ALL=C sort -u > "$staging/links.list"

cp "$out_f" "$staging/stdout"
cp "$err_f" "$staging/stderr"
{
    echo "domain=$domain"
    echo "id=$id"
    echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    echo "duration_s=$duration"
    echo "vocab=$vocab"
    echo "identity=$identity_sha"
    printf '%s\n' "${cmd[@]}" | normalize | sed 's/^/argv=/'
} > "$staging/meta"

entry_sha="$(cat "$staging/files.sha256" "$staging/links.list" \
                 "$staging/dirs.list" "$staging/dirs.digest" \
                 "$staging/absent.list" "$staging/nixpaths.list" |
             sha256sum | cut -d' ' -f1)"

mkdir -p "$domain_dir" 2>/dev/null || true
if mv -T "$staging" "$domain_dir/$entry_sha" 2>/dev/null; then
    cache_status "ran-green $domain/$id stored entry=${entry_sha:0:12}"
    find "$cache_root" -mindepth 3 -maxdepth 3 -type d -mtime +14 \
        -exec rm -rf -- '{}' + 2>/dev/null || true
    # cap stored states per identity: newest 8 win (alternating states keep
    # their proofs; unbounded churn does not accumulate)
    ls -1t "$domain_dir" 2>/dev/null | tail -n +9 | while IFS= read -r old; do
        rm -rf -- "${domain_dir:?}/$old"
    done
    # memo compaction: last write per stat-key wins; bounded, best-effort
    if [[ -f "$memo" && "$(stat -c %s "$memo" 2>/dev/null || echo 0)" -gt 10000000 ]]; then
        (
            flock -n 9 || exit 0
            awk -F'\t' '{ m[$1] = $2 } END { for (k in m) print k "\t" m[k] }' \
                "$memo" > "$memo.compact" 2>/dev/null &&
                mv "$memo.compact" "$memo"
        ) 9>>"$memo.lock" 2>/dev/null || true
    fi
else
    rm -rf -- "${staging:?}"
fi
exit 0
