Log in
scripts/make_large_fixture.py 266 lines · 10.1 KB · blame Source
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1
#!/usr/bin/env python3
2
"""Generate the deterministic large-mirror fixture (bench-large-mirror).
3
4
Produces the working tree used by the mount-vs-clone lane: the same seed
5
always yields the same bytes (fixture identity, ADR-0005), and the content
6
has realistic entropy β€” text files are seeded identifier soup that compresses
7
like real source (~3-4x), binaries are seeded incompressible bytes β€” so
8
transfer-byte measurements mean something. Pattern fixtures from
9
oakbench.fixtures compress ~1000:1 and must not be used here.
10
11
Modes:
12
13
  --tree-only        write the final working tree + MANIFEST.sha256, no VCS
14
  --git-history      also synthesize git history (growth commits + churn
15
                     commits + one binary rewrite), manifest committed last
16
  --verify DIR       recompute the manifest in DIR and compare
17
18
Both modes end at the identical final tree; --verify proves it.
19
20
The git and oak mirrors share the final tree (verified via MANIFEST.sha256),
21
not history: git carries the synthetic history because full-history transfer
22
is part of stock clone cost; the oak mirror is seeded with a single commit.
23
Record that asymmetry wherever results are published.
24
25
Usage:
26
  python3 scripts/make_large_fixture.py --dest /tmp/bench-large-mirror --git-history
27
  python3 scripts/make_large_fixture.py --verify /tmp/bench-large-mirror
28
"""
29
30
from __future__ import annotations
31
32
import argparse
33
import hashlib
34
import random
35
import subprocess
36
import sys
37
from pathlib import Path
38
39
FIXTURE_NAME = "bench-large-mirror"
40
FIXTURE_VERSION = "v1"
41
DEFAULT_SEED = f"oak-{FIXTURE_NAME}-{FIXTURE_VERSION}"
42
43
TEXT_FILE_COUNT = 28_000
44
BINARY_SPECS = [  # path, MiB (kept well under GitHub's 100 MB file limit)
45
    ("assets/media/intro.bin", 16),
46
    ("assets/media/dataset.bin", 32),
47
    ("assets/models/weights.bin", 48),
48
]
49
GROWTH_COMMITS = 20
50
CHURN_COMMITS = 400
51
CHURN_FILES_PER_COMMIT = 8
52
CHURN_LINES_PER_TOUCH = 4
53
MANIFEST = "MANIFEST.sha256"
54
55
TOP_DIRS = ["core", "api", "ui", "infra", "tools", "vendor", "docs", "tests"]
56
EXTENSIONS = {"core": ".py", "api": ".py", "ui": ".ts", "infra": ".yaml",
57
              "tools": ".py", "vendor": ".js", "docs": ".md", "tests": ".py"}
58
59
WORDS = (
60
    "handler request response buffer stream parse encode decode commit tree "
61
    "branch merge index cache token batch worker queue retry timeout config "
62
    "schema field record column filter reduce visit walk node edge graph "
63
    "mount hydrate snapshot manifest oracle subject lane scenario operation"
64
).split()
65
66
67
def make_rng(seed: str, *scope: object) -> random.Random:
68
    digest = hashlib.sha256(":".join([seed, *map(str, scope)]).encode()).digest()
69
    return random.Random(int.from_bytes(digest[:8], "big"))
70
71
72
def text_paths(seed: str) -> list[str]:
73
    rng = make_rng(seed, "paths")
74
    paths: list[str] = []
75
    seen = set()
76
    while len(paths) < TEXT_FILE_COUNT:
77
        top = rng.choice(TOP_DIRS)
78
        depth = rng.randint(1, 5)
79
        parts = [top] + [
80
            f"{rng.choice(WORDS)}_{rng.randint(0, 99):02d}" for _ in range(depth)
81
        ]
82
        name = f"{rng.choice(WORDS)}_{rng.randint(0, 9999):04d}{EXTENSIONS[top]}"
83
        rel = "/".join(parts + [name])
84
        if rel not in seen:
85
            seen.add(rel)
86
            paths.append(rel)
87
    paths.sort()
88
    return paths
89
90
91
def text_body(seed: str, rel: str) -> bytes:
92
    rng = make_rng(seed, "body", rel)
93
    # Log-normal-ish sizes centered near ~7 KB so 28k files land near 200 MB.
