iris-check 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this package, please cite it."
3
+ title: "IRIS Research Package: checks on language-model evaluation outputs before and after a run"
4
+ authors:
5
+ - name: "Heuremen LLC"
6
+ version: 1.0.0
7
+ date-released: "2026-11-01"
8
+ url: "https://indahl.ai/iris/package"
9
+ repository-code: "https://github.com/Heuremen-LLC/indahl.ai"
10
+ keywords: [language models, evaluation, benchmarks, LLM-as-judge, reproducibility, parser check, repeat floor]
@@ -0,0 +1,35 @@
1
+ # How this package was tested
2
+
3
+ Heuremen LLC · https://indahl.ai/iris
4
+
5
+ This page says what was run, on what, and what came out. It also says what was not tested.
6
+
7
+ ## The unit tests
8
+
9
+ `python tests/test_iris_check.py` runs 42 tests on a synthetic dataset in `tests/data/` where the answers are planted. The planted answers are the flips between repeats, the discordant items between two systems, and the parser errors. The tests check that those answers come back, and they also cover the exact test against a Fraction-based reference, a 1,000,000-item comparison, the row accounting, the tally rules, the CSV handling and the exit codes. The tests passed on Python 3.9, 3.12 and 3.14 on macOS.
10
+
11
+ ## A real-data test
12
+
13
+ We ran the package on our own two-machine evaluation round of 29 and 30 October 2026. That round ran FaultsBench on five open models, on a Mac and on a CUDA machine, with two same-machine repeats of each (the bare run and its exact copy). We turned the saved replies into one line per item with an id and a correct field, using the scorer from that round. Items the scorer could not read have a null correct field. The ids were joined with the model name, so the five models sit in one file per run.
14
+
15
+ The repeat command gave these flip counts. The two same-machine pairs were run with `--repeats`, and the two across-machine pairs were run without it, because they are not repeats of one setup. Each pair is compared on the items both runs hold.
16
+
17
+ - Mac repeats: 1 flip, on 393 matched items
18
+ - CUDA repeats: 4 flips, on 393 matched items
19
+ - Mac against CUDA, bare runs: 11 flips, on 391 matched items
20
+ - Mac against CUDA, copy runs: 10 flips, on 391 matched items
21
+
22
+ These equal the counts we published from separate code, the script `item_flips.py` and its output `item_flips.txt`, which are in the repository at https://github.com/Heuremen-LLC/indahl.ai/tree/main/code/iris/pub2/seats_compare and give flips 1, 4, 11 and 10 on the 391 items that all four runs hold. The two same-machine pairs hold 393 items each, and the 2 extra items have no flips. Both programs were written by us. So the agreement shows that the two implementations agree. It is not a check by anyone else.
23
+
24
+ The comparison command (`--vs`) on the Mac bare run and the CUDA bare run read CANNOT_TELL at the default margin of 0.02, with an exact two-sided McNemar p of 0.2266. The published value is 0.227.
25
+
26
+ ## A reading sheet on real replies
27
+
28
+ We wrote a reading sheet from real replies of the same round and a reader read 30 of them in full beside what the parser had extracted. 29 of the 30 extractions were correct. The one that was not was an item the scorer excludes by design.
29
+
30
+ ## What was not tested
31
+
32
+ - Output formats of other harnesses, such as lm-evaluation-harness and HELM. The package reads a JSONL file with the fields you name, and nothing more was tried.
33
+ - Files of more than 100,000 items on real data. The speed test with 1,000,000 items uses synthetic data.
34
+ - Windows and Linux. The tests ran on macOS.
35
+ - `parser-check/replycheck.py` runs a model, and only its `--help` and its argument handling are tested here.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.5
2
+ Name: iris-check
3
+ Version: 1.0.0
4
+ Summary: Checks on language-model evaluation outputs before you trust them: reading sheets, flips between repeats and paired comparison. From Heuremen LLC. Standard library only.
5
+ Project-URL: Homepage, https://indahl.ai/iris
6
+ Project-URL: Documentation, https://indahl.ai/iris/package
7
+ Project-URL: Source, https://github.com/Heuremen-LLC/indahl.ai
8
+ Author: Heuremen LLC
9
+ License: Heuremen LLC
10
+ https://indahl.ai/iris
11
+
12
+ Code
13
+ Heuremen LLC permits anyone to run, copy and change the code in this package, for any purpose, with attribution to Heuremen LLC and https://indahl.ai/iris
14
+ The code is provided as it is, without warranty of any kind.
15
+
16
+ Text and data
17
+ The text and data in this package (the README, TESTED.md and any example or test data) are free to download, read and cite with attribution (Heuremen LLC, with a link to https://indahl.ai/iris).
18
+ All rights are reserved otherwise. Text and data mining, and use of this text or data to train, fine-tune or build AI models, are not permitted.
19
+ This is an express reservation of rights, also made in machine-readable form at https://indahl.ai/robots.txt and https://indahl.ai/.well-known/tdmrep.json.
20
+ Policy: https://indahl.ai/terms#ai-training
21
+
22
+ Your results
23
+ Your files and results remain yours.
24
+ License-File: LICENSE
25
+ Keywords: LLM evaluation,McNemar,benchmark,bootstrap,reproducibility
26
+ Classifier: Environment :: Console
27
+ Classifier: Intended Audience :: Science/Research
28
+ Classifier: License :: Other/Proprietary License
29
+ Classifier: Operating System :: OS Independent
30
+ Classifier: Programming Language :: Python :: 3
31
+ Requires-Python: >=3.9
32
+ Description-Content-Type: text/markdown
33
+
34
+ # iris-check
35
+
36
+ Heuremen LLC · https://indahl.ai/iris
37
+
38
+ Tools to check the numbers from a language-model evaluation before you trust them and after a run. This applies to benchmark scores, LLM-as-judge scores and fine-tuning studies. IRIS recounts published evaluation studies from their own released output files, and this package puts the same checks in your hands for your own outputs. Your files are opened read-only and never changed. Reports go to a separate folder, and the checker refuses a folder inside the folder that holds an input, so run it from outside your data folder. The checker sends nothing anywhere. Two parts need other software or the network when you run them: `parser-check/replycheck.py` runs a model on your machine, and installing the Faults and Guards package from PyPI downloads it. Python 3.9 or later, standard library only.
39
+
40
+ ## Install
41
+
42
+ ```
43
+ pip install iris-check
44
+ ```
45
+
46
+ The tool is the command `iris-check`. It is also importable as the module `iris_check`.
47
+
48
+ ## Before a run
49
+
50
+ 1. Read the Guards before the work. Install the package with `pip install faultsguards`, then run `python -m faultsguards guards`
51
+ 2. Decide how each answer will be extracted from a reply and write that rule down before any reply exists.
52
+ 3. If you fine-tune or compare systems, plan at least two runs of the same items with the same settings. The difference between those two runs is the repeat floor, and it is the first thing any later difference is read against.
53
+ 4. Give the plan a check block. The check block and its packs for language-model evaluations (L) and public copy (P) are in the full package at https://indahl.ai/iris/package
54
+
55
+ ## If your file holds raw replies only
56
+
57
+ `iris-check repeat` needs a correctness field for each item, so score first. A few lines of Python are enough. This one reads a JSONL file of replies and a dictionary of gold answers, and writes the `{"id", "correct"}` lines the checker reads. Replace the comparison with your own extraction rule. Write `"correct": null` for an item you did not score, and it is counted as unscored and kept out of the drop warning.
58
+
59
+ ```
60
+ import json
61
+ gold = {"q1": "B", "q2": "17"} # item id -> the right answer
62
+ with open("replies.jsonl") as f, open("scored.jsonl", "w") as out:
63
+ for line in f:
64
+ r = json.loads(line)
65
+ correct = r["reply"].strip().split()[-1] == gold[r["id"]] # your own extraction and comparison
66
+ out.write(json.dumps({"id": r["id"], "correct": correct}) + "\n")
67
+ ```
68
+
69
+ ## After a run
70
+
71
+ 1. Save the outputs of each run as a JSONL file with one reply per line. Each line needs an item id, the reply, what your parser extracted and whether it was scored correct. Keep these files in one folder and write reports to a different folder.
72
+ 2. Read a sample of replies beside what your parser extracted:
73
+ `iris-check sample run1.jsonl --reply-field reply --extracted-field pred --label-field family --id-field id -n 10 --seed 1`
74
+ This writes a reading sheet as HTML and CSV with 10 random replies per label. Read each reply. In the CSV, write x in the `read` column for every reply you read. In the `misparsed` column write x, bad, 1, wrong or misparsed where the extracted value is not the answer the reply gives. A parse that is fine takes ok, fine, 0, no, n, false or a blank. Any other entry is an error that names the row. That includes yes and y, which could mean either. Keep the `sample_....json` record beside the CSV. Then run `iris-check sample --tally SHEET.csv`, which prints the result for each label and writes its report to `./heuremen_report_tally` unless you give `--out`. The record is found by the sheet's file name, the same name ending in `.json`, so keep the two files together and do not rename one. If the record has another name or place, add `--record sample_....json`. The CSV is written as UTF-8 with a byte order mark so Excel shows accents. If you save it again from Excel, choose "CSV UTF-8". A file saved in the Windows code page is refused with that instruction.
75
+ 3. The tally rule, exactly: a label reads "cannot tell (rows missing)" if the sheet holds fewer rows for it than the sample record says. Otherwise it reads "cannot tell (not read)" if any of its rows is unread. Otherwise it reads "cannot tell (parser)" when more than 10% of its read rows are mis-parsed, so 1 of 10 is fine, 2 of 10 is not, and 1 of 9 is not. Otherwise the rate can be read. The default sample is 10 replies per label.
76
+ 4. Reply text that starts with `=`, `+`, `-` or `@` gets a single quote in front of it in the CSV, so a spreadsheet does not run it as a formula. The `--extracted-field` should hold the extracted answer itself, not a scorer's verdict such as OK or FAULT, because the reader compares it with the reply. Items your scorer excludes by design should be set to JSON null in the extracted field, or left out of the file before you sample, so the reader is not shown them as parse failures. Rows where the reply or extracted field is absent or JSON null, blank lines, and rows with a missing or null label (shown as `(none)`) are counted on the console and in the JSON report. The tally takes the quote off again.
77
+ 5. Count the flips between repeats of the same items:
78
+ `iris-check repeat run1.jsonl run2.jsonl --id-field id --correct-field correct`
79
+ Item ids are turned into strings first, so 2, 2.0 and "2" are one id. Each pair of runs is compared on the items both runs hold. For every pair the output first gives the flip count, as `flips N: right->wrong a, wrong->right b, of n matched`, and then the share of matched items that flip. The share is called "flip share between these runs". It is called the "repeat floor" only when you add `--repeats`, which says that the runs are repeats of the same setup (same model, same settings, same machine). The net change alone hides flips that cancel. With three or more runs there is no combined figure, you get one row per pair of runs.
80
+ One file should hold one model, or the model should be part of the id. If a file pools several models whose item ids repeat, give every field that makes an item unique, for example `--id-field model id`, and the fields are joined into one id. When repeated ids leave no matched items, the output says so in plain words and names this fix. The command exits with status 1 and says why whenever a comparison matches no items.
81
+ 6. The report lists, for each run, how many rows were left out and why, with up to 5 example ids. Each row is counted once, under the first reason that applies, in this order: id missing, correctness field missing, correctness field explicitly null (unscored), correctness value not accepted, duplicate id. Unscored rows (null correct) are reported on their own and are not counted as errors in the 5% warning. A duplicate id leaves out every row that holds it, and duplicate rows and duplicate ids are counted separately. The console prints rows read = usable + the reasons. The correctness value is accepted as true or false, as the numbers 1 and 0 (1.0 and 0.0 too), or as text in any case: true, t, yes, y, correct, 1 or false, f, no, n, incorrect, 0. The report also lists the items present in one run of a pair and not the other. If more than 5% of a run is left out of a comparison, a warning is printed on the console and shown at the top of the report. It always names the count of ids not in the other run, next to the in-file reasons.
82
+ 7. Compare two systems on the same items:
83
+ `iris-check repeat a_run1.jsonl a_run2.jsonl --vs b_run1.jsonl b_run2.jsonl --id-field id --correct-field correct --margin 0.02`
84
+ Run k of A is paired with run k of B, in the order given on the command line. Each pair gets an exact two-sided McNemar test on the discordant items and a bootstrap 95% interval for the accuracy difference A minus B. The number of matched items and the discordant counts are always printed. Both the interval and the McNemar p are used, in this order. ABOVE if the low end of the interval is above 0 and p is below 0.05. BELOW if the high end is below 0 and p is below 0.05. If only one of the two says different, CANNOT_TELL. Then CONSISTENT if the whole interval is within plus or minus the margin, but only with at least 20 matched items and at least 1 discordant item. Otherwise CANNOT_TELL. Because ABOVE and BELOW are checked before CONSISTENT, a small difference that is resolved reads ABOVE or BELOW even when it is inside the margin. Above 5000 matched items the interval uses count draws (two binomial draws per resample) instead of resampling items one by one. It is approximate and can differ slightly by Python version, because before Python 3.12 it uses a normal approximation. A p below 1e-300 is printed as <1e-300.
85
+ 8. Read your write-up against the Faults: `python -m faultsguards faults`
86
+ 9. Give each reading a check block before it goes in a paper or a page. The check block is in the full package.
87
+
88
+ ## Test the checker
89
+
90
+ The source distribution holds `tests/` with a synthetic dataset where the answers are planted. These are the flips between repeats, the discordant items between systems and the parser errors. Unpack the source distribution and run `python tests/make_synthetic.py` and then `python tests/test_iris_check.py`, with `iris_check.py` and `tests/replycheck.py` copied into the folder that holds `tests`. How the package was tested is in TESTED.md, installed under `share/doc/iris-check/`.
91
+
92
+ ## The rest of the IRIS package
93
+
94
+ The full package adds the Faults and Guards package, the check block, FaultsBench and the parser-check script. It is at https://indahl.ai/iris/package
95
+
96
+ ## Links
97
+
98
+ - IRIS: https://indahl.ai/iris
99
+ - List of studies: https://indahl.ai/iris/list
100
+ - Key: https://indahl.ai/iris/key
101
+ - Package page: https://indahl.ai/iris/package
102
+ - Code: https://github.com/Heuremen-LLC/indahl.ai
103
+ - Faults and Guards on PyPI: https://pypi.org/project/faultsguards/
104
+ - Data and models: https://huggingface.co/indahl
105
+
106
+ ## Terms
107
+
108
+ Heuremen LLC permits anyone to run, copy and change the code in this package, for any purpose, with attribution to Heuremen LLC and https://indahl.ai/iris
109
+
110
+ Your results remain yours. The text and data follow LICENSE, and their use to train, fine-tune or build AI models is not permitted. The Faults and Guards package and its PDFs keep their own terms. LICENSE covers only the files in this package.
@@ -0,0 +1,8 @@
1
+ iris_check.py,sha256=e4Z6NNoKmYgZAsvhDIwtD-uKXMDPeLORxl4Y7gB9l60,37189
2
+ iris_check-1.0.0.data/data/share/doc/iris-check/CITATION.cff,sha256=ua_t8fa9ngEiBUlIPLVWYO17m1RTkGTjI4RYIVrbfXs,457
3
+ iris_check-1.0.0.data/data/share/doc/iris-check/TESTED.md,sha256=lqO1EeDOBTBVpeJteIwkSrB93W07CzUN25xmJA15PHo,3112
4
+ iris_check-1.0.0.dist-info/METADATA,sha256=ITwt8Dxn1T3PYGw0TAdrN4xXxmYm1R-VOLbB037fv8g,12123
5
+ iris_check-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
6
+ iris_check-1.0.0.dist-info/entry_points.txt,sha256=oK5fRUe_-5rWtnAtbNUkhazX5Uqb90HitFYLufDE-K4,47
7
+ iris_check-1.0.0.dist-info/licenses/LICENSE,sha256=vX849lM5DSKlSkl99_i5CXVhf7DipnJAdykvbM-CJFM,864
8
+ iris_check-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ iris-check = iris_check:main
@@ -0,0 +1,15 @@
1
+ Heuremen LLC
2
+ https://indahl.ai/iris
3
+
4
+ Code
5
+ Heuremen LLC permits anyone to run, copy and change the code in this package, for any purpose, with attribution to Heuremen LLC and https://indahl.ai/iris
6
+ The code is provided as it is, without warranty of any kind.
7
+
8
+ Text and data
9
+ The text and data in this package (the README, TESTED.md and any example or test data) are free to download, read and cite with attribution (Heuremen LLC, with a link to https://indahl.ai/iris).
10
+ All rights are reserved otherwise. Text and data mining, and use of this text or data to train, fine-tune or build AI models, are not permitted.
11
+ This is an express reservation of rights, also made in machine-readable form at https://indahl.ai/robots.txt and https://indahl.ai/.well-known/tdmrep.json.
12
+ Policy: https://indahl.ai/terms#ai-training
13
+
14
+ Your results
15
+ Your files and results remain yours.
iris_check.py ADDED
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env python3
2
+ """iris_check.py -- IRIS Research Package, Heuremen LLC (https://indahl.ai/iris). Standard library only. Python 3.9 or later.
3
+
4
+ Checks that run on your own released model outputs (a JSONL file with one reply per line), before you trust a rate and when you compare two
5
+ runs. Your files are opened read-only and are never changed. Their sha256 is taken before and after and printed in the report. Reports go to a
6
+ separate folder (default ./heuremen_report/) that may not be inside a folder that holds an input, so run from outside your data folder.
7
+ It sends nothing anywhere.
8
+
9
+ SAMPLE python iris_check.py sample RUN.jsonl --reply-field reply --extracted-field pred [--label-field family] [--id-field id] [-n 10] [--seed 1]
10
+ Writes a reading sheet (HTML and CSV): n random replies per label, each beside what your parser extracted. Read each reply, write x
11
+ in the read column, and write x in the misparsed column where the extracted value is not the answer the reply gives (parse fine: ok,
12
+ fine, 0, no, n, false or blank. Mis-parsed: x, bad, 1, wrong, misparsed. Anything else, including yes and y, is an error that names the
13
+ row). The sample also writes a record (sample_*.json) beside the sheet. Then run
14
+ python iris_check.py sample --tally SHEET.csv [--record sample_*.json]
15
+ Rule: a label reads "cannot tell (rows missing)" if rows were deleted from the sheet (checked against the record), "cannot tell (not
16
+ read)" if any row is unread, otherwise "cannot tell (parser)" when more than 10% of its read rows are mis-parsed (1 of 10 is fine,
17
+ 2 of 10 is not, 1 of 9 is not), otherwise "rate can be read".
18
+
19
+ REPEAT python iris_check.py repeat RUN1.jsonl RUN2.jsonl [RUN3.jsonl ...] --id-field id --correct-field correct
20
+ python iris_check.py repeat A1.jsonl A2.jsonl --vs B1.jsonl B2.jsonl --id-field id --correct-field correct [--margin 0.02]
21
+ Item ids are turned into strings first (2 and 2.0 and "2" are one id). Each pair of runs is compared on the items both hold.
22
+ For every pair of runs of one system it counts the per-item flips in each direction (right to wrong, wrong to right), prints the flip
23
+ COUNT first ("flips N: right->wrong a, wrong->right b, of n matched") and then the share of matched items that flip. The share is called
24
+ the "repeat floor" only when you mark the runs of each system as repeats of the same setup with --repeats (same model, same settings, same
25
+ machine). Without --repeats it is called "flip share between these runs". There is one row per pair and no combined figure over three or
26
+ more runs. Give several names to --id-field to join them into one id (--id-field model id), for example when one file pools several models.
27
+ With --vs, run k of A (command-line order) is paired with run k of B. Each pair gets an exact two-sided McNemar test on the discordant
28
+ items and a seeded bootstrap 95% interval (2000 resamples) for the accuracy difference A minus B. Outcome, in this order:
29
+ ABOVE if the interval's low end is above 0 and the McNemar p is below 0.05. BELOW if the high end is below 0 and p is below 0.05.
30
+ If only one of the two says different, CANNOT_TELL. Then CONSISTENT if the whole interval is within plus or minus the margin
31
+ (default 0.02), but only with at least 20 matched items and at least 1 discordant item. Otherwise CANNOT_TELL.
32
+ ABOVE and BELOW are checked before CONSISTENT, so a resolved difference inside the margin reads ABOVE or BELOW.
33
+ The correct field holds true or false, the numbers 1 and 0 (also 1.0 and 0.0), or text (any case) true, t, yes, y, correct, 1 or false, f,
34
+ no, n, incorrect, 0. Anything else leaves the row out and is counted. A correct field that is explicitly null means the item was not scored:
35
+ it is counted as "unscored (null correct)", reported, and kept out of the 5% drop warning. Every row is counted once under one reason
36
+ (missing id, missing correct field, unscored, not a boolean, duplicate id), and rows read = usable + the reasons. The command exits with
37
+ status 1 and says why when a comparison matches no items. A duplicate id leaves out every row that
38
+ holds it, and duplicate rows and duplicate ids are counted separately.
39
+ Above 5000 matched items the bootstrap interval uses count draws instead of resampling items one by one. It is approximate, and it can differ
40
+ slightly by Python version (a normal approximation before Python 3.12).
41
+
42
+ The checks read the files you give them only. They make no claim about your results.
43
+ Heuremen LLC permits anyone to run, copy and change this code, for any purpose, with attribution to Heuremen LLC and https://indahl.ai/iris. See LICENSE.txt."""
44
+ import json, sys, os, re, csv, hashlib, argparse, html, random, itertools, math, datetime as dt
45
+ from math import comb
46
+ UTC = dt.timezone.utc
47
+ NAME = 'IRIS Research Package'; ORG = 'Heuremen LLC'; SITE = 'https://indahl.ai/iris'
48
+ LOGO = ('<svg viewBox="0 0 200 200" width="44" height="44" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="9">'
49
+ '<polygon points="100,30 180,170 20,170"/><polygon points="100,170 139,84 61,84"/><circle cx="100" cy="112" r="5" fill="currentColor"/></svg>')
50
+ TRUE = {'true', '1', 'yes', 'correct', 't', 'y'}; FALSE = {'false', '0', 'no', 'incorrect', 'f', 'n'}
51
+ FINE = {'ok', 'fine', '0', 'no', 'n', 'false'}; BAD = {'x', 'bad', '1', 'wrong', 'misparsed'}
52
+ TRIG = '=+-@\t\r'; ESC = re.compile(r"^'*[=+\-@\t\r]")
53
+ MIN_ITEMS = 20; WARN_DROP = 0.05; EXACT_UP_TO = 2000
54
+
55
+ def sha(p):
56
+ h = hashlib.sha256()
57
+ with open(p, 'rb') as f:
58
+ for b in iter(lambda: f.read(1 << 20), b''): h.update(b)
59
+ return h.hexdigest()
60
+ def die(msg): sys.exit('iris_check: ' + msg)
61
+ def dig(row, path):
62
+ cur = row
63
+ for k in path.split('.'):
64
+ if not isinstance(cur, dict) or k not in cur: return None, False
65
+ cur = cur[k]
66
+ return cur, True
67
+ def text(v): return v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)
68
+ def norm_id(v):
69
+ """ids become strings first: 2, 2.0 and "2" are one id"""
70
+ if isinstance(v, bool): return str(v)
71
+ if isinstance(v, int): return str(v)
72
+ if isinstance(v, float): return str(int(v)) if v.is_integer() else repr(v)
73
+ return v if isinstance(v, str) else json.dumps(v, ensure_ascii=False, sort_keys=True)
74
+ def read_jsonl(p):
75
+ rows, blank = [], 0
76
+ with open(p, 'r', encoding='utf-8') as f:
77
+ for n, line in enumerate(f, 1):
78
+ if not line.strip(): blank += 1; continue
79
+ try: r = json.loads(line)
80
+ except json.JSONDecodeError: die(f'{p} line {n} is not JSON')
81
+ if not isinstance(r, dict): die(f'{p} line {n} is not a JSON object')
82
+ rows.append((n, r))
83
+ if not rows: die(p + ' has no rows')
84
+ return rows, blank
85
+ def guard_out(out, inputs):
86
+ if os.path.exists(out) and not os.path.isdir(out): die(f'--out ({out}) is a file. Give --out a folder.')
87
+ od = os.path.realpath(out)
88
+ for p in inputs:
89
+ d = os.path.dirname(os.path.realpath(p))
90
+ if od == d or od.startswith(d + os.sep): die(f'--out ({out}) is inside the folder of an input ({d}). Run from outside the data folder or give --out a folder outside it.')
91
+ def safe_cell(v):
92
+ """spreadsheet programs run a cell that starts with = + - @ tab or CR as a formula, so a single quote goes in front (also in front of a value that already
93
+ starts with quotes and then one of those characters, so the step can be undone exactly)"""
94
+ v = '' if v is None else str(v)
95
+ return "'" + v if ESC.match(v) else v
96
+ def unsafe_cell(v):
97
+ return v[1:] if ESC.match(v) and v[:1] == "'" else v
98
+
99
+ def page(title, meta, sections, inputs, warnings=()):
100
+ css = ('body{font:16px/1.5 Georgia,serif;background:#f6efe2;color:#1c1712;margin:0}main{max-width:900px;margin:0 auto;padding:24px 16px}'
101
+ 'header{display:flex;gap:12px;align-items:center;border-bottom:2px solid #c9a227;padding-bottom:12px}header b{font-size:22px}'
102
+ 'table{border-collapse:collapse;width:100%;font-size:14px}td,th{border-bottom:1px solid #d8ccb4;padding:4px 6px;text-align:left;vertical-align:top}'
103
+ 'td.r{white-space:pre-wrap;word-break:break-word}.note{color:#5a4d3a;font-size:14px}.warn{border:2px solid #9a2a1a;background:#f9e3dc;padding:8px 12px;font-weight:bold}'
104
+ 'footer{margin-top:28px;border-top:1px solid #d8ccb4;padding-top:10px;font-size:13px;color:#5a4d3a}')
105
+ h = [f'<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{html.escape(title)}</title><style>{css}</style><main>',
106
+ f'<header>{LOGO}<div><b>{NAME}</b><br><span class="note">{ORG} &middot; <a href="{SITE}">{SITE}</a></span></div></header>', f'<h1>{html.escape(title)}</h1>']
107
+ h += [f'<p class="warn">WARNING: {html.escape(w)}</p>' for w in warnings]
108
+ h += [f'<p class="note">{html.escape(m)}</p>' for m in meta]
109
+ for st, rows, cols in sections:
110
+ h.append(f'<h2>{html.escape(st)}</h2>')
111
+ if not rows: h.append('<p>Nothing to show.</p>'); continue
112
+ h.append('<table><tr>' + ''.join(f'<th>{html.escape(c)}</th>' for c in cols) + '</tr>' + ''.join(
113
+ '<tr>' + ''.join(f'<td class="{"r" if c in ("reply", "extracted") else ""}">{html.escape(str(r.get(c, "")))}</td>' for c in cols) + '</tr>' for r in rows) + '</table>')
114
+ h.append('<footer>Your files were opened read-only and not changed. sha256 before and after:<br>' + '<br>'.join(
115
+ f'{html.escape(os.path.basename(p))}: {a[:16]}&hellip; / {b[:16]}&hellip; {"unchanged" if a == b else "CHANGED"}' for p, a, b in inputs) +
116
+ f'<br>These checks read the files you gave them only and make no claim about your results. {ORG}, {SITE}</footer></main></html>')
117
+ return '\n'.join(h)
118
+ def write(out, stem, title, meta, sections, inputs, data, csv_rows=None, csv_cols=None, warnings=()):
119
+ os.makedirs(out, exist_ok=True); t = dt.datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ'); paths = {}
120
+ paths['html'] = os.path.join(out, f'{stem}_{t}.html')
121
+ with open(paths['html'], 'w', encoding='utf-8') as f: f.write(page(title, meta, sections, inputs, warnings))
122
+ paths['json'] = os.path.join(out, f'{stem}_{t}.json')
123
+ with open(paths['json'], 'w', encoding='utf-8') as f:
124
+ json.dump(dict(package=NAME, by=ORG, site=SITE, title=title, meta=meta, warnings=list(warnings), inputs=[dict(file=os.path.basename(p), sha256_before=a, sha256_after=b) for p, a, b in inputs], **data), f, indent=1, default=str)
125
+ if csv_rows is not None:
126
+ paths['csv'] = os.path.join(out, f'{stem}_{t}.csv')
127
+ with open(paths['csv'], 'w', encoding='utf-8-sig', newline='') as f:
128
+ w = csv.DictWriter(f, fieldnames=csv_cols); w.writeheader(); w.writerows([{k: safe_cell(r.get(k, '')) for k in csv_cols} for r in csv_rows])
129
+ return paths
130
+
131
+ # ---------- sample ----------
132
+ RULE = ('Rule: a label reads "cannot tell (rows missing)" if rows were deleted from the sheet (checked against the sample record). Otherwise it reads "cannot tell (not read)" '
133
+ 'if any of its rows is unread. Otherwise it reads "cannot tell (parser)" when more than 10% of its read rows are mis-parsed (1 of 10 is fine, 2 of 10 is not, 1 of 9 is not). '
134
+ 'Otherwise the rate can be read.')
135
+ def verdict(cell, where):
136
+ v = (cell or '').strip().lower()
137
+ if v == '' or v in FINE: return False
138
+ if v in BAD: return True
139
+ die(f'{where}: the misparsed cell holds {cell!r}. Use x, bad, 1, wrong or misparsed for a mis-parsed reply, and ok, fine, 0, no, n, false or blank for a parse that is fine. yes and y are not accepted because they could mean either.')
140
+ def tally_rows(rows, expect=None):
141
+ by = {}
142
+ for n, r in rows:
143
+ where = f'sheet row {n} (label {r["label"]!r}, id {r["id"]!r})'; rd = (r.get('read') or '').strip().lower()
144
+ if expect is not None and r['label'] not in expect: die(f'{where}: this label is not in the sample record, so the sheet was changed after it was written.')
145
+ if rd not in ('', 'x'): die(f'{where}: the read cell holds {r.get("read")!r}. Write x when the reply was read, or leave it blank.')
146
+ bad = verdict(r.get('misparsed'), where)
147
+ if rd == '' and (r.get('misparsed') or '').strip(): die(f'{where}: the misparsed cell is filled but the read cell is blank. Write x in read.')
148
+ g = by.setdefault(r['label'], dict(label=r['label'], expected_rows='' if expect is None else expect[r['label']]['replies_sampled'], rows=0, read=0, unread=0, misparsed=0, ids=[])); g['rows'] += 1; g['ids'].append(r['id'])
149
+ if rd == 'x': g['read'] += 1; g['misparsed'] += bad
150
+ else: g['unread'] += 1
151
+ if expect is not None:
152
+ for lab, e in expect.items():
153
+ if lab not in by: by[lab] = dict(label=lab, expected_rows=e['replies_sampled'], rows=0, read=0, unread=0, misparsed=0, ids=[])
154
+ res = []
155
+ for g in by.values():
156
+ miss = []
157
+ if g['expected_rows'] != '':
158
+ if g['rows'] > g['expected_rows']: die(f"label {g['label']!r}: the sheet holds {g['rows']} rows and the sample record says {g['expected_rows']}, so rows were added or copied.")
159
+ if g['rows'] < g['expected_rows']: miss = [i for i in expect[g['label']].get('ids', []) if i not in set(g['ids'])][:5]
160
+ g['rows_missing'] = (g['expected_rows'] - g['rows']) if g['expected_rows'] != '' else 0
161
+ g['reading'] = ('cannot tell (rows missing)' if g['rows_missing'] else 'cannot tell (not read)' if g['unread'] or not g['read'] else
162
+ 'cannot tell (parser)' if g['misparsed'] * 10 > g['read'] else 'rate can be read')
163
+ g['missing_ids_examples'] = ', '.join(miss); del g['ids']; res.append(g)
164
+ return res
165
+ def read_sheet(path):
166
+ try:
167
+ with open(path, 'r', encoding='utf-8-sig', newline='') as f: rows = list(enumerate(csv.DictReader(f), 2))
168
+ except UnicodeDecodeError:
169
+ die(f'{path} is not UTF-8 text (it was probably saved by Excel in the Windows code page, which breaks accents). In Excel choose Save As and then "CSV UTF-8 (Comma delimited)", and run the tally again.')
170
+ if not rows or not {'label', 'id', 'read', 'misparsed'} <= set(rows[0][1]): die(path + ' is not a reading sheet written by the sample command (needs label, id, read, misparsed columns)')
171
+ for n, r in rows: r['label'] = unsafe_cell(r['label'] or ''); r['id'] = unsafe_cell(r['id'] or '')
172
+ return rows
173
+ def cmd_sample(a):
174
+ if a.tally:
175
+ a.out = a.out or 'heuremen_report_tally' # the sheet itself sits in heuremen_report, so the tally goes beside it, not inside it
176
+ if a.run: die('give either RUN.jsonl or --tally SHEET.csv, not both')
177
+ guard_out(a.out, [a.tally]); h0 = sha(a.tally); rows = read_sheet(a.tally)
178
+ rec = a.record or os.path.splitext(a.tally)[0] + '.json'; expect = None
179
+ if os.path.isfile(rec):
180
+ try:
181
+ with open(rec, encoding='utf-8') as f: expect = {l['label']: l for l in json.load(f)['labels']}
182
+ except (ValueError, KeyError, TypeError): die(rec + ' is not a sample record written by the sample command')
183
+ elif a.record: die(rec + ' does not exist')
184
+ res = tally_rows(rows, expect)
185
+ meta = [RULE, 'A blank in the misparsed column of a read row counts as a parse that is fine.'] + (
186
+ [f'Row counts checked against the sample record {os.path.basename(rec)}.'] if expect is not None else
187
+ ['No sample record was found beside the sheet, so deleted rows could not be checked. Give --record sample_....json from the same sample run.'])
188
+ inputs = [(a.tally, h0, sha(a.tally))] + ([(rec, sha(rec), sha(rec))] if expect is not None else [])
189
+ paths = write(a.out, 'tally', 'Parser check tally', meta, [('Per label', res, ['label', 'expected_rows', 'rows', 'read', 'unread', 'misparsed', 'reading', 'missing_ids_examples'])], inputs, dict(tally=res, record_used=expect is not None))
190
+ for r in res: print(f"{r['label']}: {r['read']} of {r['rows']} rows read, {r['unread']} unread, {r['misparsed']} mis-parsed" + (f", {r['rows_missing']} rows missing" if r['rows_missing'] else '') + f" -> {r['reading']}")
191
+ if expect is None: print('NOTE: no sample record found beside the sheet, so deleted rows were not checked (--record sample_....json)', file=sys.stderr)
192
+ print(NAME, '|', ORG, '|', SITE); print('report:', paths['html']); print('data: ', paths['json']); return
193
+ a.out = a.out or 'heuremen_report'
194
+ if not a.run or not a.reply_field or not a.extracted_field: die('sample needs RUN.jsonl, --reply-field and --extracted-field (or --tally SHEET.csv)')
195
+ if a.n < 1: die('-n must be 1 or more')
196
+ guard_out(a.out, [a.run]); h0 = sha(a.run); rows, blank = read_jsonl(a.run); groups = {}; missing = 0; nulls = 0
197
+ for ln, r in rows:
198
+ rep, ok1 = dig(r, a.reply_field); ext, ok2 = dig(r, a.extracted_field)
199
+ if not (ok1 and ok2) or rep is None or ext is None: missing += 1; continue # a field that is absent or JSON null is missing
200
+ if a.label_field:
201
+ lv, okl = dig(r, a.label_field)
202
+ if not okl or lv is None or (isinstance(lv, str) and not lv.strip()): lab = '(none)'; nulls += 1
203
+ else: lab = text(lv)
204
+ else: lab = 'all'
205
+ iv, oki = dig(r, a.id_field) if a.id_field else (None, False)
206
+ rid = text(iv) if oki and iv is not None else f'line {ln}'
207
+ groups.setdefault(lab, []).append(dict(label=lab, id=rid, reply=text(rep), extracted=text(ext)))
208
+ skipped = dict(rows_read=len(rows), blank_lines=blank, rows_skipped_missing_field=missing, rows_with_null_label_shown_as_none=nulls)
209
+ if not groups: die(f'no row has both fields {a.reply_field!r} and {a.extracted_field!r}')
210
+ sheet, sections, summary = [], [], []
211
+ for lab in sorted(groups):
212
+ g = groups[lab]; pick = random.Random(f'{a.seed}:{lab}').sample(g, min(a.n, len(g)))
213
+ for r in pick: r['read'] = ''; r['misparsed'] = ''
214
+ sheet += pick; summary.append(dict(label=lab, replies_in_run=len(g), replies_sampled=len(pick), ids=[r['id'] for r in pick]))
215
+ sections.append((f'{lab}: {len(pick)} of {len(g)} replies', pick, ['id', 'reply', 'extracted', 'read', 'misparsed']))
216
+ meta = [f'{a.run}: seed {a.seed}, up to {a.n} replies per label.', RULE,
217
+ 'Read each reply and compare it with the extracted value. In the CSV, write x in the read column for every reply you read, and write x in the misparsed column where the extracted value is not the answer the reply gives. Then run the sample command with --tally and the CSV. Keep the sample_....json file beside the CSV, because the tally checks the row counts against it.',
218
+ f'Rows read {len(rows)}, blank lines {blank}, rows skipped for a missing field (absent or JSON null) {missing}, rows with a null or missing label shown as (none) {nulls}.']
219
+ cols = ['label', 'id', 'reply', 'extracted', 'read', 'misparsed']
220
+ paths = write(a.out, 'sample', 'Reading sheet: replies beside what the parser extracted', meta, [('Labels', [{k: v for k, v in x.items() if k != 'ids'} for x in summary], ['label', 'replies_in_run', 'replies_sampled'])] + sections,
221
+ [(a.run, h0, sha(a.run))], dict(seed=a.seed, n=a.n, labels=summary, skipped=skipped), csv_rows=sheet, csv_cols=cols)
222
+ print(NAME, '|', ORG, '|', SITE); print('rows read', len(rows), '| blank lines', blank, '| skipped for a missing field', missing, '| null labels shown as (none)', nulls)
223
+ print('sheet:', paths['html']); print('csv: ', paths['csv']); print('data: ', paths['json'])
224
+
225
+ # ---------- repeat ----------
226
+ def to_bool(v):
227
+ """true/false, the numbers 1 and 0 (also 1.0 and 0.0), or text true t yes y correct 1 / false f no n incorrect 0 in any case. Anything else is None."""
228
+ if isinstance(v, bool): return v
229
+ if isinstance(v, (int, float)): return True if v == 1 else False if v == 0 else None
230
+ if not isinstance(v, str): return None
231
+ s = v.strip().lower()
232
+ return True if s in TRUE else False if s in FALSE else None
233
+ REASONS = ('missing_id_field', 'missing_correct_field', 'unscored_null_correct', 'non_boolean_correct')
234
+ def load_run(p, idf, cf):
235
+ """returns the run: ok = {id: bool} for the usable items. idf is one field name or a list of names joined into one id. Every row is counted once under one reason,
236
+ in this order: missing id, missing correct field, unscored (correct is null), not a boolean, duplicate id. Rows read = usable + the reasons."""
237
+ idf = [idf] if isinstance(idf, str) else list(idf)
238
+ rows, blank = read_jsonl(p); why = {k: [] for k in REASONS}; cand = {}; normalised = 0
239
+ for ln, r in rows:
240
+ parts = [dig(r, f) for f in idf]
241
+ if any((not ok) or v is None for v, ok in parts): why['missing_id_field'].append(f'line {ln}'); continue
242
+ normalised += sum(not isinstance(v, str) for v, _ in parts)
243
+ k = '|'.join(norm_id(v) for v, _ in parts); c, ok2 = dig(r, cf)
244
+ if not ok2: why['missing_correct_field'].append(k); continue
245
+ if c is None: why['unscored_null_correct'].append(k); continue
246
+ b = to_bool(c)
247
+ if b is None: why['non_boolean_correct'].append(k); continue
248
+ cand.setdefault(k, []).append(b)
249
+ ok = {k: v[0] for k, v in cand.items() if len(v) == 1} # an id on more than one row is ambiguous, so every row that holds it is left out
250
+ dup_ids = sorted(k for k, v in cand.items() if len(v) > 1); dup_rows = sum(len(cand[k]) for k in dup_ids); why['duplicate_id'] = dup_ids
251
+ assert len(rows) == len(ok) + sum(len(why[k]) for k in REASONS) + dup_rows
252
+ return dict(path=p, name=os.path.basename(p), ok=ok, rows=len(rows), blank=blank, normalised=normalised, why=why, duplicate_ids=len(dup_ids), duplicate_rows=dup_rows, unscored=len(why['unscored_null_correct']))
253
+ def run_row(r):
254
+ w = r['why']; ex = ' | '.join(f'{k}: {", ".join(v[:5])}' for k, v in w.items() if v)
255
+ d = dict(run=r['name'], rows_read=r['rows'], usable_items=len(r['ok']), missing_id_field=len(w['missing_id_field']), missing_correct_field=len(w['missing_correct_field']),
256
+ unscored_null_correct=len(w['unscored_null_correct']), non_boolean_correct=len(w['non_boolean_correct']), duplicate_rows=r['duplicate_rows'], duplicate_ids=r['duplicate_ids'],
257
+ blank_lines=r['blank'], ids_normalised_to_text=r['normalised'], examples=ex)
258
+ d['ties_out'] = d['rows_read'] == d['usable_items'] + d['missing_id_field'] + d['missing_correct_field'] + d['unscored_null_correct'] + d['non_boolean_correct'] + d['duplicate_rows']
259
+ return d
260
+ def mcnemar_exact(b, c):
261
+ """exact two-sided sign test on b and c discordant items: min(1, 2 * P(X <= min(b, c))), X ~ Binomial(b + c, 1/2)"""
262
+ n = b + c
263
+ if n == 0: return 1.0
264
+ k = min(b, c)
265
+ if n <= EXACT_UP_TO: return min(1.0, 2 * sum(comb(n, i) for i in range(k + 1)) / 2 ** n)
266
+ # large n: the term at k from lgamma, then the tail downward by the ratio t(i-1) = t(i) * i / (n - i + 1), which falls fast, so it stops early
267
+ lt = math.lgamma(n + 1) - math.lgamma(k + 1) - math.lgamma(n - k + 1) - n * math.log(2)
268
+ if lt < -700: return 0.0 # below 1e-300
269
+ t = math.exp(lt); tot = t; i = k
270
+ while i > 0 and t > 1e-18 * tot: t *= i / (n - i + 1); i -= 1; tot += t
271
+ return min(1.0, 2 * tot)
272
+ def _binom(rng, n, p):
273
+ if n <= 0 or p <= 0: return 0
274
+ if p >= 1: return n
275
+ if hasattr(rng, 'binomialvariate'): return rng.binomialvariate(n, p)
276
+ return min(n, max(0, int(round(rng.gauss(n * p, math.sqrt(n * p * (1 - p))))))) # Python before 3.12, and only for more than 5000 items, where this is very close
277
+ def pair_ids(ra, rb):
278
+ ids = sorted(set(ra['ok']) & set(rb['ok'])); out = {}
279
+ for tag, x, y in (('a', ra, rb), ('b', rb, ra)):
280
+ miss = sorted(set(x['ok']) - set(y['ok'])); out['left_out_' + tag] = len(miss); out['examples_' + tag] = miss[:5]
281
+ scorable = x['rows'] - x['unscored'] # rows explicitly null for correct are unscored by design, so they do not count toward the 5% warning
282
+ out['dropped_share_' + tag] = 1 - len(ids) / scorable if scorable else 0.0
283
+ return ids, out
284
+ def ci_bounds(sorted_diffs):
285
+ """2.5% and 97.5% points of the sorted bootstrap differences: with 2000 resamples these are indexes 49 and 1949"""
286
+ B = len(sorted_diffs); return sorted_diffs[max(0, int(round(0.025 * B)) - 1)], sorted_diffs[min(B - 1, int(round(0.975 * B)) - 1)]
287
+ def shown_p(p): return '<1e-300' if p < 1e-300 else float(f'{p:.6g}')
288
+ def compare(x, y, ids, margin, boots, seed):
289
+ n = len(ids); xa = [x[i] for i in ids]; ya = [y[i] for i in ids]
290
+ b = sum(p and not q for p, q in zip(xa, ya)); c = sum(q and not p for p, q in zip(xa, ya)); d = (b - c) / n if n else 0.0
291
+ rng = random.Random(seed); diffs = []
292
+ if n == 0: lo = hi = 0.0
293
+ else:
294
+ if n <= 5000:
295
+ cw = [b / n, (b + c) / n, 1.0]
296
+ for _ in range(boots): diffs.append(sum(rng.choices((1, -1, 0), cum_weights=cw, k=n)) / n)
297
+ else: # resampling n items with replacement gives (nb, nc) as a multinomial draw, taken as two binomial draws
298
+ pb = b / n; pc = (c / n) / (1 - pb) if pb < 1 else 0.0
299
+ for _ in range(boots): nb = _binom(rng, n, pb); diffs.append((nb - _binom(rng, n - nb, min(1.0, pc))) / n)
300
+ diffs.sort(); lo, hi = ci_bounds(diffs)
301
+ method = 'items resampled' if n <= 5000 else ('count draws' if hasattr(rng, 'binomialvariate') else 'count draws, normal approximation'); p = mcnemar_exact(b, c); excl = lo > 0 or hi < 0
302
+ if lo > 0 and p < 0.05: out, why = 'ABOVE', 'the interval is above 0 and p is below 0.05'
303
+ elif hi < 0 and p < 0.05: out, why = 'BELOW', 'the interval is below 0 and p is below 0.05'
304
+ elif excl or p < 0.05: out, why = 'CANNOT_TELL', 'the interval and the McNemar test disagree'
305
+ elif n < MIN_ITEMS: out, why = 'CANNOT_TELL', f'fewer than {MIN_ITEMS} matched items'
306
+ elif b + c == 0: out, why = 'CANNOT_TELL', 'no discordant items'
307
+ elif lo >= -margin and hi <= margin: out, why = 'CONSISTENT', f'the whole interval is within plus or minus {margin}'
308
+ else: out, why = 'CANNOT_TELL', f'the interval reaches beyond plus or minus {margin}'
309
+ return dict(n_matched=n, acc_A=round(sum(xa) / n, 4) if n else None, acc_B=round(sum(ya) / n, 4) if n else None, A_right_B_wrong=b, A_wrong_B_right=c, discordant=b + c,
310
+ diff_A_minus_B=round(d, 4), ci95_low=round(lo, 4), ci95_high=round(hi, 4), mcnemar_exact_p=shown_p(p), margin=margin, outcome=out, reason=why, bootstrap=method)
311
+ def flips(runs, repeats=False):
312
+ rows = []
313
+ for ra, rb in itertools.combinations(runs, 2):
314
+ ids, info = pair_ids(ra, rb); rw = sum(ra['ok'][k] and not rb['ok'][k] for k in ids); wr = sum(rb['ok'][k] and not ra['ok'][k] for k in ids)
315
+ share = round((rw + wr) / len(ids), 4) if ids else None
316
+ row = dict(run_a=ra['name'], run_b=rb['name'], n_matched=len(ids), flips=rw + wr, right_to_wrong=rw, wrong_to_right=wr, net=wr - rw, flip_share=share)
317
+ if repeats: row['repeat_floor'] = share # the share is the repeat floor only for runs the user marked as repeats of the same setup
318
+ row.update(left_out_a=info['left_out_a'], left_out_b=info['left_out_b'], _info=info, _ra=ra, _rb=rb); rows.append(row)
319
+ return rows
320
+ def drop_warnings(rows):
321
+ """a warning for every run in a comparison that loses more than 5% of its rows, from any reason"""
322
+ w = []
323
+ for r in rows:
324
+ for tag, run in (('a', r['_ra']), ('b', r['_rb'])):
325
+ sh = r['_info']['dropped_share_' + tag]
326
+ if sh > WARN_DROP:
327
+ parts = [f'{k.replace("_", " ")}: {n}' for k, n in (('missing_id_field', len(run['why']['missing_id_field'])), ('missing_correct_field', len(run['why']['missing_correct_field'])),
328
+ ('non_boolean_correct', len(run['why']['non_boolean_correct'])), ('duplicate_rows', run['duplicate_rows'])) if n]
329
+ parts.append(f"ids not in the other run: {r['_info']['left_out_' + tag]}") # always listed
330
+ un = f" {run['unscored']} rows with a null correct field are unscored, reported separately and not counted here." if run['unscored'] else ''
331
+ w.append(f"{run['name']} loses {sh:.1%} of its {run['rows'] - run['unscored']} scored rows in the comparison with {(r['_rb'] if tag == 'a' else r['_ra'])['name']} ({r['n_matched']} matched). Left out: {', '.join(parts)}.{un}")
332
+ return w
333
+ def cmd_repeat(a):
334
+ a.out = a.out or 'heuremen_report'; A = a.runs; B = a.vs or []; SHARE = 'repeat_floor' if a.repeats else 'flip_share'; SHARE_NAME = 'repeat floor' if a.repeats else 'flip share between these runs'
335
+ if not B and len(A) < 2: die('give two or more runs, or one or more runs of A and --vs runs of B')
336
+ allp = A + B; guard_out(a.out, allp)
337
+ if len(set(os.path.realpath(p) for p in allp)) != len(allp): die('the same file was given twice')
338
+ h0 = {p: sha(p) for p in allp}; R = {p: load_run(p, a.id_field, a.correct_field) for p in allp}
339
+ meta = ['Item ids are turned into strings first, so 2, 2.0 and "2" are one id. Each pair of runs is compared on the items both runs hold, and the count is shown as n_matched.']
340
+ sections = [('Per run: rows read and items left out, with up to 5 example ids', [run_row(R[p]) for p in allp],
341
+ ['run', 'rows_read', 'usable_items', 'missing_id_field', 'missing_correct_field', 'non_boolean_correct', 'duplicate_rows', 'duplicate_ids', 'blank_lines', 'ids_normalised_to_text', 'ties_out', 'examples'])]
342
+ data = dict(runs=[run_row(R[p]) for p in allp]); allflips = []; warns = []
343
+ for nm, S in (('A', A), ('B', B)):
344
+ if len(S) < 2: continue
345
+ rows = flips([R[p] for p in S], a.repeats); allflips += rows
346
+ clean = [{k: v for k, v in r.items() if not k.startswith('_')} for r in rows]
347
+ sections.append((f'System {nm}: flips between runs and the {SHARE_NAME}, one row per pair of runs', clean, ['run_a', 'run_b', 'n_matched', 'flips', 'right_to_wrong', 'wrong_to_right', 'net', SHARE, 'left_out_a', 'left_out_b']))
348
+ data['repeat_' + nm] = clean; warns += drop_warnings(rows)
349
+ accs = [dict(run=R[p]['name'], accuracy=round(sum(R[p]['ok'].values()) / len(R[p]['ok']), 4) if R[p]['ok'] else None, usable_items=len(R[p]['ok'])) for p in S]
350
+ sections.append((f'System {nm}: accuracy per run, on that run\'s own usable items', accs, ['run', 'accuracy', 'usable_items'])); data['accuracy_' + nm] = accs
351
+ meta.append(f'System {nm}: a flip is an item that is right in one run and wrong in the other, and the flips are counted in each direction. ' + (
352
+ 'You marked these runs as repeats of the same setup, so the share of matched items that flip is the repeat floor.' if a.repeats else
353
+ 'The share of matched items that flip is called the flip share between these runs. It is called the repeat floor only when the runs are repeats of the same setup, which you mark with --repeats.') +
354
+ ' There is no combined figure over three or more runs, one row per pair. Net change is wrong to right minus right to wrong.')
355
+ if B:
356
+ m = min(len(A), len(B)); comp = []; crow = []
357
+ for k in range(m):
358
+ ids, info = pair_ids(R[A[k]], R[B[k]]); c = compare(R[A[k]]['ok'], R[B[k]]['ok'], ids, a.margin, a.boot, a.seed); c['pair'] = f"{R[A[k]]['name']} vs {R[B[k]]['name']}"
359
+ c['left_out_A'], c['left_out_B'] = info['left_out_a'], info['left_out_b']; comp.append(c)
360
+ crow.append(dict(n_matched=len(ids), _info=info, _ra=R[A[k]], _rb=R[B[k]]))
361
+ warns += drop_warnings(crow)
362
+ if len(A) != len(B): meta.append(f'A has {len(A)} runs and B has {len(B)}. Run k of A is paired with run k of B in command-line order, for the first {m}.')
363
+ else: meta.append('Run k of A is paired with run k of B in command-line order.')
364
+ sections.append(('A against B, run by run (difference is A minus B)', comp, ['pair', 'n_matched', 'discordant', 'A_right_B_wrong', 'A_wrong_B_right', 'acc_A', 'acc_B', 'diff_A_minus_B', 'ci95_low', 'ci95_high', 'mcnemar_exact_p', 'outcome', 'reason', 'left_out_A', 'left_out_B']))
365
+ data['comparison'] = comp
366
+ if any(c['n_matched'] > 5000 for c in comp):
367
+ meta.append('For comparisons above 5000 matched items the bootstrap interval uses count draws (two binomial draws per resample) instead of resampling items one by one. It is approximate, and it can differ slightly by Python version, because before Python 3.12 a normal approximation is used for the draws. The method used is in the bootstrap column of the JSON.')
368
+ meta.append(f'Outcome rule with margin {a.margin}, in this order. ABOVE if the low end of the 95% interval is above 0 and the exact McNemar p is below 0.05. BELOW if the high end is below 0 and p is below 0.05. If only one of the two says different, CANNOT_TELL. '
369
+ f'Then CONSISTENT if the whole interval is within plus or minus {a.margin}, with at least {MIN_ITEMS} matched items and at least 1 discordant item. Otherwise CANNOT_TELL. Bootstrap: {a.boot} resamples of items, seed {a.seed}.')
370
+ zero = [(r['run_a'], r['run_b'], r['_ra'], r['_rb']) for r in allflips if r['n_matched'] == 0] # a comparison that matches no items is an error, with a hint when repeated ids are the cause
371
+ for k, c in enumerate(data.get('comparison', [])):
372
+ if c['n_matched'] == 0: zero.append((R[A[k]]['name'], R[B[k]]['name'], R[A[k]], R[B[k]]))
373
+ zmsgs = []
374
+ for na, nb, ra, rb in zero:
375
+ m = f'No items matched between {na} and {nb}, so nothing can be compared.'
376
+ for run in (ra, rb):
377
+ if run['duplicate_ids']: m += f" {run['name']} holds {run['duplicate_ids']} ids that repeat, on {run['duplicate_rows']} rows, and every one of those rows is left out. If one file pools several models, give every field that makes an item unique, for example --id-field model id, or keep one model per file."
378
+ if not run['ok']: m += f" {run['name']} has no usable items."
379
+ zmsgs.append(m)
380
+ warns = list(dict.fromkeys(warns + zmsgs)); data['drop_warnings'] = warns; data['no_items_matched'] = zmsgs
381
+ paths = write(a.out, 'repeat', 'Repeat floor and paired comparison', meta, sections, [(p, h0[p], sha(p)) for p in allp], data, warnings=warns)
382
+ for r in data['runs']:
383
+ print(f"{r['run']}: rows read {r['rows_read']} = usable {r['usable_items']} + missing id {r['missing_id_field']} + missing correct field {r['missing_correct_field']} + unscored (null correct) {r['unscored_null_correct']} + not a boolean {r['non_boolean_correct']} + duplicate rows {r['duplicate_rows']} ({r['duplicate_ids']} duplicate ids). Blank lines {r['blank_lines']}, ids turned into text {r['ids_normalised_to_text']}" + (f" | examples {r['examples']}" if r['examples'] else ''))
384
+ for nm in ('A', 'B'):
385
+ for r in data.get('repeat_' + nm, []): print(f"{nm}: {r['run_a']} vs {r['run_b']}: flips {r['flips']}: right->wrong {r['right_to_wrong']}, wrong->right {r['wrong_to_right']}, of {r['n_matched']} matched. Net {r['net']:+d}. {SHARE_NAME} {r[SHARE]}")
386
+ for c in data.get('comparison', []): print(f"A vs B: n matched {c['n_matched']}, discordant {c['A_right_B_wrong']}/{c['A_wrong_B_right']}, diff {c['diff_A_minus_B']:+.4f} [{c['ci95_low']:+.4f}, {c['ci95_high']:+.4f}] p={c['mcnemar_exact_p']} -> {c['outcome']} ({c['reason']})")
387
+ for w in warns: print('WARNING:', w, file=sys.stderr)
388
+ print(NAME, '|', ORG, '|', SITE); print('report:', paths['html']); print('data: ', paths['json'])
389
+ if zmsgs: sys.exit(1)
390
+
391
+ def main():
392
+ ap = argparse.ArgumentParser(description=NAME + ', ' + ORG); sub = ap.add_subparsers(dest='cmd', required=True)
393
+ s = sub.add_parser('sample', help='reading sheet of replies beside the parser output'); s.add_argument('run', nargs='?'); s.add_argument('--reply-field'); s.add_argument('--extracted-field')
394
+ s.add_argument('--label-field'); s.add_argument('--id-field'); s.add_argument('-n', type=int, default=10); s.add_argument('--seed', default='1'); s.add_argument('--tally', metavar='SHEET.csv'); s.add_argument('--record', metavar='sample_....json'); s.add_argument('--out')
395
+ r = sub.add_parser('repeat', help='flips between repeats, repeat floor, paired comparison'); r.add_argument('runs', nargs='+'); r.add_argument('--vs', nargs='+'); r.add_argument('--id-field', nargs='+', required=True, help='one or more field names, joined into one id')
396
+ r.add_argument('--repeats', action='store_true', help='mark the runs of each system as repeats of the same setup, so the flip share is called the repeat floor')
397
+ r.add_argument('--correct-field', required=True); r.add_argument('--margin', type=float, default=0.02); r.add_argument('--boot', type=int, default=2000); r.add_argument('--seed', type=int, default=20261101); r.add_argument('--out')
398
+ a = ap.parse_args(); cmd_sample(a) if a.cmd == 'sample' else cmd_repeat(a)
399
+ if __name__ == '__main__': main()