File size: 10,178 Bytes
2c87024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Portable input plans and shared operations for independent corpus parts."""

import json
import sqlite3
import sys
import tomllib
from collections import Counter
from pathlib import Path

ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "src"))

from vimeml.data.build import validate, file_sha, sha, display_path, SCHEMA, dump_line, dump_json
from vimeml.data.clean import clean_block
from vimeml.data.segment import restore_linebreaks, split_sentences
from vimeml.data.annotations import load_annotations, document_annotations, match_fragment

PART_SCHEMA_VERSION = 1
AUDITS = ("review_blocks", "dropped_blocks", "fragments")


def text_sha(path):
    # Git may check out CRLF on Windows and LF on Mac. These text files have
    # identical semantics; hash normalized UTF-8 text rather than platform bytes.
    return sha(path.read_text(encoding="utf-8-sig"))


def load_plan(config_path):
    config = tomllib.loads(config_path.read_text(encoding="utf-8-sig"))
    validate(config)
    paths = config["inputs"]["fineweb_paths"]
    if not isinstance(paths, list) or not paths or any(not isinstance(p, str) for p in paths):
        raise ValueError("Partition builds require an explicit fineweb_paths list.")
    inventory = [(p, "fineweb", config["build"]["fineweb_documents_per_shard"]) for p in sorted(paths)]
    inventory.append((config["inputs"]["tatoeba_path"], "tatoeba", config["build"]["tatoeba_documents"]))
    if len({p for p, _, _ in inventory}) != len(inventory):
        raise ValueError("Input paths must be unique.")
    for p, _, _ in inventory:
        if Path(p).is_absolute() or not (ROOT / p).resolve().is_relative_to(ROOT):
            raise ValueError("Input paths must be portable paths inside the repository.")
    policy_paths = {}
    if config["build"]["quality_mode"] == "rules_with_approved_annotations":
        policy_paths["annotations"] = ROOT / config["review"]["annotations"]
    policy_paths["calibration_ids"] = ROOT / config["inputs"]["calibration_ids"]
    calibration_ids = json.loads(policy_paths["calibration_ids"].read_text(encoding="utf-8-sig"))
    if not isinstance(calibration_ids, list) or any(not isinstance(p, str) for p in calibration_ids):
        raise ValueError("calibration_ids must contain a JSON list of document IDs.")
    policy_hashes = {key: text_sha(path) for key, path in policy_paths.items()}
    names = ("build", "readers", "clean", "segment", "annotations", "corpus_parts", "corpus_export", "preprocess_part", "merge_parts")
    code_hashes = {f"src/vimeml/data/{name}.py": text_sha(ROOT / f"src/vimeml/data/{name}.py") for name in names}
    relevant = {key: value for key, value in config["build"].items() if key not in {"output_dir", "commit_every", "progress_every", "batch_size"}}
    signature = sha(json.dumps({"schema": PART_SCHEMA_VERSION, "inventory": inventory,
                               "build": relevant, "split": config["split"], "review": config.get("review", {}),
                               "policy_sha256": policy_hashes, "code_sha256": code_hashes}, sort_keys=True))
    return config, inventory, policy_paths, set(calibration_ids), policy_hashes, code_hashes, signature


def parse_part(value):
    try:
        index, total = map(int, value.split("/"))
    except (ValueError, AttributeError):
        raise ValueError("Use --part INDEX/COUNT, e.g. 0/2 or 1/2.") from None
    if not 1 <= total <= 128 or not 0 <= index < total:
        raise ValueError("Part index is zero-based and must be less than the part count.")
    return index, total


def create_database(output, annotation_path):
    if output.exists() and (not output.is_dir() or any(output.iterdir())):
        raise FileExistsError(f"Output must be absent or empty: {output}")
    output.mkdir(parents=True, exist_ok=True)
    db = sqlite3.connect(output / "index.sqlite", uri=True)
    try:
        db.row_factory = sqlite3.Row
        db.execute("PRAGMA temp_store=FILE")
        db.executescript(SCHEMA)
        db.execute("CREATE TABLE document_metrics(doc_hash TEXT PRIMARY KEY, payload TEXT NOT NULL)")
        annotation_count = load_annotations(db, annotation_path)
        return db, annotation_count
    except BaseException:
        db.close()
        raise


def aggregate_metrics(db):
    totals = {key: Counter() for key in ("counts", "rule_reasons", "fragment_flags", "effective_blocks")}
    for row in db.execute("SELECT payload FROM document_metrics ORDER BY doc_hash"):
        metrics = json.loads(row[0])
        for key in totals:
            totals[key].update(metrics[key])
    return totals


def process_content(db, document, normalized, doc_hash, settings, audits):
    counts, rule_reasons, fragment_flags, effective_blocks = (Counter() for _ in range(4))
    counts["unique_documents"] += 1
    blocks = []
    for index, original in enumerate(normalized.split("\n")):
        result = clean_block(original)
        block_id = f"b{index:03d}"
        counts["input_blocks"] += 1
        counts[result["action"] + "_blocks"] += 1
        rule_reasons[result["reason"]] += 1
        blocks.append({
            "id": block_id, "line_index": index,
            "text": result["text"], "action": result["action"],
        })
        if result["action"] != "keep":
            destination = (
                "dropped_blocks" if result["action"] == "drop"
                else "review_blocks"
            )
            dump_line(audits[destination], {
                "source": document.source,
                "doc_id": document.doc_id, "doc_hash": doc_hash,
                "block_id": block_id, "original_text": original,
                **result,
            })

    boundaries, fragment_labels, block_labels = document_annotations(db, doc_hash, blocks)
    counts["approved_block_labels"] += len(block_labels)
    effective_blocks.update(block["action"] for block in blocks)
    for block_id, label_id in block_labels.items():
        block = next(item for item in blocks if item["id"] == block_id)
        destination = "dropped_blocks" if block["action"] == "drop" else "review_blocks"
        dump_line(audits[destination], {
            "source": document.source, "doc_id": document.doc_id,
            "doc_hash": doc_hash, "block_id": block_id,
            "text": block["text"], "action": block["action"],
            "reason": "approved_local_annotation", "annotation_id": label_id,
        })

    paragraphs = restore_linebreaks(
        blocks, max_chars=settings["max_join_chars"],
        boundary_decisions={pair: label["action"] for pair, label in boundaries.items()},
    )
    counts["paragraphs"] += len(paragraphs)
    for pi, paragraph in enumerate(paragraphs):
        counts["join_events"] += len(paragraph["joins"])
        counts["approved_join_events"] += sum(join["reason"] == "approved_annotation" for join in paragraph["joins"])
        paragraph_label_ids = {
            block_labels[part["block_id"]] for part in paragraph["parts"]
            if part["block_id"] in block_labels
        }
        paragraph_label_ids.update(
            boundaries[(join["left_block"], join["right_block"])]["id"]
            for join in paragraph["joins"] if join["reason"] == "approved_annotation"
        )
        for si, sentence in enumerate(split_sentences(paragraph["text"])):
            flags = sorted(set(
                paragraph["flags"] + sentence["flags"]
            ))
            spans = []
            for part in paragraph["parts"]:
                left = max(sentence["start"], part["start"])
                right = min(sentence["end"], part["end"])
                if left < right:
                    spans.append({
                        "block_id": part["block_id"],
                        "start": left - part["start"],
                        "end": right - part["start"],
                    })
            fragment_label = match_fragment(db, fragment_labels, sentence["text"], spans)
            explicitly_kept = fragment_label is not None and fragment_label["action"] == "keep"
            explicitly_excluded = fragment_label is not None and fragment_label["action"] in {"drop", "review"}
            if explicitly_kept and flags:
                raise ValueError(f"Annotation {fragment_label['id']}: flagged fragments cannot be rescued.")
            if (
                (not sentence["terminated"] and not explicitly_kept) or flags or explicitly_excluded
                or not any(c.isalnum() for c in sentence["text"])
            ):
                counts["fragments"] += 1
                fragment_flags.update(flags)
                dump_line(audits["fragments"], {
                    "source": document.source,
                    "doc_id": document.doc_id, "doc_hash": doc_hash,
                    "paragraph_index": pi, "sentence_index": si,
                    "text": sentence["text"],
                    "terminated": sentence["terminated"],
                    "flags": flags, "cleaned_block_spans": spans,
                    "paragraph_joins": paragraph["joins"],
                    "approved_annotation_id": fragment_label["id"] if fragment_label else None,
                })
                continue
            text = sentence["text"]
            label_ids = set(paragraph_label_ids)
            if fragment_label:
                label_ids.add(fragment_label["id"])
                counts["approved_fragment_recoveries"] += int(not sentence["terminated"])
            db.execute(
                "INSERT INTO units VALUES (?, ?, ?, ?, ?, ?, ?)",
                (
                    doc_hash, sha(text), text, pi, si,
                    json.dumps(spans, ensure_ascii=False),
                    json.dumps(sorted(label_ids)),
                ),
            )
            counts["sentence_occurrences"] += 1
    return {'counts': dict(counts), 'rule_reasons': dict(rule_reasons),
            'fragment_flags': dict(fragment_flags), 'effective_blocks': dict(effective_blocks)}