94
    lines = max(12, int(rng.lognormvariate(4.6, 0.7)))
95
    out = [f"# {FIXTURE_NAME} {FIXTURE_VERSION} {rel}"]
96
    for i in range(lines):
97
        w = [rng.choice(WORDS) for _ in range(rng.randint(4, 11))]
98
        out.append(f"{w[0]}_{rng.randint(0,999)} = {w[1]}({', '.join(w[2:])})  # {i}")
99
    return ("\n".join(out) + "\n").encode()
100
101
102
def churn_block(seed: str, rel: str, commit_index: int) -> str:
103
    """The exact lines appended to rel by churn commit commit_index.
104
105
    Shared by --git-history (incremental appends) and --tree-only (bulk
106
    appends) so both modes converge on identical bytes.
107
    """
108
    rng = make_rng(seed, "churnline", rel, commit_index)
109
    lines = []
110
    for _ in range(CHURN_LINES_PER_TOUCH):
111
        w = [rng.choice(WORDS) for _ in range(5)]
112
        lines.append(f"{w[0]}_{rng.randint(0,999)} = {w[1]}({', '.join(w[2:])})  # churn")
113
    return "".join(line + "\n" for line in lines)
114
115
116
def churn_schedule(seed: str, paths: list[str]) -> list[list[str]]:
117
    rng = make_rng(seed, "churn")
118
    return [sorted(rng.sample(paths, CHURN_FILES_PER_COMMIT)) for _ in range(CHURN_COMMITS)]
119
120
121
def write_binary(path: Path, mib: int, seed: str) -> None:
122
    rng = make_rng(seed, "binary", path.name, mib)
123
    path.parent.mkdir(parents=True, exist_ok=True)
124
    with path.open("wb") as fh:
125
        for _ in range(mib):
126
            fh.write(rng.randbytes(1024 * 1024))
127
128
129
def write_readme(dest: Path, seed: str) -> None:
130
    (dest / "README.md").write_text(
131
        f"# {FIXTURE_NAME}\n\nDeterministic benchmark fixture "
132
        f"({FIXTURE_VERSION}, seed `{seed}`), generated by "
133
        f"`scripts/make_large_fixture.py` in oak/benchmarks.\n"
134
        f"Do not edit by hand; regenerate and bump {FIXTURE_VERSION} instead.\n"
135
    )
136
137
138
def write_base_tree(dest: Path, seed: str) -> list[str]:
139
    """The pre-churn tree: base text bodies + original binaries + README."""
140
    paths = text_paths(seed)
141
    for rel in paths:
142
        p = dest / rel
143
        p.parent.mkdir(parents=True, exist_ok=True)
144
        p.write_bytes(text_body(seed, rel))
145
    for rel, mib in BINARY_SPECS:
146
        write_binary(dest / rel, mib, seed + ":orig")
147
    write_readme(dest, seed)
148
    return paths
149
150
151
def apply_churn_to_tree(dest: Path, seed: str, paths: list[str]) -> None:
152
    """Bulk-apply every churn append and the final binary rewrite."""
153
    appends: dict[str, list[str]] = {}
154
    for i, batch in enumerate(churn_schedule(seed, paths)):
155
        for rel in batch:
156
            appends.setdefault(rel, []).append(churn_block(seed, rel, i))
157
    for rel, blocks in appends.items():
158
        with (dest / rel).open("a", encoding="utf-8") as fh:
159
            fh.write("".join(blocks))
160
    for rel, mib in BINARY_SPECS:
161
        write_binary(dest / rel, mib, seed)
162
163
164
def compute_manifest(root: Path) -> str:
165
    entries = []
166
    for p in sorted(root.rglob("*")):
167
        rel = p.relative_to(root).as_posix()
168
        if not p.is_file() or rel == MANIFEST or rel.split("/", 1)[0] in (".git", ".oak"):
169
            continue
170
        entries.append(f"{hashlib.sha256(p.read_bytes()).hexdigest()}  {rel}")
171
    return "\n".join(entries) + "\n"
172
173
174
def git(args: list[str], cwd: Path, commit_index: int | None = None) -> None:
175
    env = None
176
    if commit_index is not None:
177
        # Deterministic identity/dates make the history reproducible run-to-run.
178
        ts = 1767225600 + commit_index * 60  # 2026-01-01T00:00:00Z + 60s per commit
