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

Both sides previous revisionPrevious revision
Next revision
Previous revision
provenance:programming:crawler:webxray:random_sample [2026/09/11 16:02] – Inter-rater re-adjudication (2026-09-11): add sections AA-AK (subsample draw, kappa estimator, citation corrections, per-page figures guard and its mutation harness, six unedited outputs); sections now emit in letter order; owner_verify_sources.py normali 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 7: Line 7:
 **inter-rater re-adjudication of 2026-09-11** (sections AA–AK), which measured **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. 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 17: Line 26:
 AF–AK their outputs. AF–AK their outputs.
  
-**Why this is a separate page.** These blocks are 271 kB, and the parent+**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 48: 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'' | 7,660 | ''fc14614b7ea49205'' | 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 61: 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 71: Line 80:
 | 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 | | AA | ''scripts/owner_irr_sample.py'' | 7,467 | ''d91c99eeaaf8c2c3'' | no |
-| AB | ''scripts/owner_irr_kappa.py'' | 14,767 | ''e88cee751a4d3cec'' | no | +| AB | ''scripts/owner_irr_kappa.py'' | 17,009 | ''71465e59c6aebe1d'' | no | 
-| AC | ''scripts/owner_irr_corrections.py'' | 4,856 | ''73791a3769f8f3be'' | no | +| AC | ''scripts/owner_irr_corrections.py'' | 6,067 | ''96f52c0778ae6f54'' | no | 
-| AD | ''scripts/check_irr_figures.py'' | 7,036 | ''de595feb61c18bcc'' | no | +| AD | ''scripts/check_irr_figures.py'' | 11,784 | ''1274e33732093cc3'' | no | 
-| AE | ''scripts/check_irr_figures_mutations.sh'' | 2,827 | ''08060c7e7cf0a2d7'' | no |+| AE | ''scripts/check_irr_figures_mutations.sh'' | 4,185 | ''6668eacfbe352477'' | no |
 | AF | ''out/irr_sample-output.txt'' | 1,736 | ''0901099a81e2c06f'' | no | | AF | ''out/irr_sample-output.txt'' | 1,736 | ''0901099a81e2c06f'' | no |
 | AG | ''out/irr/owner_merge_rows_irr-output.txt'' | 178 | ''9babdfba6f3125bb'' | no | | AG | ''out/irr/owner_merge_rows_irr-output.txt'' | 178 | ''9babdfba6f3125bb'' | no |
-| AH | ''out/owner_verify_sources_irr-output.txt'' | 2,706 | ''8ed04b136819980e'' | no | +| AH | ''out/owner_verify_sources_irr-output.txt'' | 2,707 | ''2535d8331b36304e'' | no | 
-| AI | ''out/owner_irr_corrections-output.txt'' | 836 | ''fd42f159cde87ca7'' | no | +| AI | ''out/owner_irr_corrections-output.txt'' | 893 | ''02ca68994c4983f3'' | no | 
-| AJ | ''out/owner_irr_kappa-output.txt'' | 10,415 | ''45186af3c0a69249'' | no | +| AJ | ''out/owner_irr_kappa-output.txt'' | 10,550 | ''14a0d749f0686da5'' | no | 
-| AK | ''out/check_irr_figures_mutations-output.txt'' | 649 | ''a82a561b1bc84079'' | 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 552: 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 563: 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 575: 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
Line 593: Line 612:
     s = re.sub(r"[^a-z0-9\u00c0-\uffff]+", " ", s)     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 613: 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 664: 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 671: 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 677: 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 767: 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 785: 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 855: 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 907: 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 2138: 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 2192: 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 2198: 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 2299: 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 4505: Line 4645:
   ALL          all 35 rows, raw vocabulary.  Includes the forced `absent` cells.   ALL          all 35 rows, raw vocabulary.  Includes the forced `absent` cells.
                Published only so a reader can see how much they inflate kappa.                Published only so a reader can see how much they inflate kappa.
-  NAMED        rows where the list file names an owner, i.e. neither rater is +  NAMED        rows where the list file names an owner.  `absent` is re-derived 
-               forced to `absent`.  This is the population the brief actually +               from the list file by `owner_merge_rows.py` rather than judged, so 
-               asks a rater to judge.+               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   ADJUDICABLE  NAMED rows where **both** raters reached an ownership verdict
                (current / stale / granularity / error).  A row one rater could                (current / stale / granularity / error).  A row one rater could
Line 4530: Line 4676:
     kappa = (po - pe) / (1 - pe),  pe = sum_c p1(c) * p2(c)     kappa = (po - pe) / (1 - pe),  pe = sum_c p1(c) * p2(c)
  
