File size: 4,777 Bytes
53ea208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
#!/usr/bin/env python3
"""Build an annotation queue from rafmacalaba/datause-ner via datasets-server.

Stdlib only (urllib) so it runs anywhere, including Spaces builds.

    uv run python human_labeling/build_pool_queue.py [--config probe_candidates] [--split pool]
        [--limit 500] [--out human_labeling/queue.json]

Defaults target the annotatable config: probe_candidates/pool is the only
one with an embedded `passage`. --config probe_splits yields surface-only
triage items flagged ctx_missing (no passage on the Hub; see README).
Stratified sample: round-robin over origin x score-band so the queue
covers keep/confusion/drop and every origin instead of the head-heavy
random draw (proxy bands on the Hub score; true tags come from rescore).
"""

import argparse
import json
import urllib.parse
import urllib.request
from pathlib import Path

REPO = Path(__file__).resolve().parent
DATASET = "rafmacalaba/datause-ner"
API = "https://datasets-server.huggingface.co/rows"

BANDS = (("drop", 0.0, 0.05), ("confusion", 0.05, 0.9), ("keep", 0.9, 1.01))
# Proxy strata on the Hub (v3-era) score, used ONLY to stratify the sample
# across the eventual decision zones (same 0.05/0.9 edges as the audit
# deck). True keep/confusion/drop labels are assigned later by
# rescore_singlepass.probe_labels.decide; the written item band is always
# "unscored" until then.
def band_of(score: float) -> str:
    for name, lo, hi in BANDS:
        if lo <= score < hi:
            return name
    return "confusion"


def fetch_rows(config: str, split: str, offset: int, length: int) -> list[dict]:
    import time
    import urllib.error
    q = urllib.parse.urlencode(
        {"dataset": DATASET, "config": config, "split": split,
         "offset": offset, "length": length}
    )
    last = None
    for attempt in range(6):
        try:
            with urllib.request.urlopen(f"{API}?{q}", timeout=60) as r:
                payload = json.load(r)
            return [d["row"] for d in payload.get("rows", [])]
        except urllib.error.HTTPError as e:
            last = e
            if e.code not in (429, 500, 502, 503):
                raise
            time.sleep(2 ** attempt)
    raise last
def to_item(row: dict, config: str, split: str) -> dict:
    score = float(row.get("head_score") or 0.0)
    passage = row.get("passage")
    start, end = row.get("start"), row.get("end")
    item = {
        "key": row.get("key"),
        "surface": row.get("surface"),
        "ctx": passage,  # None for probe_splits rows (no passage on Hub)
        "ctx_missing": passage is None,
        "head_score": score,  # Hub v3-era placeholder; rescore overwrites
        "start": start, "end": end,
        "band": "unscored",  # rescore_singlepass tags keep/confusion/drop
        "origin": row.get("origin"),
        "specificity": row.get("extractor_label") or row.get("stratum") or "",
        "split": row.get("split", split),
        "queue": f"{config}/{split}",
    }
    return item


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="probe_candidates")
    ap.add_argument("--split", default="pool")
    ap.add_argument("--limit", type=int, default=500)
    ap.add_argument("--fetch", type=int, default=5000,
                    help="rows scanned from the Hub for stratification")
    ap.add_argument("--seed", type=int, default=7)
    ap.add_argument("--out", default=str(REPO / "queue.json"))
    a = ap.parse_args()

    scanned: list[dict] = []
    offset = 0
    while len(scanned) < a.fetch:
        batch = fetch_rows(a.config, a.split, offset, min(100, a.fetch - len(scanned)))
        if not batch:
            break
        scanned.extend(batch)
        offset += len(batch)

    # stratify: round-robin over (origin, band)
    import random
    rng = random.Random(a.seed)
    buckets: dict[tuple, list[dict]] = {}
    for row in scanned:
        item = to_item(row, a.config, a.split)
        buckets.setdefault((item["origin"], item["band"]), []).append(item)
    for b in buckets.values():
        rng.shuffle(b)
    queue: list[dict] = []
    while len(queue) < min(a.limit, len(scanned)) and buckets:
        for k in sorted(buckets):
            if len(queue) >= a.limit:
                break
            if buckets[k]:
                queue.append(buckets[k].pop())
        buckets = {k: v for k, v in buckets.items() if v}
    rng.shuffle(queue)

    out = Path(a.out)
    out.write_text("\n".join(json.dumps(q) for q in queue) + "\n")
    origins = sorted({q["origin"] for q in queue})
    missing = sum(1 for q in queue if q["ctx_missing"])
    print(f"scanned={len(scanned)} queued={len(queue)} origins={origins} "
          f"ctx_missing={missing} -> {out}")


if __name__ == "__main__":
    main()