File size: 9,178 Bytes
d9099a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
#!/usr/bin/env python3
"""Score parquet shards with one language's C / A / Q scorers.

One row is one source file; a parquet shard holds many rows. Four columns are
appended to every input table and the result is written under ``--output-root``,
mirroring the input directory structure:

    category         argmax of the file-role classifier C
    cls_confidence   softmax max probability of C
    algo_rel_score   sigmoid output of the algorithmic-relevance model A
    quality_score    fused 0..10 output of the quality model Q

With ``--apply-policy`` a boolean ``selected`` column is added as well, using
the retention rule ``algo_rel_score >= t AND category not in excluded AND
quality_score >= q``. It marks rows rather than dropping them.

Inputs must carry an ``embedding`` column.  A ``relative_path`` column (or a
``meta`` struct with ``file_path``) is required only when the scorers
consume path features — the script checks and refuses to run silently without
them.

This is a single-process scorer.  It walks a directory recursively, but has no
worker pool and no resume ledger, so for corpus-scale runs drive it from your own
scheduler (one invocation per shard or per subtree) rather than pointing it at
millions of rows in one go.
"""

from __future__ import annotations

import argparse
import json
import sys
import time
from pathlib import Path

import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq

sys.path.insert(0, str(Path(__file__).resolve().parent))

from path_features import extract_relative_paths  # noqa: E402
from scorers import LanguageScorers  # noqa: E402


def collect_inputs(input_path: Path) -> list[Path]:
    if input_path.is_file():
        return [input_path]
    files = sorted(input_path.rglob("*.parquet"))
    if not files:
        raise FileNotFoundError(f"no parquet files under {input_path}")
    return files


def score_table(table, scorers: LanguageScorers, batch_size: int):
    embeddings = table.column("embedding").to_pylist()
    if scorers.needs_paths():
        paths = extract_relative_paths(table, scorers.path_column)
        if not any(paths):
            raise ValueError(
                "these scorers use path features but no 'relative_path' column "
                "(or meta.file_path) was found in the input"
            )
    else:
        paths = [""] * len(embeddings)

    columns = {"category": [], "cls_confidence": [], "algo_rel_score": [], "quality_score": []}
    for start in range(0, len(embeddings), batch_size):
        stop = start + batch_size
        chunk = np.asarray(embeddings[start:stop], dtype=np.float32)
        scored = scorers.score(chunk, paths[start:stop])
        columns["category"].extend(scored["category"])
        for key in ("cls_confidence", "algo_rel_score", "quality_score"):
            columns[key].extend(np.asarray(scored[key]).reshape(-1).tolist())
    return columns


