Files
amethyst/cli/tests/sno/sno-conformance.sh
T
Vitor PamplonaandClaude Opus 5 288ed01105 feat(cli): amy sno, and a conformance harness against the reference
`amy sno parse|work|verify` — validate a DECK-0003 payload against §1.9,
price the proof of work an avatar owes for it, and check whether a
kind:11333 avatar has paid. Local, accountless, thin over quartz: the
verbs exist so the reader can be diffed against the implementations the
spec points at, without a device or a relay.

cli/tests/sno/ is that diff. The reference's own fixtures drive it —
_rejections() out of decks/sno-reference.py and the golden vectors out of
cyberspace-cli — so the cases are theirs, not ours. 26 §1.9 verdicts
agree on both the answer and the rule number; all 8 avatar vectors agree.

The four places the two differ are asserted as divergences rather than
skipped, because a reader that quietly stopped diverging would be as
interesting as one that started:

- we read the v:2-with-literal-triples cohort, the arbiter refuses it (§5
  permits the generosity; three of seven live objects need it);
- we refuse a vertex past 64 units, the arbiter accepts it (§8's own open
  question, and whole*120 overflows an Int);
- the Python reference still calls 33331 the avatar kind, a week behind
  §8.10's move to 11333, so it reads a conformant avatar as
  not-an-avatar and would price a standalone object as one;
- empty content is §8.10's default avatar to us and not-an-avatar to it.
  Same outcome — the client draws its default — different word.

SnoAvatarEvent gains payment(), which reports required/committed/zeros
and why, mirroring verify_avatar_work's shape so the two are directly
comparable; isPaid() is now that result's ok.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa
2026-09-22 02:41:01 +00:00

