Cion-lab commited on
Commit
484f967
·
verified ·
1 Parent(s): c5f35bf

Upload build/audit_contamination.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. build/audit_contamination.py +230 -70
build/audit_contamination.py CHANGED
@@ -39,18 +39,26 @@ P = np.uint64(0x100000001B3) # FNV-1a prime, odd, so invertible mod 2^6
39
  PINV = np.uint64(pow(int(P), -1, 1 << 64))
40
  MODMASK = np.uint64((1 << 64) - 1)
41
 
42
- # task -> (repo, config(s), splits treated as reference material; 'test' deliberately absent)
 
 
 
43
  REFS = [
44
  ("arc_easy", "allenai/ai2_arc", ["ARC-Easy"], ["train", "validation"]),
45
  ("arc_challenge", "allenai/ai2_arc", ["ARC-Challenge"], ["train", "validation"]),
46
  ("hellaswag", "Rowan/hellaswag", [None], ["train", "validation"]),
47
- ("piqa", "baber/piqa", [None], ["train", "validation"]),
48
  ("winogrande", "allenai/winogrande", ["winogrande_xl"], ["train", "validation"]),
49
  ("mmlu", "cais/mmlu", ["all"], ["dev", "validation"]),
50
- ("truthfulqa", "sylinrl/TruthfulQA", [None], ["validation"]),
51
- ("gsm8k", "openai/gsm8k", ["main"], ["train", "validation"]),
52
  ]
53
  TEXT_KEYS = ("question", "text", "choices", "story", "sentence", "prompt", "ctx", "input", "query")
 
 
 
 
 
54
 
55
 
56
  def window_hashes(ids, k=K):
@@ -95,7 +103,7 @@ def flatten(v, out):
95
 
96
 
97
  def build_reference(tk, max_items_per_split):
98
- """Returns (sorted uint64 array of every reference gram, parallel uint16 task-bitmask, per-task
99
  item counts, per-reference errors). Only counts and hashes are ever held or printed."""
100
  import datasets
101
 
@@ -103,17 +111,21 @@ def build_reference(tk, max_items_per_split):
103
  counts, errors = {}, {}
104
  for ti, (task, repo, configs, splits) in enumerate(REFS):
105
  bit = np.uint16(1 << ti)
106
- for cfg in configs:
107
- for sp in splits:
108
- key = f"{task}::{cfg or 'default'}::{sp}"
 
 
 
 
109
  try:
110
  ds = (datasets.load_dataset(repo, cfg, split=sp, streaming=True) if cfg
111
  else datasets.load_dataset(repo, split=sp, streaming=True))
112
  except Exception as e:
113
- errors[key] = f"{type(e).__name__}: {str(e)[:140]}"
114
- print(f" ref {key}: UNREADABLE {type(e).__name__}", flush=True)
115
  continue
116
- g, n = [], 0
117
  for row in ds:
118
  if n >= max_items_per_split:
119
  break
@@ -129,12 +141,20 @@ def build_reference(tk, max_items_per_split):
129
  h = window_hashes(ids)
130
  if h.size:
131
  g.append(h)
132
- if g:
133
- arr = np.concatenate(g)
134
- grams.append(arr)
135
- masks.append(np.full(arr.shape, bit, dtype=np.uint16))
136
- counts[key] = n
137
- print(f" ref {key}: {n} items -> {sum(a.size for a in g):,} grams", flush=True)
 
 
 
 
 
 
 
 
138
  if not grams:
139
  raise SystemExit("no reference grams built at all -- the audit would be a pass over nothing")
140
  allg = np.concatenate(grams)
@@ -143,10 +163,89 @@ def build_reference(tk, max_items_per_split):
143
  return allg[order], allm[order], counts, errors
144
 
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  def main():
147
  ap = argparse.ArgumentParser()
148
  ap.add_argument("--root", default="/kaggle/working/mixroot")
149
- ap.add_argument("--max-items-per-split", type=int, default=20000)
 
 
 
 
 
 
150
  ap.add_argument("--out", default="", help="write the report JSON here (publish_mix expects "
151
  "mixroot/audit.json)")
152
  a = ap.parse_args()
@@ -158,77 +257,138 @@ def main():
158
 
159
  print("building reference gram table (only counts ever leave this process)", flush=True)
160
  ref, refmask, refcounts, errors = build_reference(tk, a.max_items_per_split)