-Intervals are bootstrap percentiles over **domains** (10,000 resamples), not the+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 normal approximation: at n = 12-35 with several empty cells the asymptotic
 variance is not usable.  A resample in which the denominator collapses (every variance is not usable.  A resample in which the denominator collapses (every
Line 4559: Line 4709:
     po = sum(1 for a, b in pairs if a == b) / n     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)     m1, m2 = Counter(a for a, _ in pairs), Counter(b for _, b in pairs)
-    pe = sum(m1[c] / n * m2[c] / n for c in set(m1) | set(m2))+    # 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:     if abs(1 - pe) < 1e-12:
         return {"n": n, "po": po, "pe": pe, "kappa": None}         return {"n": n, "po": po, "pe": pe, "kappa": None}
Line 4597: Line 4751:
         s += f"  ({deg} degenerate resamples dropped)"         s += f"  ({deg} degenerate resamples dropped)"
     return s     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.")
  
  
Line 4617: Line 4790:
     if missing:     if missing:
         raise SystemExit(f"FATAL: rows missing for {missing}")         raise SystemExit(f"FATAL: rows missing for {missing}")
 +    assert_absent_agrees(doms, R1, R2)
  
     print("=" * 78)     print("=" * 78)
Line 4861: Line 5035:
                evidence exists and says what the row claims. The row stands and                evidence exists and says what the row claims. The row stands and
                the gap is the brief's.                the gap is the brief's.
-  kameleoon.io NOTFOUND in bytes and NOTFOUND in the browser. The quote is a +  kameleoon.io NOTFOUND in bytes and NOTFOUND in the browser -- and the citation 
-               ``<script src>`` line the rater says is on +               is sound. **This row was demoted and the demotion was wrong**, and 
-               ``kameleoon.com/legal-notice``; it is not on that page in either +               it is recorded here rather than quietly reversed. The quote is a 
-               instrument. **Demoted to unresolved**, which is the rule the +               ``<script src>`` line, and BOTH instruments are structurally blind 
-               first sitting wrote and applied to ``cnevids.com``.+               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.
  
-Demoting changes one (domain, list) verdict, so the inter-rater figures are +**No row is demoted.** All three machine failures turned out to be limits of the 
-reported both ways and the difference is stated rather than absorbed.+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 import argparse, json, sys
Line 4882: Line 5065:
         "A port-43 query to whois.nic.it returns the quoted line verbatim. Row "         "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://."),         "unchanged; the brief should name a whois form the way it names tls://."),
-    "kameleoon.io": ("demote", +    "kameleoon.io": ("keep", 
-        "NOTFOUND in bytes and NOTFOUND in a rendered browser. Demoted to " +        "NOTFOUND in both instruments was a FALSE disqualification: the quote is " 
-        "unresolved under the first sitting's rule; its `disconnect` verdict " +        "a `<script src>` line, and both the bytes pass (which strips tags) and " 
-        "becomes unknown, and webXray/Tracker Radar stay absent because absent is " +        "the browser pass (which reads innerText) are blind to markup by " 
-        "a fact about the file."),+        "construction. The string is verbatim in the fetched bytes. Row unchanged; " 
 +        "the verifier now reports OK-MARKUP instead."),
 } }
  
Line 4929: Line 5113:
     print()     print()
     print(f"rows examined: {len(CORRECTIONS)}; verdict cells changed: {changed}")     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)     json.dump(rows, open(args.out, "w"), indent=1)
     print(f"wrote {args.out}")     print(f"wrote {args.out}")