285 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# sno-conformance.sh — diffs amy's DECK-0003 reader against the cyberspace
# project's own reference implementations. No relay, no account, no device.
#
# Three comparisons, each driven by the reference's own fixtures rather than
# by anything we wrote:
#
# 1. §1.9 verdicts — the deck's rejection table (`_rejections()` in
# decks/sno-reference.py) plus Appendix A, through
# `amy sno parse` and through the reference. The
# valid/invalid answer AND the rule number must agree.
# 2. Avatar work — the golden vectors in cyberspace-cli's
# tests/fixtures/avatar_work.json, which §8.10 says
# both reference implementations are pinned to,
# through `amy sno work` and `avatar_work()`.
# 3. Avatar payment — synthesised events through `amy sno verify` and
# `verify_avatar_work()`.
# 4. Reader divergence — the two places we knowingly differ from the §1.9
# arbiter, pinned so neither can drift quietly.
#
# Divergences we already know about are asserted as divergences, not ignored:
# a reader that silently stopped diverging would be just as interesting as one
# that started.
#
# Usage: ./sno-conformance.sh [--no-build]
#
# Reference checkouts are cloned into state/ unless CYBERSPACE_DIR and
# CYBERSPACE_CLI_DIR already point at them.
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-sno-conformance"
LOG_DIR="$STATE_DIR/logs"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
DRIVER="$SCRIPT_DIR/refdriver.py"
NO_BUILD=0
while [[ $# -gt 0 ]]; do
case "$1" in
--no-build) NO_BUILD=1 ;;
-h|--help) sed -n '3,27p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'; exit 0 ;;
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
esac
shift
done
mkdir -p "$STATE_DIR" "$LOG_DIR"
: >"$LOG_FILE"
: >"$RESULTS_FILE"
# shellcheck source=../lib.sh
source "$SCRIPT_DIR/../lib.sh"
command -v jq >/dev/null || { fail_msg "jq is required"; exit 1; }
command -v python3 >/dev/null || { fail_msg "python3 is required"; exit 1; }
# ---- the reference checkouts ------------------------------------------------
clone_ref() {
local url="$1" dest="$2"
if [[ -d "$dest/.git" ]]; then info "reusing $dest"; return 0; fi
step "cloning $url"
GIT_LFS_SKIP_SMUDGE=1 git clone --quiet --depth 1 "$url" "$dest" >>"$LOG_FILE" 2>&1
}
: "${CYBERSPACE_DIR:=$STATE_DIR/cyberspace}"
: "${CYBERSPACE_CLI_DIR:=$STATE_DIR/cyberspace-cli}"
export CYBERSPACE_DIR CYBERSPACE_CLI_DIR
if [[ ! -f "$CYBERSPACE_DIR/decks/sno-reference.py" ]]; then
clone_ref https://github.com/arkin0x/cyberspace "$CYBERSPACE_DIR" || true
fi
if [[ ! -f "$CYBERSPACE_CLI_DIR/src/cyberspace_core/avatar.py" ]]; then
clone_ref https://github.com/arkin0x/cyberspace-cli "$CYBERSPACE_CLI_DIR" || true
fi
HAVE_SNO_REF=0; [[ -f "$CYBERSPACE_DIR/decks/sno-reference.py" ]] && HAVE_SNO_REF=1
HAVE_AVATAR_REF=0; [[ -f "$CYBERSPACE_CLI_DIR/src/cyberspace_core/avatar.py" ]] && HAVE_AVATAR_REF=1
if [[ $NO_BUILD -eq 0 ]]; then
step "building amy (installDist)"
(cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \
|| { fail_msg "gradle :cli:installDist failed"; exit 1; }
fi
[[ -x "$AMY_BIN" ]] || { fail_msg "amy not built at $AMY_BIN"; exit 1; }
amy() { "$AMY_BIN" --json "$@" 2>>"$LOG_FILE"; }
ref() { python3 "$DRIVER" "$@" 2>>"$LOG_FILE"; }
# ---- 1. §1.9 verdicts -------------------------------------------------------
banner "1. §1.9 verdicts — the deck's own rejection table"
if [[ $HAVE_SNO_REF -eq 0 ]]; then
skip_msg "sno-reference.py unavailable"
record_result "sno-verdicts" skip "no cyberspace checkout"
else
AGREE=0; DISAGREE=0; DIVERGENCES=""
while IFS= read -r line; do
NAME="$(jq -r .name <<<"$line")"
PAYLOAD="$(jq -c .payload <<<"$line")"
OURS="$(printf '%s' "$PAYLOAD" | amy sno parse -)"
THEIRS="$(printf '%s' "$PAYLOAD" | ref verdict)"
OUR_VALID="$(jq -r '.valid' <<<"$OURS")"
OUR_RULE="$(jq -r '.rule // "null"' <<<"$OURS")"
THEIR_VALID="$(jq -r '.valid' <<<"$THEIRS")"
THEIR_RULE="$(jq -r '.rule // "null"' <<<"$THEIRS")"
if [[ "$OUR_VALID" == "$THEIR_VALID" && "$OUR_RULE" == "$THEIR_RULE" ]]; then
AGREE=$((AGREE+1))
else
DISAGREE=$((DISAGREE+1))
DIVERGENCES+=" $NAME: amy(valid=$OUR_VALID rule=$OUR_RULE) ref(valid=$THEIR_VALID rule=$THEIR_RULE)"$'\n'
fi
done < <(ref cases)
info "$AGREE agreed, $DISAGREE diverged"
[[ -n "$DIVERGENCES" ]] && printf '%s' "$DIVERGENCES" >&2
if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then
record_result "sno-verdicts" pass "$AGREE cases agree"
else
record_result "sno-verdicts" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged"
fi
fi
# ---- 2. avatar work ---------------------------------------------------------
banner "2. avatar work — cyberspace-cli's golden vectors"
if [[ $HAVE_AVATAR_REF -eq 0 ]]; then
skip_msg "cyberspace-cli unavailable"
record_result "sno-avatar-work" skip "no cyberspace-cli checkout"
else
AGREE=0; DISAGREE=0
while IFS= read -r line; do
NAME="$(jq -r .name <<<"$line")"
EXPECTED="$(jq -r .required <<<"$line")"
PAYLOAD="$(jq -c .payload <<<"$line")"
THEIRS="$(printf '%s' "$PAYLOAD" | ref work | jq -r .required)"
# The fixtures carry only the fields the formula reads, and some index
# faces past the vertex list because it never looks at them — so dress
# each one as a payload §1.9 accepts, keeping the counts and the reach.
FULL="$(jq -c --argjson p "$PAYLOAD" -n '
($p.vertices | length) as $nv
| {v:2, name:"vector", unit:($p.unit // 0),
mode:(if (($p.faces // []) | length) > 0 then "solid" else "points" end),
vertices:$p.vertices,
colors:[range($nv) | 225],
faces:[range(($p.faces // []) | length) | [(. % $nv), ((.+1) % $nv), ((.+2) % $nv)]]}
+ (if $p.ticks then {ticks:$p.ticks} else {} end)')"
OURS="$(printf '%s' "$FULL" | amy sno work | jq -r .required)"
if [[ "$OURS" == "$THEIRS" && "$OURS" == "$EXPECTED" ]]; then
AGREE=$((AGREE+1))
info "$NAME: $OURS bits"
else
DISAGREE=$((DISAGREE+1))
fail_msg "$NAME: amy=$OURS ref=$THEIRS fixture=$EXPECTED"
fi
done < <(ref vectors)
if [[ $DISAGREE -eq 0 && $AGREE -gt 0 ]]; then
record_result "sno-avatar-work" pass "$AGREE golden vectors agree"
else
record_result "sno-avatar-work" fail "$DISAGREE of $((AGREE+DISAGREE)) diverged"
fi
fi
# ---- 3. avatar payment, and the divergences we expect -----------------------
banner "3. avatar payment — and the divergences we know about"
SHAPE='{"v":2,"name":"dot","unit":0,"mode":"points","vertices":[[0,0,0],[1,0,0]],"colors":[225,225],"faces":[]}'
# id with exactly N leading zero bits, padded to 64 hex chars
id_with_bits() {
python3 - "$1" <<'PY'
import sys
bits = int(sys.argv[1])
head = "0" * (bits // 4) + {0: "f", 1: "4", 2: "2", 3: "1"}[bits % 4]
print(head + "f" * (64 - len(head)))
PY
}
avatar_event() { # kind, id-bits, committed|none, content
local kind="$1" bits="$2" committed="$3" content="$4"
local tags="[]"
[[ "$committed" != "none" ]] && tags="[[\"nonce\",\"1\",\"$committed\"]]"
jq -cn --arg id "$(id_with_bits "$bits")" --argjson kind "$kind" \
--argjson tags "$tags" --arg content "$content" \
'{id:$id, pubkey:"11111111111111111111111111111111111111111111111111111111111111111", kind:$kind, created_at:0, tags:$tags, content:$content, sig:("22"*32)}' \
2>/dev/null || jq -cn --arg id "$(id_with_bits "$bits")" --argjson kind "$kind" \
--argjson tags "$tags" --arg content "$content" \
'{id:$id, pubkey:("11"*32), kind:$kind, created_at:0, tags:$tags, content:$content, sig:("22"*32)}'
}
check_verify() { # label, event, expected-amy-reason, expected-ref-reason
local label="$1" event="$2" want_ours="$3" want_theirs="$4"
local ours theirs
ours="$(printf '%s' "$event" | amy sno verify - | jq -r .reason)"
if [[ $HAVE_AVATAR_REF -eq 1 ]]; then
theirs="$(printf '%s' "$event" | ref verify | jq -r .reason)"
else
theirs="$want_theirs"
fi
if [[ "$ours" == "$want_ours" && "$theirs" == "$want_theirs" ]]; then
record_result "$label" pass "amy=$ours ref=$theirs"
else
record_result "$label" fail "amy=$ours (want $want_ours) ref=$theirs (want $want_theirs)"
fi
}
# A paid avatar. The reference wants kind 33331 — it predates the move to
# 11333 that §8.10 documents — so it refuses a conformant one outright.
check_verify "verify-paid" "$(avatar_event 11333 16 16 "$SHAPE")" ok not-an-avatar
check_verify "verify-no-nonce" "$(avatar_event 11333 32 none "$SHAPE")" no-nonce not-an-avatar
check_verify "verify-under-committed" "$(avatar_event 11333 32 8 "$SHAPE")" under-committed not-an-avatar
# The trap: committed 30, owes 16, id carries 20. min(20,30)=20 clears 16, so
# a reader comparing the wrong number calls this paid. Both of us refuse it.
check_verify "verify-short-of-commitment" "$(avatar_event 11333 20 30 "$SHAPE")" unpaid not-an-avatar
# Empty content is §8.10's default avatar and owes no work; the reference
# calls it not-an-avatar because empty is not JSON. Same outcome — the client
# draws its default — different word for it.
check_verify "verify-default-avatar" "$(avatar_event 11333 0 none "")" default-avatar not-an-avatar
# The reference's own kind. We refuse it as an avatar because DECK-0003 gave
# 33331 to standalone objects; it accepts it and prices it.
check_verify "verify-legacy-kind" "$(avatar_event 33331 16 16 "$SHAPE")" not-an-avatar ok
# ---- 4. the two places our reader knowingly differs ------------------------
banner "4. reader divergence — pinned, not ignored"
# These are not in the deck's rejection table, so section 1 never sees them.
# Both are deliberate and documented; a change in either direction is news.
check_verdict() { # label, payload, expected-amy-valid, expected-ref-valid, note
local label="$1" payload="$2" want_ours="$3" want_theirs="$4" note="$5"
local ours theirs
ours="$(printf '%s' "$payload" | amy sno parse - | jq -r .valid)"
if [[ $HAVE_SNO_REF -eq 1 ]]; then
theirs="$(printf '%s' "$payload" | ref verdict | jq -r .valid)"
else
theirs="$want_theirs"
fi
if [[ "$ours" == "$want_ours" && "$theirs" == "$want_theirs" ]]; then
record_result "$label" pass "amy=$ours ref=$theirs — $note"
else
record_result "$label" fail "amy=$ours (want $want_ours) ref=$theirs (want $want_theirs)"
fi
}
# D1. A v:2 payload whose colours are still literal triples. Version 2 shipped
# as two changes that did not land together, so these exist and are right in
# every other respect; §5 permits a reader to be generous and sno-core is.
# Three of the seven objects on the network are this shape.
TRIPLES='{"v":2,"name":"legacy","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[1,2,1]],"colors":[[1,0.15,0.15],[0,1,0],[0,0,1],[1,1,1]],"faces":[[0,1,2]]}'
check_verdict "divergence-v2-triples" "$TRIPLES" true false "we read the legacy colour cohort; the arbiter refuses it"
# D5. A vertex past 64 units. §1.8 puts that bound on publishers only and lets
# a reader repair instead; §8's third open question admits leaving it off
# readers is unsafe. Neither reference bounds it. We draw strangers' events in
# a feed, and whole*120 overflows an Int long before the text's limit.
FAR='{"v":2,"name":"far","unit":0,"mode":"solid","vertices":[[0,0,0],[2,0,0],[1,0,2],[9999,2,1]],"colors":[238,235,239,225],"faces":[[0,1,2]]}'
check_verdict "divergence-position-bound" "$FAR" false true "we refuse an unbounded vertex; the arbiter accepts it"
print_summary
grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1
exit 0