161
- print(f"reference: {ref.size:,} grams, {len(set(refcounts))} splits ok, {len(errors)} unreadable",
162
- flush=True)
 
 
163
 
164
- man = json.load(open(os.path.join(a.root, "manifest.json")))
165
  import build_mix as BM
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  hits = {}
168
- hits_by_source = {}
169
- docs = grams_seen = 0
170
  missing = []
171
- for rec in man["shards"]:
172
- p = os.path.join(a.root, rec["file"])
173
- if not os.path.exists(p):
174
- # A shard not on disk is unmeasured, not clean (E-017's shape).
175
- missing.append(rec["file"])
176
- continue
177
- for d in BM.iter_shard_docs(p):
178
- docs += 1
179
- ids = np.frombuffer(bytes(d), dtype=np.uint16)
180
- g = window_hashes(ids)
181
- if g.size == 0:
182
  continue
183
- grams_seen += g.size
184
- pos = np.searchsorted(ref, g)
185
- pos_c = np.clip(pos, 0, ref.size - 1)
186
- eq = ref[pos_c] == g
187
- if eq.any():
188
- # Fold per-task attribution into bit counts; no item text is ever reconstructed.
189
- bits = refmask[pos_c[eq]]
190
- for ti in range(len(REFS)):
191
- c = int((bits & np.uint16(1 << ti)).astype(np.uint16).sum())
192
- if c:
193
- name = REFS[ti][0]
194
- hits[name] = hits.get(name, 0) + c
195
- for srcname in rec["sources"]:
196
- d0 = hits_by_source.setdefault(srcname, {})
197
- d0[name] = d0.get(name, 0) + c // max(1, len(rec["sources"]))
198
- if docs % 100000 < 3000:
199
- print(f" audited {docs:,} docs, {grams_seen:,} mix grams", flush=True)
200
-
201
- total_overlap = sum(hits.values())
202
- expected_fp = grams_seen * ref.size / float(1 << 64)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  report = {
204
  "k": K,
205
  "fingerprint": "multiplicative rolling hash mod 2^64, P=0x100000001B3",
206
  "reference_items_by_split": refcounts,
207
- "reference_unreadable": errors,
208
  "reference_grams_distinct": int(np.unique(ref).size),
209
  "reference_grams_total": int(ref.size),
210
- "shards_expected": len(man["shards"]),
211
  "shards_missing": missing,
212
  "mix_documents_audited": docs,
 
213
  "mix_grams_audited": grams_seen,
214
- "overlap_by_task": hits,
215
- "overlap_by_source": hits_by_source,
216
- "expected_false_positives": round(expected_fp, 3),
217
  "overlap_total": total_overlap,
218
- "overlap_exceeds_noise": total_overlap > max(10.0, 3.0 * expected_fp),
 
 
 
 
 
 
 
219
  "note": ("counts only; no item text read or printed. Reference splits are train/validation/dev "
220
- "per task -- never test, per §3.3. Overlap above the stated false-positive expectation "
221
- "means the named source must be dropped or cleaned before Gate 2 is claimed."),
222
- "COVERED_ALL_SHARDS": bool(man["shards"] and not missing),
223
- "MEASURED": bool(docs > 0 and grams_seen > 0 and len(refcounts) >= 6 and not errors),
224
- "AUDIT_PASSED": bool(man["shards"] and not missing and docs > 0 and grams_seen > 0
225
- and len(refcounts) >= 6 and not errors
226
- and total_overlap <= max(10.0, 3.0 * expected_fp)),
227
  }
 
228
  txt = json.dumps(report, indent=1, default=str)
229
- print(f"VERDICT AUDIT_PASSED={report['AUDIT_PASSED']} docs={docs:,} overlap={total_overlap} "
230
- f"expected_fp={expected_fp:.2f} missing_shards={len(missing)} refs_ok={len(refcounts)} "
231
- f"refs_failed={len(errors)}")
 
232
  print("AUDIT_JSON_BEGIN")
233
  print(txt)
234
  print("AUDIT_JSON_END")
 
39
  PINV = np.uint64(pow(int(P), -1, 1 << 64))
40
  MODMASK = np.uint64((1 << 64) - 1)
41
 