def load_policy_defaults(language: str) -> dict:
    """Read the retention policy defaults for one language from config.json.

    Kept out of the argparse defaults on purpose: the cuts are a policy choice,
    not a property of the models, so they live in one editable place rather than
    baked into this file. ``scorers.py`` never reads this — only the CLI does.

    ``selection_policy`` holds the cuts that apply to every language. An optional
    ``per_language`` block overrides any of them for a single language, so each
    language can carry its own operating point without touching the others::

        "selection_policy": {
          "algo_rel_min": 0.8,
          "per_language": {"r": {"algo_rel_min": 0.9}}
        }

    Precedence is CLI flag > per_language > shared default.
    """
    path = Path(__file__).resolve().parent.parent / "config.json"
    try:
        policy = json.loads(path.read_text())["selection_policy"]
        override = (policy.get("per_language") or {}).get(language, {})
        merged = {**policy, **override}
        return {
            "algo_rel_min": float(merged["algo_rel_min"]),
            "quality_min": float(merged["quality_min"]),
            "exclude_categories": ",".join(merged["exclude_categories"]),
            "source": "per_language" if override else "shared",
        }
    except (OSError, KeyError, ValueError, TypeError) as exc:
        raise SystemExit(
            f"could not read selection_policy from {path}: {exc}. "
            "Pass --algo-rel-min / --quality-min / --exclude-categories explicitly, "
            "or restore the config file."
        )


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--scorers", required=True, help="scorer-set dir for one language, e.g. classifiers/cpp")
    parser.add_argument("--input", help="parquet file or directory of parquet files")
    parser.add_argument("--output-root", help="destination directory for scored parquet")
    parser.add_argument("--batch-size", type=int, default=1024)
    parser.add_argument("--device", default="cpu")
    parser.add_argument("--limit", type=int, default=0, help="score at most N input parquet files (0 = all)")
    parser.add_argument("--describe", action="store_true", help="print the scorer-set configuration and exit")
    parser.add_argument("--apply-policy", action="store_true", help="also write a boolean 'selected' column")
    parser.add_argument("--algo-rel-min", type=float, help="default: config.json selection_policy")
    parser.add_argument("--quality-min", type=float, help="default: config.json selection_policy")
    parser.add_argument("--exclude-categories", help="comma separated categories to veto; default: config.json")
    args = parser.parse_args()

    scorers = LanguageScorers.load(args.scorers, device=args.device)

    if args.describe:
        print(json.dumps(scorers.describe(), indent=2, ensure_ascii=False))
        return

    if not args.input or not args.output_root:
        parser.error("--input and --output-root are required unless --describe is given")

    input_path = Path(args.input)
    output_root = Path(args.output_root)
    files = collect_inputs(input_path)
    if args.limit:
        files = files[: args.limit]
    root = input_path if input_path.is_dir() else input_path.parent

    # Resolve the retention cuts only when they are actually needed, so a plain
    # scoring run does not fail on a missing/edited config.json.
    algo_rel_min = quality_min = None
    excluded = set()
    if args.apply_policy:
        defaults = load_policy_defaults(scorers.language)
        algo_rel_min = (
            args.algo_rel_min if args.algo_rel_min is not None
            else defaults["algo_rel_min"]
        )
        quality_min = (
            args.quality_min if args.quality_min is not None
            else defaults["quality_min"]
        )
        categories = (
            args.exclude_categories if args.exclude_categories is not None
            else defaults["exclude_categories"]
        )
        excluded = {c.strip() for c in categories.split(",") if c.strip()}
        print(
            f"policy [{scorers.language}, {defaults['source']} defaults]: "
            f"algo_rel_score >= {algo_rel_min} AND category not in "
            f"{sorted(excluded)} AND quality_score >= {quality_min}",
            flush=True,
        )

    total_rows = 0
    for index, in_path in enumerate(files, 1):
        started = time.time()
        table = pq.read_table(in_path)
        columns = score_table(table, scorers, args.batch_size)

        out = table
        for name, values, arrow_type in (
            ("category", columns["category"], pa.string()),
            ("cls_confidence", columns["cls_confidence"], pa.float32()),
            ("algo_rel_score", columns["algo_rel_score"], pa.float32()),
            ("quality_score", columns["quality_score"], pa.float32()),
        ):
            if name in out.column_names:
                out = out.drop([name])
            out = out.append_column(name, pa.array(values, type=arrow_type))

        if args.apply_policy:
            selected = [
                bool(
                    relevance >= algo_rel_min
                    and category not in excluded
                    and quality >= quality_min
                )
                for relevance, category, quality in zip(
                    columns["algo_rel_score"], columns["category"], columns["quality_score"]
                )
            ]
            if "selected" in out.column_names:
                out = out.drop(["selected"])
            out = out.append_column("selected", pa.array(selected, type=pa.bool_()))

        out_path = output_root / in_path.relative_to(root)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        pq.write_table(out, out_path)
        total_rows += table.num_rows
        print(
            f"[{index}/{len(files)}] {in_path.name}: {table.num_rows} rows "
            f"in {time.time() - started:.1f}s -> {out_path}",
            flush=True,
        )

    print(f"done: {len(files)} parquet files, {total_rows} rows -> {output_root}")


if __name__ == "__main__":
    main()