179
        env = {"GIT_AUTHOR_DATE": f"{ts} +0000", "GIT_COMMITTER_DATE": f"{ts} +0000",
180
               "GIT_AUTHOR_NAME": "oak-bench", "GIT_AUTHOR_EMAIL": "[email protected]",
181
               "GIT_COMMITTER_NAME": "oak-bench", "GIT_COMMITTER_EMAIL": "[email protected]",
182
               "PATH": "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin",
183
               "HOME": str(Path.home())}
184
    subprocess.run(["git", *args], cwd=cwd, env=env, check=True,
185
                   stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
186
187
188
def build_git_history(dest: Path, seed: str) -> None:
189
    paths = write_base_tree(dest, seed)
190
    git(["init", "-q", "-b", "main"], dest)
191
    git(["config", "core.autocrlf", "false"], dest)
192
193
    n = 0
194
    growth_groups: list[list[str]] = [[] for _ in range(GROWTH_COMMITS)]
195
    for i, rel in enumerate(paths):
196
        growth_groups[i % GROWTH_COMMITS].append(rel)
197
    for i, group in enumerate(growth_groups):
198
        git(["add", "--"] + group + (["README.md"] if i == 0 else []), dest)
199
        n += 1
200
        git(["commit", "-q", "-m", f"growth {i+1}/{GROWTH_COMMITS}"], dest, n)
201
    git(["add", "-A"], dest)
202
    n += 1
203
    git(["commit", "-q", "-m", "add binary assets"], dest, n)
204
205
    for i, batch in enumerate(churn_schedule(seed, paths)):
206
        for rel in batch:
207
            with (dest / rel).open("a", encoding="utf-8") as fh:
208
                fh.write(churn_block(seed, rel, i))
209
        git(["add", "--"] + batch, dest)
210
        n += 1
211
        git(["commit", "-q", "-m", f"churn {i+1}/{CHURN_COMMITS}"], dest, n)
212
213
    # One binary rewrite so history carries a large non-text delta.
214
    for rel, mib in BINARY_SPECS:
215
        write_binary(dest / rel, mib, seed)
216
    git(["add", "-A"], dest)
217
    n += 1
218
    git(["commit", "-q", "-m", "refresh binary assets"], dest, n)
219
220
    (dest / MANIFEST).write_text(compute_manifest(dest))
221
    git(["add", MANIFEST], dest)
222
    n += 1
223
    git(["commit", "-q", "-m", f"manifest {FIXTURE_VERSION}"], dest, n)
224
225
226
def main() -> int:
227
    ap = argparse.ArgumentParser(description=__doc__)
228
    ap.add_argument("--dest", type=Path)
229
    ap.add_argument("--seed", default=DEFAULT_SEED)
230
    ap.add_argument("--tree-only", action="store_true")
231
    ap.add_argument("--git-history", action="store_true")
232
    ap.add_argument("--verify", type=Path)
233
    args = ap.parse_args()
234
235
    if args.verify:
236
        expected = (args.verify / MANIFEST).read_text()
237
        actual = compute_manifest(args.verify)
238
        if expected == actual:
239
            print(f"OK: {args.verify} matches {MANIFEST} ({expected.count(chr(10))} files)")
240
            return 0
241
        print("MISMATCH: tree does not match MANIFEST.sha256", file=sys.stderr)
242
        return 1
243
244
    if not args.dest:
245
        ap.error("--dest required unless --verify")
246
    dest = args.dest
247
    dest.mkdir(parents=True, exist_ok=True)
248
    if any(dest.iterdir()):
249
        print(f"refusing to write into non-empty {dest}", file=sys.stderr)
250
        return 1
251
252
    if args.git_history:
253
        build_git_history(dest, args.seed)
254
    else:
255
        paths = write_base_tree(dest, args.seed)
256
        apply_churn_to_tree(dest, args.seed, paths)
257
        (dest / MANIFEST).write_text(compute_manifest(dest))
258
    total = sum(p.stat().st_size for p in dest.rglob("*")
259
                if p.is_file() and ".git" not in p.parts)
260
    print(f"{FIXTURE_NAME} {FIXTURE_VERSION} seed={args.seed} -> {dest}")
261
    print(f"working tree bytes (excl .git): {total:,}")
262
    return 0
263
264
265
if __name__ == "__main__":
266
    raise SystemExit(main())