Line 5052: Line 5242:
     ap.add_argument("--json-corrected", required=True)     ap.add_argument("--json-corrected", required=True)
     ap.add_argument("--output", 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()     args = ap.parse_args()
  
Line 5086: Line 5280:
             fails.append(f"the committed output has no section {needle!r}; "             fails.append(f"the committed output has no section {needle!r}; "
                          "it is stale or the script was renamed")                          "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     # A three-decimal value in kappa context that neither JSON produces is a
Line 5136: Line 5420:
 JC=out/owner_irr_kappa_corrected.json JC=out/owner_irr_kappa_corrected.json
 OUT=out/owner_irr_kappa-output.txt OUT=out/owner_irr_kappa-output.txt
 +VOUT=out/owner_verify_sources_irr-output.txt
 TMP=$(mktemp -d) TMP=$(mktemp -d)
 fails=0 fails=0
Line 5154: Line 5439:
   else roles=(--page "content=$tgt" --page "prov=$other"); fi   else roles=(--page "content=$tgt" --page "prov=$other"); fi
   if python3 scripts/check_irr_figures.py "${roles[@]}" \   if python3 scripts/check_irr_figures.py "${roles[@]}" \
-       --json "$J" --json-corrected "$JC" --output "$OUT" >/dev/null 2>&1; then+       --json "$J" --json-corrected "$JC" --output "$OUT" \ 
 +       --verify-output "$VOUT" >/dev/null 2>&1; then
     echo "SURVIVED  $1  <- the guard accepted a corrupted page"     echo "SURVIVED  $1  <- the guard accepted a corrupted page"
     fails=$((fails+1))     fails=$((fails+1))
Line 5170: Line 5456:
 mutate "prov: ADJUDICABLE pooled +0.680 -> +0.690"      prov 's/+0\.680/+0.690/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' mutate "prov: current-vs-rest pooled +0.708 -> +0.718"  prov 's/+0\.708/+0.718/g'
-mutate "prov: corrected Disconnect +0.518 -> +0.519"    prov 's/+0\.518/+0.519/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/' 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: 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' mutate "page: Disconnect NAMED kappa +0.460 -> +0.461"  page 's/+0\.460/+0.461/g'
Line 5186: Line 5487:
 ===== AF. Unedited output: scripts/owner_irr_sample.py ===== ===== 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''.+Run as ''python3 scripts/owner_irr_sample.py %%--sample%% out/owner_sample.json %%--batches%% out/adj %%--out%% out/irr %%--n%% 35''.
  
 <code> <code>
Line 5210: Line 5511:
 ===== AG. Unedited output: scripts/owner_merge_rows.py on the second rater's rows ===== ===== 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''.+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> <code>
Line 5222: Line 5523:
 ===== AH. Unedited output: scripts/owner_verify_sources.py on the second rater's rows ===== ===== 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''.+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> <code>
Line 5240: Line 5541:
 OK         iqzone.com                   https://iqzone.com/privacy-policy/ OK         iqzone.com                   https://iqzone.com/privacy-policy/
 OK         juicer.cc                    https://juicer.cc/privacy/en/ OK         juicer.cc                    https://juicer.cc/privacy/en/
-NOTFOUND   kameleoon.io                 https://www.kameleoon.com/legal-notice+OK-MARKUP  kameleoon.io                 https://www.kameleoon.com/legal-notice
 OK         lijit.com                    https://lijit.com/ OK         lijit.com                    https://lijit.com/
 OK         makaira.io                   https://www.makaira.io/de/impressum OK         makaira.io                   https://www.makaira.io/de/impressum
Line 5257: Line 5558:
 OK         wrbm.com                     http://wrbm.com/ OK         wrbm.com                     http://wrbm.com/
  
-Verification of cited primary sources: FETCHFAIL=2, NOSOURCE=2, NOTFOUND=1, OK=30+Verification of cited primary sources: FETCHFAIL=2, NOSOURCE=2, OK=30, OK-MARKUP=1
 </code> </code>
  
 ===== AI. Unedited output: scripts/owner_irr_corrections.py ===== ===== 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''.+Run as ''python3 scripts/owner_irr_corrections.py %%--rows%% out/irr/adj2_rows.json %%--out%% out/irr/adj2_rows_final.json''.
  
 <code> <code>
Line 5269: Line 5570:
 KEEP    cedscdn.it 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://.         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://.
-DEMOTE  kameleoon.io +KEEP    kameleoon.io 
-        NOTFOUND in bytes and NOTFOUND in a rendered browser. Demoted to unresolved under the first sitting's rule; its `disconnect` verdict becomes unknown, and webXray/Tracker Radar stay absent because absent is a fact about the file. +        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.
-        disconnect: current -> unknown+
  
-rows examined: 3; verdict cells changed: 1+rows examined: 3; verdict cells changed: 0
 wrote out/irr/adj2_rows_final.json wrote out/irr/adj2_rows_final.json
 </code> </code>
Line 5279: Line 5579:
 ===== AJ. Unedited output: scripts/owner_irr_kappa.py — every agreement figure and every disagreement ===== ===== 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''.+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> <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 INTER-RATER AGREEMENT, OWNERSHIP ADJUDICATION
Line 5484: Line 5785:
 caught    prov: ADJUDICABLE pooled +0.680 -> +0.690 caught    prov: ADJUDICABLE pooled +0.680 -> +0.690
 caught    prov: current-vs-rest pooled +0.708 -> +0.718 caught    prov: current-vs-rest pooled +0.708 -> +0.718
-caught    prov: corrected Disconnect +0.518 -> +0.519+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: 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: pooled kappa +0.400 -> +0.410
 caught    page: Disconnect NAMED kappa +0.460 -> +0.461 caught    page: Disconnect NAMED kappa +0.460 -> +0.461
Line 5497: 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.1789142548.txt.gz · Last modified: by karel.kubicek.claude