42
+ # task -> (repo, config(s) to try in order, splits treated as reference material; 'test' deliberately
43
+ # absent). Split names were read off the dataset server on 2026-09-19 rather than assumed: gsm8k has
44
+ # train/test only, truthfulqa's repo is `truthfulqa/truthful_qa` and exposes `validation` only, and
45
+ # `sylinrl/TruthfulQA` -- the name the first version of this file used -- no longer exists.
46
  REFS = [
47
  ("arc_easy", "allenai/ai2_arc", ["ARC-Easy"], ["train", "validation"]),
48
  ("arc_challenge", "allenai/ai2_arc", ["ARC-Challenge"], ["train", "validation"]),
49
  ("hellaswag", "Rowan/hellaswag", [None], ["train", "validation"]),
50
+ ("piqa", "baber/piqa", [None, "plain_text"], ["train", "validation"]),
51
  ("winogrande", "allenai/winogrande", ["winogrande_xl"], ["train", "validation"]),
52
  ("mmlu", "cais/mmlu", ["all"], ["dev", "validation"]),
53
+ ("truthfulqa", "truthfulqa/truthful_qa", ["multiple_choice", "generation"], ["validation"]),
54
+ ("gsm8k", "openai/gsm8k", ["main"], ["train"]),
55
  ]
56
  TEXT_KEYS = ("question", "text", "choices", "story", "sentence", "prompt", "ctx", "input", "query")
57
+ # A reference split that yields fewer rows than this did not load, it merely failed to raise: the first
58
+ # run of this audit reported `piqa::default::train: 0 items -> 0 grams` with no exception, which a
59
+ # coverage-blind verdict would have scored as "no contamination found in PIQA".
60
+ MIN_ITEMS_PER_SPLIT = 200
61
+
62
 
63
 
64
  def window_hashes(ids, k=K):
 
103
 
104
 
105
  def build_reference(tk, max_items_per_split):
106
+ """Returns (sorted uint64 array of every reference gram, parallel uint16 task-bitmask, per-split
107
  item counts, per-reference errors). Only counts and hashes are ever held or printed."""
108
  import datasets
109
 
 
111
  counts, errors = {}, {}
112
  for ti, (task, repo, configs, splits) in enumerate(REFS):
113
  bit = np.uint16(1 << ti)
114
+ for sp in splits:
115
+ # One split, tried against each candidate config in order, until a loader yields rows. The
116
+ # alternative -- trusting the first config that does not raise -- is what scored PIQA as
117
+ # clean while reading 0 items from it.
118
+ key, g, loaded = f"{task}::{repo}::{sp}", [], None
119
+ for cfg in configs:
120
+ ckey = f"{key}[{cfg or 'default'}]"
121
  try:
122
  ds = (datasets.load_dataset(repo, cfg, split=sp, streaming=True) if cfg
123
  else datasets.load_dataset(repo, split=sp, streaming=True))
124
  except Exception as e:
125
+ errors[ckey] = f"{type(e).__name__}: {str(e)[:140]}"
126
+ print(f" ref {ckey}: UNREADABLE {type(e).__name__}: {str(e)[:80]}", flush=True)
127
  continue
128
+ n = 0
129
  for row in ds:
130
  if n >= max_items_per_split:
131
  break
 
141
  h = window_hashes(ids)
142
  if h.size:
143
  g.append(h)
144
+ counts[ckey] = n
145
+ print(f" ref {ckey}: {n} items -> {sum(a.size for a in g):,} grams", flush=True)
146
+ if n >= MIN_ITEMS_PER_SPLIT:
147
+ loaded = ckey
148
+ break
149
+ errors[ckey] = f"only {n} rows (needs >= {MIN_ITEMS_PER_SPLIT}); trying next config"
150
+ if loaded is None:
151
+ errors[key] = f"no config of {repo} yielded >= {MIN_ITEMS_PER_SPLIT} rows for {sp}"
152
+ continue
153
+ per_task = [a for a in g]
154
+ if per_task:
155
+ arr = np.concatenate(per_task)
156
+ grams.append(arr)
157
+ masks.append(np.full(arr.shape, bit, dtype=np.uint16))
158
  if not grams:
159
  raise SystemExit("no reference grams built at all -- the audit would be a pass over nothing")
160
  allg = np.concatenate(grams)
 
163
  return allg[order], allm[order], counts, errors
164
 
165
 
