User Tools

Site Tools


provenance:programming:crawler:webxray:random_sample

Differences

This shows you the differences between two versions of the page.

Link to this comparison view

Next revision
Previous revision
provenance:programming:crawler:webxray:random_sample [2026/09/05 19:35] – Random-sample ownership accuracy appendix: all 16 scripts, 10 unedited outputs and the adjudication brief, with a per-file hash manifest. Authored by Claude karel.kubicek.claudeprovenance:programming:crawler:webxray:random_sample [2026/09/17 07:24] (current) – Markup rendering sweep: wrap escapes, CLI flag typography, and heading quotes. Authored by Claude. karel.kubicek.claude
Line 4: Line 4:
 measurement of 2026-09-05** — the one that produced the per-list current / measurement of 2026-09-05** — the one that produced the per-list current /
 stale / granularity / error rates on stale / granularity / error rates on
-[[programming:crawler:webxray|Programming:Crawler:webXray]].+[[programming:crawler:webxray|Programming:Crawler:webXray]] — and behind the 
 +**inter-rater re-adjudication of 2026-09-11** (sections AA–AK), which measured 
 +how far a second model reading the same brief reaches the same verdicts. 
 + 
 +The **second pass over the residue**, run later the same day — which 
 +re-adjudicated the 40 domains this sample could not settle, moved 28 of them 
 +into the rates, and changed the content page's headline table and its Fisher 
 +table — has its own appendix, for exactly the reason given below: 
 +[[provenance:programming:crawler:webxray:residue_pass]]. Its blocks are another 
 +343 kB, most of it one probe log, and adding them here would have made this page 
 +628 kB. That page computes both figures when it is generated, from the same file 
 +lists both manifests use.
  
 Read [[provenance:programming:crawler:webxray|the provenance page]] first: the Read [[provenance:programming:crawler:webxray|the provenance page]] first: the
Line 11: Line 22:
 Corpus-level caveats are on [[literature:corpus]]. Corpus-level caveats are on [[literature:corpus]].
  
-**Why this is a separate page.** These blocks are 218 kB, and the parent+**Sections are in letter order, A–Z then AA–AK, and that is also chronological:** 
 +A–P are the 2026-09-05 scripts, Q–Z its outputs, AA–AE the 2026-09-11 scripts and 
 +AF–AK their outputs. 
 + 
 +**Why this is a separate page.** These blocks are 290 kB, and the parent
 provenance page is already large without them. Raw size alone would not decide provenance page is already large without them. Raw size alone would not decide
 it — ''literature:bibliography'' is 388 kB and renders fine — but ~60 kB of this it — ''literature:bibliography'' is 388 kB and renders fine — but ~60 kB of this
Line 29: Line 44:
  
   * **trailing newlines are stripped**, because DokuWiki discards them inside a block anyway;   * **trailing newlines are stripped**, because DokuWiki discards them inside a block anyway;
-  * **line endings are normalised to LF.** The last column of the table below says, per file, whether that changed a byte. It changes one byte in one file (''out/probe_all.txt'', which carries a lone CR inside a copyright line scraped from a live site) and nothing in the other twenty-two.+  * **line endings are normalised to LF.** The last column of the table below says, per file, whether that changed a byte. It changes one byte in one file (''out/probe_all.txt'', which carries a lone CR inside a copyright line scraped from a live site) and nothing in the other 36.
  
 The **bytes and SHA-256 in the table are of the file on disk**, not of the The **bytes and SHA-256 in the table are of the file on disk**, not of the
Line 42: Line 57:
 | C | ''out/adj/INSTRUCTIONS.md'' | 4,965 | ''1985026abac3d888'' | no | | C | ''out/adj/INSTRUCTIONS.md'' | 4,965 | ''1985026abac3d888'' | no |
 | D | ''scripts/owner_merge_rows.py'' | 4,572 | ''84ee130f2e8c2310'' | no | | D | ''scripts/owner_merge_rows.py'' | 4,572 | ''84ee130f2e8c2310'' | no |
-| E | ''scripts/owner_verify_sources.py'' | 6,814 | ''b7c45900dc5ed28e'' | no |+| E | ''scripts/owner_verify_sources.py'' | 12,941 | ''4e07cc2df4e06b37'' | no |
 | F | ''scripts/owner_verify_rendered.mjs'' | 2,923 | ''0bf6b4615724e84d'' | no | | F | ''scripts/owner_verify_rendered.mjs'' | 2,923 | ''0bf6b4615724e84d'' | no |
-| G | ''scripts/owner_corrections.py'' | 6,847 | ''f145c0908d4bf9a6'' | no |+| G | ''scripts/owner_corrections.py'' | 9,595 | ''a9e92c9f427e408c'' | no |
 | H | ''scripts/owner_verdict_fixes.py'' | 7,894 | ''2b1bc378b8cb6750'' | no | | H | ''scripts/owner_verdict_fixes.py'' | 7,894 | ''2b1bc378b8cb6750'' | no |
 | I | ''scripts/owner_random_sample.py'' | 17,780 | ''9ff3ddf1dab339c5'' | no | | I | ''scripts/owner_random_sample.py'' | 17,780 | ''9ff3ddf1dab339c5'' | no |
Line 55: Line 70:
 | P | ''scripts/wayback_webxray.sh'' | 2,625 | ''80c3b9a6378508b0'' | no | | P | ''scripts/wayback_webxray.sh'' | 2,625 | ''80c3b9a6378508b0'' | no |
 | Q | ''out/owner_sample-output.txt'' | 1,657 | ''90f34e73b14c797b'' | no | | Q | ''out/owner_sample-output.txt'' | 1,657 | ''90f34e73b14c797b'' | no |
-| R | ''out/owner_corrections-output.txt'' | 4,830 | ''961e16e7549cd98c'' | no |+| R | ''out/owner_corrections-output.txt'' | 6,399 | ''c0a0a60fb92b6e67'' | no |
 | S | ''out/owner_verdict_fixes-output.txt'' | 4,006 | ''0322ffec6b5dbdbf'' | no | | S | ''out/owner_verdict_fixes-output.txt'' | 4,006 | ''0322ffec6b5dbdbf'' | no |
 | T | ''out/owner_citation_audit-output.txt'' | 1,252 | ''b40a2f1db1afce1d'' | no | | T | ''out/owner_citation_audit-output.txt'' | 1,252 | ''b40a2f1db1afce1d'' | no |
Line 64: Line 79:
 | Y | ''out/check_owner_sample_figures_mutations-output.txt'' | 467 | ''3a175553faf88b25'' | no | | Y | ''out/check_owner_sample_figures_mutations-output.txt'' | 467 | ''3a175553faf88b25'' | no |
 | Z | ''out/probe_all.txt'' | 51,227 | ''3def89e0d3fc8a14'' | **yes** — one lone CR normalised to LF | | Z | ''out/probe_all.txt'' | 51,227 | ''3def89e0d3fc8a14'' | **yes** — one lone CR normalised to LF |
 +| AA | ''scripts/owner_irr_sample.py'' | 7,467 | ''d91c99eeaaf8c2c3'' | no |
 +| AB | ''scripts/owner_irr_kappa.py'' | 17,009 | ''71465e59c6aebe1d'' | no |
 +| AC | ''scripts/owner_irr_corrections.py'' | 6,067 | ''96f52c0778ae6f54'' | no |
 +| AD | ''scripts/check_irr_figures.py'' | 11,784 | ''1274e33732093cc3'' | no |
 +| AE | ''scripts/check_irr_figures_mutations.sh'' | 4,185 | ''6668eacfbe352477'' | no |
 +| AF | ''out/irr_sample-output.txt'' | 1,736 | ''0901099a81e2c06f'' | no |
 +| AG | ''out/irr/owner_merge_rows_irr-output.txt'' | 178 | ''9babdfba6f3125bb'' | no |
 +| AH | ''out/owner_verify_sources_irr-output.txt'' | 2,707 | ''2535d8331b36304e'' | no |
 +| AI | ''out/owner_irr_corrections-output.txt'' | 893 | ''02ca68994c4983f3'' | no |
 +| AJ | ''out/owner_irr_kappa-output.txt'' | 10,550 | ''14a0d749f0686da5'' | no |
 +| AK | ''out/check_irr_figures_mutations-output.txt'' | 1,008 | ''0a0ea1545e50b937'' | no |
  
 ===== A. scripts/owner_sample.py — the frame and the draw ===== ===== A. scripts/owner_sample.py — the frame and the draw =====
Line 314: Line 340:
 ===== C. adjudication brief, as given to the sub-agents ===== ===== C. adjudication brief, as given to the sub-agents =====
  
-Twenty-one batches of 8–9 domains were adjudicated against this brief. It is published verbatim because the verdict vocabulary and the sourcing bar are the study's instrument, and because one of its six verdicts (''self-named'') turned out to be wrong and was retired mid-run — which is only checkable if the brief that produced it is on the record.+Nineteen batches of 9 domains, one of 4, and a one-domain re-issue of the row one agent dropped — 21 sub-agents, 175 domains — were adjudicated against this brief on 2026-09-05, and a 35-domain random subsample was **re-adjudicated against the same bytes by a second model** on 2026-09-11 (sections AA–AF). It is published verbatim because the verdict vocabulary and the sourcing bar are the study's instrument; because one of its six verdicts (''self-named'') turned out to be wrong and was retired mid-run, which is only checkable if the brief that produced it is on the record; and because it is the instrument whose reliability the kappa figures measure.
  
 <code> <code>
Line 535: Line 561:
   OK        the quote (or, for a long quote, a 60-character window of it)   OK        the quote (or, for a long quote, a 60-character window of it)
             appears in the fetched bytes after normalisation             appears in the fetched bytes after normalisation
 +  OK-MARKUP the quote is not in the page's *text* but is in its markup -- a
 +            citation to a `<script src=...>`, a `<link>` or an attribute. The
 +            text normaliser strips tags, so such a quote can never be found in
 +            the haystack it builds; checking the raw bytes as well is what
 +            distinguishes "the evidence is not there" from "this checker cannot
 +            see that kind of evidence". Added 2026-09-11 after the checker, and
 +            the browser pass, both returned NOTFOUND for a `<script src>` line
 +            that is verbatim in kameleoon.com/legal-notice -- a **false**
 +            disqualification that had already been acted on.
   NOTFOUND  the URL fetched but the quote is not on it   <- disqualifying   NOTFOUND  the URL fetched but the quote is not on it   <- disqualifying
   FETCHFAIL the URL could not be fetched from this host  <- needs a human look,   FETCHFAIL the URL could not be fetched from this host  <- needs a human look,
Line 546: Line 581:
  
  
-def norm(s):+def norm(s, strip_tags=True):
     s = s.lower()     s = s.lower()
     # Modern sites ship their page text inside an escaped JSON payload, so the     # Modern sites ship their page text inside an escaped JSON payload, so the
Line 558: Line 593:
     s = s.replace("&amp;", "&").replace("&nbsp;", " ").replace("&#39;", "'")     s = s.replace("&amp;", "&").replace("&nbsp;", " ").replace("&#39;", "'")
     s = s.replace("&quot;", '"').replace("&rsquo;", "'").replace("&ldquo;", '"').replace("&rdquo;", '"')     s = s.replace("&quot;", '"').replace("&rsquo;", "'").replace("&ldquo;", '"').replace("&rdquo;", '"')
-    s = re.sub(r"<[^>]{0,400}>", " ", s)+    if strip_tags: 
 +        s = re.sub(r"<[^>]{0,400}>", " ", s)
     # Every remaining entity becomes a space on BOTH sides, or "&copy;" in the     # Every remaining entity becomes a space on BOTH sides, or "&copy;" in the
     # page and "(c)" in the quote normalise to different tokens and a correct     # page and "(c)" in the quote normalise to different tokens and a correct
     # citation is reported as a fabrication. Caught on medallia.com.au.     # citation is reported as a fabrication. Caught on medallia.com.au.
     s = re.sub(r"&(?:#\d{1,6}|#x[0-9a-fA-F]{1,6}|[a-zA-Z]{2,10});", " ", s)     s = re.sub(r"&(?:#\d{1,6}|#x[0-9a-fA-F]{1,6}|[a-zA-Z]{2,10});", " ", s)
