File size: 9,760 Bytes
80c9326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
220
221
"""Tokenizer-training corpora from raw (unfiltered) FineWeb / FineWeb2.

Both tokenizer conditions are sampled from the SAME corpus family as the
model-training pools (data/fineweb.py's FineWeb-HQ / FineWeb2-HQ), just the
unfiltered releases -- HuggingFaceFW/fineweb (English) and
HuggingFaceFW/fineweb-2 (everyone else). This is a deliberate deviation from
ATLAS's literal MADLAD-400-trained tokenizer: it removes a tokenizer-corpus-
vs-model-corpus domain-mismatch confound that could otherwise hit AR/ZH
differently than DE/FR (MADLAD's non-Latin-script cleaning/LangID is less
consistent than FineWeb's), at the cost of no longer being a byte-for-byte
ATLAS replication for the starved tokenizer's source text.

  starved   -- ATLAS-style: T=100 temperature sampling (p_l ~ n_l^(1/100),
               i.e. near-uniform) over ~419 languages -- English (raw FineWeb)
               plus the ~418 largest FineWeb2 language-script configs by
               volume. "Largest by volume" is the FineWeb2 analogue of how
               MADLAD-400's own ~419-language "clean" set was itself
               determined (languages with enough clean text to clear a
               volume floor), so it preserves the same selection logic, just
               applied to a different corpus.
  destarved -- our 5 study languages only; per-language byte budgets scaled
               by the FLORES+ byte premium so *content* (not bytes) is
               uniform across languages.

Both stream parquet with column pruning (reusing data.fineweb's `_iter_texts`),
so only the `text` column is ever pulled.
"""
import json
import random
import urllib.request
from pathlib import Path

from ..langs import LANGS
from ..paths import MANIFEST_CACHE, TOK_CORPORA, ensure
from .fineweb import _iter_texts

FINEWEB_EN_REPO = "HuggingFaceFW/fineweb"
FINEWEB2_REPO = "HuggingFaceFW/fineweb-2"
N_STARVED_LANGS = 419  # matches MADLAD-400/ATLAS's ~419-language scale

# SentencePiece skips sentences longer than max_sentence_length (default 4192
# bytes); we pre-split long documents so no text is silently dropped.
MAX_LINE_BYTES = 4000


def _get_json(url: str) -> dict:
    with urllib.request.urlopen(url, timeout=120) as r:
        return json.loads(r.read().decode("utf-8"))


def _fineweb2_size_manifest(refresh: bool = False) -> dict[str, int]:
    """{config_name: num_bytes_original_files} for every FineWeb2 language-script config."""
    cache = ensure(MANIFEST_CACHE) / "fineweb2_sizes.json"
    if cache.exists() and not refresh:
        return json.loads(cache.read_text())
    d = _get_json(f"https://datasets-server.huggingface.co/size?dataset={FINEWEB2_REPO}")
    sizes = {c["config"]: c["num_bytes_original_files"] for c in d["size"]["configs"]}
    cache.write_text(json.dumps(sizes))
    print(f"[tokcorpus] fetched FineWeb2 sizes for {len(sizes)} configs")
    return sizes


def _fineweb_en_size(refresh: bool = False) -> int:
    cache = ensure(MANIFEST_CACHE) / "fineweb_en_size.json"
    if cache.exists() and not refresh:
        return json.loads(cache.read_text())["bytes"]
    d = _get_json(f"https://datasets-server.huggingface.co/size?dataset={FINEWEB_EN_REPO}&config=default")
    n = d["size"]["config"]["num_bytes_original_files"]
    cache.write_text(json.dumps({"bytes": n}))
    return n


def select_starved_languages(n_langs: int = N_STARVED_LANGS) -> dict[str, int]:
    """{code: available_bytes} for the starved condition's language universe.

    "en" (raw FineWeb) plus the (n_langs-1) largest FineWeb2 configs by volume.
    Our other 4 study languages (de/fr/ar/zh) are always near the top of that
    ranking on volume alone (verified: ranks 2-13 of 1314), so no forced
    inclusion is needed.
    """
    sizes = _fineweb2_size_manifest()
    top = sorted(sizes.items(), key=lambda kv: -kv[1])[: n_langs - 1]
    universe = {"en": _fineweb_en_size(), **dict(top)}
    return universe


def _source_for_code(code: str) -> tuple[str, str]:
    """(repo, subdir) of raw FineWeb-family text for a language code.

    `code` is either "en", one of our other 4 study codes, or a raw FineWeb2
    language-script config name (a starved-only competing language).
    """
    if code == "en":
        return FINEWEB_EN_REPO, "data"
    if code in LANGS:
        code = LANGS[code].fineweb_subdir  # study code -> FineWeb2 config name
    return FINEWEB2_REPO, f"data/{code}/train"


