Two defects made the earlier A/B numbers meaningless:
* Without `--stop-on-run-end` a session contained several runs, so runs could
not be attributed to an arm.
* The script then read "the last 2N runs", which silently compared runs from
earlier experiments rather than the two arms.
Pass `--stop-on-run-end`, and attribute each run record to its arm by diffing
the history directory around each session.
Also raise the step budget and warn on a thin sample: measured, a run still
going at step 600 produces NO run record, so a short budget biases the sample
toward runs that died early -- exactly the wrong bias for this question.
210 lines
8.5 KiB
Bash
Executable file
210 lines
8.5 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# A/B the card-reward skip policy.
|
|
#
|
|
# ./ab_card_skip.sh <sessions-per-arm> <steps-per-session>
|
|
#
|
|
# Everything except --card-skip-policy is identical between the arms.
|
|
#
|
|
# Three things this script does that earlier versions did not, each because an
|
|
# earlier attempt produced meaningless numbers:
|
|
#
|
|
# 1. --stop-on-run-end, so ONE SESSION = ONE RUN. Without it the bot dies,
|
|
# returns to the menu and starts a fresh run inside the same session, and a
|
|
# session yields several run records that cannot be attributed to an arm.
|
|
# 2. Each run record is attributed to the arm that produced it, by diffing the
|
|
# history directory around each session. The old version just took "the
|
|
# last 2N runs", which silently compared old runs from earlier experiments.
|
|
# 3. Every attributed run carries a hash of the decision layer, and the report
|
|
# compares only runs from ONE revision. A change to brain.py / facts.py /
|
|
# jev.py / run.py changes how both arms behave, so runs collected before it
|
|
# are not comparable with runs collected after it -- and the working copy is
|
|
# usually dirty, so a commit id would not distinguish the revisions.
|
|
set -u
|
|
|
|
N="${1:-5}"
|
|
# Steps per session. This must be generous: measured, a run that is still going
|
|
# at step 600 produces NO run record, so the sample silently fills with runs
|
|
# that died early -- exactly the wrong bias. --stop-on-run-end is the real stop
|
|
# condition; this is only a safety cap.
|
|
STEPS="${2:-4000}"
|
|
HIST="$HOME/Library/Application Support/SlayTheSpire2/steam/76561198141226155/modded/profile1/saves/history"
|
|
RESULTS=/tmp/ab_results.tsv
|
|
|
|
# The revision every arm in this invocation is collected from. Content hash, not
|
|
# a commit id: the decision layer is normally edited in place, uncommitted.
|
|
#
|
|
# Recomputed BEFORE EVERY SESSION, not once: this file is edited in place while
|
|
# experiments run, which is the whole reason the guard exists. A hash taken once
|
|
# at startup would be stamped onto every row regardless, and because $RESULTS is
|
|
# truncated below, the mixed-revision warning could never fire.
|
|
CODE_FILES=(brain.py facts.py jev.py run.py sts2.py)
|
|
code_hash() {
|
|
cat "${CODE_FILES[@]}" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-12
|
|
}
|
|
CODE_HASH=$(code_hash)
|
|
ABORTED=0
|
|
echo "code revision: $CODE_HASH (${CODE_FILES[*]})"
|
|
|
|
# Fail fast if the bot cannot start a run (e.g. a pending Timeline epoch).
|
|
if ! python3 run.py --steps 1 >/tmp/preflight.log 2>&1; then
|
|
cat /tmp/preflight.log
|
|
exit 2
|
|
fi
|
|
|
|
: > "$RESULTS"
|
|
|
|
snapshot() { ls "$HIST" 2>/dev/null | sort; }
|
|
|
|
run_arm() {
|
|
local policy="$1"
|
|
for i in $(seq 1 "$N"); do
|
|
local before after attempt=0 rc=1 session_hash
|
|
before=$(snapshot)
|
|
|
|
# An arm collected across two revisions is not an arm. Stop the experiment
|
|
# rather than stamp rows with a revision that did not produce them.
|
|
session_hash=$(code_hash)
|
|
if [ "$session_hash" != "$CODE_HASH" ]; then
|
|
echo
|
|
echo "!! ABORT: the decision layer changed mid-experiment."
|
|
echo "!! started on $CODE_HASH, now $session_hash"
|
|
echo "!! Rows already collected are in $RESULTS and are NOT comparable"
|
|
echo "!! with anything collected on the other revision."
|
|
echo "!! Re-run BOTH arms from one revision."
|
|
ABORTED=1
|
|
return 1
|
|
fi
|
|
|
|
while [ "$attempt" -lt 3 ]; do
|
|
attempt=$((attempt + 1))
|
|
echo "--- $policy $i/$N (attempt $attempt) rev=$session_hash ---"
|
|
python3 run.py --steps "$STEPS" --pause 0.3 \
|
|
--card-skip-policy "$policy" --stop-on-run-end \
|
|
> "/tmp/ab_${policy}_${i}.log" 2>&1
|
|
rc=$?
|
|
[ "$rc" -eq 0 ] && break
|
|
[ "$rc" -ne 3 ] && break # 2 = blocked, not worth retrying
|
|
echo " retrying after model failures..."
|
|
sleep 20
|
|
done
|
|
|
|
after=$(snapshot)
|
|
local new_runs session
|
|
new_runs=$(comm -13 <(echo "$before") <(echo "$after") | tr '\n' ' ')
|
|
# The session id the bot printed, so an attribution row joins to the same
|
|
# decisions.jsonl rows and to sessions.jsonl. Without it the two ends of the
|
|
# join carry different identifiers and nothing lines up.
|
|
session=$(sed -n 's/^session: //p' "/tmp/ab_${policy}_${i}.log" | head -1)
|
|
echo " exit=$rc session=${session:-?} takes=$(grep -c 'select_card_reward' "/tmp/ab_${policy}_${i}.log") skips=$(grep -c 'skip_card_reward' "/tmp/ab_${policy}_${i}.log") runs='${new_runs}'"
|
|
|
|
# Stamped with the hash that produced THIS run, not the startup hash.
|
|
for r in $new_runs; do
|
|
printf '%s\t%s\t%s\t%s\n' "$policy" "$r" "$session_hash" "${session:-?}" >> "$RESULTS"
|
|
done
|
|
done
|
|
return 0
|
|
}
|
|
|
|
echo "### ARM A: jev decides skip"
|
|
run_arm jev
|
|
echo
|
|
if [ "$ABORTED" -eq 0 ]; then
|
|
echo "### ARM B: combined (jev + a floor)"
|
|
run_arm combined
|
|
else
|
|
echo "### ARM B: skipped (aborted mid-experiment)"
|
|
fi
|
|
|
|
echo
|
|
POLICY=combined RESULTS="$RESULTS" CODE_HASH="$CODE_HASH" HIST="$HIST" python3 - <<'PY'
|
|
import json, os, statistics as st
|
|
|
|
# The same directory the shell snapshotted. A second hardcoded copy of this path
|
|
# silently reported "no runs recorded" for sessions that did produce runs.
|
|
hist = os.environ["HIST"]
|
|
results = os.environ["RESULTS"]
|
|
this_hash = os.environ["CODE_HASH"]
|
|
|
|
rows = []
|
|
with open(results) as fh:
|
|
for line in fh:
|
|
parts = line.rstrip("\n").split("\t")
|
|
if len(parts) < 2 or not parts[1]:
|
|
continue
|
|
rows.append((parts[0], parts[1],
|
|
parts[2] if len(parts) > 2 and parts[2] else "unstamped",
|
|
parts[3] if len(parts) > 3 and parts[3] else "?"))
|
|
|
|
hashes = sorted({h for _, _, h, _ in rows})
|
|
print(f"code revision(s) in {results}: {', '.join(hashes) if hashes else '(none)'}")
|
|
if len(hashes) > 1:
|
|
print()
|
|
print("!! MIXED REVISIONS. A change to the decision layer changes how BOTH")
|
|
print("!! arms behave, so a run collected before it is not comparable with")
|
|
print("!! one collected after it. Only ONE revision is scored below; re-run")
|
|
print("!! BOTH arms on one revision before interpreting anything.")
|
|
# Score the revision this invocation collected from when it has rows; otherwise
|
|
# the most recently appended one. Never "the largest hash": hex is not ordered.
|
|
if this_hash in hashes:
|
|
current = this_hash
|
|
elif rows:
|
|
current = rows[-1][2]
|
|
else:
|
|
current = this_hash
|
|
print(f"scoring revision: {current}")
|
|
print()
|
|
|
|
arms = {"jev": [], "combined": []}
|
|
sessions = {"jev": 0, "combined": 0}
|
|
for policy, run, code, session in rows:
|
|
if policy not in arms or code != current:
|
|
continue
|
|
sessions[policy] += 1
|
|
path = os.path.join(hist, run)
|
|
if not os.path.exists(path):
|
|
continue
|
|
d = json.load(open(path))
|
|
pl = (d.get("players") or [{}])[0]
|
|
mph = d.get("map_point_history") or []
|
|
arms[policy].append({
|
|
"killed": d.get("killed_by_encounter", "?").replace("ENCOUNTER.", ""),
|
|
"deck": len(pl.get("deck") or []),
|
|
"pts": sum(len(a) for a in mph if isinstance(a, list)),
|
|
"session": session,
|
|
})
|
|
|
|
print(f"{'arm':<10} {'sess':>4} {'runs':>4} {'deck':>6} {'pts':>6} {'added/pt':>9} {'act2':>6}")
|
|
print("-" * 54)
|
|
for name, grp in arms.items():
|
|
if not grp:
|
|
print(f"{name:<10} {sessions[name]:>4} {0:>4} (no runs recorded)")
|
|
continue
|
|
decks = [r["deck"] for r in grp]
|
|
pts = [r["pts"] for r in grp]
|
|
act2 = sum(1 for r in grp if r["pts"] > 17)
|
|
added = max(0.0, st.mean(decks) - 10) / max(1e-9, st.mean(pts))
|
|
print(f"{name:<10} {sessions[name]:>4} {len(grp):>4} {st.mean(decks):>6.1f} "
|
|
f"{st.mean(pts):>6.1f} {added:>9.2f} {act2:>4}/{len(grp)}")
|
|
|
|
print()
|
|
print("sess = sessions that produced a COMPLETED run record")
|
|
print("runs = run records attributed to this arm, on the scored revision")
|
|
print("added/pt = non-starting cards per map point reached <- the metric that")
|
|
print(" matters, because deck size at death scales with run length")
|
|
print("act2 = runs past the Act 1 boss (17 map points)")
|
|
|
|
thin = [n for n, g in arms.items() if len(g) < 5]
|
|
if thin:
|
|
print()
|
|
print(f"!! THIN SAMPLE: {', '.join(thin)} have fewer than 5 runs.")
|
|
print("!! Sessions that hit the step cap mid-run produce NO record, so a thin")
|
|
print("!! sample is biased toward runs that died early. Raise STEPS and re-run.")
|
|
print()
|
|
for name, grp in arms.items():
|
|
if grp:
|
|
print(f"{name} killed by: {[r['killed'] for r in grp]}")
|
|
print(f"{name} sessions : {[r['session'] for r in grp]}")
|
|
print()
|
|
print("sessions are the join key: grep one in capture/decisions.jsonl for that")
|
|
print("session's decisions, and in capture/sessions.jsonl for its outcome row.")
|
|
PY
|