-    s = re.sub(r"[^a-z0-9]+", " ", s)+    # Keep non-Latin letters. `[^a-z0-9]` deletes every Cyrillic, Greek, Hebrew 
 +    # and CJK character, so a quote in one of those scripts normalises to 
 +    # whatever Latin substring it happens to contain -- or, if it contains none, 
 +    # to the empty string, which this function then reports as NOQUOTE rather 
 +    # than as a citation it could not check. It was doing exactly that: 
 +    # `tns-counter.ru`'s Russian quote was verified on the single word 
 +    # "Mediascope" and `compass-fit.jp`'s Japanese one on "COMPASS-FIT", and 
 +    # both were reported OK. Found on 2026-09-11, when the second rater cited a 
 +    # wholly Chinese sentence for `qbox.me` and the result was NOQUOTE. 
 +    # The character class is now the one `owner_verify_rendered.mjs` has always 
 +    # used, so the bytes pass and the browser pass normalise alike. 
 +    s = re.sub(r"[^a-z0-9\u00c0-\uffff]+", " ", s)
     return re.sub(r"\s+", " ", s).strip()     return re.sub(r"\s+", " ", s).strip()
 +
 +
 +def decode(body):
 +    """Decode fetched bytes using the charset the document declares.
 +
 +    Decoding as UTF-8 with ``errors="replace"`` turns every byte of a
 +    windows-1251 or shift_jis page into U+FFFD, so a Cyrillic or Japanese quote
 +    can never match and the row is reported NOTFOUND -- a fabrication verdict on
 +    a correct citation. The normaliser was already fixed once for this class of
 +    bug (it used to delete non-Latin letters); this is the same bug one layer
 +    earlier, found on 2026-09-11 when ``help.i.ua/agreement/`` was cited: that
 +    page declares ``charset=windows-1251`` and every Ukrainian word in it
 +    decoded to replacement characters.
 +
 +    Sniffed from the bytes rather than from the HTTP header, because the header
 +    is not cached -- the cache holds the body alone. If nothing is declared, or
 +    the declared codec is unknown, the old behaviour is the fallback.
 +    """
 +    m = re.search(rb"""charset=["']?([A-Za-z0-9_.:-]{2,24})""", body[:4096], re.I)
 +    if m:
 +        enc = m.group(1).decode("ascii", "ignore")
 +        if enc.lower().replace("-", "") not in ("utf8", "utf"):
 +            try:
 +                return body.decode(enc)
 +            except (LookupError, UnicodeDecodeError):
 +                pass
 +    return body.decode("utf8", "replace")
  
  
Line 585: Line 659:
         except Exception as e:         except Exception as e:
             return None, f"tls: {e}"             return None, f"tls: {e}"
 +        os.makedirs(cache, exist_ok=True)
 +        open(path, "wb").write(out)
 +        return out, None
 +    if url.startswith("whois://"):
 +        # The 2026-09-11 second pass over the residue cites registry and
 +        # registrar WHOIS records, which are not reachable over HTTP: the
 +        # protocol is a port-43 socket. Without this branch every such row
 +        # FETCHFAILs, which reads as an unverifiable citation when in fact the
 +        # checker had no client. The URL is `whois://<server>/<domain>` and the
 +        # query is re-issued against the *named server*, not re-resolved
 +        # through IANA, so the row is checked against the record it cites.
 +        rest = url[len("whois://"):]
 +        server, _, dom = rest.partition("/")
 +        if not dom:
 +            return None, "whois: URL must be whois://<server>/<domain>"
 +        sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
 +        import time
 +        import whois43
 +        out = b""
 +        # Two attempts, 20 s apart. One retry is enough: the observed limit is a
 +        # burst limit, and both stubs in the first run answered correctly 25 s
 +        # later. Retrying forever would turn a registrar's block into a hang.
 +        err = None
 +        for attempt in range(2):
 +            if attempt:
 +                time.sleep(20)
 +            try:
 +                out = whois43.ask(server, dom).encode("utf8")
 +            except Exception as e:
 +                # A raised exception is a transport failure too -- a reset or a
 +                # timeout is exactly what a rate limiter does -- so it must take
 +                # the retry as well. It did not until 2026-09-11, when a review
 +                # pass read the comment above ("two attempts, 20 s apart") and
 +                # then read the code, which returned on the first exception.
 +                err, out = e, b""
 +                continue
 +            err = None
 +            if len(out) >= 400 and dom.encode().lower() in out.lower():
 +                break
 +        if err is not None:
 +            return None, f"whois: {err}"
 +        # Registrar WHOIS servers rate-limit. MarkMonitor answers the fourth
 +        # rapid query with a stub that has no registrant block, and caching that
 +        # stub turns a correct citation into a permanent NOTFOUND: it happened
 +        # to ``sa-as.com`` and ``blogblog.com`` in this script's first run on
 +        # 2026-09-11, and both verified on a retry 25 seconds later. A short or
 +        # domain-less answer is a transport failure, not evidence, so it is
 +        # neither returned nor cached.
 +        if len(out) < 400 or dom.encode().lower() not in out.lower():
 +            return None, (f"whois: {len(out)}-byte answer from {server} does not name "
 +                          f"{dom} after two attempts -- rate-limited, not cached")
         os.makedirs(cache, exist_ok=True)         os.makedirs(cache, exist_ok=True)
         open(path, "wb").write(out)         open(path, "wb").write(out)
Line 636: Line 761:
                             "url": url, "error": err})                             "url": url, "error": err})
             continue             continue
-        hay = norm(body.decode("utf8", "replace"))+        hay = norm(decode(body))
         nq = norm(quote)         nq = norm(quote)
         # A quote the adjudicator elided ("A ... B") is checked fragment by         # A quote the adjudicator elided ("A ... B") is checked fragment by
Line 643: Line 768:
         frags = [norm(f) for f in re.split(r"\.\.\.|\u2026|\s--\s", quote)]         frags = [norm(f) for f in re.split(r"\.\.\.|\u2026|\s--\s", quote)]
         frags = [f for f in frags if len(f) >= 12]         frags = [f for f in frags if len(f) >= 12]
 +        # The same normalisation WITHOUT the tag strip, so a quote that is
 +        # itself markup can be found. Kept separate rather than merged into
 +        # `hay`: a prose quote must match the page's text, and dropping the tag
 +        # strip for every row would let a quote match an HTML comment or an
 +        # attribute it was never on.
 +        raw_hay = norm(decode(body), strip_tags=False)
         if not nq:         if not nq:
             status = "NOQUOTE"             status = "NOQUOTE"
Line 649: Line 780:
         elif len(frags) > 1 and all(f in hay for f in frags):         elif len(frags) > 1 and all(f in hay for f in frags):
             status = "OK-FRAGMENTS"             status = "OK-FRAGMENTS"
 +        elif nq in raw_hay:
 +            status = "OK-MARKUP"
         else:         else:
             # long quotes get spliced by boilerplate; try the longest window that fits             # long quotes get spliced by boilerplate; try the longest window that fits
Line 739: Line 872:
 </file> </file>
  
-===== G. scripts/owner_corrections.py — the ten citations fixed by hand =====+===== G. scripts/owner_corrections.py — the 11 citations fixed by hand =====
  
 Every row a machine flagged and a human then re-checked, with the action taken and why: ''requote'' (the claim held, the quote was a paraphrase), ''resource'' (wrong page for a right claim), ''demote'' (no primary source on a second look; the row leaves every rate). Every row a machine flagged and a human then re-checked, with the action taken and why: ''requote'' (the claim held, the quote was a paraphrase), ''resource'' (wrong page for a right claim), ''demote'' (no primary source on a second look; the row leaves every rate).
Line 757: Line 890:
     quotation marks" failure, and it happened in 4 of 175 rows despite an     quotation marks" failure, and it happened in 4 of 175 rows despite an
     instruction that said verbatim -- which is the argument for verifying every     instruction that said verbatim -- which is the argument for verifying every
-    citation by machine rather than trusting the adjudicator.+    citation by machine rather than trusting the adjudicator. A fifth requote, 
 +    ``compass-fit.jp``, is a transcription slip rather than a paraphrase: one 
 +    dropped character. It is counted with them because the remedy is the same, 
 +    not because it is the same defect. It was 
 +    found on 2026-09-11 and applied on 2026-09-17, by re-running a *fixed* 
 +    checker over rows the broken one had passed: ``norm()`` used to delete every non-Latin character, 
 +    so ``compass-fit.jp``'s Japanese quote had been checked on the six Latin 
 +    letters ``COMPASS-FIT`` it happens to contain. A checker that cannot see a 
 +    script cannot police it, and every row it passed in that script is 
 +    unverified until it is re-run.
   * ``resource`` -- the cited URL was the wrong page (a 404 template, a   * ``resource`` -- the cited URL was the wrong page (a 404 template, a
     JS-only route, a blocked host). A different primary source for the same     JS-only route, a blocked host). A different primary source for the same
Line 827: Line 969:
         "registrant law firm, which is a textbook WHOIS trap, but calling it an "         "registrant law firm, which is a textbook WHOIS trap, but calling it an "
         "error needs a source naming the real owner and there is none."),         "error needs a source naming the real owner and there is none."),
 +    "compass-fit.jp": ("requote",
 +        "https://www.microad.co.jp/news/detail/1185/",
 +        "株式会社マイクロアド(本社:東京都渋谷区 代表取締役社長:渡辺健太郎 以下、マイクロアド)の台湾における現地法人MicroAd Taiwan,Ltd.(本社:台北市 董事長:丸木勇人 以下、マイクロアド台湾)は、ログリー株式会社(本社:東京都渋谷区、代表取締役社長:吉永浩和、証券コード:6579、以下、ログリー社)と業務提携し、台湾国内での、ネイティブ型広告配信サービス「COMPASS-FIT」の提供を開始致しました。",
 +        "found 2026-09-11, applied 2026-09-17. The norm() character-class fix "
 +        "of 2026-09-11 was re-run that day over all 175 rows of the first "
 +        "sitting -- rows the broken checker had passed, this one on the six "
 +        "Latin letters COMPASS-FIT its Japanese quote happens to contain. The "
 +        "mismatch is one character: the body reads "
 +        "台湾国内での、ネイティブ型広告配信サービス... and the old quote drops the "
 +        "full-width comma after 台湾国内での. (The comma-less form is the "
 +        "page's headline, which stops at 開始; the 開始致しました。 ending is the "
 +        "body's. The old quote is one and then the other, so it is on "
 +        "neither.) Replaced by the body sentence in full, 株式会社マイクロアド ... "
 +        "開始致しました。. This is NOT the shortest span on the page naming a "
 +        "MicroAd entity and the service -- the page title is 71 normalised "
 +        "characters and names both -- it is the one that names the parent and "
 +        "the operating subsidiary together, which is what owner_today = "
 +        "MicroAd, Inc. rests on where the old quote named only the service. "
 +        "Verified OK against a live fetch of 2026-09-17 (39,110 bytes). A "
 +        "transcription slip, not a paraphrase: the claim never rested on the "
 +        "missing character, so no published rate moves."),
 } }
  
Line 879: Line 1042:
 </file> </file>
  
-===== H. scripts/owner_verdict_fixes.py — retiring the ''self-named'' verdict =====+===== H. scripts/owner_verdict_fixes.py — retiring the self-named verdict =====
  
 The methodological correction made during the run, kept rather than smoothed away. Each of the 26 re-scores carries the entity string, the adjudicated owner and the reason. The methodological correction made during the run, kept rather than smoothed away. Each of the 26 re-scores carries the entity string, the adjudicated owner and the reason.
Line 2110: Line 2273:
 ===== R. Unedited output: scripts/owner_corrections.py ===== ===== R. Unedited output: scripts/owner_corrections.py =====
  
-Run as ''python3 scripts/owner_corrections.py --rows out/adj_rows.json --out out/adj_rows_final.json''.+Run as ''python3 scripts/owner_corrections.py %%--rows%% out/adj_rows.json %%--out%% out/adj_rows_final.json''.
  
 <code> <code>
-10 corrections applied to 175 rows, after machine verification of every cited source: demote=1, requote=4, resource=5+11 corrections applied to 175 rows, after machine verification of every cited source: demote=1, requote=5, resource=5
  
 twiago.com             requote   quote was re-ordered ('HRB 81984 (Amtsgericht Köln)'); the page reads 'Handelsregisternummer : Amtsgericht Köln HRB 81984'. Claim unchanged. twiago.com             requote   quote was re-ordered ('HRB 81984 (Amtsgericht Köln)'); the page reads 'Handelsregisternummer : Amtsgericht Köln HRB 81984'. Claim unchanged.
Line 2164: Line 2327:
                        was quote : Condé Nast Entertainment                        was quote : Condé Nast Entertainment
                        now source: None                        now source: None
 +
 +compass-fit.jp         requote   found 2026-09-11, applied 2026-09-17. The norm() character-class fix of 2026-09-11 was re-run that day over all 175 rows of the first sitting -- rows the broken checker had passed, this one on the six Latin letters COMPASS-FIT its Japanese quote happens to contain. The mismatch is one character: the body reads 台湾国内での、ネイティブ型広告配信サービス... and the old quote drops the full-width comma after 台湾国内での. (The comma-less form is the page's headline, which stops at 開始; the 開始致しました。 ending is the body's. The old quote is one and then the other, so it is on neither.) Replaced by the body sentence in full, 株式会社マイクロアド ... 開始致しました。. This is NOT the shortest span on the page naming a MicroAd entity and the service -- the page title is 71 normalised characters and names both -- it is the one that names the parent and the operating subsidiary together, which is what owner_today = MicroAd, Inc. rests on where the old quote named only the service. Verified OK against a live fetch of 2026-09-17 (39,110 bytes). A transcription slip, not a paraphrase: the claim never rested on the missing character, so no published rate moves.
 +                       was source: https://www.microad.co.jp/news/detail/1185/
 +                       was quote : 台湾国内でのネイティブ型広告配信サービス「COMPASS-FIT」の提供を開始致しました。
 +                       now source: https://www.microad.co.jp/news/detail/1185/
  
 wrote out/adj_rows_final.json wrote out/adj_rows_final.json