166
+
167
+ def gram_lookup(g, ref, refmask):
168
+ """(number of hits, the task bitmask of the matched reference grams) for one document.
169
+
170
+ numpy only, because this runs ~1.1 M times. `searchsorted` into the sorted reference table plus the
171
+ parallel bitmask is what makes per-task attribution possible without holding eight sets.
172
+ """
173
+ if g.size == 0:
174
+ return 0, None
175
+ pos = np.clip(np.searchsorted(ref, g), 0, ref.size - 1)
176
+ eq = ref[pos] == g
177
+ if not eq.any():
178
+ return 0, None
179
+ return int(eq.sum()), refmask[pos[eq]]
180
+
181
+
182
+ def per_task_counts(bits):
183
+ return [int((bits & np.uint16(1 << ti)).astype(np.uint16).sum()) for ti in range(len(REFS))]
184
+
185
+
186
+ def scan_docs(ref, refmask, docs):
187
+ """Fold an iterator of (ordinal_within_source, raw_bytes) into counts.
188
+
189
+ The ordinal is the only identity a document ever gets here: a filter pass can drop it by index later
190
+ without anything in this process knowing what it said. `hit_docs_over_half` is the column that decides
191
+ interpretation -- contamination by verbatim inclusion shows up as documents whose grams are *mostly*
192
+ matched, while one shared boilerplate sentence shows up as a single gram in a handful of documents.
193
+ """
194
+ import time
195
+ import hashlib
196
+ t0 = time.time()
197
+ s = {"docs": 0, "tokens": 0, "grams": 0, "hit_docs": 0, "hits": 0, "hit_docs_over_half": 0,
198
+ "hit_tokens": 0, "dup_docs": 0, "dup_tokens": 0, "per_task": [0] * len(REFS), "ordinals": []}
199
+ seen = set()
200
+ names = [t[0] for t in REFS]
201
+ for idx, raw in docs:
202
+ b = bytes(raw)
203
+ ids = np.frombuffer(b, dtype=np.uint16)
204
+ s["docs"] += 1
205
+ s["tokens"] += int(ids.size)
206
+ dg = hashlib.blake2b(b, digest_size=8).digest()
207
+ if dg in seen: # counted, never removed: de-duplication is out of scope (D-014)
208
+ s["dup_docs"] += 1
209
+ s["dup_tokens"] += int(ids.size)
210
+ else:
211
+ seen.add(dg)
212
+ g = window_hashes(ids)
213
+ s["grams"] += int(g.size)
214
+ h, bits = gram_lookup(g, ref, refmask)
215
+ if h:
216
+ s["hit_docs"] += 1
217
+ s["hits"] += h
218
+ s["hit_tokens"] += int(ids.size)
219
+ s["per_task"] = [a + c for a, c in zip(s["per_task"], per_task_counts(bits))]
220
+ s["ordinals"].append(idx)
221
+ if h * 2 >= int(g.size):
222
+ s["hit_docs_over_half"] += 1
223
+ s["seconds"] = round(time.time() - t0, 1)
224
+ s["per_task_named"] = {n: c for n, c in zip(names, s["per_task"]) if c}
225
+ return s
226
+
227
+
228
+ def doc_stream(BM, shard_paths):
229
+ """(ordinal, raw document bytes) over a fixed list of shards. Ordinal order is the shard order in
230
+ the source's own record.json, which is exactly what the merge replays -- so an index computed here
231
+ still means the same document after a session restart."""
232
+ n = 0
233
+ for p in shard_paths:
234
+ for d in BM.iter_shard_docs(p):
235
+ yield n, d
236
+ n += 1
237
+
238
+
239
  def main():
240
  ap = argparse.ArgumentParser()
241
  ap.add_argument("--root", default="/kaggle/working/mixroot")
242
+ ap.add_argument("--stage-subdir", default="stage",
243
+ help="per-source staged shards live here under --root; scanning them is what makes "
244
+ "attribution possible, because every merged shard contains all sources at once")
245
+ ap.add_argument("--max-items-per-split", type=int, default=50000)
246
+ ap.add_argument("--write-filter", default="",
247
+ help="directory for <source>.u8 exclusion masks + filter.json (read back by "
248
+ "build_mix --exclude-dir)")
249
  ap.add_argument("--out", default="", help="write the report JSON here (publish_mix expects "
250
  "mixroot/audit.json)")
251
  a = ap.parse_args()
 
