#!/usr/bin/env python """Generate an invented note collection plus its answer key. Seeded and deterministic: the same ``--seed`` and ``--profile`` write byte-identical files and a byte-identical key. The key is written from this generator's own random draws, never from product output, and it is written OUTSIDE the collection folder so the product under test can never read it. The ``small``, ``standard`` and ``heavy`` profiles write an Obsidian-style collection that fits the vault. The ``raw`` and ``raw-standard`` profiles add messy real-world input beside it (see ``raw_corpus.py``) and write the page spec of an existing vault next to the key. Pure standard library. Fixture bytes come from ``tests/fixtures`` (media samples and the base64 graph-value media); unique-phrase variants of the same formats are rebuilt here so each carries its own recall phrase. python generate.py --seed 7 --profile standard --out /scratch/collection --key /scratch/key.json python generate.py --seed 7 --profile raw --out /scratch/raw --key /scratch/raw.key.json """ from __future__ import annotations import argparse import base64 import hashlib import json import os import random import unicodedata import zipfile from collections.abc import Iterator from dataclasses import dataclass, field from pathlib import Path import raw_corpus GENERATOR_VERSION = 1 REPO_ROOT = Path(__file__).resolve().parents[2] FIXTURES = REPO_ROOT / "tests" / "fixtures" # Fixed timestamp base so inventory stat data (size, mtime) is reproducible. MTIME_BASE = 1_735_689_600 # 2025-01-01T00:00:00Z # --------------------------------------------------------------------------- # # profiles # --------------------------------------------------------------------------- # @dataclass(frozen=True) class Profile: notes: int attachments: int conflict_copies: int zero_byte: int trash_notes: int duplicate_basename_pairs: int folders: int | None # None = every folder in FOLDERS heavy_bytes: int = 0 raw_snippets: int | None = None # None = no raw items; else the file count of the flat dump # A raw profile has its own name, so its draws never move those of the profile it resembles. PROFILES = { "small": Profile(60, 15, 3, 2, 2, 2, 9), "standard": Profile(1000, 100, 24, 10, 8, 8, None), "heavy": Profile(1000, 100, 24, 10, 8, 8, None, heavy_bytes=2 * 1024**3), "raw": Profile(60, 15, 3, 2, 2, 2, 9, raw_snippets=40), "raw-standard": Profile(1000, 100, 24, 10, 8, 8, None, raw_snippets=300), } JA_RATIO = 0.10 NFD_SHARE_OF_JA = 0.4 # (path, weight, kind). Order matters: ``small`` keeps the first ``folders`` entries # that are not Japanese-only, plus the first Japanese-only one. FOLDERS: tuple[tuple[str, int, str], ...] = ( ("projects/harbor-survey", 5, "project"), ("journal/2024", 11, "daily"), ("people", 6, "person"), ("reading/books", 5, "reading"), ("meetings/2025", 6, "meeting"), ("recipes", 4, "recipe"), ("ideas", 5, "idea"), ("reference/glossary", 3, "reference"), ("areas/home", 3, "area"), ("projects/harbor-survey/field-notes", 5, "project"), ("projects/garden-planner", 4, "project"), ("projects/kite-workshop", 4, "project"), ("projects/tide-tables", 3, "project"), ("journal/2025", 11, "daily"), ("journal/2025/weekly", 2, "weekly"), ("reading/articles", 6, "reading"), ("reading/papers", 3, "reading"), ("meetings/2024", 5, "meeting"), ("reference/checklists", 3, "reference"), ("areas/travel", 3, "area"), ("inbox", 2, "idea"), ("日本語/メモ", 3, "ja-only"), ("日本語/読書", 2, "ja-only"), ) ATTACHMENT_FOLDERS = { "image": ("attachments", "journal/images", "projects/harbor-survey/assets", "areas/travel/photos"), "document": ("attachments", "reading/pdfs", "projects/harbor-survey/assets"), "audio": ("meetings/recordings", "attachments"), "video": ("meetings/recordings", "attachments"), "text": ("attachments", "reference/data"), } # --------------------------------------------------------------------------- # # invented vocabulary (fixture data, not product vocabulary) # --------------------------------------------------------------------------- # ADJ = ( "amber brisk calm copper dusky early faded gentle hollow idle jaunty keen lunar mellow " "narrow open pale quiet rustic salted tidy umber vivid windy young zesty bright coarse " "distant ember frosted granite humble ivory jade kindled linen mossy nimble oaken pearl" ).split() NOUN = ( "anchor beacon compass dune ember ferry gable harbor island jetty kettle lantern meadow " "orchard pier quarry reef shingle thicket trellis upland vessel wharf yard almanac basin " "cairn delta estuary fjord grove hedge inlet knoll ledger marsh outpost paddock ridge" ).split() VERB = ( "review sketch measure compare draft revise tidy catalogue outline test summarise " "plan schedule survey label sort polish rehearse inspect archive" ).split() PERSON_A = "Veltrin Orsa Marlow Tavish Korin Ilsa Brevo Quenby Davor Lysa Hesk Nerys".split() PERSON_B = "Okonar Pellick Strand Ravel Thorne Winsley Yarrow Zephren Aldous Birchley Corwin".split() TAGS = ( "harbor garden kite tides recipe idea reading meeting travel home weekly review draft " "followup question reference checklist plan log personal craft weather data" ).split() NESTED_TAGS = ("project/harbor", "project/garden", "project/kite", "area/home", "area/travel", "status/active", "status/waiting", "reading/book", "reading/paper") STATUSES = ("draft", "active", "waiting", "done") JA_TOPIC = "港 庭 凧 潮 灯台 地図 茶 雲 森 橋 畑 風 星 湖 駅 市場 帆 岬 窓 道".split() JA_SUFFIX = ("ガイド", "ログ", "ブック", "プラン", "ボード", "デザイン", "バージョン", "ガーデン", "ドキュメント", "プロジェクト", "メモ", "ノート", "記録", "計画") JA_VERB = "整理 確認 見直し 準備 比較 記録 点検 検討".split() JA_NOUN = "予定 手順 資料 写真 地図 日程 一覧 感想 課題 候補".split() JA_PROBE = "霧笛 朝凪 夕凪 星屑 潮騒 灯火 風車 小舟 白帆 青葉".split() URL_HOST = "example.invalid" PHRASE_ADJ = "orchid cobalt saffron tundra velvet quartz maple harbor lilac basalt".split() PHRASE_NOUN = "heron ledger compass lantern kettle atlas ferry orchard glacier marble".split() PHRASE_TAIL = "inventory schedule briefing journal tally register summary roster".split() def sentence(rng: random.Random) -> str: templates = ( "The {a} {n} needs another pass before we {v} the {n2}.", "We agreed to {v} the {a} {n} and keep the {n2} for later.", "A {a} {n} sits next to the {n2}, which makes it easy to {v}.", "Next step: {v} the {n} and note how the {a} {n2} changes.", "Nothing urgent here, but the {a} {n} deserves a closer look.", "The {n} and the {n2} do not match yet; {v} both again.", ) return rng.choice(templates).format( a=rng.choice(ADJ), n=rng.choice(NOUN), n2=rng.choice(NOUN), v=rng.choice(VERB) ) def ja_sentence(rng: random.Random) -> str: templates = ( "今日は{t1}の{n1}を{v}しました。", "{t1}と{t2}の違いについて考えた。", "次の{n1}は{t1}に合わせて{v}する予定です。", "{t1}の{n1}がまだ足りないので、{t2}の{n2}も{v}したい。", "特に急ぎではないが、{t1}の{n1}は早めに{v}しておく。", ) return rng.choice(templates).format( t1=rng.choice(JA_TOPIC), t2=rng.choice(JA_TOPIC), n1=rng.choice(JA_NOUN), n2=rng.choice(JA_NOUN), v=rng.choice(JA_VERB), ) # --------------------------------------------------------------------------- # # data model # --------------------------------------------------------------------------- # @dataclass class Note: index: int folder: str folder_kind: str lang: str stem: str # NFC stem used when writing links disk_stem: str # stem as it exists on disk (NFD for some Japanese names) date: str title: str probe: str = "" frontmatter_tags: list[str] = field(default_factory=list) inline_tags: list[str] = field(default_factory=list) aliases: list[str] = field(default_factory=list) properties: dict[str, str] = field(default_factory=dict) has_frontmatter: bool = False has_overview: bool = False links: list[dict] = field(default_factory=list) embeds: list[dict] = field(default_factory=list) @property def path(self) -> str: return f"{self.folder}/{self.disk_stem}.md" if self.folder else f"{self.disk_stem}.md" @property def link_path(self) -> str: """Vault-relative path without extension, as a human writes it in a link.""" return f"{self.folder}/{self.stem}" if self.folder else self.stem @dataclass class Attachment: path: str data: bytes ext: str media_type: str # image | document | audio | video | text phrase: str | None phrase_lang: str | None origin: str group: str = "" filler: bool = False embedded_by: list[str] = field(default_factory=list) @property def sha256(self) -> str: return hashlib.sha256(self.data).hexdigest() # --------------------------------------------------------------------------- # # attachment payloads # --------------------------------------------------------------------------- # MEDIA_TYPE = { ".png": "image", ".heic": "image", ".pdf": "document", ".docx": "document", ".xlsx": "document", ".pptx": "document", ".odt": "document", ".ods": "document", ".odp": "document", ".epub": "document", ".rtf": "document", ".html": "document", ".eml": "text", ".ics": "text", ".txt": "text", ".csv": "text", ".json": "text", ".ogg": "audio", ".mp4": "video", } def _phrase_lang(name: str, phrase: str) -> str: if any("" <= ch <= "ヿ" or "一" <= ch <= "鿿" for ch in phrase): return "ja" if "est" in name: return "et" return "en" def load_fixture_payloads() -> list[tuple[str, bytes, str | None]]: """(name, bytes, phrase) for every reusable fixture, in a fixed order.""" media = FIXTURES / "media-samples" expected = json.loads((media / "expected.json").read_text(encoding="utf-8")) out: list[tuple[str, bytes, str | None]] = [] for name in sorted(expected): out.append((name, (media / name).read_bytes(), expected[name])) graph = FIXTURES / "graph_value" / "media" for name in sorted(p.name for p in graph.glob("*.b64")): decoded = base64.b64decode((graph / name).read_text(encoding="ascii")) out.append((name[: -len(".b64")], decoded, None)) return out def _rezip_with_phrase(raw: bytes, old: str, new: str, tmp: Path) -> bytes | None: """Rewrite a zip-based document with ``old`` replaced by ``new`` in every entry.""" src = tmp / "in.zip" dst = tmp / "out.zip" src.write_bytes(raw) hit = False with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w") as zout: for info in zin.infolist(): blob = zin.read(info) if old.encode() in blob: blob = blob.replace(old.encode(), new.encode()) hit = True fresh = zipfile.ZipInfo(info.filename, date_time=(2025, 1, 1, 0, 0, 0)) fresh.compress_type = info.compress_type fresh.external_attr = info.external_attr zout.writestr(fresh, blob) return dst.read_bytes() if hit else None def minimal_pdf(phrase: str) -> bytes: stream = f"BT /F1 14 Tf 72 720 Td ({phrase}) Tj ET".encode("latin-1") objs = [ b"<>", b"<>", b"<>>>/Contents 5 0 R>>", b"<>", b"<>\nstream\n" + stream + b"\nendstream", ] out = bytearray(b"%PDF-1.4\n") offsets = [] for number, body in enumerate(objs, start=1): offsets.append(len(out)) out += f"{number} 0 obj\n".encode() + body + b"\nendobj\n" xref = len(out) out += f"xref\n0 {len(objs) + 1}\n0000000000 65535 f \n".encode() for off in offsets: out += f"{off:010d} 00000 n \n".encode() out += f"trailer\n<>\nstartxref\n{xref}\n%%EOF\n".encode() return bytes(out) def variant_payload(ext: str, phrase: str, rng: random.Random, fixtures: dict[str, bytes], tmp: Path) -> bytes | None: if ext == ".txt": return f"Reminder: {phrase}.\n".encode() if ext == ".html": return (f"
The {phrase} changed.
").encode() if ext == ".ics": return ( "BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\n" f"UID:gen-{rng.randrange(10**8)}\nDTSTART:20260112T090000Z\nSUMMARY:{phrase}\n" "END:VEVENT\nEND:VCALENDAR\n" ).encode() if ext == ".eml": return ( "From: keeper@example.org\nTo: crew@example.org\nSubject: Plan\n" "Content-Type: text/plain; charset=utf-8\n\n" f"Please read the {phrase}.\n" ).encode() if ext == ".rtf": # Raw string with single backslashes: a doubled backslash reads as a UNC path to the privacy gate. rtf = r"{\rtf1\ansi\deff0{\fonttbl{\f0 Times New Roman;}}\f0\fs24 The \b PHRASE\b0 begins here.\par}" return rtf.replace("PHRASE", phrase).encode() if ext == ".pdf": return minimal_pdf(phrase) if ext == ".csv": return f"item,note\nentry,{phrase}\nentry two,steady\n".encode() if ext == ".json": return json.dumps({"entry": phrase, "state": "steady"}, indent=1).encode() fixture_phrase = { ".docx": "velvet compass expedition ledger", ".xlsx": "lantern oil inventory", ".pptx": "glacier orchard roadmap", ".odt": "heron census at the northern marsh", ".ods": "barley harvest tally", ".odp": "tidal observatory briefing", ".epub": "the cartographer of salt marshes", }.get(ext) if fixture_phrase: return _rezip_with_phrase(fixtures[f"sample{ext}"], fixture_phrase, phrase, tmp) return None VARIANT_EXTS = (".pdf", ".docx", ".txt", ".html", ".xlsx", ".pptx", ".ics", ".eml", ".rtf", ".odt", ".ods", ".odp", ".epub", ".csv", ".json") def invented_phrase(rng: random.Random, used: set[str]) -> str: while True: phrase = f"{rng.choice(PHRASE_ADJ)} {rng.choice(PHRASE_NOUN)} {rng.choice(PHRASE_TAIL)}" phrase = f"{phrase} {rng.randrange(100, 999)}" if phrase not in used: used.add(phrase) return phrase def attachment_name(rng: random.Random, ext: str, counter: int, ja: bool) -> str: if ext in (".png", ".heic"): style = rng.randrange(3) if style == 0: return f"IMG_{counter:04d}{ext}" if style == 1: return f"Screenshot 2025-0{rng.randrange(1, 9)}-{rng.randrange(10, 28)} at 14.0{rng.randrange(10)}{ext}" return f"diagram-{rng.choice(NOUN)}-{counter}{ext}" if ext in (".ogg", ".mp4"): return f"Recording {counter:03d} {rng.choice(NOUN)}{ext}" if ja: return f"{rng.choice(JA_TOPIC)}{rng.choice(JA_SUFFIX)}{counter}{ext}" return f"{rng.choice(ADJ)}-{rng.choice(NOUN)}-{counter}{ext}" def build_attachments(rng: random.Random, profile: Profile, tmp: Path) -> list[Attachment]: payloads = load_fixture_payloads() fixtures = {name: data for name, data, _ in payloads} used_phrases = {p for _, _, p in payloads if p} pool: list[tuple[str, bytes, str | None, str]] = [ (Path(name).suffix, data, phrase, f"fixture:{name}") for name, data, phrase in payloads ] small = profile.attachments < 30 base_count = 8 if small else len(pool) if small: # Keep the small profile representative: one payload per distinct extension. seen: set[str] = set() chosen = [] for item in pool: if item[0] not in seen: seen.add(item[0]) chosen.append(item) pool = chosen[:base_count] wanted_unique = max(profile.attachments * 65 // 100, len(pool) + 3) variants_needed = max(wanted_unique - len(pool), 0) exts = list(VARIANT_EXTS) produced = 0 while produced < variants_needed: ext = exts[produced % len(exts)] phrase = invented_phrase(rng, used_phrases) data = variant_payload(ext, phrase, rng, fixtures, tmp) if data is None: exts.remove(ext) continue pool.append((ext, data, phrase, f"generated:{ext.lstrip('.')}")) produced += 1 # Names: every payload once, then re-used bytes under extra names up to the target. slots: list[int] = list(range(len(pool))) extra = max(profile.attachments - len(pool), 0) dup_sources = rng.sample(range(len(pool)), min(len(pool), max(extra // 2, 1))) cursor = 0 while extra > 0: slots.append(dup_sources[cursor % len(dup_sources)]) cursor += 1 extra -= 1 rng.shuffle(slots) attachments: list[Attachment] = [] used_paths: set[str] = set() counts: dict[int, int] = {} for counter, slot in enumerate(slots, start=1): ext, data, phrase, origin = pool[slot] media_type = MEDIA_TYPE.get(ext, "document") folder_choices = ATTACHMENT_FOLDERS.get(media_type, ("attachments",)) if small: folder_choices = ("attachments",) folder = rng.choice(folder_choices) ja_name = rng.random() < 0.08 while True: name = attachment_name(rng, ext, counter + rng.randrange(1000), ja_name) if ja_name and rng.random() < 0.5: name = unicodedata.normalize("NFD", name) rel = f"{folder}/{name}" if rel not in used_paths and name.lower() not in {p.rsplit("/", 1)[-1].lower() for p in used_paths}: used_paths.add(rel) break counts[slot] = counts.get(slot, 0) + 1 lang = _phrase_lang(origin, phrase) if phrase else None attachments.append(Attachment(rel, data, ext, media_type, phrase, lang, origin)) # Duplicate-bytes groups, ordered by first path for stable ids. by_hash: dict[str, list[Attachment]] = {} for att in attachments: by_hash.setdefault(att.sha256, []).append(att) for number, digest in enumerate(sorted(by_hash, key=lambda h: min(a.path for a in by_hash[h]))): for att in by_hash[digest]: att.group = f"dg-{number:03d}" attachments.sort(key=lambda a: a.path) return attachments def heavy_attachments(rng: random.Random, total_bytes: int) -> list[tuple[Attachment, int]]: """Large filler binaries for byte-volume pressure; not decodable media. Returns each placeholder with the size it will be streamed at, so the bytes never sit in memory. """ count = 12 each = total_bytes // count out = [] for index in range(count): ext = (".mp4", ".mov", ".wav")[index % 3] path = f"meetings/recordings/bulk-{index:02d}-{rng.choice(NOUN)}{ext}" media_type = "audio" if ext == ".wav" else "video" placeholder = Attachment(path, b"", ext, media_type, None, None, f"filler:{each}", group=f"heavy-{index:02d}", filler=True) out.append((placeholder, each)) return out # --------------------------------------------------------------------------- # # notes # --------------------------------------------------------------------------- # def apportion(total: int, weights: list[int]) -> list[int]: """Largest-remainder split of ``total`` across ``weights``.""" scale = sum(weights) raw = [total * w / scale for w in weights] counts = [int(x) for x in raw] order = sorted(range(len(weights)), key=lambda i: (raw[i] - counts[i], -i), reverse=True) for i in order[: total - sum(counts)]: counts[i] += 1 return counts def pick_folders(profile: Profile) -> list[tuple[str, int, str]]: if profile.folders is None: return list(FOLDERS) normal = [f for f in FOLDERS if f[2] != "ja-only"][: profile.folders - 1] japanese = [f for f in FOLDERS if f[2] == "ja-only"][:1] return normal + japanese def make_title(rng: random.Random, kind: str, lang: str, index: int, dates: Iterator[str]) -> tuple[str, str]: """Return (title, stem).""" if kind in ("daily", "weekly"): date = next(dates) if lang == "ja": return f"{date} ログ", f"{date}-ログ" return (f"Week of {date}", f"{date}-week") if kind == "weekly" else (date, date) if lang == "ja": title = f"{rng.choice(JA_TOPIC)}{rng.choice(JA_SUFFIX)}" return title, title if kind == "person": title = f"{rng.choice(PERSON_A)} {rng.choice(PERSON_B)}" elif kind == "recipe": title = f"{rng.choice(ADJ).title()} {rng.choice(NOUN)} bake" elif kind == "meeting": title = f"{rng.choice(NOUN).title()} sync {rng.choice(ADJ)}" elif kind == "reading": title = f"{rng.choice(ADJ).title()} {rng.choice(NOUN)}: notes" else: title = f"{rng.choice(ADJ).title()} {rng.choice(NOUN)} {rng.choice(VERB)}" return title, title.replace(":", "") def date_stream(year_hint: str, rng: random.Random) -> Iterator[str]: year = 2024 if "2024" in year_hint else 2025 day = 0 while True: day += rng.randrange(1, 3) month = 1 + (day // 28) % 12 yield f"{year}-{month:02d}-{1 + day % 28:02d}" def build_notes(rng: random.Random, profile: Profile) -> list[Note]: folders = pick_folders(profile) counts = apportion(profile.notes, [w for _, w, _ in folders]) ja_total = round(profile.notes * JA_RATIO) ja_only_total = sum(c for c, (_, _, k) in zip(counts, folders, strict=True) if k == "ja-only") extra_ja = max(ja_total - ja_only_total, 0) plan: list[tuple[str, str]] = [] # (folder, kind) per note for count, (folder, _, kind) in zip(counts, folders, strict=True): plan += [(folder, kind)] * count ja_candidates = [i for i, (_, kind) in enumerate(plan) if kind != "ja-only"] ja_pick = set(rng.sample(ja_candidates, min(extra_ja, len(ja_candidates)))) notes: list[Note] = [] seen: set[tuple[str, str]] = set() date_iters = {folder: date_stream(folder, rng) for folder, _, _ in folders} used_numbers = rng.sample(range(1000, 10000), profile.notes + 50) for index, (folder, kind) in enumerate(plan): lang = "ja" if (kind == "ja-only" or index in ja_pick) else "en" for attempt in range(50): title, stem = make_title(rng, kind, lang, index, date_iters[folder]) if attempt >= 3 and kind not in ("daily", "weekly"): suffix = f" {rng.randrange(2, 99)}" title, stem = title + suffix, stem + suffix if (folder, unicodedata.normalize("NFC", stem).lower()) not in seen: break seen.add((folder, unicodedata.normalize("NFC", stem).lower())) disk = unicodedata.normalize("NFD", stem) if ( lang == "ja" and unicodedata.normalize("NFD", stem) != stem and rng.random() < NFD_SHARE_OF_JA ) else stem date = f"202{rng.choice((4, 5))}-{rng.randrange(1, 13):02d}-{rng.randrange(1, 29):02d}" number = used_numbers[index] if lang == "ja": probe = f"{rng.choice(JA_PROBE)}{rng.choice(JA_PROBE)}{number}" else: probe = f"{rng.choice(PHRASE_ADJ)}-{rng.choice(PHRASE_NOUN)}-{number}" notes.append(Note(index, folder, kind, lang, stem, disk, date, title, probe)) # Same basename in two folders: rename the second of each pair (never daily notes). candidates = [n for n in notes if n.folder_kind not in ("daily", "weekly")] rng.shuffle(candidates) pairs = 0 for first in candidates: if pairs >= profile.duplicate_basename_pairs: break for second in candidates: if second.folder != first.folder and second.stem != first.stem and second.lang == first.lang: clash = any(n.folder == second.folder and n.stem == first.stem for n in notes) if clash: continue second.stem, second.disk_stem, second.title = first.stem, first.disk_stem, first.title pairs += 1 break # Front matter, tags, aliases. for note in notes: decorate(rng, note) return notes def decorate(rng: random.Random, note: Note) -> None: note.has_frontmatter = rng.random() < 0.7 pool = [*TAGS, *NESTED_TAGS] if note.has_frontmatter: note.frontmatter_tags = sorted(rng.sample(pool, rng.randrange(1, 4))) if rng.random() < 0.3: alias = f"{rng.choice(ADJ)} {note.stem[:12]}".strip() if note.lang == "ja": alias = f"{rng.choice(JA_TOPIC)}別名" note.aliases = [alias] note.properties = { "status": rng.choice(STATUSES), "priority": str(rng.randrange(1, 4)), "created": note.date, } if rng.random() < 0.3: note.properties["source"] = f"https://{URL_HOST}/{rng.choice(NOUN)}/{rng.randrange(1000)}" note.inline_tags = sorted(rng.sample(pool, rng.randrange(0, 3))) note.has_overview = note.folder_kind not in ("daily", "weekly") and rng.random() < 0.8 # --------------------------------------------------------------------------- # # links and embeds # --------------------------------------------------------------------------- # def link_all(rng: random.Random, notes: list[Note], attachments: list[Attachment]) -> None: nfc_stems: dict[str, list[Note]] = {} for note in notes: nfc_stems.setdefault(unicodedata.normalize("NFC", note.stem).lower(), []).append(note) alias_owner = {a.lower(): n for n in notes for a in n.aliases} by_folder: dict[str, list[Note]] = {} for note in notes: by_folder.setdefault(note.folder, []).append(note) unique = [n for n in notes if len(nfc_stems[unicodedata.normalize("NFC", n.stem).lower()]) == 1] unique_ids = {id(n) for n in unique} ambiguous = [n for n in notes if len(nfc_stems[unicodedata.normalize("NFC", n.stem).lower()]) > 1] for note in notes: for _ in range(rng.choice((0, 1, 1, 2, 2, 3, 3, 4, 5))): local = by_folder[note.folder] pool_unique = [n for n in (local if rng.random() < 0.6 else unique) if id(n) in unique_ids and n is not note] roll = rng.random() if roll < 0.07 and ambiguous: target = rng.choice(ambiguous) cands = sorted(n.path for n in nfc_stems[unicodedata.normalize("NFC", target.stem).lower()]) note.links.append({"raw": f"[[{target.stem}]]", "form": "ambiguous-basename", "resolution": "ambiguous", "target": None, "candidates": cands}) continue if roll < 0.15: label = f"{rng.choice(ADJ)} {rng.choice(NOUN)} {rng.randrange(10, 99)}" note.links.append({"raw": f"[[{label}]]", "form": "unresolvable", "resolution": "unresolvable", "target": None, "candidates": []}) continue if not pool_unique: continue target = rng.choice(pool_unique) if roll < 0.20 and target.aliases: alias = target.aliases[0] if alias_owner.get(alias.lower()) is target: note.links.append({"raw": f"[[{alias}]]", "form": "frontmatter-alias", "resolution": "resolvable", "target": target.path, "candidates": []}) continue forms = ["path", "basename", "basename-alias"] if target.has_overview: forms += ["basename-heading", "path-heading-alias"] form = rng.choice(forms) label = rng.choice(ADJ) raw = { "path": f"[[{target.link_path}]]", "basename": f"[[{target.stem}]]", "basename-alias": f"[[{target.stem}|{label} {rng.choice(NOUN)}]]", "basename-heading": f"[[{target.stem}#Overview]]", "path-heading-alias": f"[[{target.link_path}#Overview|{label}]]", }[form] note.links.append({"raw": raw, "form": form, "resolution": "resolvable", "target": target.path, "candidates": []}) # Embeds: most attachments are embedded by one or two notes; ~10% stay orphaned. for att in attachments: if att.filler or rng.random() < 0.10: continue for _ in range(1 if rng.random() < 0.7 else 2): host = rng.choice(notes) name = att.path.rsplit("/", 1)[-1] by_path = rng.random() < 0.4 raw = f"![[{att.path if by_path else name}]]" host.embeds.append({"raw": raw, "form": "path" if by_path else "basename", "attachment": att.path}) att.embedded_by.append(host.path) # --------------------------------------------------------------------------- # # rendering # --------------------------------------------------------------------------- # def render_note(rng: random.Random, note: Note) -> str: gen = ja_sentence if note.lang == "ja" else sentence lines: list[str] = [] if note.has_frontmatter: lines.append("---") style = rng.randrange(3) if note.frontmatter_tags: if style == 0: lines.append("tags: [" + ", ".join(note.frontmatter_tags) + "]") elif style == 1: lines.append("tags:") lines += [f" - {t}" for t in note.frontmatter_tags] else: lines.append("tags: " + ", ".join(note.frontmatter_tags)) if note.aliases: lines.append("aliases: [" + ", ".join(note.aliases) + "]") for key, value in note.properties.items(): lines.append(f"{key}: {value}") lines.append("---") lines.append("") if rng.random() < 0.85: lines += [f"# {note.title}", ""] if note.has_overview: lines += ["## Overview", ""] body: list[str] = [" ".join(gen(rng) for _ in range(rng.randrange(2, 5)))] probe_line = (f"整理番号 {note.probe} を参照。" if note.lang == "ja" else f"Reference code {note.probe} applies to this entry.") body.append(probe_line) if rng.random() < 0.5: body.append("\n".join(f"- [ ] {gen(rng)}" for _ in range(rng.randrange(1, 4)))) if rng.random() < 0.15: body.append("```\nsample = 'inline code'\n```") if rng.random() < 0.2: body.append("| item | state |\n| --- | --- |\n| first | open |\n| second | done |") if note.inline_tags: body.append(" ".join(f"#{t}" for t in note.inline_tags)) for link in note.links: body.append(f"See also {link['raw']}.") for embed in note.embeds: body.append(embed["raw"]) if rng.random() < 0.5: body.append(gen(rng)) lines.append("\n\n".join(body)) return "\n".join(lines) + "\n" def write_bytes(root: Path, rel: str, data: bytes, order: int) -> None: target = root / rel target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) stamp = MTIME_BASE + order * 61 os.utime(target, (stamp, stamp)) def write_filler(root: Path, rel: str, seed: int, size: int, order: int) -> None: target = root / rel target.parent.mkdir(parents=True, exist_ok=True) rng = random.Random(seed) chunk = 8 * 1024 * 1024 with target.open("wb") as handle: left = size while left > 0: part = min(chunk, left) handle.write(rng.randbytes(part)) left -= part stamp = MTIME_BASE + order * 61 os.utime(target, (stamp, stamp)) def write_stream(root: Path, rel: str, chunks: Iterator[bytes], order: int) -> tuple[str, int]: """Write a file block by block; return its sha256 and size without reading it back.""" target = root / rel target.parent.mkdir(parents=True, exist_ok=True) digest, size = hashlib.sha256(), 0 with target.open("wb") as handle: for block in chunks: handle.write(block) digest.update(block) size += len(block) stamp = MTIME_BASE + order * 61 os.utime(target, (stamp, stamp)) return digest.hexdigest(), size def build_junk(rng: random.Random, profile: Profile, notes: list[Note], contents: dict[str, bytes]) -> list[dict]: """Add junk files to ``contents`` and return their key rows.""" rows: list[dict] = [] def add(rel: str, data: bytes, junk_kind: str, of: str | None = None) -> None: contents[rel] = data rows.append({"path": rel, "kind": "junk", "junk_kind": junk_kind, "copy_of": of, "bytes": len(data)}) sources = rng.sample(notes, min(profile.conflict_copies, len(notes))) styles = ("conflict-dropbox", "conflict-syncthing", "conflict-onedrive", "conflict-numbered") for number, note in enumerate(sources): style = styles[number % len(styles)] data = contents[note.path] + b"\nEdited on another device.\n" stem, folder = note.disk_stem, note.folder name = { "conflict-dropbox": f"{stem} (conflicted copy 2025-02-0{1 + number % 8}).md", "conflict-syncthing": f"{stem}.sync-conflict-20250209-1012{number:02d}-ABC{number:04d}.md", "conflict-onedrive": f"{stem}-DESKTOP-4F{number:02d}K.md", "conflict-numbered": f"{stem} 2.md", }[style] add(f"{folder}/{name}", data, style, note.path) for number in range(profile.zero_byte): folder = rng.choice([n.folder for n in notes]) add(f"{folder}/Untitled{'' if number == 0 else f' {number}'}.md", b"", "zero-byte") add(".obsidian/app.json", b'{"alwaysUpdateLinks": true, "newFileLocation": "folder"}\n', "obsidian-config") add(".obsidian/appearance.json", b'{"baseFontSize": 16}\n', "obsidian-config") add(".obsidian/core-plugins.json", b'["file-explorer", "global-search", "backlink"]\n', "obsidian-config") add(".obsidian/workspace.json", b'{"main": {"type": "split", "children": []}}\n', "obsidian-config") add(".obsidian/plugins/sample-plugin/manifest.json", b'{"id": "sample-plugin", "name": "Sample Plugin", "version": "1.0.0"}\n', "obsidian-config") add(".obsidian/snippets/custom.css", b"body { line-height: 1.5; }\n", "obsidian-config") for number in range(profile.trash_notes): words = " ".join(sentence(rng) for _ in range(2)) add(f".trash/Deleted {rng.choice(NOUN)} {number}.md", f"# Deleted {number}\n\n{words}\n".encode(), "trash") add(".DS_Store", bytes(rng.randrange(256) for _ in range(64)), "os-metadata") return rows # --------------------------------------------------------------------------- # # assembly # --------------------------------------------------------------------------- # def tree_hash(root: Path) -> str: digest = hashlib.sha256() for path in sorted(p for p in root.rglob("*") if p.is_file()): rel = path.relative_to(root).as_posix().encode("utf-8", "surrogateescape") digest.update(len(rel).to_bytes(4, "big") + rel) size = path.stat().st_size digest.update(size.to_bytes(8, "big")) with path.open("rb") as handle: while block := handle.read(1 << 20): digest.update(block) return digest.hexdigest() def generate(seed: int, profile_name: str, out: Path, key_path: Path, heavy_bytes: int = 0, huge_bytes: int | None = None) -> dict: profile = PROFILES[profile_name] if huge_bytes is not None and profile.raw_snippets is None: raise SystemExit("--huge-bytes applies only to the raw profiles") out = out.resolve() key_path = key_path.resolve() if key_path == out or out in key_path.parents: raise SystemExit("--key must be outside the collection directory") if out.exists() and any(out.iterdir()): raise SystemExit(f"--out {out} is not empty") out.mkdir(parents=True, exist_ok=True) rng = random.Random(f"collection-ingest/{GENERATOR_VERSION}/{profile_name}/{seed}") tmp = out.parent / f".{out.name}-gen-tmp" tmp.mkdir(parents=True, exist_ok=True) try: attachments = build_attachments(rng, profile, tmp) finally: for leftover in tmp.glob("*"): leftover.unlink() tmp.rmdir() filler_sizes: dict[str, int] = {} if heavy_bytes or profile.heavy_bytes: for placeholder, size in heavy_attachments(rng, heavy_bytes or profile.heavy_bytes): attachments.append(placeholder) filler_sizes[placeholder.path] = size notes = build_notes(rng, profile) link_all(rng, notes, attachments) contents: dict[str, bytes] = {} for note in notes: contents[note.path] = render_note(rng, note).encode("utf-8") for att in attachments: if not att.filler: contents[att.path] = att.data junk_rows = build_junk(rng, profile, notes, contents) corpus = None huge_total = raw_corpus.DEFAULT_HUGE_BYTES if huge_bytes is None else huge_bytes if profile.raw_snippets is not None: try: corpus = raw_corpus.build( random.Random(f"collection-ingest/{GENERATOR_VERSION}/{profile_name}/{seed}/raw"), flat_files=profile.raw_snippets, huge_bytes=huge_total, prose=sentence, probe_words=(PHRASE_ADJ, PHRASE_NOUN)) except ValueError as error: raise SystemExit(str(error)) from error for item in corpus.items: if item.path in contents: raise SystemExit(f"raw path {item.path!r} collides with a collection path") if not item.streamed: contents[item.path] = item.data order = 0 for rel in sorted(contents): write_bytes(out, rel, contents[rel], order) order += 1 for index, att in enumerate(a for a in attachments if a.filler): write_filler(out, att.path, seed * 1000 + index, filler_sizes[att.path], order) order += 1 raw_streamed: dict[str, tuple[str, int]] = {} for item in (i for i in (corpus.items if corpus else []) if i.streamed): raw_streamed[item.path] = write_stream( out, item.path, raw_corpus.huge_chunks(item, f"{seed}/{item.path}", sentence), order) order += 1 items = [] for note in notes: items.append({ "path": note.path, "kind": "note", "lang": note.lang, "folder": note.folder, "folder_kind": note.folder_kind, "title": note.title, "nfd_filename": note.disk_stem != note.stem, "bytes": len(contents[note.path]), "sha256": hashlib.sha256(contents[note.path]).hexdigest(), "tags": sorted(set(note.frontmatter_tags) | set(note.inline_tags)), "frontmatter_tags": note.frontmatter_tags, "inline_tags": note.inline_tags, "aliases": note.aliases, "properties": note.properties, "probe": {"text": note.probe, "lang": note.lang}, "links": note.links, "embeds": note.embeds, }) for att in attachments: if att.filler: file_path = out / att.path size = file_path.stat().st_size digest = hashlib.sha256() with file_path.open("rb") as handle: while block := handle.read(1 << 20): digest.update(block) sha, nbytes = digest.hexdigest(), size else: sha, nbytes = att.sha256, len(att.data) items.append({ "path": att.path, "kind": "attachment", "media_type": att.media_type, "ext": att.ext, "folder": att.path.rsplit("/", 1)[0], "bytes": nbytes, "sha256": sha, "media_phrase": ({"text": att.phrase, "lang": att.phrase_lang} if att.phrase else None), "dup_group": att.group, "filler": att.filler, "origin": att.origin, "nfd_filename": unicodedata.normalize("NFD", att.path) == att.path and unicodedata.normalize("NFC", att.path) != att.path, "embedded_by": sorted(att.embedded_by), }) items += junk_rows if corpus is not None: items += [raw_corpus.key_row(item, raw_streamed.get(item.path)) for item in corpus.items] items.sort(key=lambda row: row["path"]) groups: dict[str, list[str]] = {} for row in items: if row["kind"] == "attachment": groups.setdefault(row["dup_group"], []).append(row["path"]) notes_in_key = [r for r in items if r["kind"] == "note"] atts_in_key = [r for r in items if r["kind"] == "attachment"] all_links = [lk for r in notes_in_key for lk in r["links"]] summary = { "notes": len(notes_in_key), "attachments": len(atts_in_key), "junk": len(junk_rows), "japanese_notes": sum(1 for r in notes_in_key if r["lang"] == "ja"), "nfd_note_names": sum(1 for r in notes_in_key if r["nfd_filename"]), "folders": len({r["folder"] for r in notes_in_key}), "links_resolvable": sum(1 for lk in all_links if lk["resolution"] == "resolvable"), "links_ambiguous": sum(1 for lk in all_links if lk["resolution"] == "ambiguous"), "links_unresolvable": sum(1 for lk in all_links if lk["resolution"] == "unresolvable"), "embeds": sum(len(r["embeds"]) for r in notes_in_key), "attachments_in_duplicate_groups": sum(len(m) for m in groups.values() if len(m) > 1), "duplicate_groups": sum(1 for m in groups.values() if len(m) > 1), "total_bytes": sum(r["bytes"] for r in items), } if corpus is not None: summary.update(raw_corpus.summary(corpus)) key = { "format": 1, "generator_version": GENERATOR_VERSION, "seed": seed, "profile": profile_name, "fixture_tree_sha256": tree_hash(out), "summary": summary, "duplicate_groups": {g: m for g, m in sorted(groups.items()) if len(m) > 1}, "items": items, } key_path.parent.mkdir(parents=True, exist_ok=True) if corpus is not None: key["raw"] = raw_corpus.key_block(corpus, huge_total) key["existing_vault"] = raw_corpus.existing_vault_block(corpus) raw_corpus.vault_pages_path(key_path).write_text( json.dumps(raw_corpus.vault_pages_spec(corpus), ensure_ascii=False, indent=1, sort_keys=True) + "\n", encoding="utf-8") key_path.write_text(json.dumps(key, ensure_ascii=False, indent=1, sort_keys=True) + "\n", encoding="utf-8") return key def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--seed", type=int, default=7) parser.add_argument("--profile", choices=sorted(PROFILES), default="standard") parser.add_argument("--out", type=Path, required=True, help="collection directory (must be empty)") parser.add_argument("--key", type=Path, required=True, help="answer key path, outside --out") parser.add_argument("--heavy-bytes", type=int, default=0, help="override the filler byte total of the heavy profile (default 2 GiB)") parser.add_argument("--huge-bytes", type=int, default=None, help="byte total of the two huge raw files, a text file and a binary file " f"(raw profiles only; default {raw_corpus.DEFAULT_HUGE_BYTES})") args = parser.parse_args(argv) key = generate(args.seed, args.profile, args.out, args.key, args.heavy_bytes, args.huge_bytes) print(json.dumps({"fixture_tree_sha256": key["fixture_tree_sha256"], **key["summary"]}, indent=1)) return 0 if __name__ == "__main__": raise SystemExit(main())