| 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())
|