def _list_parquet_files(repo: str, subdir: str) -> list[str]:
    cache = ensure(MANIFEST_CACHE / "parquet_files") / f"{repo.replace('/', '__')}__{subdir.replace('/', '_')}.json"
    if cache.exists():
        return json.loads(cache.read_text())
    from huggingface_hub import HfApi
    api = HfApi()
    files = [e.path for e in api.list_repo_tree(repo, subdir, repo_type="dataset", recursive=True)
             if e.__class__.__name__ == "RepoFile" and e.path.endswith(".parquet")]
    # dump-stratified sources (raw FineWeb's CC-MAIN-*/) interleave across dumps
    # for a temporally representative sample; FineWeb2's per-language train/
    # files have no such structure, so plain sort is enough.
    by_dump: dict[str, list[str]] = {}
    for f in sorted(files):
        parts = f[len(subdir):].strip("/").split("/")
        key = parts[0] if len(parts) > 1 and "CC-MAIN" in parts[0] else ""
        by_dump.setdefault(key, []).append(f)
    if len(by_dump) > 1:
        out = []
        for i in range(max(len(v) for v in by_dump.values())):
            for d in sorted(by_dump):
                if i < len(by_dump[d]):
                    out.append(by_dump[d][i])
        files = out
    else:
        files = sorted(files)
    cache.write_text(json.dumps(files))
    return files


def _split_line(text: str):
    """Yield non-empty lines, splitting any line over MAX_LINE_BYTES at whitespace."""
    for ln in text.split("\n"):
        ln = ln.strip()
        if not ln:
            continue
        enc = ln.encode("utf-8")
        while len(enc) > MAX_LINE_BYTES:
            cut = enc[:MAX_LINE_BYTES].rfind(b" ")
            if cut < MAX_LINE_BYTES // 2:
                cut = MAX_LINE_BYTES
                while cut > 0 and (enc[cut] & 0xC0) == 0x80:  # don't split mid UTF-8 char
                    cut -= 1
            yield enc[:cut].decode("utf-8", errors="replace").strip()
            enc = enc[cut:].lstrip()
        if enc:
            yield enc.decode("utf-8", errors="replace")


def _collect(code: str, budget_bytes: int, out_path: Path, seed: int = 0) -> int:
    """Stream parquet files (shuffled order) for one language until budget is met."""
    repo, subdir = _source_for_code(code)
    files = list(_list_parquet_files(repo, subdir))
    random.Random(seed).shuffle(files)
    got = 0
    with open(out_path, "w", encoding="utf-8") as out:
        for f in files:
            if got >= budget_bytes:
                break
            try:
                for doc in _iter_texts(repo, f):
                    for ln in _split_line(doc):
                        out.write(ln + "\n")
                        got += len(ln.encode("utf-8")) + 1
                    if got >= budget_bytes:
                        break
            except Exception as exc:
                print(f"[tokcorpus] WARN {repo}/{f} failed mid-stream: {exc}")
    return got


def build_starved(total_bytes: float = 4e9, T: float = 100.0, seed: int = 0,
                  n_langs: int = N_STARVED_LANGS) -> Path:
    """T-temperature sample over ~419 FineWeb/FineWeb2 languages (ATLAS-scale replication)."""
    universe = select_starved_languages(n_langs)
    out_dir = ensure(TOK_CORPORA / "starved")
    weights = {l: b ** (1.0 / T) for l, b in universe.items()}
    z = sum(weights.values())
    stats = {}
    for i, code in enumerate(sorted(universe)):
        budget = int(total_bytes * weights[code] / z)
        out_path = out_dir / f"{code}.txt"
        if out_path.exists() and out_path.stat().st_size >= 0.9 * budget:
            stats[code] = {"budget": budget, "bytes": out_path.stat().st_size, "cached": True}
            continue
        got = _collect(code, budget, out_path, seed=seed + i)
        stats[code] = {"budget": budget, "bytes": got}
        print(f"[starved] {code}: {got/1e6:.1f}MB / budget {budget/1e6:.1f}MB "
              f"({i+1}/{len(universe)})")
    (out_dir / "stats.json").write_text(json.dumps(
        {"total_bytes": total_bytes, "T": T, "n_langs": len(universe), "per_lang": stats},
        indent=2))
    return out_dir


def build_destarved(total_bytes: float = 4e9, seed: int = 0) -> Path:
    """5 study languages; byte budgets scaled by FLORES+ byte premium (content-uniform)."""
    from ..byte_premium import load_premiums
    premiums = load_premiums()
    out_dir = ensure(TOK_CORPORA / "destarved")
    z = sum(premiums[l] for l in LANGS)
    stats = {}
    for i, code in enumerate(LANGS):
        budget = int(total_bytes * premiums[code] / z)
        out_path = out_dir / f"{code}.txt"
        if out_path.exists() and out_path.stat().st_size >= 0.9 * budget:
            stats[code] = {"budget": budget, "bytes": out_path.stat().st_size, "cached": True}
            continue
        got = _collect(code, budget, out_path, seed=seed + i)
        stats[code] = {"budget": budget, "bytes": got}
        print(f"[destarved] {code}: {got/1e6:.1f}MB / budget {budget/1e6:.1f}MB")
    (out_dir / "stats.json").write_text(json.dumps(
        {"total_bytes": total_bytes, "premiums": premiums, "per_lang": stats}, indent=2))
    return out_dir


def corpus_files(condition: str) -> list[Path]:
    d = TOK_CORPORA / condition
    files = sorted(d.glob("*.txt"))
    if not files:
        raise FileNotFoundError(f"no corpus at {d} - run `xscript tok-corpus --condition {condition}`")
    return files