257
 
258
  print("building reference gram table (only counts ever leave this process)", flush=True)
259
  ref, refmask, refcounts, errors = build_reference(tk, a.max_items_per_split)
260
+ tasks_covered = sorted({k.split("::")[0] for k, v in refcounts.items()
261
+ if v >= MIN_ITEMS_PER_SPLIT})
262
+ print(f"reference: {ref.size:,} grams, {len(tasks_covered)}/8 tasks covered, "
263
+ f"{len(errors)} loader problems", flush=True)
264
 
 
265
  import build_mix as BM
266
 
267
+ # ---- per-source attribution, over the staged (pre-merge) shards ------------------------------
268
+ # The first version attributed merged hits as `c // len(rec["sources"])` to every source named in the
269
+ # shard. All 141 shards name all 15 sources, so every source received an almost identical number and
270
+ # the table looked like a finding while being arithmetically meaningless.
271
+ stage_root = os.path.join(a.root, a.stage_subdir)
272
+ by_source, masks = {}, {}
273
+ if os.path.isdir(stage_root):
274
+ for key in sorted(os.listdir(stage_root)):
275
+ sd = os.path.join(stage_root, key)
276
+ rd = os.path.join(sd, "record.json")
277
+ if not os.path.isdir(sd) or not os.path.exists(rd):
278
+ continue
279
+ rec = json.load(open(rd))
280
+ paths = [os.path.join(a.root, s["file"]) for s in rec.get("shards", [])]
281
+ absent = [p for p in paths if not os.path.exists(p)]
282
+ if absent:
283
+ errors[f"stage::{key}"] = f"{len(absent)} staged shard(s) missing on disk"
284
+ print(f" stage {key}: {len(absent)} shard(s) missing, skipped", flush=True)
285
+ continue
286
+ s = scan_docs(ref, refmask, doc_stream(BM, paths))
287
+ s["shards"] = len(paths)
288
+ s["stage_complete"] = bool(rec.get("complete"))
289
+ by_source[key] = s
290
+ masks[key] = (np.asarray(sorted(s["ordinals"]), dtype=np.int64), s["docs"])
291
+ print(f" stage {key}: {s['docs']:,} docs {s['tokens']:,} tok -> hits={s['hits']:,} in "
292
+ f"{s['hit_docs']:,} docs ({s['hit_docs_over_half']:,} of them >50 % matched) "
293
+ f"[{s['seconds']}s] {json.dumps(s['per_task_named'])}", flush=True)
294
+ s.pop("ordinals")
295
+ else:
296
+ print(f"no staged sources under {stage_root}: attribution unavailable, totals only", flush=True)
297
+
298
+ # ---- the verdict is always measured on the bytes that will actually train ---------------------
299
+ man_path = os.path.join(a.root, "manifest.json")
300
  hits = {}
301
+ docs = grams_seen = mix_tokens = hit_docs = hit_docs_over_half = 0
302
+ dup_docs = dup_tokens = 0
303
  missing = []
304
+ if os.path.exists(man_path):
305
+ man = json.load(open(man_path))
306
+ for rec in man["shards"]:
307
+ p = os.path.join(a.root, rec["file"])
308
+ if not os.path.exists(p):
309
+ # A shard not on disk is unmeasured, not clean (E-017's shape).
310
+ missing.append(rec["file"])
 
 
 
 
311
  continue