Line 2170: Line 2338:
 ===== S. Unedited output: scripts/owner_verdict_fixes.py ===== ===== S. Unedited output: scripts/owner_verdict_fixes.py =====
  
-Run as ''python3 scripts/owner_verdict_fixes.py --rows out/adj_rows_final.json --out out/adj_rows_scored.json''.+Run as ''python3 scripts/owner_verdict_fixes.py %%--rows%% out/adj_rows_final.json %%--out%% out/adj_rows_scored.json''.
  
 <code> <code>
Line 2271: Line 2439:
 ===== V. Unedited output: scripts/owner_random_sample.py — every figure, and the 175-row table ===== ===== V. Unedited output: scripts/owner_random_sample.py — every figure, and the 175-row table =====
  
-Run as ''python3 scripts/owner_random_sample.py --sample out/owner_sample.json --rows out/adj_rows_scored.json --table --wiki''.+Run as ''python3 scripts/owner_random_sample.py %%--sample%% out/owner_sample.json %%--rows%% out/adj_rows_scored.json %%--table%% %%--wiki%%''.
  
 <code> <code>
Line 4271: Line 4439:
 TITLE: Live chat software TITLE: Live chat software
 COPYRIGHT: © 2026 Zendesk, Inc| COPYRIGHT: © 2026 Zendesk, Inc|
 +</code>
 +
 +===== AA. scripts/owner_irr_sample.py — the inter-rater subsample and its batches =====
 +
 +Draws the 20% subsample re-adjudicated on 2026-09-11 under its own seed (20260911, deliberately not the parent draw's 20260905) and rebuilds the sub-agent batch files for it by **copying the first rater's per-domain blocks verbatim**, so the second rater saw the same claims and the same 2026-09-05 mechanical probe rather than a fresh one.
 +
 +<file python owner_irr_sample.py>
 +#!/usr/bin/env python3
 +"""Draw the inter-rater subsample of the 2026-09-05 ownership adjudication, and
 +rebuild the sub-agent batch files for it **byte-identically** to the ones the
 +first rater saw.
 +
 +Why this exists
 +---------------
 +The 175 domains of ``owner_sample.py`` were adjudicated once, by one Sonnet
 +sub-agent per batch, against ``out/adj/INSTRUCTIONS.md``.  Section C3.7 of the
 +provenance page recorded that as the study's main methodological weakness and
 +named the fix: "re-adjudicate a random 20% with a different model and report
 +agreement".  This script draws that 20%.
 +
 +Design, and why each choice was made:
 +
 +* **Simple random sample of the 175 distinct domains, not a re-stratification.**
 +  The subsample is not estimating anything about the web; it is estimating
 +  agreement between two readings of the same brief.  Re-stratifying by
 +  prevalence would make the subsample non-representative *of the sample*, which
 +  is the population that matters here.  35 = round(0.2 * 175).
 +
 +* **A separate seed, recorded.**  ``owner_sample.py`` uses 20260905.  Reusing it
 +  would make the subsample a deterministic function of the draw it is auditing
 +  -- harmless in fact, but indefensible in principle.  ``SEED = 20260911`` is
 +  this sitting's date and is used nowhere else.
 +
 +* **Per-domain blocks copied verbatim out of ``out/adj/batch*.txt``.**  The first
 +  rater saw the three lists' claims plus a mechanical probe (TLS / RDAP / HTTP)
 +  captured on 2026-09-05.  Re-running ``owner_probe.sh`` today would hand the
 +  second rater a *different* stimulus, and any disagreement would then be partly
 +  the world moving rather than the raters differing.  The blocks are therefore
 +  reused exactly, and the six-day gap between the frozen probe and the second
 +  rater's live fetches is stated as a limit instead.
 +
 +    python3 owner_irr_sample.py --sample out/owner_sample.json \
 +        --batches out/adj --out out/irr --n 35
 +"""
 +
 +import argparse
 +import hashlib
 +import json
 +import os
 +import random
 +import re
 +import sys
 +
 +SEED = 20260911
 +SEP = "-" * 64
 +
 +
 +def read_blocks(batchdir):
 +    """domain -> the verbatim per-domain block of the first rater's batch file."""
 +    blocks = {}
 +    for fn in sorted(os.listdir(batchdir)):
 +        if not re.fullmatch(r"batch\d+\.txt", fn):
 +            continue
 +        text = open(os.path.join(batchdir, fn), encoding="utf8").read()
 +        for chunk in text.split(SEP):
 +            chunk = chunk.strip("\n")
 +            if not chunk.strip():
 +                continue
 +            m = re.search(r"^DOMAIN:\s*(\S+)\s*$", chunk, re.M)
 +            if m is None:
 +                raise SystemExit(f"FATAL: block in {fn} has no DOMAIN line:\n{chunk[:200]}")
 +            d = m.group(1)
 +            # forter.com legitimately appears twice: batch04's agent returned 8
 +            # of its 9 rows, and the dropped domain was re-issued as the
 +            # one-domain batch20. The two blocks are byte-identical, so the
 +            # duplicate is harmless -- but a duplicate that DIFFERED would mean
 +            # the two raters saw different stimuli, so it is checked.
 +            if d in blocks and blocks[d] != chunk:
 +                raise SystemExit(f"FATAL: {d} appears in two batch blocks that differ")
 +            blocks[d] = chunk
 +    return blocks
 +
 +
 +def main():
 +    ap = argparse.ArgumentParser()
 +    ap.add_argument("--sample", required=True)
 +    ap.add_argument("--batches", required=True)
 +    ap.add_argument("--out", required=True)
 +    ap.add_argument("--n", type=int, default=35)
 +    ap.add_argument("--per-batch", type=int, default=7)
 +    args = ap.parse_args()
 +
 +    S = json.load(open(args.sample))
 +    union = sorted(r["domain"] for r in S["union"])
 +    claims = {r["domain"]: r for r in S["union"]}
 +    blocks = read_blocks(args.batches)
 +
 +    missing = sorted(set(union) - set(blocks))
 +    extra = sorted(set(blocks) - set(union))
 +    if missing or extra:
 +        raise SystemExit(f"FATAL: batch blocks do not cover the union "
 +                         f"(missing {missing}, extra {extra})")
 +
 +    rng = random.Random(SEED)
 +    drawn = sorted(rng.sample(union, args.n))
 +
 +    print(f"Universe: {len(union)} distinct domains adjudicated on 2026-09-05.")
 +    print(f"Seed {SEED} (separate from the 20260905 draw); simple random sample, "
 +          f"n={args.n} ({100*args.n/len(union):.1f}%).")
 +    print()
 +
 +    # How the subsample lands across the three per-list draws and the strata --
 +    # reported, not controlled for. A simple random sample of the 175 is not
 +    # balanced by construction and pretending otherwise would be the error.
 +    for lab in ("webXray", "Tracker Radar", "Disconnect"):
 +        insample = [d for d in drawn if lab in claims[d]["in_sample_of"]]
 +        named = [d for d in drawn if claims[d][lab] is not None]
 +        print(f"  {lab:14s}: {len(insample):2d} of the subsample are in this list's own "
 +              f"60-domain draw; {len(named):2d} of {args.n} have an entry in the file")
 +    strat = {}
 +    for d in drawn:
 +        for lab in S["lists"]:
 +            for r in S["lists"][lab]["rows"]:
 +                if r["domain"] == d:
 +                    strat[r["stratum"]] = strat.get(r["stratum"], 0) + 1
 +                    break
 +    print(f"  strata of origin (a domain drawn for two lists counts twice): "
 +          + ", ".join(f"Q{k}={v}" for k, v in sorted(strat.items())))
 +    print()
 +
 +    os.makedirs(args.out, exist_ok=True)
 +    adjdir = os.path.join(args.out, "adj2")
 +    os.makedirs(adjdir, exist_ok=True)
 +    nb = 0
 +    for i in range(0, len(drawn), args.per_batch):
 +        part = drawn[i:i + args.per_batch]
 +        body = "\n" + f"\n{SEP}\n".join(blocks[d] for d in part) + "\n"
 +        path = os.path.join(adjdir, f"batch{nb:02d}.txt")
 +        open(path, "w", encoding="utf8").write(SEP + body + SEP + "\n")
 +        print(f"  {os.path.basename(path)}: {len(part)} domains -- " + ", ".join(part))
 +        nb += 1
 +    print()
 +
 +    out = {
 +        "seed": SEED,
 +        "n": args.n,
 +        "universe": len(union),
 +        "parent_sample": os.path.basename(args.sample),
 +        "parent_seed": S["seed"],
 +        "domains": drawn,
 +    }
 +    # A sample-shaped file restricted to the subsample, so that
 +    # `owner_merge_rows.py` -- the same gate the first rater's rows went
 +    # through, unmodified -- can check the second rater's rows against the
 +    # list files without believing 140 domains are missing.
 +    sub = {k: v for k, v in S.items() if k != "union" and k != "lists"}
 +    sub["union"] = [claims[d] for d in drawn]
 +    sub["lists"] = {lab: {**S["lists"][lab],
 +                          "rows": [r for r in S["lists"][lab]["rows"]
 +                                   if r["domain"] in set(drawn)]}
 +                    for lab in S["lists"]}
 +    mergefile = os.path.join(args.out, "irr_sample_for_merge.json")
 +    json.dump(sub, open(mergefile, "w"), indent=1, sort_keys=True)
 +    print(f"Wrote {mergefile} (union of {len(sub['union'])}, for owner_merge_rows.py)")
 +
 +    manifest = os.path.join(args.out, "irr_subsample.json")
 +    json.dump(out, open(manifest, "w"), indent=1, sort_keys=True)
 +    digest = hashlib.sha256(open(manifest, "rb").read()).hexdigest()[:16]
 +    print(f"Wrote {manifest} ({nb} batches) sha256:{digest}")
 +    # The subsample must be reproducible from the seed alone; print the list so
 +    # the page can carry it without the reader needing the file.
 +    print("Subsample: " + " ".join(drawn))
 +    return 0
 +
 +
 +if __name__ == "__main__":
 +    sys.exit(main())
 +</file>
 +
 +===== AB. scripts/owner_irr_kappa.py — the agreement estimator =====
 +
 +Cohen kappa per list over five populations, with percentile-bootstrap intervals over domains. Its docstring carries the reason there are five: ''owner_merge_rows.py'' forces a verdict to ''absent'' from the list file, and counting those as agreement inflates every figure. Section 2b splits the disagreements into //sourcing// and //reading//, which is the section's main result.
 +
 +<file python owner_irr_kappa.py>
 +#!/usr/bin/env python3
 +"""Inter-rater agreement between the two adjudications of the ownership sample.
 +
 +    python3 owner_irr_kappa.py --r1 out/adj_rows.json \
 +        --r1-scored out/adj_rows_scored.json --r2 out/irr/adj2_rows.json \
 +        --subsample out/irr/irr_subsample.json
 +
 +Rater 1  21 Sonnet sub-agents, 2026-09-05, one batch each (`out/adj`).
 +Rater 2  5 Fable sub-agents, 2026-09-11, the same brief byte-for-byte and the
 +         same per-domain blocks, on a 35-domain random subsample (`out/irr`).
 +
 +What is and is not measured
 +---------------------------
 +Both rater's rows pass through `owner_merge_rows.py`, which **forces** a verdict
 +to `absent` when the list file has no entry for the domain.  That is a fact
 +about the file, not a judgement, so agreement on those cells is a property of
 +the merge script and would inflate every figure: 96 of webXray's 175 first-sitting
 +verdicts are forced `absent`.  Three populations are therefore reported, and the
 +**second** is the headline:
 +
 +  ALL          all 35 rows, raw vocabulary.  Includes the forced `absent` cells.
 +               Published only so a reader can see how much they inflate kappa.
 +  NAMED        rows where the list file names an owner.  `absent` is re-derived
 +               from the list file by `owner_merge_rows.py` rather than judged, so
 +               the two raters' `absent` cells must be the SAME cells; the
 +               selection is written as `or` and `assert_absent_agrees()` enforces
 +               the equivalence rather than assuming it.  If the list files move
 +               between two sittings -- and C3.1 records that two of the six did
 +               move between 2026-08-17 and 2026-09-05 -- the sets could diverge,
 +               and an `or` selection would then silently pair a real verdict
 +               against a non-judgement.  It raises instead.
 +  ADJUDICABLE  NAMED rows where **both** raters reached an ownership verdict
 +               (current / stale / granularity / error).  A row one rater could
 +               not source is a difference in what was found, not in how it was
 +               read; that axis is measured separately as ADJUDICABILITY.
 +
 +and two collapses of ADJUDICABLE, because the published rate is a `current`
 +share and nothing else:
 +
 +  CURRENT-vs-REST   binary, the distinction every headline figure rests on.
 +  ADJUDICABILITY    binary over NAMED: did the rater reach a verdict at all.
 +
 +`self-named` was retired after the first sitting (`owner_verdict_fixes.py`), but
 +it is in the brief both raters were given, so it is kept as its own category in
 +the raw comparison and its per-rater usage is reported: how often two raters
 +independently reach for a verdict is evidence about the verdict.
 +
 +Kappa
 +-----
 +Cohen's kappa with the two raters' own marginals::
 +
 +    kappa = (po - pe) / (1 - pe),  pe = sum_c p1(c) * p2(c)
 +
 +Intervals are bootstrap percentiles over **domains** for the per-list rows and
 +over **cells** for the pooled rows -- a pooled row has already flattened the
 +three lists' verdicts into one sequence, so its resample can draw two cells of
 +one domain independently and its interval is narrower than it should be. That is
 +stated on the page as well. 10,000 resamples, percentile rather than the
 +normal approximation: at n = 12-35 with several empty cells the asymptotic
 +variance is not usable.  A resample in which the denominator collapses (every
 +row one category, so 1 - pe = 0) is dropped and the count of drops is printed --
 +silently dropping them would narrow the interval.
 +
 +Kappa is not read against the usual verbal bands here.  They are a convention,
 +not a threshold, and at these marginals -- one category carrying most of the
 +mass -- kappa is driven as much by the marginals as by the disagreements.  The
 +observed agreement is printed beside every kappa for that reason.
 +"""
 +
 +import argparse, json, random, sys
 +from collections import Counter
 +
 +LISTS = [("webXray", "webXray"), ("Tracker Radar", "tracker_radar"),
 +         ("Disconnect", "disconnect")]
 +OWNERSHIP = {"current", "stale", "granularity", "error"}
 +NBOOT = 10000
 +SEED = 20260911
 +
 +
 +def kappa(pairs):
 +    """Cohen's kappa for a list of (rater1, rater2) category pairs."""
 +    n = len(pairs)
 +    if n == 0:
 +        return None
 +    po = sum(1 for a, b in pairs if a == b) / n
 +    m1, m2 = Counter(a for a, _ in pairs), Counter(b for _, b in pairs)
 +    # sorted(), not set(): Python's set iteration order is hash-seed randomised,
 +    # so the float addition order varied between runs and the JSON artefact's
 +    # 15th-16th decimal moved. No published 3-decimal figure ever changed, but a
 +    # "deterministic" artefact that is not byte-stable is a bad artefact.
 +    pe = sum(m1[c] / n * m2[c] / n for c in sorted(set(m1) | set(m2)))
 +    if abs(1 - pe) < 1e-12:
 +        return {"n": n, "po": po, "pe": pe, "kappa": None}
 +    return {"n": n, "po": po, "pe": pe, "kappa": (po - pe) / (1 - pe)}
 +
 +
 +def boot(pairs, seed):
 +    rng = random.Random(seed)
 +    n = len(pairs)
 +    ks, degenerate = [], 0
 +    if n < 2:
 +        return None, None, 0
 +    for _ in range(NBOOT):
 +        r = [pairs[rng.randrange(n)] for _ in range(n)]
 +        k = kappa(r)
 +        if k is None or k["kappa"] is None:
 +            degenerate += 1
 +            continue
 +        ks.append(k["kappa"])
 +    if len(ks) < NBOOT // 10:
 +        return None, None, degenerate
 +    ks.sort()
 +    return ks[int(0.025 * len(ks))], ks[int(0.975 * len(ks))], degenerate
 +
 +
 +def fmt(k, lo, hi, deg):
 +    if k is None:
 +        return "      n/a"
 +    if k["kappa"] is None:
 +        return f"  n={k['n']:2d}  po={k['po']:.3f}  kappa undefined (pe=1)"
 +    s = f"  n={k['n']:2d}  po={k['po']:.3f}  pe={k['pe']:.3f}  kappa={k['kappa']:+.3f}"
 +    if lo is not None:
 +        s += f"  [{lo:+.3f}, {hi:+.3f}]"
 +    else:
 +        s += "  [interval not estimable]"
 +    if deg:
 +        s += f"  ({deg} degenerate resamples dropped)"
 +    return s
 +
 +
 +def assert_absent_agrees(doms, R1, R2):
 +    """`absent` is a fact about the list file, so both raters must carry it on
 +    exactly the same cells. Every population below is selected with `or`, which
 +    is only equivalent to the docstring's "neither rater is forced to absent"
 +    while that holds. Checked, not assumed."""
 +    bad = [(d, lab, R1[d][key], R2[d][key])
 +           for lab, key in LISTS for d in doms
 +           if (R1[d][key] == "absent") != (R2[d][key] == "absent")]
 +    if bad:
 +        lines = "\n".join(f"  {d} {lab}: rater1={a!r} rater2={b!r}" for d, lab, a, b in bad)
 +        raise SystemExit(
 +            f"FATAL: `absent` disagrees on {len(bad)} cell(s). It is re-derived "
 +            "from the list file, not judged, so this means the two raters were "
 +            "merged against different list snapshots. Every population below "
 +            "would be wrong.\n" + lines)
 +    print(f"`absent` agrees on all {3*len(doms)} cells (it is a fact about the "
 +          "list file, not a judgement) -- the NAMED/ADJUDICABILITY selections are valid.")
 +
 +
 +def main():
 +    ap = argparse.ArgumentParser()
 +    ap.add_argument("--r1", required=True)
 +    ap.add_argument("--r1-scored", required=True)
 +    ap.add_argument("--r2", required=True)
 +    ap.add_argument("--subsample", required=True)
 +    ap.add_argument("--json-out")
 +    args = ap.parse_args()
 +
 +    sub = json.load(open(args.subsample))
 +    doms = sorted(sub["domains"])
 +    R1 = {r["domain"]: r for r in json.load(open(args.r1))}
 +    R1S = {r["domain"]: r for r in json.load(open(args.r1_scored))}
 +    R2 = {r["domain"]: r for r in json.load(open(args.r2))}
 +
 +    missing = [d for d in doms if d not in R1 or d not in R2]
 +    if missing:
 +        raise SystemExit(f"FATAL: rows missing for {missing}")
 +    assert_absent_agrees(doms, R1, R2)
 +
 +    print("=" * 78)
 +    print("INTER-RATER AGREEMENT, OWNERSHIP ADJUDICATION")
 +    print("=" * 78)
 +    print(f"Subsample: {len(doms)} of {sub['universe']} domains, seed {sub['seed']} "
 +          f"(parent draw seed {sub['parent_seed']}).")
 +    print(f"Rater 1: 2026-09-05, Sonnet sub-agents ({args.r1}).")
 +    print(f"Rater 2: 2026-09-11, Fable sub-agents ({args.r2}).")
 +    print(f"Bootstrap: {NBOOT:,} resamples over domains, seed {SEED}.")
 +    print()
 +
 +    print("-" * 78)
 +    print("0. Vocabulary each rater used on these 35 domains (105 cells per rater)")
 +    print("-" * 78)
 +    for lab, key in LISTS:
 +        c1 = Counter(R1[d][key] for d in doms)
 +        c2 = Counter(R2[d][key] for d in doms)
 +        cats = sorted(set(c1) | set(c2))
 +        print(f"  {lab}")
 +        for c in cats:
 +            print(f"    {c:12s} rater1={c1[c]:2d}  rater2={c2[c]:2d}")
 +    both = Counter()
 +    for lab, key in LISTS:
 +        for d in doms:
 +            if R1[d][key] == "self-named":
 +                both["r1"] += 1
 +            if R2[d][key] == "self-named":
 +                both["r2"] += 1
 +            if R1[d][key] == "self-named" and R2[d][key] == "self-named":
 +                both["both"] += 1
 +    print(f"  `self-named` (retired after sitting 1, still in the brief): "
 +          f"rater1 {both['r1']} cells, rater2 {both['r2']} cells, both on the same cell {both['both']}")
 +    print()
 +
 +    results = {}
 +    for title, pick in (
 +        ("1. ALL rows, raw vocabulary  (includes merge-script-forced `absent`)",
 +         lambda d, key: (R1[d][key], R2[d][key])),
 +    ):
 +        print("-" * 78); print(title); print("-" * 78)
 +        for lab, key in LISTS:
 +            pairs = [pick(d, key) for d in doms]
 +            k = kappa(pairs); lo, hi, deg = boot(pairs, SEED)
 +            print(f"  {lab:14s}{fmt(k, lo, hi, deg)}")
 +            results[("ALL", lab)] = (k, lo, hi)
 +        print()
 +
 +    print("-" * 78)
 +    print("2. NAMED rows only  (the list file names an owner: the rows the brief")
 +    print("   actually asks a rater to judge)  <-- HEADLINE")
 +    print("-" * 78)
 +    for lab, key in LISTS:
 +        sel = [d for d in doms if R1[d][key] != "absent" or R2[d][key] != "absent"]
 +        pairs = [(R1[d][key], R2[d][key]) for d in sel]
 +        k = kappa(pairs); lo, hi, deg = boot(pairs, SEED)
 +        print(f"  {lab:14s}{fmt(k, lo, hi, deg)}")
 +        results[("NAMED", lab)] = (k, lo, hi)
 +    allp = [(R1[d][key], R2[d][key]) for lab, key in LISTS for d in doms
 +            if R1[d][key] != "absent" or R2[d][key] != "absent"]
 +    k = kappa(allp); lo, hi, deg = boot(allp, SEED)
 +    print(f"  {'POOLED':14s}{fmt(k, lo, hi, deg)}   (cells, not domains: "
 +          f"the three lists' verdicts on one domain are not independent)")
 +    results[("NAMED", "POOLED")] = (k, lo, hi)
 +    print()
 +
 +    print("-" * 78)
 +    print("2b. What the NAMED disagreements are made of")
 +    print("    An `unknown` against a verdict is a difference in what was SOURCED.")
 +    print("    A verdict against a different verdict is a difference in what was READ.")
 +    print("-" * 78)
 +    sourced, read_, tot = 0, 0, 0
 +    for lab, key in LISTS:
 +        a = b = n = 0
 +        for d in doms:
 +            v1, v2 = R1[d][key], R2[d][key]
 +            if v1 == "absent" and v2 == "absent":
 +                continue
 +            n += 1
 +            if v1 == v2:
 +                continue
 +            if "unknown" in (v1, v2):
 +                a += 1
 +            else:
 +                b += 1
 +        print(f"  {lab:14s} n={n:2d}  agreed={n-a-b:2d}  differ-on-sourcing={a:2d}  "
 +              f"differ-on-reading={b:2d}")
 +        sourced += a; read_ += b; tot += n
 +    dis = sourced + read_
 +    print(f"  {'POOLED':14s} n={tot:2d}  agreed={tot-dis:2d}  differ-on-sourcing={sourced:2d}  "
 +          f"differ-on-reading={read_:2d}")
 +    if dis:
 +        print(f"  -> {100*sourced/dis:.1f}% of the {dis} disagreeing cells are one rater "
 +              f"reaching `unknown` where the other reached a verdict.")
 +    results[("SPLIT", "POOLED")] = (None, None, None)
 +    print()
 +
 +    print("-" * 78)
 +    print("3. ADJUDICABLE rows  (both raters reached an ownership verdict)")
 +    print("-" * 78)
 +    pooled = []
 +    for lab, key in LISTS:
 +        sel = [d for d in doms if R1[d][key] in OWNERSHIP and R2[d][key] in OWNERSHIP]
 +        pairs = [(R1[d][key], R2[d][key]) for d in sel]
 +        pooled += pairs
 +        k = kappa(pairs); lo, hi, deg = boot(pairs, SEED)
 +        print(f"  {lab:14s}{fmt(k, lo, hi, deg)}")
 +        results[("ADJ", lab)] = (k, lo, hi)
 +    k = kappa(pooled); lo, hi, deg = boot(pooled, SEED)
 +    print(f"  {'POOLED':14s}{fmt(k, lo, hi, deg)}   (cells, not domains)")
 +    results[("ADJ", "POOLED")] = (k, lo, hi)
 +    print()
 +
 +    print("-" * 78)
 +    print("4. CURRENT vs REST, on ADJUDICABLE rows  (the distinction every")
 +    print("   published accuracy figure rests on)")
 +    print("-" * 78)
 +    pooled = []
 +    for lab, key in LISTS:
 +        sel = [d for d in doms if R1[d][key] in OWNERSHIP and R2[d][key] in OWNERSHIP]
 +        pairs = [(R1[d][key] == "current", R2[d][key] == "current") for d in sel]
 +        pooled += pairs
 +        k = kappa(pairs); lo, hi, deg = boot(pairs, SEED)
 +        print(f"  {lab:14s}{fmt(k, lo, hi, deg)}")
 +        results[("CUR", lab)] = (k, lo, hi)
 +    k = kappa(pooled); lo, hi, deg = boot(pooled, SEED)
 +    print(f"  {'POOLED':14s}{fmt(k, lo, hi, deg)}   (cells, not domains)")
 +    results[("CUR", "POOLED")] = (k, lo, hi)
 +    print()
 +
 +    print("-" * 78)
 +    print("5. ADJUDICABILITY, on NAMED rows  (did the rater reach a verdict at all)")
 +    print("-" * 78)
 +    for lab, key in LISTS:
 +        sel = [d for d in doms if R1[d][key] != "absent" or R2[d][key] != "absent"]
 +        pairs = [(R1[d][key] in OWNERSHIP, R2[d][key] in OWNERSHIP) for d in sel]
 +        k = kappa(pairs); lo, hi, deg = boot(pairs, SEED)
 +        n1 = sum(1 for a, _ in pairs if a); n2 = sum(1 for _, b in pairs if b)
 +        print(f"  {lab:14s}{fmt(k, lo, hi, deg)}   settled: rater1 {n1}, rater2 {n2}")
 +        results[("ADJY", lab)] = (k, lo, hi)
 +    r1u = sum(1 for d in doms if R1[d]["resolution"] != "resolved")
 +    r2u = sum(1 for d in doms if R2[d]["resolution"] != "resolved")
 +    agree = sum(1 for d in doms
 +                if (R1[d]["resolution"] == "resolved") == (R2[d]["resolution"] == "resolved"))
 +    print(f"  row-level `resolution`: rater1 unresolved {r1u}/{len(doms)}, "
 +          f"rater2 {r2u}/{len(doms)}, same call on {agree}/{len(doms)}")
 +    print()
 +
 +    print("-" * 78)
 +    print("6. Unweighted `current` share on the subsample, per rater")
 +    print("   NOT the estimator: 35 rows with no stratum weights. It answers one")
 +    print("   question only -- would the second rater push the rate up or down.")
 +    print("-" * 78)
 +    for lab, key in LISTS:
 +        s1 = [d for d in doms if R1[d][key] in OWNERSHIP]
 +        s2 = [d for d in doms if R2[d][key] in OWNERSHIP]
 +        c1 = sum(1 for d in s1 if R1[d][key] == "current")
 +        c2 = sum(1 for d in s2 if R2[d][key] == "current")
 +        f1 = f"{100*c1/len(s1):.1f}% ({c1}/{len(s1)})" if s1 else "n/a (0 rows)"
 +        f2 = f"{100*c2/len(s2):.1f}% ({c2}/{len(s2)})" if s2 else "n/a (0 rows)"
 +        print(f"  {lab:14s} rater1 {f1:18s}  rater2 {f2}")
 +    print()
 +
 +    print("-" * 78)
 +    print("7. Every disagreement, on NAMED rows")
 +    print("-" * 78)
 +    ndis = 0
 +    for d in doms:
 +        diffs = [(lab, R1[d][key], R2[d][key]) for lab, key in LISTS
 +                 if (R1[d][key] != "absent" or R2[d][key] != "absent")
 +                 and R1[d][key] != R2[d][key]]
 +        if not diffs:
 +            continue
 +        ndis += 1
 +        print(f"  {d}")
 +        print(f"     owner_today  r1: {R1[d]['owner_today']}")
 +        print(f"                  r2: {R2[d]['owner_today']}")
 +        for lab, a, b in diffs:
 +            print(f"     {lab:14s} r1={a:12s} r2={b}")
 +    print(f"  -> {ndis} of {len(doms)} domains carry at least one disagreement.")
 +    print()
 +
 +    print("-" * 78)
 +    print("8. Rater 2 against the PUBLISHED verdicts (rater 1 after the hand")
 +    print("   corrections of owner_corrections.py and owner_verdict_fixes.py).")
 +    print("   Asymmetric by construction -- rater 2 got no correction pass -- so")
 +    print("   this is a sensitivity figure, not an inter-rater figure.")
 +    print("-" * 78)
 +    for lab, key in LISTS:
 +        sel = [d for d in doms if R1S[d][key] != "absent" or R2[d][key] != "absent"]
 +        pairs = [(R1S[d][key], R2[d][key]) for d in sel]
 +        k = kappa(pairs); lo, hi, deg = boot(pairs, SEED)
 +        print(f"  {lab:14s}{fmt(k, lo, hi, deg)}")
 +        results[("SCORED", lab)] = (k, lo, hi)
 +    changed = sum(1 for d in doms for _, key in LISTS if R1[d][key] != R1S[d][key])
 +    print(f"  (the correction pass moved {changed} of {3*len(doms)} cells on this subsample)")
 +    print()
 +
 +    if args.json_out:
 +        out = {f"{scope}|{lab}": (None if k is None or k["kappa"] is None else
 +                                  {"n": k["n"], "po": k["po"], "pe": k["pe"],
 +                                   "kappa": k["kappa"], "lo": lo, "hi": hi})
 +               for (scope, lab), (k, lo, hi) in results.items()}
 +        json.dump(out, open(args.json_out, "w"), indent=1, sort_keys=True)
 +        print(f"wrote {args.json_out}")
 +    return 0
 +
 +
 +if __name__ == "__main__":
 +    sys.exit(main())
 +</file>
 +
 +===== AC. scripts/owner_irr_corrections.py — the second rater's citation failures =====
 +
 +The three rows that failed ''owner_verify_sources.py'', with what a hand check found. Two are kept — an expired certificate and an unsupported URL scheme are not bad citations — and one is demoted to ''unresolved'' under the rule the first sitting wrote.
 +
 +<file python owner_irr_corrections.py>
 +#!/usr/bin/env python3
 +"""Corrections to the **second** rater's rows, on the same rule the first
 +rater's rows were corrected on (``owner_corrections.py``): a citation that is
 +NOTFOUND in the bytes **and** NOTFOUND in a real browser is a failed citation,
 +and its row is demoted to ``unresolved`` rather than published.
 +
 +    python3 owner_irr_corrections.py --rows out/irr/adj2_rows.json \
 +        --out out/irr/adj2_rows_final.json
 +
 +Three rows failed ``owner_verify_sources.py`` on 2026-09-11 and each is recorded
 +here with what a hand check found, because two of the three are **not** bad
 +citations and demoting them would have been the wrong call:
 +
 +  adgrx.com    FETCHFAIL, and the citation is sound. ``samsungads.ca`` serves a
 +               Let's Encrypt certificate that expired **2026-03-12** and has not
 +               been renewed, so ``curl -sL`` and Chromium both refuse the
 +               connection; ``curl -sk`` returns 59,465 bytes carrying the quoted
 +               sentence verbatim. The row stands. What this actually found is a
 +               limit of the verifier: an expired certificate makes a correct
 +               citation indistinguishable from an unreachable one.
 +  cedscdn.it   FETCHFAIL, and the citation is sound. The rater cited
 +               ``whois://whois.nic.it/cedscdn.it``, a scheme neither the bytes
 +               pass nor the browser pass can open -- the brief offers exactly one
 +               non-HTTP form, ``tls://``, and the rater invented a parallel one.
 +               A port-43 query to ``whois.nic.it`` returns
 +               ``Organization:     CED DIGITAL & SERVIZI Srl`` verbatim, so the
 +               evidence exists and says what the row claims. The row stands and
 +               the gap is the brief's.
 +  kameleoon.io NOTFOUND in bytes and NOTFOUND in the browser -- and the citation
 +               is sound. **This row was demoted and the demotion was wrong**, and
 +               it is recorded here rather than quietly reversed. The quote is a
 +               ``<script src>`` line, and BOTH instruments are structurally blind
 +               to markup: ``owner_verify_sources.norm()`` strips ``<...>`` before
 +               searching, and ``owner_verify_rendered.mjs`` reads
 +               ``document.body.innerText``, which excludes script elements. The
 +               string is verbatim in the 27,121 bytes ``curl`` returns from
 +               ``kameleoon.com/legal-notice``. Caught by a reviewer who fetched
 +               the page instead of trusting the checker. The verifier now has an
 +               ``OK-MARKUP`` status and the row stands.
 +
 +**No row is demoted.** All three machine failures turned out to be limits of the
 +instruments rather than bad citations, which is itself the finding: two of the
 +three would have been acted on without a hand check, and one of them was. The
 +inter-rater figures are therefore identical before and after this script, and
 +that equality is asserted below rather than assumed.
 +"""
 +import argparse, json, sys
 +
 +# domain -> (action, note)
 +CORRECTIONS = {
 +    "adgrx.com": ("keep",
 +        "FETCHFAIL is an expired certificate on samsungads.ca (Let's Encrypt, "
 +        "notAfter 2026-03-12), not a bad citation: `curl -sk` returns the quoted "
 +        "sentence verbatim in 59,465 bytes. Row unchanged."),
 +    "cedscdn.it": ("keep",
 +        "FETCHFAIL is an unsupported URL scheme: `whois://whois.nic.it/cedscdn.it`. "
 +        "A port-43 query to whois.nic.it returns the quoted line verbatim. Row "
 +        "unchanged; the brief should name a whois form the way it names tls://."),
 +    "kameleoon.io": ("keep",
 +        "NOTFOUND in both instruments was a FALSE disqualification: the quote is "
 +        "a `<script src>` line, and both the bytes pass (which strips tags) and "
 +        "the browser pass (which reads innerText) are blind to markup by "
 +        "construction. The string is verbatim in the fetched bytes. Row unchanged; "
 +        "the verifier now reports OK-MARKUP instead."),
 +}
 +
 +KEYS = ("webXray", "tracker_radar", "disconnect")
 +
 +
 +def main():
 +    ap = argparse.ArgumentParser()
 +    ap.add_argument("--rows", required=True)
 +    ap.add_argument("--out", required=True)
 +    args = ap.parse_args()
 +    rows = json.load(open(args.rows))
 +    by = {r["domain"]: r for r in rows}
 +
 +    unlisted = sorted(set(CORRECTIONS) - set(by))
 +    if unlisted:
 +        raise SystemExit(f"FATAL: corrections for rows that are not in the file: {unlisted}")
 +
 +    changed = 0
 +    for dom, (action, note) in CORRECTIONS.items():
 +        r = by[dom]
 +        print(f"{action.upper():7s} {dom}")
 +        print(f"        {note}")
 +        if action == "keep":
 +            continue
 +        before = {k: r[k] for k in KEYS}
 +        r["resolution"] = "unresolved"
 +        r["owner_today"] = None
 +        r["when_changed"] = "UNRESOLVED -- citation failed verification, see owner_irr_corrections.py"
 +        for k in KEYS:
 +            # `absent` is a fact about the list file and survives a demotion;
 +            # every other verdict becomes unknown, exactly as the brief says an
 +            # unresolved row must.
 +            if r[k] != "absent":
 +                r[k] = "unknown"
 +        after = {k: r[k] for k in KEYS}
 +        for k in KEYS:
 +            if before[k] != after[k]:
 +                print(f"        {k}: {before[k]} -> {after[k]}")
 +                changed += 1
 +        r["note"] = (r["note"] or "") + "  [demoted 2026-09-11: " + note + "]"
 +    print()
 +    print(f"rows examined: {len(CORRECTIONS)}; verdict cells changed: {changed}")
 +    # The page states that the corrected and uncorrected inter-rater figures are
 +    # the same. That is only true while nothing is demoted, so it is checked
 +    # here rather than left as a sentence somebody has to remember.
 +    if changed:
 +        print("NOTE: verdicts changed -- the corrected kappa figures now differ "
 +              "from the uncorrected ones and both must be published.")
 +    json.dump(rows, open(args.out, "w"), indent=1)
 +    print(f"wrote {args.out}")
 +    return 0
 +
 +
 +if __name__ == "__main__":
 +    sys.exit(main())
 +</file>
 +
 +===== AD. scripts/check_irr_figures.py — the page-versus-estimator guard =====
 +
 +Asserts that every inter-rater figure on each page matches the estimator's output, **each page against its own claims**. Its first version joined the two pages and asked whether a figure was on either; its own mutation harness then showed two corruptions surviving, because breaking one copy of a kappa left the other to satisfy the check.
 +
 +<file python check_irr_figures.py>
 +#!/usr/bin/env python3
 +"""Assert that every inter-rater figure on the webXray pages matches
 +``owner_irr_kappa.py``'s own output.
 +
 +    python3 check_irr_figures.py --page prov=pages/wxprov_irr.txt \
 +        --page content=pages/wx_irr.txt --json out/owner_irr_kappa.json \
 +        --json-corrected out/owner_irr_kappa_corrected.json \
 +        --output out/owner_irr_kappa-output.txt
 +
 +**Each page is checked against its own claims, not against the union.** The first
 +version joined both pages and asked whether a figure was on *either*; its own
 +mutation harness then showed two corruptions surviving, because the same kappa
 +appears on both pages and breaking one copy left the other to satisfy the check.
 +A guard over a union of documents cannot tell you which document is wrong.
 +
 +Same contract, and same limit, as ``check_owner_sample_figures.py``: it compares
 +the page to the tool, so a bug **inside** ``owner_irr_kappa.py`` passes it, and
 +so does a correct figure attached to the wrong sentence. It catches one thing --
 +a page figure drifting from the script that produced it.
 +
 +It fails when a figure is **missing** as well as when it disagrees, because a
 +guard that passes because its needle was deleted asserts nothing. ``REQUIRED``
 +names, per figure, which of its fields the pages actually publish: a guard that
 +demanded every field of every scope would have to be weakened the first time a
 +page chose not to quote one, and a weakened guard is how the published-figure
 +defects on these pages got through before.
 +
 +The last check is the one that catches the opposite error: a three-decimal value
 +sitting in kappa context on the page that neither JSON produces. That is how a
 +hand-typed figure gets in.
 +"""
 +import argparse, json, re, sys
 +
 +# role -> {scope|list -> fields THAT page publishes}: k=kappa, p=raw agreement,
 +# n=denominator ("n=<N>" verbatim), i=interval.
 +PROV_REQUIRED = {
 +    "NAMED|webXray": "kpni",
 +    "NAMED|Tracker Radar": "kpni",
 +    "NAMED|Disconnect": "kpni",
 +    "NAMED|POOLED": "kpni",
 +    "ALL|webXray": "k",
 +    "ALL|Tracker Radar": "k",
 +    "ALL|Disconnect": "k",
 +    "ADJ|webXray": "kn",
 +    "ADJ|Tracker Radar": "kn",
 +    "ADJ|Disconnect": "kn",
 +    "ADJ|POOLED": "kpn",
 +    "CUR|webXray": "kn",
 +    "CUR|Tracker Radar": "kn",
 +    "CUR|Disconnect": "kn",
 +    "CUR|POOLED": "kpn",
 +}
 +
 +# The content page quotes a subset, and writes its denominators in prose
 +# ("on 12 rows") rather than as "n=12", so it is not asked for `n`.
 +CONTENT_REQUIRED = {
 +    "NAMED|POOLED": "kpi",
 +    "NAMED|webXray": "k",
 +    "NAMED|Tracker Radar": "k",
 +    "NAMED|Disconnect": "k",
 +    "ADJ|POOLED": "k",
 +}
 +
 +REQUIRED = {"prov": PROV_REQUIRED, "content": CONTENT_REQUIRED}
 +
 +# Figures taken from the post-demotion recomputation. Only the provenance page
 +# carries these.
 +CORRECTED = {"prov": {"NAMED|Disconnect": "k", "NAMED|POOLED": "k"},
 +             "content": {}}
 +
 +SIGNED = lambda x: f"{x:+.3f}"
 +BARE = lambda x: f"{abs(x):.3f}"
 +
 +
 +def check(K, want, joined, tag, fails, counter):
 +    for key, fields in want.items():
 +        if key not in K:
 +            sys.exit(f"FATAL: {tag} has no {key!r}; the script changed and "
 +                     "nothing below was checked")
 +        v = K[key]
 +        if v is None:
 +            fails.append(f"{tag} {key}: kappa undefined in the script output, so "
 +                         "the page cannot be carrying it")
 +            continue
 +        probes = []
 +        if "k" in fields:
 +            probes.append(("kappa", SIGNED(v["kappa"])))
 +        if "p" in fields:
 +            probes.append(("agreement", f"{100*v['po']:.1f}%"))
 +        if "n" in fields:
 +            probes.append(("n", f"n={v['n']}"))
 +        for label, val in probes:
 +            counter[0] += 1
 +            if val not in joined:
 +                fails.append(f"{tag} {key} {label}={val!r} is on no page")
 +        if "i" in fields and v["lo"] is not None:
 +            counter[0] += 1
 +            lo, hi = SIGNED(v["lo"]), SIGNED(v["hi"])
 +            pat = re.escape(lo) + r"\s*(?:to|–|—|-|,)\s*" + re.escape(hi)
 +            if not re.search(pat, joined):
 +                fails.append(f"{tag} {key} interval [{lo}, {hi}] is on no page")
 +
 +
 +def main():
 +    ap = argparse.ArgumentParser()
 +    ap.add_argument("--page", action="append", required=True)
 +    ap.add_argument("--json", required=True)
 +    ap.add_argument("--json-corrected", required=True)
 +    ap.add_argument("--output", required=True)
 +    ap.add_argument("--verify-output", required=True,
 +                    help="owner_verify_sources.py's output for the second rater; "
 +                         "the citation tally on the page comes from there and "
 +                         "from no JSON this guard otherwise reads")
 +    args = ap.parse_args()
 +
 +    pages = {}
 +    for spec in args.page:
 +        if "=" not in spec:
 +            sys.exit(f"FATAL: --page wants role=path, got {spec!r}; "
 +                     f"roles are {sorted(REQUIRED)}")
 +        role, path = spec.split("=", 1)
 +        if role not in REQUIRED:
 +            sys.exit(f"FATAL: unknown page role {role!r}; roles are {sorted(REQUIRED)}")
 +        pages[role] = open(path, encoding="utf8").read()
 +    missing_roles = sorted(set(REQUIRED) - set(pages))
 +    if missing_roles:
 +        sys.exit(f"FATAL: no page given for role(s) {missing_roles}; every role "
 +                 "must be checked or the guard silently covers less than it claims")
 +
 +    K = json.load(open(args.json))
 +    Kc = json.load(open(args.json_corrected))
 +    out = open(args.output, encoding="utf8").read()
 +
 +    fails, counter = [], [0]
 +    for role, text in sorted(pages.items()):
 +        check(K, REQUIRED[role], text, f"{role}/uncorrected", fails, counter)
 +        check(Kc, CORRECTED[role], text, f"{role}/corrected", fails, counter)
 +    joined = "\n".join(pages.values())
 +
 +    # The committed output must still carry the sections the figures come from,
 +    # or the JSON above is from a different run than the published log.
 +    for needle in ("2. NAMED rows only", "2b. What the NAMED disagreements are made of",
 +                   "4. CURRENT vs REST", "7. Every disagreement"):
 +        counter[0] += 1
 +        if needle not in out:
 +            fails.append(f"the committed output has no section {needle!r}; "
 +                         "it is stale or the script was renamed")
 +
 +    # Figures that live only in the scripts' TEXT output, not in the kappa JSON.
 +    # A reviewer mutated each of these on the page and this guard survived every
 +    # one, because it only ever read the JSON. They are parsed out of the two
 +    # committed outputs so they are checked against the tool, not retyped here.
 +    kout = out
 +    vout = open(args.verify_output, encoding="utf8").read()
 +
 +    def need(label, value, where):
 +        counter[0] += 1
 +        if value not in where:
 +            fails.append(f"{label}: {value!r} is on no page")
 +
 +    # section 0: how many cells each rater scored `self-named`
 +    m = re.search(r"`self-named`[^\n]*rater1 (\d+) cells, rater2 (\d+) cells, "
 +                  r"both on the same cell (\d+)", kout)
 +    if not m:
 +        fails.append("estimator output has no `self-named` tally line")
 +    else:
 +        r1, r2, both = m.groups()
 +        # Bare digits are useless needles -- "6" is on every long page. These
 +        # are deliberately coupled to the sentence's shape, so rewording the
 +        # sentence breaks the guard instead of silently unchecking the figure.
 +        # Checked wherever the sentence SHAPE occurs, on every page, rather
 +        # than as a needle in the union: both pages carry this sentence, so
 +        # corrupting one copy left the other to satisfy a union check.
 +        counter[0] += 1
 +        seen = 0
 +        for role, text in sorted(pages.items()):
 +            for mm in re.finditer(r"(\d+) times in 105 cells", text):
 +                seen += 1
 +                if mm.group(1) != r2:
 +                    fails.append(f"{role}: page says {mm.group(1)} self-named "
 +                                 f"cells for rater 2; the script says {r2}")
 +        if not seen:
 +            fails.append("no page carries the self-named rater-2 count")
 +        need("self-named rater1", f"against rater 1's {r1}", joined)
 +        need("self-named shared", f"on {both} of them", joined)
 +
 +    # section 2b: the sourcing-vs-reading split, per list and pooled
 +    for line in kout.splitlines():
 +        m = re.match(r"\s+(webXray|Tracker Radar|Disconnect|POOLED)\s+n=\s*(\d+)\s+"
 +                     r"agreed=\s*(\d+)\s+differ-on-sourcing=\s*(\d+)\s+"
 +                     r"differ-on-reading=\s*(\d+)", line)
 +        if m:
 +            lab, n, ag, so, rd = m.groups()
 +            # Whole-row match, not four bare digits: "17" and "10" appear all
 +            # over these pages, so per-cell needles passed a mutated table.
 +            name = "Pooled cells" if lab == "POOLED" else lab
 +            row = (r"\|\s*\**" + re.escape(name) + r"\**\s*\|\s*\**" +
 +                   r"\**\s*\|\s*\**".join(re.escape(v) for v in (n, ag, so, rd)) +
 +                   r"\**\s*\|")
 +            counter[0] += 1
 +            if not re.search(row, joined):
 +                fails.append(f"split table row for {lab} "
 +                             f"({n}/{ag}/{so}/{rd}) is on no page")
 +    m = re.search(r"-> ([\d.]+)% of the (\d+) disagreeing cells", kout)
 +    if not m:
 +        fails.append("estimator output has no sourcing/reading headline line")
 +    else:
 +        need("sourcing share", m.group(1) + "%", joined)
 +
 +    # section 5: row-level resolution counts
 +    m = re.search(r"row-level `resolution`: rater1 unresolved (\d+)/(\d+), "
 +                  r"rater2 (\d+)/\d+, same call on (\d+)/", kout)
 +    if not m:
 +        fails.append("estimator output has no row-level resolution line")
 +    else:
 +        a, n, b, same = m.groups()
 +        need("resolution rater1", f"{a} of {n}", joined)
 +        need("resolution rater2", b, joined)
 +        need("resolution same call", same, joined)
 +
 +    # section 6: the unweighted current-share table, both raters, all three lists
 +    for line in kout.splitlines():
 +        m = re.match(r"\s+(webXray|Tracker Radar|Disconnect)\s+rater1 "
 +                     r"([\d.]+% \(\d+/\d+\))\s+rater2 ([\d.]+% \(\d+/\d+\))", line)
 +        if m:
 +            lab, a, b = m.groups()
 +            need(f"current-share {lab} rater1", a.replace(" (", " ("), joined)
 +            need(f"current-share {lab} rater2", b, joined)
 +
 +    # the citation tally, from owner_verify_sources.py's own output
 +    m = re.search(r"Verification of cited primary sources: (.+)$", vout, re.M)
 +    if not m:
 +        fails.append("verify output has no tally line")
 +    else:
 +        for part in m.group(1).split(", "):
 +            k_, v_ = part.split("=")
 +            need(f"citation tally {k_}", f"{k_} {v_}", joined)
 +
 +    # A three-decimal value in kappa context that neither JSON produces is a
 +    # figure nobody can source.
 +    produced = set()
 +    for src in (K, Kc):
 +        for v in src.values():
 +            if not v:
 +                continue
 +            produced.add(BARE(v["kappa"]))
 +            if v["lo"] is not None:
 +                produced |= {BARE(v["lo"]), BARE(v["hi"])}
 +    for m in re.finditer(r"kappa[^\n]{0,400}", joined, re.I):
 +        for num in re.findall(r"(?<![\d.])[-+]?(\d\.\d{3})(?![\d])", m.group(0)):
 +            counter[0] += 1
 +            if num not in produced:
 +                fails.append(f"page carries kappa-context value {num!r} that "
 +                             "neither JSON produces")
 +
 +    for f in fails:
 +        print("  FAIL " + f)
 +    print(f"checked {counter[0]} assertion(s) against {args.json} (+corrected) "
 +          f"over {len(pages)} page(s), each against its own claims; "
 +          f"{len(fails)} failure(s)")
 +    return 1 if fails else 0
 +
 +
 +if __name__ == "__main__":
 +    sys.exit(main())
 +</file>
 +
 +===== AE. scripts/check_irr_figures_mutations.sh — proving that guard bites =====
 +
 +Twelve corruptions, one figure each, across both pages. Two survived the first run and that is why the guard above is per-page.
 +
 +<file bash check_irr_figures_mutations.sh>
 +#!/bin/bash
 +# Mutation test for check_irr_figures.py.
 +#
 +# A guard that passes tells you nothing until you have seen it fail. This
 +# corrupts one inter-rater figure at a time -- on either page -- and asserts the
 +# guard rejects every corruption. If any mutation survives, the guard is not
 +# checking what its name claims and the exit code says so.
 +#
 +#   bash scripts/check_irr_figures_mutations.sh
 +set -u
 +PROV=pages/wxprov_irr.txt
 +PAGE=pages/wx_irr.txt
 +J=out/owner_irr_kappa.json
 +JC=out/owner_irr_kappa_corrected.json
 +OUT=out/owner_irr_kappa-output.txt
 +VOUT=out/owner_verify_sources_irr-output.txt
 +TMP=$(mktemp -d)
 +fails=0
 +
 +mutate () {                     # $1 = label, $2 = which page, $3 = sed expression
 +  local src tgt other
 +  if [ "$2" = prov ]; then src=$PROV; tgt="$TMP/prov.txt"; other=$PAGE; else src=$PAGE; tgt="$TMP/page.txt"; other=$PROV; fi
 +  sed "$3" "$src" > "$tgt"
 +  if cmp -s "$src" "$tgt"; then
 +    echo "SURVIVED  $1  <- mutation changed nothing; the guard was never exercised"
 +    fails=$((fails+1)); return
 +  fi
 +  # The mutated file keeps its own role, so the guard is asked whether THAT
 +  # page still matches its own claims. Joining the two pages and asking whether
 +  # a figure is on either is what let two of these mutations survive the first
 +  # version of this harness.
 +  if [ "$2" = prov ]; then roles=(--page "prov=$tgt" --page "content=$other")
 +  else roles=(--page "content=$tgt" --page "prov=$other"); fi
 +  if python3 scripts/check_irr_figures.py "${roles[@]}" \
 +       --json "$J" --json-corrected "$JC" --output "$OUT" \
 +       --verify-output "$VOUT" >/dev/null 2>&1; then
 +    echo "SURVIVED  $1  <- the guard accepted a corrupted page"
 +    fails=$((fails+1))
 +  else
 +    echo "caught    $1"
 +  fi
 +}
 +
 +mutate "prov: pooled NAMED kappa +0.400 -> +0.500"      prov 's/+0\.400/+0.500/g'
 +mutate "prov: webXray NAMED kappa +0.027 -> +0.270"     prov 's/+0\.027/+0.270/g'
 +mutate "prov: Tracker Radar NAMED kappa +0.478 deleted" prov 's/+0\.478/+0.479/g'
 +mutate "prov: NAMED interval upper +0.551 -> +0.552"    prov 's/+0\.551/+0.552/g'
 +mutate "prov: webXray NAMED n=12 -> n=13"               prov 's/n=12/n=13/g'
 +mutate "prov: pooled raw agreement 58.5% -> 59.5%"      prov 's/58\.5%/59.5%/g'
 +mutate "prov: ADJUDICABLE pooled +0.680 -> +0.690"      prov 's/+0\.680/+0.690/g'
 +mutate "prov: current-vs-rest pooled +0.708 -> +0.718"  prov 's/+0\.708/+0.718/g'
 +# The corrected-variant figures are currently EQUAL to the uncorrected ones,
 +# because owner_irr_corrections.py demotes nothing. Mutating a value that is no
 +# longer on the page is a no-op, and this harness reports a no-op mutation as a
 +# survivor rather than as a pass -- which is how this line was caught after the
 +# kameleoon.io demotion was reversed. These two target figures that are on the
 +# page today and are not covered by the kappa mutations above.
 +mutate "prov: adjudicable-pooled agreement 84.6% -> 85.6%" prov 's/84\.6%/85.6%/g'
 +mutate "prov: current-vs-rest agreement 87.2% -> 88.2%"    prov 's/87\.2%/88.2%/g'
 +mutate "prov: invent an unsourced kappa of 0.842"       prov 's/pooled kappa is$/pooled kappa is +0.842 or/'
 +# Figures that live only in the scripts' TEXT output. A reviewer mutated every
 +# one of these and the guard survived, because it only read the kappa JSON.
 +mutate "prov: self-named rater2 6 -> 9 cells"           prov 's/\*\*6 times in 105 cells\*\*/**9 times in 105 cells**/'
 +mutate "prov: split table sourcing 17 -> 20"            prov 's/| \*\*17\*\* | \*\*10\*\* |/| **20** | **7** |/'
 +mutate "prov: sourcing headline 63.0% -> 70.0%"         prov 's/63\.0%/70.0%/g'
 +mutate "prov: rater-1 unresolved 10 of 35 -> 13 of 35"  prov 's/\*\*10 of 35\*\*/**13 of 35**/'
 +mutate "prov: webXray current-share 70.0% (7\/10)"       prov 's|70\.0% (7/10)|90.0% (9/10)|'
 +mutate "prov: citation tally OK 30 -> OK 32"            prov 's/OK 30/OK 32/g'
 +mutate "page: pooled kappa +0.400 -> +0.410"            page 's/+0\.400/+0.410/g'
 +mutate "page: Disconnect NAMED kappa +0.460 -> +0.461"  page 's/+0\.460/+0.461/g'
 +
 +rm -rf "$TMP"
 +echo
 +if [ "$fails" -gt 0 ]; then
 +  echo "$fails mutation(s) survived: the guard does not check what it claims."
 +  exit 1
 +fi
 +echo "all mutations caught."
 +</file>
 +
 +===== AF. Unedited output: scripts/owner_irr_sample.py =====
 +
 +Run as ''python3 scripts/owner_irr_sample.py %%--sample%% out/owner_sample.json %%--batches%% out/adj %%--out%% out/irr %%--n%% 35''.
 +
 +<code>
 +Universe: 175 distinct domains adjudicated on 2026-09-05.
 +Seed 20260911 (separate from the 20260905 draw); simple random sample, n=35 (20.0%).
 +
 +  webXray       :  9 of the subsample are in this list's own 60-domain draw; 12 of 35 have an entry in the file
 +  Tracker Radar : 12 of the subsample are in this list's own 60-domain draw; 30 of 35 have an entry in the file
 +  Disconnect    : 15 of the subsample are in this list's own 60-domain draw; 23 of 35 have an entry in the file
 +  strata of origin (a domain drawn for two lists counts twice): Q1=11, Q2=6, Q3=14, Q4=5
 +
 +  batch00.txt: 7 domains -- accessibe.com, adgrx.com, amperwave.net, awltovhc.com, azure.com, britishairways.com, cedexis.com
 +  batch01.txt: 7 domains -- cedscdn.it, cnn.com, company-target.com, eyeota.net, forter.com, glomex.com, histats.com
 +  batch02.txt: 7 domains -- hitc.com, iqzone.com, juicer.cc, kameleoon.io, lijit.com, makaira.io, mapixl.com
 +  batch03.txt: 7 domains -- mediaset.es, mountain.com, myaccountaccess.com, owneriq.net, pages02.net, qbox.me, sa-as.com
 +  batch04.txt: 7 domains -- sharethis.com, sparkasse.de, stripst.com, tqlkg.com, tvtime.com, wishabi.com, wrbm.com
 +
 +Wrote out/irr/irr_sample_for_merge.json (union of 35, for owner_merge_rows.py)
 +Wrote out/irr/irr_subsample.json (5 batches) sha256:07208284bbd90fd6
 +Subsample: accessibe.com adgrx.com amperwave.net awltovhc.com azure.com britishairways.com cedexis.com cedscdn.it cnn.com company-target.com eyeota.net forter.com glomex.com histats.com hitc.com iqzone.com juicer.cc kameleoon.io lijit.com makaira.io mapixl.com mediaset.es mountain.com myaccountaccess.com owneriq.net pages02.net qbox.me sa-as.com sharethis.com sparkasse.de stripst.com tqlkg.com tvtime.com wishabi.com wrbm.com
 +</code>
 +
 +===== AG. Unedited output: scripts/owner_merge_rows.py on the second rater's rows =====
 +
 +Run as ''python3 scripts/owner_merge_rows.py %%--sample%% out/irr/irr_sample_for_merge.json %%--dir%% out/irr/adj2 %%--out%% out/irr/adj2_rows.json''.
 +
 +<code>
 +batches merged: 5, rows: 35
 +sampled domains: 35; missing rows: 0; rows for unsampled domains: 0
 +absent-verdicts corrected against the list files: 0
 +
 +wrote out/irr/adj2_rows.json
 +</code>
 +
 +===== AH. Unedited output: scripts/owner_verify_sources.py on the second rater's rows =====
 +
 +Run as ''python3 scripts/owner_verify_sources.py %%--rows%% out/irr/adj2_rows.json %%--cache%% out/irr/verify_cache %%--out%% out/irr/verify_irr.json''.
 +
 +<code>
 +OK         accessibe.com                https://accessibe.com/terms-of-service
 +OK         amperwave.net                https://audacyinc.com/press/audacy-announces-acquisition-of-wideorbit-digital-audio-stre
 +OK         awltovhc.com                 https://www.cj.com/legal/privacy
 +OK         azure.com                    tls://azure.com:443
 +OK         britishairways.com           tls://britishairways.com:443
 +OK         cedexis.com                  tls://cedexis.com:443
 +OK         cnn.com                      https://edition.cnn.com/
 +OK         company-target.com           https://support.demandbase.com/api/v2/help_center/en-us/articles/360019872411.json
 +OK         eyeota.net                   https://www.sec.gov/Archives/edgar/data/1799208/000179920825000012/exhibit211subsidiarie
 +OK         forter.com                   https://www.forter.com/privacy-policy/
 +OK         glomex.com                   https://www.glomex.com/de/impressum/
 +OK         histats.com                  https://www.histats.com/?act=103
 +OK         hitc.com                     https://www.hitc.com/
 +OK         iqzone.com                   https://iqzone.com/privacy-policy/
 +OK         juicer.cc                    https://juicer.cc/privacy/en/
 +OK-MARKUP  kameleoon.io                 https://www.kameleoon.com/legal-notice
 +OK         lijit.com                    https://lijit.com/
 +OK         makaira.io                   https://www.makaira.io/de/impressum
 +OK         mediaset.es                  https://www.boe.es/diario_borme/txt.php?id=BORME-C-2023-1010
 +OK         mountain.com                 https://www.sec.gov/Archives/edgar/data/1891027/000189102726000019/exhibit1052021optiona
 +OK         myaccountaccess.com          tls://www.myaccountaccess.com:443
 +OK         owneriq.net                  https://www.inmar.com/about/privacy-policy/owneriq
 +OK         pages02.net                  https://help.goacoustic.com/api/v2/help_center/articles/search.json?query=pages02
 +OK         qbox.me                      https://www.qiniu.com/agreements/privacy-right
 +OK         sa-as.com                    https://rdap.markmonitor.com/rdap/domain/SA-AS.COM
 +OK         sharethis.com                https://sharethis.com/privacy/
 +OK         sparkasse.de                 https://www.sparkasse.de/impressum.html
 +OK         tqlkg.com                    https://www.cj.com/legal/privacy
 +OK         tvtime.com                   https://whipmedia.com/news/whip-media-group-parent-to-tv-show-tracking-app-tv-time-raise
 +OK         wishabi.com                  https://corp.flipp.com/storefrontstos/
 +OK         wrbm.com                     http://wrbm.com/
 +
 +Verification of cited primary sources: FETCHFAIL=2, NOSOURCE=2, OK=30, OK-MARKUP=1
 +</code>
 +
 +===== AI. Unedited output: scripts/owner_irr_corrections.py =====
 +
 +Run as ''python3 scripts/owner_irr_corrections.py %%--rows%% out/irr/adj2_rows.json %%--out%% out/irr/adj2_rows_final.json''.
 +
 +<code>
 +KEEP    adgrx.com
 +        FETCHFAIL is an expired certificate on samsungads.ca (Let's Encrypt, notAfter 2026-03-12), not a bad citation: `curl -sk` returns the quoted sentence verbatim in 59,465 bytes. Row unchanged.
 +KEEP    cedscdn.it
 +        FETCHFAIL is an unsupported URL scheme: `whois://whois.nic.it/cedscdn.it`. A port-43 query to whois.nic.it returns the quoted line verbatim. Row unchanged; the brief should name a whois form the way it names tls://.
 +KEEP    kameleoon.io
 +        NOTFOUND in both instruments was a FALSE disqualification: the quote is a `<script src>` line, and both the bytes pass (which strips tags) and the browser pass (which reads innerText) are blind to markup by construction. The string is verbatim in the fetched bytes. Row unchanged; the verifier now reports OK-MARKUP instead.
 +
 +rows examined: 3; verdict cells changed: 0
 +wrote out/irr/adj2_rows_final.json
 +</code>
 +
 +===== AJ. Unedited output: scripts/owner_irr_kappa.py — every agreement figure and every disagreement =====
 +
 +Run as ''python3 scripts/owner_irr_kappa.py %%--r1%% out/adj_rows.json %%--r1-scored%% out/adj_rows_scored.json %%--r2%% out/irr/adj2_rows.json %%--subsample%% out/irr/irr_subsample.json %%--json-out%% out/owner_irr_kappa.json''.
 +
 +<code>
 +`absent` agrees on all 105 cells (it is a fact about the list file, not a judgement) -- the NAMED/ADJUDICABILITY selections are valid.
 +==============================================================================
 +INTER-RATER AGREEMENT, OWNERSHIP ADJUDICATION
 +==============================================================================
 +Subsample: 35 of 175 domains, seed 20260911 (parent draw seed 20260905).
 +Rater 1: 2026-09-05, Sonnet sub-agents (out/adj_rows.json).
 +Rater 2: 2026-09-11, Fable sub-agents (out/irr/adj2_rows.json).
 +Bootstrap: 10,000 resamples over domains, seed 20260911.
 +
 +------------------------------------------------------------------------------
 +0. Vocabulary each rater used on these 35 domains (105 cells per rater)
 +------------------------------------------------------------------------------
 +  webXray
 +    absent       rater1=23  rater2=23
 +    current      rater1= 4  rater2= 7
 +    error        rater1= 1  rater2= 1
 +    granularity  rater1= 1  rater2= 0
 +    self-named   rater1= 0  rater2= 2
 +    stale        rater1= 2  rater2= 2
 +    unknown      rater1= 4  rater2= 0
 +  Tracker Radar
 +    absent       rater1= 5  rater2= 5
 +    current      rater1=16  rater2=20
 +    error        rater1= 0  rater2= 1
 +    granularity  rater1= 1  rater2= 0
 +    stale        rater1= 7  rater2= 9
 +    unknown      rater1= 6  rater2= 0
 +  Disconnect
 +    absent       rater1=12  rater2=12
 +    current      rater1= 8  rater2=13
 +    granularity  rater1= 2  rater2= 4
 +    self-named   rater1= 4  rater2= 4
 +    unknown      rater1= 9  rater2= 2
 +  `self-named` (retired after sitting 1, still in the brief): rater1 4 cells, rater2 6 cells, both on the same cell 3
 +
 +------------------------------------------------------------------------------
 +1. ALL rows, raw vocabulary  (includes merge-script-forced `absent`)
 +------------------------------------------------------------------------------
 +  webXray         n=35  po=0.743  pe=0.459  kappa=+0.525  [+0.385, +0.703]
 +  Tracker Radar   n=35  po=0.743  pe=0.333  kappa=+0.614  [+0.407, +0.809]
 +  Disconnect      n=35  po=0.743  pe=0.237  kappa=+0.663  [+0.479, +0.837]
 +
 +------------------------------------------------------------------------------
 +2. NAMED rows only  (the list file names an owner: the rows the brief
 +   actually asks a rater to judge)  <-- HEADLINE
 +------------------------------------------------------------------------------
 +  webXray         n=12  po=0.250  pe=0.229  kappa=+0.027  [-0.200, +0.280]
 +  Tracker Radar   n=30  po=0.700  pe=0.426  kappa=+0.478  [+0.244, +0.706]
 +  Disconnect      n=23  po=0.609  pe=0.276  kappa=+0.460  [+0.193, +0.707]
 +  POOLED          n=65  po=0.585  pe=0.307  kappa=+0.400  [+0.241, +0.551]   (cells, not domains: the three lists' verdicts on one domain are not independent)
 +
 +------------------------------------------------------------------------------
 +2b. What the NAMED disagreements are made of
 +    An `unknown` against a verdict is a difference in what was SOURCED.
 +    A verdict against a different verdict is a difference in what was READ.
 +------------------------------------------------------------------------------
 +  webXray        n=12  agreed= 3  differ-on-sourcing= 4  differ-on-reading= 5
 +  Tracker Radar  n=30  agreed=21  differ-on-sourcing= 6  differ-on-reading= 3
 +  Disconnect     n=23  agreed=14  differ-on-sourcing= 7  differ-on-reading= 2
 +  POOLED         n=65  agreed=38  differ-on-sourcing=17  differ-on-reading=10
 +  -> 63.0% of the 27 disagreeing cells are one rater reaching `unknown` where the other reached a verdict.
 +
 +------------------------------------------------------------------------------
 +3. ADJUDICABLE rows  (both raters reached an ownership verdict)
 +------------------------------------------------------------------------------
 +  webXray         n= 6  po=0.500  pe=0.333  kappa=+0.250  [-0.111, +0.714]  (10 degenerate resamples dropped)
 +  Tracker Radar   n=24  po=0.875  pe=0.557  kappa=+0.718  [+0.357, +1.000]
 +  Disconnect      n= 9  po=1.000  pe=0.654  kappa=+1.000  [+1.000, +1.000]  (1030 degenerate resamples dropped)
 +  POOLED          n=39  po=0.846  pe=0.519  kappa=+0.680  [+0.433, +0.890]   (cells, not domains)
 +
 +------------------------------------------------------------------------------
 +4. CURRENT vs REST, on ADJUDICABLE rows  (the distinction every
 +   published accuracy figure rests on)
 +------------------------------------------------------------------------------
 +  webXray         n= 6  po=0.667  pe=0.444  kappa=+0.400  [+0.000, +1.000]  (20 degenerate resamples dropped)
 +  Tracker Radar   n=24  po=0.875  pe=0.569  kappa=+0.710  [+0.333, +1.000]
 +  Disconnect      n= 9  po=1.000  pe=0.654  kappa=+1.000  [+1.000, +1.000]  (1030 degenerate resamples dropped)
 +  POOLED          n=39  po=0.872  pe=0.561  kappa=+0.708  [+0.441, +0.930]   (cells, not domains)
 +
 +------------------------------------------------------------------------------
 +5. ADJUDICABILITY, on NAMED rows  (did the rater reach a verdict at all)
 +------------------------------------------------------------------------------
 +  webXray         n=12  po=0.500  pe=0.611  kappa=-0.286  [-0.588, +0.000]  (1 degenerate resamples dropped)   settled: rater1 8, rater2 10
 +  Tracker Radar   n=30  po=0.800  pe=0.800  kappa=+0.000  [+0.000, +0.000]  (9 degenerate resamples dropped)   settled: rater1 24, rater2 30
 +  Disconnect      n=23  po=0.609  pe=0.469  kappa=+0.263  [-0.042, +0.582]   settled: rater1 10, rater2 17
 +  row-level `resolution`: rater1 unresolved 10/35, rater2 2/35, same call on 27/35
 +
 +------------------------------------------------------------------------------
 +6. Unweighted `current` share on the subsample, per rater
 +   NOT the estimator: 35 rows with no stratum weights. It answers one
 +   question only -- would the second rater push the rate up or down.
 +------------------------------------------------------------------------------
 +  webXray        rater1 50.0% (4/8)         rater2 70.0% (7/10)
 +  Tracker Radar  rater1 66.7% (16/24)       rater2 66.7% (20/30)
 +  Disconnect     rater1 80.0% (8/10)        rater2 76.5% (13/17)
 +
 +------------------------------------------------------------------------------
 +7. Every disagreement, on NAMED rows
 +------------------------------------------------------------------------------
 +  adgrx.com
 +     owner_today  r1: None
 +                  r2: AdGear Technologies, Inc. (Samsung Ads), wholly-owned subsidiary of Samsung Electronics Canada, Inc.
 +     webXray        r1=unknown      r2=current
 +     Tracker Radar  r1=unknown      r2=current
 +     Disconnect     r1=unknown      r2=granularity
 +  awltovhc.com
 +     owner_today  r1: None
 +                  r2: Commission Junction LLC (CJ Affiliate), part of Publicis Groupe
 +     webXray        r1=unknown      r2=error
 +     Tracker Radar  r1=unknown      r2=stale
 +     Disconnect     r1=unknown      r2=granularity
 +  cedexis.com
 +     owner_today  r1: Cloud Software Group, Inc.
 +                  r2: Cloud Software Group, Inc. (Citrix brand; formerly Cedexis, Inc.)
 +     Tracker Radar  r1=stale        r2=current
 +  cedscdn.it
 +     owner_today  r1: None
 +                  r2: CED Digital & Servizi S.r.l.
 +     Tracker Radar  r1=unknown      r2=error
 +  company-target.com
 +     owner_today  r1: None
 +                  r2: Demandbase, Inc. (Demandbase)
 +     webXray        r1=unknown      r2=current
 +     Tracker Radar  r1=unknown      r2=current
 +     Disconnect     r1=unknown      r2=current
 +  eyeota.net
 +     owner_today  r1: Eyeota, a Dun & Bradstreet company
 +                  r2: Eyeota Pte Ltd (Eyeota), a subsidiary of Dun & Bradstreet Holdings, Inc.
 +     webXray        r1=current      r2=self-named
 +  forter.com
 +     owner_today  r1: Forter Ltd. (Israel; affiliates include Forter, Inc. — US, Forter Solutions UK Ltd., Forter Pte Ltd.)
 +                  r2: Forter Ltd. (Israel) with US affiliate Forter, Inc. (Forter)
 +     Disconnect     r1=current      r2=self-named
 +  histats.com
 +     owner_today  r1: Wisecode S.r.l. Unipersonale (Histats)
 +                  r2: Wisecode S.r.l. unipersonale (Histats)
 +     webXray        r1=current      r2=self-named
 +  iqzone.com
 +     owner_today  r1: IQzone Inc.
 +                  r2: IQzone Inc.
 +     Disconnect     r1=self-named   r2=current
 +  kameleoon.io
 +     owner_today  r1: None
 +                  r2: Kameleoon SAS (Kameleoon)
 +     Disconnect     r1=unknown      r2=current
 +  owneriq.net
 +     owner_today  r1: Inmar, Inc.
 +                  r2: Inmar-OIQ, LLC (ownerIQ), a subsidiary of Inmar, Inc.
 +     webXray        r1=stale        r2=current
 +  pages02.net
 +     owner_today  r1: None
 +                  r2: Acoustic, L.P. (Acoustic Campaign, formerly IBM Watson Campaign Automation / Silverpop)
 +     Tracker Radar  r1=unknown      r2=stale
 +     Disconnect     r1=unknown      r2=current
 +  sa-as.com
 +     owner_today  r1: None
 +                  r2: FoundryCo, Inc. (Foundry, formerly IDG Communications, Inc.)
 +     Disconnect     r1=unknown      r2=current
 +  sharethis.com
 +     owner_today  r1: Predactiv, Inc. (ShareThis)
 +                  r2: Predactiv, Inc. (ShareThis, a Predactiv Company; contact entity ShareThis, Inc.)
 +     webXray        r1=granularity  r2=current
 +     Tracker Radar  r1=granularity  r2=current
 +  tqlkg.com
 +     owner_today  r1: Conversant, LLC (Epsilon Data Management, LLC; ultimate parent Publicis Groupe S.A.)
 +                  r2: Commission Junction LLC (CJ Affiliate), part of Publicis Groupe
 +     webXray        r1=error        r2=stale
 +     Tracker Radar  r1=current      r2=stale
 +  wishabi.com
 +     owner_today  r1: None
 +                  r2: Flipp Corp. (Flipp; formerly Wishabi Inc.)
 +     webXray        r1=unknown      r2=current
 +     Tracker Radar  r1=unknown      r2=current
 +     Disconnect     r1=unknown      r2=current
 +  -> 16 of 35 domains carry at least one disagreement.
 +
 +------------------------------------------------------------------------------
 +8. Rater 2 against the PUBLISHED verdicts (rater 1 after the hand
 +   corrections of owner_corrections.py and owner_verdict_fixes.py).
 +   Asymmetric by construction -- rater 2 got no correction pass -- so
 +   this is a sensitivity figure, not an inter-rater figure.
 +------------------------------------------------------------------------------
 +  webXray         n=12  po=0.333  pe=0.236  kappa=+0.127  [-0.168, +0.406]
 +  Tracker Radar   n=30  po=0.700  pe=0.426  kappa=+0.478  [+0.244, +0.706]
 +  Disconnect      n=23  po=0.522  pe=0.344  kappa=+0.271  [+0.004, +0.537]
 +  (the correction pass moved 5 of 105 cells on this subsample)
 +
 +wrote out/owner_irr_kappa.json
 +</code>
 +
 +===== AK. Unedited output: scripts/check_irr_figures_mutations.sh =====
 +
 +Run as ''bash scripts/check_irr_figures_mutations.sh''.
 +
 +<code>
 +caught    prov: pooled NAMED kappa +0.400 -> +0.500
 +caught    prov: webXray NAMED kappa +0.027 -> +0.270
 +caught    prov: Tracker Radar NAMED kappa +0.478 deleted
 +caught    prov: NAMED interval upper +0.551 -> +0.552
 +caught    prov: webXray NAMED n=12 -> n=13
 +caught    prov: pooled raw agreement 58.5% -> 59.5%
 +caught    prov: ADJUDICABLE pooled +0.680 -> +0.690
 +caught    prov: current-vs-rest pooled +0.708 -> +0.718
 +caught    prov: adjudicable-pooled agreement 84.6% -> 85.6%
 +caught    prov: current-vs-rest agreement 87.2% -> 88.2%
 +caught    prov: invent an unsourced kappa of 0.842
 +caught    prov: self-named rater2 6 -> 9 cells
 +caught    prov: split table sourcing 17 -> 20
 +caught    prov: sourcing headline 63.0% -> 70.0%
 +caught    prov: rater-1 unresolved 10 of 35 -> 13 of 35
 +caught    prov: webXray current-share 70.0% (7\/10)
 +caught    prov: citation tally OK 30 -> OK 32
 +caught    page: pooled kappa +0.400 -> +0.410
 +caught    page: Disconnect NAMED kappa +0.460 -> +0.461
 +
 +all mutations caught.
 </code> </code>
  
Line 4278: Line 5805:
   * [[provenance:programming:crawler:webxray]] — the provenance page these blocks belong to: queries, denominators, corrections, limits, reviewers.   * [[provenance:programming:crawler:webxray]] — the provenance page these blocks belong to: queries, denominators, corrections, limits, reviewers.
   * [[programming:crawler:webxray]] — the content page the figures are on.   * [[programming:crawler:webxray]] — the content page the figures are on.
 +  * [[provenance:programming:crawler:webxray:residue_pass]] — the same treatment for the 2026-09-11 second pass over the 40 rows this sample could not settle.
   * [[literature:corpus]] — corpus scope, the selection funnel, extraction stability.   * [[literature:corpus]] — corpus scope, the selection funnel, extraction stability.
 +
 +===== Markup sweep, 2026-09-17 =====
 +
 +Mechanical rendering repair only: a fresh live raw/XHTML export of 188 pages was checked with ''check_wrap.mjs'' and ''check_typography.mjs''. Affected plugin tags, CLI flags and heading markup were repaired; no figures or substantive prose were changed. The resulting source and rendered DOM were re-checked after saving.
  
provenance/programming/crawler/webxray/random_sample.1788636914.txt.gz · Last modified: by karel.kubicek.claude