mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
459 lines
24 KiB
Python
459 lines
24 KiB
Python
"""Drift canary for the browser write path.
|
|
|
|
WHY THIS EXISTS: on 2026-07-21 X renamed its compose control and our sends silently went to 0/2
|
|
WITH false success claims. No code of ours changed. A passing test suite is a snapshot; sites move
|
|
underneath it, so the only way a coverage claim stays true is to re-prove it against the live site
|
|
on a schedule and shout the day it breaks.
|
|
|
|
WHAT IT DOES: per site, one full round trip on the user's own account, then cleans up after itself:
|
|
post a unique marker -> confirm the receipt -> delete it -> confirm it is gone
|
|
A site "passes" only if the post was receipt-verified AND the cleanup verified gone. Anything else
|
|
is drift, reported with the stage it died at.
|
|
|
|
"Confirm" means the canary drives a browser card to the destination itself and reads
|
|
document.body.innerText (see `marker_in_page`). It never asks a model whether the post is there,
|
|
and it never takes the receipt's word for it: those are the two claims under audit.
|
|
|
|
SAFETY:
|
|
- Never runs by itself. No cron, no import side effects; a human or a CI job invokes it.
|
|
- --dry (default) posts NOTHING: it exercises discovery only, so it is safe anywhere.
|
|
- --live is required to actually post, and every post is deleted in the same run.
|
|
- Markers are random and carry no removal words ("delete"/"remove" in a payload trips the
|
|
removal classifier and makes the send path stand down, which cost us a confusing hour once).
|
|
- Exits 1 on drift so a scheduler can alert on it.
|
|
|
|
USAGE
|
|
python scripts/browser_canary.py # discovery only, posts nothing
|
|
python scripts/browser_canary.py --live # real round trip, self-cleaning
|
|
python scripts/browser_canary.py --live --sites x,reddit
|
|
(backend must be running; set OSW_CANARY_BASE if it is not on :8326)
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
from typing import Dict, List, Optional
|
|
from urllib.parse import urlparse
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
BASE = os.environ.get("OSW_CANARY_BASE", "http://127.0.0.1:8326") + "/api/agents"
|
|
LOG = os.environ.get("OSW_CANARY_LOG", "/tmp/osw_backend_mr.log")
|
|
MODEL = os.environ.get("OSW_CANARY_MODEL", "opus-4-8")
|
|
|
|
# Per site: how to post it, how to delete it. Kept deliberately small: these are the surfaces we
|
|
# CLAIM to support, so the canary's job is to keep that claim honest, not to explore new ones.
|
|
SITES: Dict[str, Dict[str, str]] = {
|
|
"x": {
|
|
"probe": 'Go to x.com. Do NOT type or post anything. Is the tweet compose box present on the page? Answer with exactly one word: YES or NO.',
|
|
"post": 'Go to x.com and post this tweet, exactly: "{m}"',
|
|
"delete": 'Go to x.com/{handle} and delete the post that says "{m}"',
|
|
"audit": "https://x.com/{handle}",
|
|
"handle_env": "OSW_CANARY_X_HANDLE",
|
|
},
|
|
"linkedin": {
|
|
"probe": 'Go to linkedin.com. Do NOT type or post anything. Is the "Start a post" compose control present on the feed? Answer with exactly one word: YES or NO.',
|
|
"post": 'Go to linkedin.com and create a post with exactly this text: "{m}"',
|
|
"delete": 'Go to my LinkedIn activity and delete the post that says "{m}"',
|
|
"audit": "https://www.linkedin.com/in/me/recent-activity/all/",
|
|
},
|
|
"reddit": {
|
|
"probe": 'Go to reddit.com/r/test/submit. Do NOT type or submit anything. Is the post title/body compose form present? Answer with exactly one word: YES or NO.',
|
|
# ONE quoted span, deliberately. The old wording quoted a title AND a body, and
|
|
# quoted_payload refuses a task with two candidate payloads rather than guess which text to
|
|
# send, which is correct: guessing there is how you post the wrong string. So the send
|
|
# script declined every reddit round, the model finished it the slow way, no receipt fired,
|
|
# and the canary scored the PRODUCT for a defect in this prompt. A measurement instrument
|
|
# that trips the thing it is measuring reports its own bug as the system's.
|
|
"post": 'Go to reddit.com/r/test/submit and create a text post whose title is exactly "{m}". Submit it.',
|
|
"delete": 'Go to reddit.com and delete my post titled "{m}"',
|
|
# The AUTHOR's own submitted list, not r/test/new. reddit's spam filter removes automated
|
|
# posts from the subreddit listing within minutes while leaving them on the author's
|
|
# profile, so auditing the subreddit would call a post that genuinely landed a false
|
|
# success. Auditing where the author can see it separates "we never posted" from "reddit
|
|
# removed it", which are different bugs with different owners.
|
|
# Get the handle from the environment, the same way x does. It is whoever the browser
|
|
# profile is signed in as, so hardcoding it both bakes one person's account into an
|
|
# open-source repo and silently audits the WRONG profile for everyone else. Do NOT scrape it
|
|
# from reddit's home page either: a /user/<name>/ regex there matches whoever posted the
|
|
# first card, and the first version of this line audited a stranger's profile.
|
|
"audit": "https://www.reddit.com/user/{handle}/submitted/",
|
|
"handle_env": "OSW_CANARY_REDDIT_HANDLE",
|
|
},
|
|
}
|
|
|
|
# Every line the product emits when a write is receipt-VERIFIED. There are two producers and the
|
|
# canary knew about one of them, plus one string ("DELIVERY CONFIRMED") that appears nowhere in the
|
|
# backend at all and therefore could never match. So a write completed through the agent loop, which
|
|
# is what LinkedIn does, was recorded as "no receipt" while the run's own words were the verbatim
|
|
# receipt-verified message. Grep for the strings the code actually prints, and keep this next to the
|
|
# code that prints them.
|
|
P_RECEIPT_MARKERS = (
|
|
"done sent_receipt=True", # browser_send_script.py, the fast lane
|
|
"two-sided receipt passed", # browser_agent.py, the agent loop
|
|
"code-send delivered (receipt verified)", # browser_agent.py, post-fill autosend
|
|
)
|
|
|
|
|
|
def req(method: str, url: str, body: Optional[dict] = None) -> dict:
|
|
tok = open(os.path.join(ROOT, "backend/data/auth.token")).read().strip()
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
r = urllib.request.Request(url, data=data, method=method,
|
|
headers={"Content-Type": "application/json",
|
|
"Authorization": "Bearer " + tok})
|
|
with urllib.request.urlopen(r, timeout=260) as resp:
|
|
return json.loads(resp.read().decode() or "{}")
|
|
|
|
|
|
def log_lines() -> int:
|
|
try:
|
|
return sum(1 for _ in open(LOG, errors="ignore"))
|
|
except OSError:
|
|
return 0
|
|
|
|
|
|
def run_task(prompt: str, name: str, budget: int = 300, keep: bool = False) -> Dict[str, object]:
|
|
"""Dispatch one browser task; return {said, status, wall, log} for the slice it produced.
|
|
|
|
`keep` leaves the session (and therefore its browser card) alive so the caller can audit the
|
|
destination through that card before tearing it down. The caller MUST call `teardown(sid)`.
|
|
"""
|
|
mark = log_lines()
|
|
dash = req("GET", BASE.replace("/agents", "") + "/dashboards/list")
|
|
dashboards = dash if isinstance(dash, list) else dash.get("dashboards", [])
|
|
if not dashboards:
|
|
return {"said": "", "status": "error", "wall": 0.0, "log": "", "err": "no dashboard"}
|
|
sid = req("POST", f"{BASE}/launch", {"mode": "agent", "model": MODEL, "provider": "anthropic",
|
|
"dashboard_id": dashboards[0]["id"], "name": name})["session"]["id"]
|
|
t0 = time.time()
|
|
try:
|
|
req("POST", f"{BASE}/sessions/{sid}/message", {"prompt": prompt, "mode": "agent", "model": MODEL})
|
|
except Exception:
|
|
pass
|
|
status = ""
|
|
said = ""
|
|
while time.time() - t0 < budget:
|
|
try:
|
|
s = req("GET", f"{BASE}/sessions/{sid}")
|
|
status = str(s.get("status") or "")
|
|
if status in ("completed", "error", "stopped"):
|
|
msgs = [m for m in s.get("messages", []) if m.get("role") == "assistant"]
|
|
if msgs:
|
|
c = msgs[-1].get("content")
|
|
said = c if isinstance(c, str) else str(c)
|
|
break
|
|
except Exception:
|
|
pass
|
|
time.sleep(1.5)
|
|
time.sleep(1.0)
|
|
try:
|
|
slice_ = "".join(open(LOG, errors="ignore").readlines()[mark:])
|
|
except OSError:
|
|
slice_ = ""
|
|
if not keep:
|
|
teardown(sid)
|
|
return {"said": said, "status": status, "wall": round(time.time() - t0, 1), "log": slice_,
|
|
"sid": sid}
|
|
|
|
|
|
def teardown(sid: str) -> None:
|
|
"""Stop a run and reap its browser card.
|
|
|
|
Each canary round launches several sessions and each leaves a live browser card with its own
|
|
webview; a multi-round loop stacks them until the renderer is compositing dozens and stops
|
|
answering, which then reads as a product failure. delete_session stops the browser-agent
|
|
children first, so this reaps the cards too.
|
|
"""
|
|
try:
|
|
req("DELETE", f"{BASE}/sessions/{sid}")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def p_cards() -> List[str]:
|
|
"""Browser cards currently on the dashboard, newest last."""
|
|
dash = req("GET", BASE.replace("/agents", "") + "/dashboards/list")
|
|
ds = dash if isinstance(dash, list) else dash.get("dashboards", [])
|
|
if not ds:
|
|
return []
|
|
full = req("GET", BASE.replace("/api/agents", "/api/dashboards") + "/" + ds[0]["id"])
|
|
cards = (full.get("layout") or {}).get("browser_cards") or {}
|
|
return list(cards)
|
|
|
|
|
|
AUDIT_CARD_PREFIX = "browser-audit"
|
|
|
|
|
|
def p_audit_card() -> str:
|
|
"""A browser card that belongs to the AUDIT and to nothing else, created once and reused.
|
|
|
|
Borrowing whatever card happened to be lying around is what made the destination read
|
|
unreliable: agent cards are reaped down to the newest few once their session stops running
|
|
(browser_agent.p_reap_idle_agent_cards), and a card whose session was torn down mid-run wedges,
|
|
answering `navigate` with a cheerful "Navigated to <url>" while never leaving the page it was on.
|
|
Measured across one canary round: the audit read the destination on the first site and had no
|
|
card at all by the third, which is indistinguishable from "the post is not there".
|
|
|
|
A card with no `spawned_by` is skipped by the reaper entirely (that is what a user's own card
|
|
looks like), so this one survives the whole run. Created through the dashboard PUT and verified
|
|
drivable end to end.
|
|
"""
|
|
dash = req("GET", BASE.replace("/agents", "") + "/dashboards/list")
|
|
ds = dash if isinstance(dash, list) else dash.get("dashboards", [])
|
|
if not ds:
|
|
return ""
|
|
did = ds[0]["id"]
|
|
full = req("GET", BASE.replace("/api/agents", "/api/dashboards") + "/" + did)
|
|
cards = (full.get("layout") or {}).get("browser_cards") or {}
|
|
for bid in cards:
|
|
if bid.startswith(AUDIT_CARD_PREFIX):
|
|
return bid
|
|
bid = AUDIT_CARD_PREFIX + secrets.token_hex(3)
|
|
tid = "tab-" + secrets.token_hex(4)
|
|
url = "https://example.com"
|
|
cards[bid] = {"browser_id": bid, "url": url, "dashboard_id": did,
|
|
"tabs": [{"id": tid, "url": url, "title": ""}], "activeTabId": tid,
|
|
"x": 60, "y": 120, "width": 1200, "height": 760}
|
|
req("PUT", BASE.replace("/api/agents", "/api/dashboards") + "/" + did, full)
|
|
# The renderer mounts it a beat after the layout lands; the first evaluate against an unattached
|
|
# webview errors rather than lying, so a short poll is enough and never masks a real failure.
|
|
for _ in range(8):
|
|
time.sleep(4)
|
|
try:
|
|
r = p_cmd(bid, "evaluate", {"expression": "1"})
|
|
except Exception:
|
|
continue
|
|
if r.get("text") and "error" not in r:
|
|
return bid
|
|
return bid
|
|
|
|
|
|
def p_cmd(browser_id: str, action: str, params: dict) -> dict:
|
|
return req("POST", BASE.replace("/api/agents", "/api/browser") + "/command",
|
|
{"action": action, "browser_id": browser_id, "params": params})
|
|
|
|
|
|
def marker_in_page(url: str, marker: str) -> Optional[bool]:
|
|
"""Is the marker ACTUALLY rendered on `url`? None when the read could not be made.
|
|
|
|
THE INSTRUMENT THIS REPLACES COULD NOT ANSWER THE QUESTION. It looked for the marker in the
|
|
backend LOG, and the backend does not log page text: a grep for every canary marker ever
|
|
generated, across every backend log on this box, returns zero lines. So the audit could only
|
|
ever say "could not look", and LinkedIn and reddit scored `unprovable` for reasons that had
|
|
nothing to do with LinkedIn or reddit. The session API is no better: it carries the model's
|
|
ANSWER, never the tool results, so reading it is asking the same model we are trying to audit.
|
|
|
|
So read the page directly. Drive a real browser card through /api/browser/command (the same
|
|
channel the agent's tools use), navigate to the destination, and pull `document.body.innerText`.
|
|
No model is consulted at any point, which is what makes a "posted" claim falsifiable.
|
|
|
|
Poll rather than sleep once: a fresh post can take a few seconds to appear in a feed, and
|
|
calling absence early would invent a failure. Missing evidence is None, never False, because
|
|
asserting absence from a read that did not happen is the same lie as claiming a delivery nobody
|
|
saw, just pointed the other way.
|
|
"""
|
|
want_host = (urlparse(url).hostname or "").lower().replace("www.", "")
|
|
saw_page = False
|
|
cards = [p_audit_card()] or []
|
|
cards = [c for c in cards if c]
|
|
if not cards:
|
|
print(" [audit] no dashboard; cannot read the destination", flush=True)
|
|
return None
|
|
print(f" [audit] {url} via {cards[0]}", flush=True)
|
|
# Never trust the navigate REPLY, always re-read location.href. A card can WEDGE: it answers
|
|
# `navigate` with "Navigated to <url>" in ~80ms and never leaves the page it was on. Measured
|
|
# on a reddit card that reported a successful hop to x.com/home four times running while
|
|
# document.title stayed "Submit to Reddit". Trusting that reply would have this function read
|
|
# some other site and report a confident answer about the destination.
|
|
for bid in cards:
|
|
try:
|
|
p_cmd(bid, "navigate", {"url": url})
|
|
except Exception as e:
|
|
print(f" [audit] {bid} navigate raised: {type(e).__name__}", flush=True)
|
|
continue
|
|
last = ""
|
|
for wait in (2.0, 3.0, 4.0, 5.0):
|
|
time.sleep(wait)
|
|
try:
|
|
r = p_cmd(bid, "evaluate", {"expression": AUDIT_EXPR})
|
|
except Exception as e:
|
|
last = f"evaluate raised {type(e).__name__}"
|
|
continue
|
|
try:
|
|
v = json.loads(str(r.get("text") or ""))
|
|
except ValueError:
|
|
last = f"unparseable evaluate reply {str(r)[:70]}"
|
|
continue
|
|
landed = (urlparse(str(v.get("u") or "")).hostname or "").lower().replace("www.", "")
|
|
body = str(v.get("body") or "")
|
|
# The URL is the load-bearing half. A body with no host check is a page we cannot name.
|
|
if not body or (want_host and landed != want_host):
|
|
last = f"landed on {landed or '(nothing)'}, wanted {want_host}, {len(body)} chars"
|
|
continue
|
|
saw_page = True
|
|
if marker in body:
|
|
print(f" [audit] {bid} FOUND {marker} on {landed} ({len(body)} chars)", flush=True)
|
|
return True
|
|
last = f"read {landed} ok, {len(body)} chars, marker absent"
|
|
print(f" [audit] {bid}: {last}", flush=True)
|
|
if saw_page:
|
|
break
|
|
return False if saw_page else None
|
|
|
|
|
|
# One expression, so what counts as "the page" is defined in exactly one place. innerText only:
|
|
# innerHTML would match the marker inside a hidden template or a JSON blob the site ships, and a
|
|
# post nobody can see is not a post that landed.
|
|
AUDIT_EXPR = ('(()=>{try{return {body:(document.body&&document.body.innerText)||"",'
|
|
'u:location.href};}catch(e){return {body:"",u:""};}})()')
|
|
|
|
|
|
def check_site(site: str, cfg: Dict[str, str], live: bool) -> Dict[str, object]:
|
|
marker = "canary" + secrets.token_hex(4) # no removal words, unique per run
|
|
res: Dict[str, object] = {"site": site, "marker": marker, "live": live}
|
|
|
|
if not live:
|
|
# Discovery-only, safe BY CONSTRUCTION rather than by configuration: we ask the agent to
|
|
# REPORT whether the compose surface is reachable, never to write. An earlier draft set
|
|
# OSW_SENDSCRIPT_DRYRUN in this process, which does nothing to the backend, so a "dry" run
|
|
# against a normal backend would have posted for real. Never trust a flag you don't own.
|
|
r = run_task(cfg["probe"].format(handle=os.environ.get(cfg.get("handle_env", ""), "")),
|
|
f"canary-probe-{site}")
|
|
said = str(r["said"])
|
|
found = bool(re.search(r"\bYES\b", said)) and not re.search(r"\bNO\b", said)
|
|
if "sent_receipt=True" in str(r["log"]): # must never happen on this path
|
|
res.update(stage="SAFETY", ok=False,
|
|
detail="ABORT: a discovery probe performed a real send; check the task text")
|
|
return res
|
|
res.update(stage="discovery", ok=found, wall=r["wall"],
|
|
detail=("compose surface reachable" if found else f"NOT reachable: {said[:110]}"))
|
|
return res
|
|
|
|
# 1. POST, and require the two-sided receipt (composer cleared), not the model's word for it.
|
|
env = cfg.get("handle_env", "")
|
|
handle = os.environ.get(env, "") if env else ""
|
|
# REFUSE rather than audit a broken URL. Without this, an unset handle silently builds
|
|
# ".../user//submitted/", the read comes back empty, and the round reports "unprovable" as
|
|
# though the product were at fault. That is the exact silent-failure shape this whole file
|
|
# exists to eliminate, so it must not be reintroduced by a missing environment variable.
|
|
if env and not handle:
|
|
res.update(stage="config", ok=False, invalid=True,
|
|
detail=f"INVALID: ${env} is not set, so the {site} audit URL cannot be built")
|
|
return res
|
|
audit_url = cfg["audit"].format(handle=handle)
|
|
r = run_task(cfg["post"].format(m=marker, handle=handle), f"canary-post-{site}", keep=True)
|
|
log = str(r["log"])
|
|
sid = str(r["sid"])
|
|
res["post_wall"] = r["wall"]
|
|
# A run whose log slice is EMPTY was not observed, and the receipt question cannot be answered
|
|
# about it either way. Saying "no receipt" there charges the product for the harness pointing at
|
|
# the wrong file, which is exactly what happened: OSW_CANARY_LOG named a file the backend was no
|
|
# longer writing to, so every LinkedIn round scored a receipt failure with no receipt evidence in
|
|
# the frame at all. Refuse to grade instead.
|
|
if not log.strip():
|
|
teardown(sid)
|
|
res.update(stage="post", ok=False, invalid=True,
|
|
detail=f"INVALID: no log slice (is OSW_CANARY_LOG={LOG} the live backend's log?)")
|
|
return res
|
|
delivered = any(k in log for k in P_RECEIPT_MARKERS)
|
|
# What the run TOLD the user, kept apart from what the page shows, because the gap between the
|
|
# two is the number that matters. A claim with no evidence under it is a false success, and it
|
|
# cannot be counted at all unless the two are recorded separately.
|
|
said = str(r["said"])
|
|
res["claimed"] = delivered or bool(
|
|
re.search(r"\b(posted|published|tweeted|submitted|sent it|has been posted)\b", said, re.I))
|
|
# Audit through the run's OWN card, before teardown: it is already signed in and on the site.
|
|
proven = marker_in_page(audit_url, marker)
|
|
teardown(sid)
|
|
res["proven"] = proven
|
|
res["false_success"] = bool(res["claimed"]) and proven is False
|
|
if not delivered:
|
|
res.update(stage="post", ok=False,
|
|
detail=f"no receipt (status={r['status']}, on_page={proven}): {said[:100]}")
|
|
return res
|
|
if proven is False:
|
|
# The receipt fired and the destination does not have it. This is exactly the failure the
|
|
# canary was built for, and it must never be reported as a pass.
|
|
res.update(stage="post", ok=False,
|
|
detail=f"FALSE SUCCESS: receipt says sent, {marker} is not on the destination")
|
|
return res
|
|
|
|
# 2. DELETE, and require the in-page verify-gone, so cleanup can't be claimed falsely. The
|
|
# authority is the OUTCOME (is it still on the page?) and never the implementation detail:
|
|
# grepping for `removed=True`, which only the BrowserDeleteItem dispatch path prints, called
|
|
# DRIFT on a model-driven delete that had plainly worked, twice on X.
|
|
d = run_task(cfg["delete"].format(m=marker, handle=handle), f"canary-clean-{site}", keep=True)
|
|
res["delete_wall"] = d["wall"]
|
|
still_there = marker_in_page(audit_url, marker)
|
|
teardown(str(d["sid"]))
|
|
removed = still_there is False
|
|
res["verified_by"] = {True: "marker still on page", False: "marker absent from raw page text",
|
|
None: "could not read the destination"}[still_there]
|
|
if not removed:
|
|
res.update(stage="cleanup", ok=False,
|
|
detail=f"POSTED BUT NOT CLEANED, marker {marker} may be live "
|
|
f"({res['verified_by']}): {str(d['said'])[:70]}")
|
|
return res
|
|
res.update(stage="done", ok=True, detail="posted, receipt-verified, deleted, verified gone")
|
|
return res
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--live", action="store_true",
|
|
help="actually post (and delete) on the real accounts; default is discovery-only")
|
|
ap.add_argument("--sites", default="", help="comma list (default: all)")
|
|
args = ap.parse_args()
|
|
|
|
want: List[str] = [s.strip() for s in args.sites.split(",") if s.strip()] or list(SITES)
|
|
unknown = [s for s in want if s not in SITES]
|
|
if unknown:
|
|
print(f"unknown site(s): {', '.join(unknown)}; known: {', '.join(SITES)}")
|
|
return 2
|
|
|
|
print(f"browser drift canary mode={'LIVE (posts+deletes)' if args.live else 'discovery-only'} "
|
|
f"sites={','.join(want)} model={MODEL}")
|
|
rows = []
|
|
for s in want:
|
|
r = check_site(s, SITES[s], args.live)
|
|
rows.append(r)
|
|
flag = "PASS" if r.get("ok") else "DRIFT"
|
|
# The marker rides on EVERY row, not just the ones that admit trouble. It is the only record
|
|
# of what this run put on a real account: it is never written to the backend log (a grep for
|
|
# every marker ever generated returns nothing), so a row that printed no marker and later
|
|
# turned out to be stranded could not be cleaned up by hand at all.
|
|
print(f" [{flag}] {s:10} {r.get('marker','')} stage={r.get('stage','?'):10} "
|
|
f"{r.get('detail','')}", flush=True)
|
|
|
|
bad = [r for r in rows if not r.get("ok")]
|
|
stranded = [r for r in bad if r.get("stage") == "cleanup"]
|
|
print(f"\n{len(rows) - len(bad)}/{len(rows)} sites healthy")
|
|
if args.live:
|
|
graded = [r for r in rows if not r.get("invalid")]
|
|
proven = [r for r in graded if r.get("proven") is True]
|
|
unprovable = [r for r in graded if r.get("proven") is None]
|
|
liars = [r for r in graded if r.get("false_success")]
|
|
invalid = [r for r in rows if r.get("invalid")]
|
|
print(f"verified writes: {len(proven)}/{len(graded)} proven on the destination"
|
|
+ (f", {len(unprovable)} unprovable (audit read failed)" if unprovable else ""))
|
|
print(f"FALSE SUCCESS CLAIMS: {len(liars)}"
|
|
+ (" <- hard gate, must be 0: " + ", ".join(str(r["site"]) for r in liars)
|
|
if liars else ""))
|
|
# Printed, never folded in. An excluded row is a claim that the product was not on trial,
|
|
# and that claim has to survive being read out loud.
|
|
for r in invalid:
|
|
print(f" EXCLUDED (not graded): {r['site']} - {r['detail']}")
|
|
if stranded:
|
|
print("!! MANUAL CLEANUP NEEDED: " + ", ".join(f"{r['site']}:{r['marker']}" for r in stranded))
|
|
if bad:
|
|
print("DRIFT DETECTED on: " + ", ".join(str(r["site"]) for r in bad))
|
|
return 1 if bad else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|