312
+ s = scan_docs(ref, refmask, doc_stream(BM, [p]))
313
+ docs += s["docs"]
314
+ mix_tokens += s["tokens"]
315
+ grams_seen += s["grams"]
316
+ hit_docs += s["hit_docs"]
317
+ hit_docs_over_half += s["hit_docs_over_half"]
318
+ dup_docs += s["dup_docs"]
319
+ dup_tokens += s["dup_tokens"]
320
+ for n, c in s["per_task_named"].items():
321
+ hits[n] = hits.get(n, 0) + c
322
+ total_overlap = sum(hits.values())
323
+ print(f" mix: {docs:,} docs, {grams_seen:,} grams, {total_overlap:,} hits in {hit_docs:,} docs "
324
+ f"({hit_docs_over_half:,} majority-hit)", flush=True)
325
+ else:
326
+ total_overlap = 0
327
+ print(f"no manifest at {a.root}: merged mix not scanned", flush=True)
328
+
329
+ expected_fp = grams_seen * int(np.unique(ref).size) / float(1 << 64)
330
+ noise_allow = max(10.0, 10.0 * expected_fp)
331
+ per_task_named = {t[0]: hits.get(t[0], 0) for t in REFS}
332
+
333
+ if a.write_filter:
334
+ os.makedirs(a.write_filter, exist_ok=True)
335
+ summary = {"k": K, "criterion": "any 13-gram of the document appears in a reference "
336
+ "train/validation/dev split", "sources": {}}
337
+ for key, (ord_sorted, n_docs) in masks.items():
338
+ m = np.zeros(n_docs, dtype=np.uint8)
339
+ if ord_sorted.size:
340
+ m[ord_sorted] = 1
341
+ m.tofile(os.path.join(a.write_filter, key + ".u8"))
342
+ s = by_source[key]
343
+ summary["sources"][key] = {"docs": s["docs"], "tokens": s["tokens"],
344
+ "dropped_docs": int(ord_sorted.size),
345
+ "dropped_tokens": int(s["hit_tokens"]),
346
+ "kept_docs": int(n_docs - ord_sorted.size)}
347
+ with open(os.path.join(a.write_filter, "filter.json"), "w") as f:
348
+ json.dump(summary, f, indent=1, default=str)
349
+ print(f"filter masks written to {a.write_filter} for {len(masks)} source(s)", flush=True)
350
+
351
+ contaminated_sources = {k: v["hit_docs"] / max(1, v["docs"]) for k, v in by_source.items()
352
+ if v["hit_docs"]}
353
  report = {
354
  "k": K,
355
  "fingerprint": "multiplicative rolling hash mod 2^64, P=0x100000001B3",
356
  "reference_items_by_split": refcounts,
357
+ "reference_loader_problems": errors,
358
  "reference_grams_distinct": int(np.unique(ref).size),
359
  "reference_grams_total": int(ref.size),
360
+ "tasks_covered": tasks_covered,
361
  "shards_missing": missing,
362
  "mix_documents_audited": docs,
363
+ "mix_tokens_audited": mix_tokens,
364
  "mix_grams_audited": grams_seen,
365
+ "mix_documents_with_any_hit": hit_docs,
366
+ "mix_documents_majority_hit": hit_docs_over_half,
367
+ "overlap_by_task": per_task_named,
368
  "overlap_total": total_overlap,
369
+ "by_source": by_source,
370
+ "source_hit_document_fraction": {k: round(v, 6) for k, v in sorted(
371
+ contaminated_sources.items(), key=lambda kv: -kv[1])},
372
+ "duplicate_documents": dup_docs,
373
+ "duplicate_tokens": dup_tokens,
374
+ "expected_false_positives": round(expected_fp, 3),
375
+ "noise_allowance": round(noise_allow, 3),
376
+ "overlap_exceeds_noise": bool(total_overlap > noise_allow),
377
  "note": ("counts only; no item text read or printed. Reference splits are train/validation/dev "
378
+ "per task -- never test, per §3.3. overlap_by_task sums gram hits, so one verbatim "
379
+ "benchmark document contributes as many hits as it has windows; read "
380
+ "mix_documents_majority_hit and source_hit_document_fraction to judge severity. "
381
+ "Duplicate counting is diagnostic only: de-duplication is out of scope (D-014), "
382
+ "contamination removal is not (§3.3)."),
383
+ "COVERED_ALL_SHARDS": bool(not missing and docs > 0),
384
+ "MEASURED": bool(docs > 0 and grams_seen > 0 and not errors and len(tasks_covered) == 8),
385
  }
386
+ report["AUDIT_PASSED"] = bool(report["MEASURED"] and total_overlap <= noise_allow)
387
  txt = json.dumps(report, indent=1, default=str)
388
+ print(f"VERDICT AUDIT_PASSED={report['AUDIT_PASSED']} MEASURED={report['MEASURED']} "
389
+ f"docs={docs:,} overlap={total_overlap} hit_docs={hit_docs:,} "
390
+ f"majority_hit_docs={hit_docs_over_half:,} expected_fp={expected_fp:.3f} "
391
+ f"tasks={len(tasks_covered)}/8 problems={len(errors)}")
392
  print("AUDIT_JSON_BEGIN")
393
  print(txt)
394
  print("AUDIT_JSON_END")