aa5fb2cc40
The brief's gate: if the harness varies bidState AND coinsProcessed together, the client's reaction is attributable to neither. The env knobs were already orthogonal (--variant and --coins-processed are independent, cp defaults to 0), but sold-wire-check.py was flipping BOTH for variant B as a convenience, which is exactly the contaminated A/B the brief forbids. Fixed: the primary pair now holds coinsProcessed at 0 and asserts the differing-field set is exactly ['bidState']. New scripts/sold-ab-differential.py is the pre-live gate. It settles ONE synthetic sale, then re-reads every seller-facing surface under each variant by restarting only the host (same Core, same DBs, same sale), and diffs with explicit classification -- MISSING / EXTRA / TYPE_MISMATCH / VALUE_MISMATCH -- rather than a boolean "equal?". Two orthogonal pairs: PRIMARY bidState highest vs buyNow, coinsProcessed held at 0 ORTHOGONAL coinsProcessed 0 vs 1, bidState held at highest Result, 36/36: the ONLY finding on /tradePile is VALUE_MISMATCH auctionInfo[0].bidState A='highest' B='buyNow'; /trade/status differs in exactly the same one path; counts are byte-identical. The orthogonal pair's only finding is auctionInfo[0].coinsProcessed. C_cp0's sha256 equals A_highest's, so the capture is reproducible rather than merely consistent. Counts states the live run has to interpret, measured not guessed: S1 0 active + 1 sold -> count 0, selling 0, sold 1 S2 1 active + 1 sold -> count 1 (active mode) vs 2 (membership mode) That divergence IS the open question for the client; production is unchanged. scripts/sold-client-ports.py switches ONLY the two client Blaze port lines, and is built so restoration cannot depend on memory: it records the production values to a sidecar on the client BEFORE the first edit and restore reads that sidecar, refusing if it is absent. It rewrites only known keys (a missing key is an error, never a silent append), re-reads and verifies afterwards, and REFUSES to edit while a FIFA client is running because the hook reads the file at connect time. Phase 0 evidence under docs/evidence/sold-ab-2026-08-18/ with a sha256 per surface, one file per variant so A can never overwrite B. Live client A/B NOT run: a production FIFA session is currently live on 10.10.0.105 (pid 32188), and live-session mutual exclusion applies. The client config was NOT touched -- the switcher's guard refused, as designed. Production untouched: prod-host pid 3631953, coins 29,843,976, /tradePile 0, counts.sold 0, club 1966; nothing under /home/alex/openfut-promotion/state/ opened.
376 lines
16 KiB
Python
Executable File
376 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""PRE-LIVE machine differential for the seller-facing sold-row A/B.
|
|
|
|
Purpose: prove, before any operator time is spent at a real FIFA client, that the
|
|
two variants differ in EXACTLY the field under test. A contaminated A/B cannot
|
|
attribute the client's reaction to anything, so this gate runs first.
|
|
|
|
It performs ONE synthetic settlement, then re-reads every seller-facing surface
|
|
under each variant by restarting only the host (same Core, same DBs, same sale), and
|
|
diffs the payloads with explicit classification:
|
|
|
|
MISSING key present in A, absent in B
|
|
EXTRA key absent in A, present in B
|
|
TYPE_MISMATCH same key, different JSON type
|
|
VALUE_MISMATCH same key and type, different value
|
|
|
|
Two orthogonal pairs are checked:
|
|
|
|
PRIMARY bidState highest vs buyNow coinsProcessed held at 0
|
|
ORTHOGONAL coinsProcessed 0 vs 1 bidState held at highest
|
|
|
|
Plus the two counts states the live experiment needs to interpret:
|
|
|
|
S1 0 active + 1 sold
|
|
S2 1 active + 1 sold
|
|
|
|
Evidence is written per variant under docs/evidence/sold-ab-<date>/ with a sha256
|
|
of each payload, so variant A evidence can never be confused with variant B.
|
|
|
|
ISOLATION: reuses the verified staging bring-up from sold-wire-check.py, which binds
|
|
only ephemeral loopback ports, refuses every production port, and opens nothing under
|
|
/home/alex/openfut-promotion/state/.
|
|
|
|
python3 scripts/sold-ab-differential.py [--out DIR] [--keep]
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
# Reuse the proven harness helpers rather than re-deriving them. The filename has
|
|
# hyphens, so it cannot be imported normally.
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"sold_wire_check", os.path.join(REPO, "scripts", "sold-wire-check.py"))
|
|
H = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(H)
|
|
|
|
SURFACES = {
|
|
"tradePile": "/ut/game/fifa17/tradePile",
|
|
"counts": "/ut/game/fifa17/tradePile/counts",
|
|
"trade_status_filtered": f"/ut/game/fifa17/trade/status?tradeIds={H.TRADE_ID}",
|
|
"trade_status_unfiltered": "/ut/game/fifa17/trade/status",
|
|
}
|
|
|
|
results = []
|
|
|
|
|
|
def check(label, ok, detail=""):
|
|
results.append((label, bool(ok), detail))
|
|
print(f" [{'PASS' if ok else 'FAIL'}] {label}{(': ' + detail) if detail else ''}")
|
|
return ok
|
|
|
|
|
|
def banner(t):
|
|
print("\n" + "=" * 74)
|
|
print(f"== {t}")
|
|
print("=" * 74)
|
|
|
|
|
|
def jtype(v):
|
|
if isinstance(v, bool):
|
|
return "bool"
|
|
if isinstance(v, int):
|
|
return "int"
|
|
if isinstance(v, float):
|
|
return "float"
|
|
if isinstance(v, str):
|
|
return "str"
|
|
if isinstance(v, list):
|
|
return "list"
|
|
if isinstance(v, dict):
|
|
return "dict"
|
|
return "null"
|
|
|
|
|
|
def classify(a, b, path=""):
|
|
"""Recursive classified diff. Returns a list of (kind, path, a, b)."""
|
|
out = []
|
|
if jtype(a) != jtype(b):
|
|
return [("TYPE_MISMATCH", path or "<root>", jtype(a), jtype(b))]
|
|
if isinstance(a, dict):
|
|
for k in a:
|
|
if k not in b:
|
|
out.append(("MISSING", f"{path}.{k}".lstrip("."), a[k], None))
|
|
for k in b:
|
|
if k not in a:
|
|
out.append(("EXTRA", f"{path}.{k}".lstrip("."), None, b[k]))
|
|
for k in a:
|
|
if k in b:
|
|
out += classify(a[k], b[k], f"{path}.{k}".lstrip("."))
|
|
return out
|
|
if isinstance(a, list):
|
|
if len(a) != len(b):
|
|
out.append(("VALUE_MISMATCH", f"{path}[len]", len(a), len(b)))
|
|
for i, (x, y) in enumerate(zip(a, b)):
|
|
out += classify(x, y, f"{path}[{i}]")
|
|
return out
|
|
if a != b:
|
|
out.append(("VALUE_MISMATCH", path or "<root>", a, b))
|
|
return out
|
|
|
|
|
|
def capture(port):
|
|
"""Every seller-facing surface, as parsed JSON plus a sha256 of the raw bytes."""
|
|
snap = {}
|
|
for name, path in SURFACES.items():
|
|
st, body = H.req(port, "GET", path)
|
|
raw = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
|
|
snap[name] = {
|
|
"status": st,
|
|
"body": body,
|
|
"sha256": hashlib.sha256(raw).hexdigest(),
|
|
}
|
|
return snap
|
|
|
|
|
|
def sold_row(snap):
|
|
rows = snap["tradePile"]["body"].get("auctionInfo", [])
|
|
return rows[0] if rows else {}
|
|
|
|
|
|
def report_diff(label, a, b, allowed):
|
|
"""Print the classified diff and assert only `allowed` paths differ."""
|
|
diffs = classify(a, b)
|
|
print(f" classified diff ({len(diffs)} finding(s)):")
|
|
for kind, path, av, bv in diffs:
|
|
print(f" {kind:15s} {path:34s} A={av!r} B={bv!r}")
|
|
kinds = {k for k, _, _, _ in diffs}
|
|
for bad in ("MISSING", "EXTRA", "TYPE_MISMATCH"):
|
|
check(f"{label}: no {bad}", bad not in kinds,
|
|
", ".join(p for k, p, _, _ in diffs if k == bad) or "none")
|
|
paths = sorted(p for _, p, _, _ in diffs)
|
|
check(f"{label}: only {allowed} differ", paths == sorted(allowed), str(paths))
|
|
return diffs
|
|
|
|
|
|
def restart_host(state, variant, coins_processed):
|
|
"""Same Core, same DBs, same settled sale — only the host's variant changes."""
|
|
if state.get("host"):
|
|
state["host"].terminate()
|
|
state["host"].wait(timeout=20)
|
|
state["host"] = H.start_host(state["tmp"], state["host_port"], state["core_port"],
|
|
state["host_log"], variant,
|
|
coins_processed=coins_processed)
|
|
return state["host"]
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--out", default=os.path.join(
|
|
REPO, "docs/evidence", f"sold-ab-{time.strftime('%Y-%m-%d')}"))
|
|
ap.add_argument("--keep", action="store_true")
|
|
args = ap.parse_args()
|
|
os.makedirs(args.out, exist_ok=True)
|
|
|
|
tmp = tempfile.mkdtemp(prefix="openfut-sold-ab-")
|
|
state = {"tmp": tmp, "host": None}
|
|
evidence = {"generated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
"variants": {}, "diffs": {}, "counts_states": {}}
|
|
try:
|
|
state["core_port"] = H.free_port()
|
|
state["host_port"] = H.free_port()
|
|
state["core_log"] = os.path.join(tmp, "core.log")
|
|
state["host_log"] = os.path.join(tmp, "host.log")
|
|
banner("ISOLATED STAGING")
|
|
print(f" tmp {tmp}\n core 127.0.0.1:{state['core_port']}"
|
|
f"\n host 127.0.0.1:{state['host_port']}")
|
|
core, core_db = H.start_core(tmp, state["core_port"], state["core_log"])
|
|
state["core"] = core
|
|
state["core_db"] = core_db
|
|
mdb = os.path.join(tmp, "market.db")
|
|
|
|
# ---- one authentic listing + one real settlement -------------------
|
|
restart_host(state, "highest", "0")
|
|
con = sqlite3.connect(mdb)
|
|
con.execute(
|
|
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, "
|
|
"wire_resource_id, start_price, buy_now_price, owner, state, created_at, "
|
|
"item_json, duration_secs) VALUES (?,?,?,?,?,?,?,?, 'active', ?, ?, ?)",
|
|
(H.TRADE_ID, H.CARD, H.ITEM, 100000178, 212188, H.GROSS, H.GROSS, "CAGE",
|
|
str(int(time.time() * 1000)), json.dumps({
|
|
"id": 100000178, "resourceId": 212188, "rating": 75,
|
|
"preferredPosition": "ST", "itemState": "forSale",
|
|
"untradeable": False, "assetId": 212188}), 3600))
|
|
con.commit(); con.close()
|
|
banner(f"SETTLE — synthetic Buyer B buys at {H.GROSS}")
|
|
out = subprocess.run(
|
|
[os.path.join(REPO, "target/release/staging_sell"),
|
|
"--market-db", mdb, "--core-url", f"http://127.0.0.1:{state['core_port']}",
|
|
"--trade-id", H.TRADE_ID, "--item", H.ITEM,
|
|
"--seller", H.SELLER_CLUB, "--buyer", H.BUYER_CLUB,
|
|
"--gross", str(H.GROSS)],
|
|
capture_output=True, text=True, timeout=120)
|
|
print(" " + "\n ".join((out.stdout + out.stderr).strip().splitlines()))
|
|
check("settlement succeeded", out.returncode == 0)
|
|
own, n, coins = H.owner_of(core_db, H.ITEM)
|
|
fee = H.GROSS * 5 // 100
|
|
check("ownership -> buyer", own == H.BUYER_CLUB, str(own))
|
|
check("exactly one authoritative instance", n == 1, str(n))
|
|
check("seller credited net", coins[H.SELLER_CLUB] == 1000 + H.GROSS - fee,
|
|
str(coins[H.SELLER_CLUB]))
|
|
check("buyer debited gross", coins[H.BUYER_CLUB] == 20000 - H.GROSS,
|
|
str(coins[H.BUYER_CLUB]))
|
|
economy = {"seller": coins[H.SELLER_CLUB], "buyer": coins[H.BUYER_CLUB],
|
|
"fee": fee, "owner": own, "instances": n}
|
|
evidence["settlement"] = economy
|
|
|
|
# ---- PRIMARY A/B: bidState only ------------------------------------
|
|
banner("PRIMARY A/B — bidState highest vs buyNow (coinsProcessed held at 0)")
|
|
snaps = {}
|
|
for name, variant in (("A_highest", "highest"), ("B_buyNow", "buyNow")):
|
|
restart_host(state, variant, "0")
|
|
snaps[name] = capture(state["host_port"])
|
|
evidence["variants"][name] = {
|
|
"env": {"OPENFUT_FIFA17_SOLD_EXPERIMENT": variant,
|
|
"OPENFUT_FIFA17_SOLD_COINS_PROCESSED": "0"},
|
|
"surfaces": snaps[name],
|
|
}
|
|
row = sold_row(snaps[name])
|
|
print(f" {name}: tradeState={row.get('tradeState')} "
|
|
f"bidState={row.get('bidState')} currentBid={row.get('currentBid')} "
|
|
f"coinsProcessed={row.get('coinsProcessed')} atoms={len(row)}")
|
|
# Every variant must independently be a well-formed sold row.
|
|
check(f"{name}: tradeState closed", row.get("tradeState") == "closed")
|
|
check(f"{name}: twelve atoms", len(row) == 12, str(len(row)))
|
|
check(f"{name}: currentBid == gross", row.get("currentBid") == H.GROSS)
|
|
check(f"{name}: expires 0", row.get("expires") == 0)
|
|
check(f"{name}: counts.sold == 1",
|
|
snaps[name]["counts"]["body"].get("sold") == 1)
|
|
evidence["diffs"]["primary_bidstate"] = [
|
|
{"kind": k, "path": p, "a": av, "b": bv}
|
|
for k, p, av, bv in report_diff(
|
|
"PRIMARY", snaps["A_highest"]["tradePile"]["body"],
|
|
snaps["B_buyNow"]["tradePile"]["body"],
|
|
["auctionInfo[0].bidState"])
|
|
]
|
|
# /trade/status must move in lockstep, or the screen would contradict itself.
|
|
report_diff("PRIMARY /trade/status",
|
|
snaps["A_highest"]["trade_status_filtered"]["body"],
|
|
snaps["B_buyNow"]["trade_status_filtered"]["body"],
|
|
["auctionInfo[0].bidState"])
|
|
report_diff("PRIMARY counts",
|
|
snaps["A_highest"]["counts"]["body"],
|
|
snaps["B_buyNow"]["counts"]["body"], [])
|
|
|
|
# ---- ORTHOGONAL A/B: coinsProcessed only ---------------------------
|
|
banner("ORTHOGONAL A/B — coinsProcessed 0 vs 1 (bidState held at highest)")
|
|
for name, cp in (("C_cp0", "0"), ("D_cp1", "1")):
|
|
restart_host(state, "highest", cp)
|
|
snaps[name] = capture(state["host_port"])
|
|
evidence["variants"][name] = {
|
|
"env": {"OPENFUT_FIFA17_SOLD_EXPERIMENT": "highest",
|
|
"OPENFUT_FIFA17_SOLD_COINS_PROCESSED": cp},
|
|
"surfaces": snaps[name],
|
|
}
|
|
row = sold_row(snaps[name])
|
|
print(f" {name}: bidState={row.get('bidState')} "
|
|
f"coinsProcessed={row.get('coinsProcessed')}")
|
|
evidence["diffs"]["orthogonal_coinsprocessed"] = [
|
|
{"kind": k, "path": p, "a": av, "b": bv}
|
|
for k, p, av, bv in report_diff(
|
|
"ORTHOGONAL", snaps["C_cp0"]["tradePile"]["body"],
|
|
snaps["D_cp1"]["tradePile"]["body"],
|
|
["auctionInfo[0].coinsProcessed"])
|
|
]
|
|
check("C_cp0 is identical to A_highest (same env => same bytes)",
|
|
snaps["C_cp0"]["tradePile"]["sha256"]
|
|
== snaps["A_highest"]["tradePile"]["sha256"],
|
|
"reproducible")
|
|
|
|
# ---- counts states the live run must interpret ----------------------
|
|
banner("COUNTS STATES")
|
|
restart_host(state, "highest", "0")
|
|
s1 = H.req(state["host_port"], "GET", SURFACES["counts"])[1]
|
|
print(f" S1 0 active + 1 sold -> {json.dumps(s1)}")
|
|
con = sqlite3.connect(mdb)
|
|
con.execute(
|
|
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, "
|
|
"wire_resource_id, start_price, buy_now_price, owner, state, created_at, "
|
|
"item_json, duration_secs) VALUES (?,?,?,?,?,?,?,?, 'active', ?, ?, ?)",
|
|
("900500999", H.CARD, "core-second-x", 100000179, 212188, 300, 400, "CAGE",
|
|
str(int(time.time() * 1000)), json.dumps({
|
|
"id": 100000179, "resourceId": 212188, "rating": 75,
|
|
"preferredPosition": "ST", "itemState": "forSale",
|
|
"untradeable": False, "assetId": 212188}), 3600))
|
|
con.commit(); con.close()
|
|
s2_active = H.req(state["host_port"], "GET", SURFACES["counts"])[1]
|
|
print(f" S2 1 active + 1 sold -> {json.dumps(s2_active)} (count mode: active)")
|
|
restart_host(state, "highest", "0")
|
|
os.environ["_"] = "_" # no-op; count mode is a host env, set below
|
|
state["host"].terminate(); state["host"].wait(timeout=20)
|
|
state["host"] = H.start_host(tmp, state["host_port"], state["core_port"],
|
|
state["host_log"], "highest",
|
|
coins_processed="0",
|
|
count_mode="active_plus_sold")
|
|
s2_member = H.req(state["host_port"], "GET", SURFACES["counts"])[1]
|
|
print(f" S2 1 active + 1 sold -> {json.dumps(s2_member)} (count mode: membership)")
|
|
evidence["counts_states"] = {
|
|
"S1_zero_active_one_sold": s1,
|
|
"S2_one_active_one_sold_count_active": s2_active,
|
|
"S2_one_active_one_sold_count_membership": s2_member,
|
|
}
|
|
check("S1 reports sold 1 / selling 0", s1.get("sold") == 1 and s1.get("selling") == 0)
|
|
check("S2 reports sold 1 / selling 1",
|
|
s2_active.get("sold") == 1 and s2_active.get("selling") == 1)
|
|
check("S2 count differs by mode (1 vs 2) — the open question for the client",
|
|
s2_active.get("count") == 1 and s2_member.get("count") == 2,
|
|
f"{s2_active.get('count')} vs {s2_member.get('count')}")
|
|
|
|
# ---- emit evidence --------------------------------------------------
|
|
banner("EVIDENCE")
|
|
for name, v in evidence["variants"].items():
|
|
path = os.path.join(args.out, f"{name}.json")
|
|
with open(path, "w") as f:
|
|
json.dump(v, f, indent=2, sort_keys=True)
|
|
print(f" {os.path.relpath(path, REPO)} "
|
|
f"tradePile sha256={v['surfaces']['tradePile']['sha256'][:16]}…")
|
|
idx = os.path.join(args.out, "differential.json")
|
|
with open(idx, "w") as f:
|
|
json.dump(evidence, f, indent=2, sort_keys=True)
|
|
print(f" {os.path.relpath(idx, REPO)}")
|
|
|
|
banner("PRODUCTION UNTOUCHED")
|
|
alive = subprocess.run(["ps", "-o", "pid=", "-p", "3631953"],
|
|
capture_output=True, text=True).stdout.strip()
|
|
check("prod-host pid 3631953 alive", alive == "3631953", alive or "gone")
|
|
|
|
banner("RESULT")
|
|
passed = sum(1 for _, ok, _ in results if ok)
|
|
failed = [l for l, ok, _ in results if not ok]
|
|
print(f" {passed}/{len(results)} checks passed")
|
|
if failed:
|
|
print(" FAILED: " + "; ".join(failed))
|
|
print("\n A/B IS CONTAMINATED — do NOT run the live experiment")
|
|
return 1
|
|
print("\n A/B IS CLEAN — one-field attribution proven; ready for the live client")
|
|
return 0
|
|
finally:
|
|
for k in ("host", "core"):
|
|
p = state.get(k)
|
|
if p:
|
|
try:
|
|
p.terminate(); p.wait(timeout=15)
|
|
except Exception:
|
|
try:
|
|
p.kill()
|
|
except Exception:
|
|
pass
|
|
if args.keep:
|
|
print(f"\n kept {tmp}")
|
|
else:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|