|
1 | 1 | #!/usr/bin/env python3 |
2 | | -"""Build an enriched unified-wide parquet by left-joining OC thumbnails. |
| 2 | +"""Build an enriched unified-wide parquet by left-joining OC thumbnails — deterministically. |
3 | 3 |
|
4 | | -Takes the unified Zenodo wide parquet (which has thumbnail_url = NULL for all |
5 | | -6.7M samples because the upstream iSamples export doesn't carry thumbnails — |
6 | | -see issue #131) and fills in thumbnail_url for the ~47K OpenContext samples |
7 | | -that appear in Eric Kansa's oc_isamples_pqg.parquet. |
| 4 | +Takes the unified wide parquet (thumbnail_url is NULL for all samples because the |
| 5 | +upstream iSamples export doesn't carry thumbnails — see issue #131) and fills in |
| 6 | +thumbnail_url for the OpenContext samples that appear in Eric Kansa's |
| 7 | +oc_isamples_pqg.parquet (the narrow one — thumbnails live on |
| 8 | +MaterialSampleRecord rows). |
8 | 9 |
|
9 | | -Input: |
10 | | - --src local path to source unified wide parquet |
11 | | - (e.g. ~/Data/iSample/pqg_refining/zenodo_wide_*.parquet) |
12 | | - --oc local path to Eric's oc_isamples_pqg.parquet (the narrow |
13 | | - one — thumbnails live on MaterialSampleRecord rows) |
14 | | - --out path to write the enriched output |
| 10 | +This is step 2 of the reproducible 202609 chain (REPRODUCIBLE_PIPELINE_PLAN_2026-08-25.md): |
| 11 | + export → wide (pqg.sql_converter) → wide+thumbnails (THIS) → OC concepts → OC true-sync |
| 12 | + → derived files → search index. |
| 13 | +
|
| 14 | +Policy: OC is authoritative — for every MaterialSampleRecord row whose pid has an OC |
| 15 | +thumbnail, thumbnail_url is REPLACED by the OC value (a pre-existing src value is |
| 16 | +overwritten; the manifest counts overlaps and changed values). Rows of other entity |
| 17 | +types are never touched even if they share a pid. Whitespace-only URLs count as empty. |
| 18 | +
|
| 19 | +Determinism contract (mirrors pqg.sql_converter's): |
| 20 | + * Same input bytes → same output ROWS: one thumbnail per pid (the lexically smallest, |
| 21 | + and the script FAILS if any pid carries more than one distinct non-empty URL unless |
| 22 | + --allow-multi is given), rows emitted in ascending `row_id` (must be unique and |
| 23 | + non-null in --src; the script fails otherwise). The output is verified row by row |
| 24 | + against that policy (identical row_id set, exact thumbnail per row, physical order) |
| 25 | + before it is moved into place. |
| 26 | + * With the same installed DuckDB binary and --threads 1 (default) repeated runs have |
| 27 | + produced byte-identical files (verified on the 202609 chain and the January wide). |
| 28 | + DuckDB documents row-order preservation, not stable Parquet encoding, so byte |
| 29 | + identity is a per-environment observation; the manifest records enough to say |
| 30 | + which environment (DuckDB version, platform, Python, script SHA-256, git SHA + dirty flag). |
| 31 | + * Unless --no-manifest, every run writes {out}.manifest.json: input SHA-256s (taken |
| 32 | + BEFORE the build), output SHA-256 + size, counts, environment, argv. |
15 | 33 |
|
16 | 34 | Usage: |
17 | 35 | python scripts/enrich_wide_with_oc_thumbnails.py \\ |
18 | | - --src ~/Data/iSample/pqg_refining/zenodo_wide_2026-01-09.parquet \\ |
19 | | - --oc /tmp/oc_isamples_pqg_20251107.parquet \\ |
20 | | - --out /tmp/isamples_202604_wide.parquet |
| 36 | + --src ~/Data/iSample/pqg_refining/202609/isamples_202609_wide_step1.parquet \\ |
| 37 | + --oc ~/Data/iSample/oc_snapshots/oc_isamples_pqg_2026-06-09.parquet \\ |
| 38 | + --out ~/Data/iSample/pqg_refining/202609/isamples_202609_wide_step2_thumbs.parquet |
21 | 39 |
|
22 | | -Then upload to R2 under a date-stamped filename (e.g. isamples_202604_wide.parquet) |
23 | | -and update current/manifest.json to point at it. |
| 40 | +Run it twice on the same inputs and compare the two SHA-256s in the manifests: they |
| 41 | +must match. (Older usage, 202604: --src zenodo_wide_2026-01-09.parquet --oc the |
| 42 | +Nov-2025 OC narrow, which no longer exists — see the plan's block B.) |
24 | 43 | """ |
25 | 44 | import argparse |
| 45 | +import hashlib |
| 46 | +import json |
26 | 47 | import os |
| 48 | +import platform |
| 49 | +import subprocess |
27 | 50 | import sys |
| 51 | +import tempfile |
28 | 52 | import time |
| 53 | + |
29 | 54 | import duckdb |
30 | 55 |
|
31 | 56 |
|
| 57 | +def sha256_file(path, _bufsize=1 << 20): |
| 58 | + h = hashlib.sha256() |
| 59 | + with open(path, "rb") as f: |
| 60 | + for chunk in iter(lambda: f.read(_bufsize), b""): |
| 61 | + h.update(chunk) |
| 62 | + return h.hexdigest() |
| 63 | + |
| 64 | + |
| 65 | +def git_sha(): |
| 66 | + try: |
| 67 | + return subprocess.check_output( |
| 68 | + ["git", "rev-parse", "HEAD"], |
| 69 | + cwd=os.path.dirname(os.path.abspath(__file__)), |
| 70 | + stderr=subprocess.DEVNULL, |
| 71 | + ).decode().strip() |
| 72 | + except Exception: |
| 73 | + return None |
| 74 | + |
| 75 | + |
| 76 | +def q(path): |
| 77 | + """Quote a filesystem path as a SQL string literal (DuckDB: double the single quotes).""" |
| 78 | + return "'" + str(path).replace("'", "''") + "'" |
| 79 | + |
| 80 | + |
| 81 | +def same_file(a, b): |
| 82 | + """True if two paths name the same file (resolving symlinks); False if either is missing.""" |
| 83 | + try: |
| 84 | + return os.path.samefile(a, b) |
| 85 | + except OSError: |
| 86 | + return os.path.realpath(a) == os.path.realpath(b) |
| 87 | + |
| 88 | + |
| 89 | +def git_dirty(): |
| 90 | + """True if THIS SCRIPT differs from HEAD (so git_sha alone would misdescribe it); |
| 91 | + it says nothing about the rest of the repository.""" |
| 92 | + try: |
| 93 | + out = subprocess.check_output( |
| 94 | + ["git", "status", "--porcelain", "--", os.path.abspath(__file__)], |
| 95 | + cwd=os.path.dirname(os.path.abspath(__file__)), |
| 96 | + stderr=subprocess.DEVNULL, |
| 97 | + ).decode().strip() |
| 98 | + return bool(out) |
| 99 | + except Exception: |
| 100 | + return None |
| 101 | + |
| 102 | + |
32 | 103 | def main(): |
33 | 104 | p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
34 | | - p.add_argument('--src', required=True, help='source unified wide parquet') |
35 | | - p.add_argument('--oc', required=True, help="Eric's OC narrow parquet (for thumbnails)") |
36 | | - p.add_argument('--out', required=True, help='output path for enriched parquet') |
| 105 | + p.add_argument('--src', required=True, help='source unified wide parquet (row_id unique, non-null)') |
| 106 | + p.add_argument('--oc', required=True, help="Eric's OC narrow parquet (pid, thumbnail_url)") |
| 107 | + p.add_argument('--out', required=True, help='output path for the enriched parquet') |
| 108 | + p.add_argument('--threads', type=int, default=1, |
| 109 | + help='DuckDB threads (default 1 for byte-reproducible output; rows are identical regardless)') |
| 110 | + p.add_argument('--allow-multi', action='store_true', |
| 111 | + help='tolerate pids with >1 distinct thumbnail (keeps the lexically smallest) instead of failing') |
| 112 | + p.add_argument('--no-manifest', action='store_true', help='skip writing {out}.manifest.json') |
37 | 113 | args = p.parse_args() |
38 | 114 |
|
39 | 115 | for f in (args.src, args.oc): |
40 | 116 | if not os.path.exists(f): |
41 | 117 | print(f'ERROR: missing {f}', file=sys.stderr) |
42 | 118 | return 2 |
| 119 | + out_abs = os.path.abspath(args.out) |
| 120 | + mpath = out_abs + ".manifest.json" |
| 121 | + inputs = (os.path.abspath(args.src), os.path.abspath(args.oc)) |
| 122 | + for dest in (out_abs, mpath): |
| 123 | + if any(same_file(dest, i) for i in inputs): |
| 124 | + print(f'ERROR: {dest} would overwrite an input', file=sys.stderr) |
| 125 | + return 2 |
| 126 | + out_dir = os.path.dirname(out_abs) or '.' |
| 127 | + os.makedirs(out_dir, exist_ok=True) |
| 128 | + |
| 129 | + # Input sizes + hashes BEFORE the build, so the manifest describes what was read. |
| 130 | + # (A concurrent writer between this hash and the COPY would not be detected.) |
| 131 | + t0 = time.time() |
| 132 | + src_bytes, oc_bytes = os.path.getsize(args.src), os.path.getsize(args.oc) |
| 133 | + src_sha, oc_sha = sha256_file(args.src), sha256_file(args.oc) |
| 134 | + print(f'[{time.time()-t0:.1f}s] hashed inputs') |
43 | 135 |
|
44 | 136 | con = duckdb.connect() |
| 137 | + con.execute(f"PRAGMA threads={int(args.threads)}") |
| 138 | + con.execute("SET preserve_insertion_order = true") |
| 139 | + SRC, OC = q(args.src), q(args.oc) |
45 | 140 |
|
46 | | - print(f'source: {args.src}') |
47 | | - print(f'oc: {args.oc}') |
48 | | - print(f'out: {args.out}') |
| 141 | + print(f'source: {args.src}') |
| 142 | + print(f'oc: {args.oc}') |
| 143 | + print(f'out: {args.out}') |
| 144 | + print(f'duckdb: {duckdb.__version__} threads={args.threads}') |
49 | 145 |
|
| 146 | + # --- preconditions on --src: row_id is the total order we emit in ------------- |
50 | 147 | t0 = time.time() |
51 | | - con.execute(f""" |
52 | | - CREATE TEMP TABLE oc_thumbs AS |
53 | | - SELECT DISTINCT pid, thumbnail_url |
54 | | - FROM read_parquet('{args.oc}') |
55 | | - WHERE thumbnail_url IS NOT NULL AND thumbnail_url <> '' |
56 | | - """) |
57 | | - n = con.sql('SELECT COUNT(*) FROM oc_thumbs').fetchone()[0] |
58 | | - print(f'[{time.time()-t0:.1f}s] oc_thumbs lookup: {n:,} (pid, thumbnail) pairs') |
| 148 | + n_src, n_rowid, n_rowid_distinct, n_null_pid = con.execute(f""" |
| 149 | + SELECT COUNT(*), COUNT(row_id), COUNT(DISTINCT row_id), COUNT(*) FILTER (WHERE pid IS NULL) |
| 150 | + FROM read_parquet({SRC}) |
| 151 | + """).fetchone() |
| 152 | + if not (n_src == n_rowid == n_rowid_distinct): |
| 153 | + print(f'ERROR: --src row_id must be unique and non-null ' |
| 154 | + f'(rows={n_src:,} non-null={n_rowid:,} distinct={n_rowid_distinct:,})', file=sys.stderr) |
| 155 | + return 3 |
| 156 | + src_cols = [r[0] for r in con.execute(f"DESCRIBE SELECT * FROM read_parquet({SRC})").fetchall()] |
| 157 | + for needed in ('row_id', 'pid', 'otype', 'thumbnail_url'): |
| 158 | + if needed not in src_cols: |
| 159 | + print(f'ERROR: --src lacks column {needed}', file=sys.stderr) |
| 160 | + return 3 |
| 161 | + print(f'[{time.time()-t0:.1f}s] src rows: {n_src:,} (row_id unique, non-null; {n_null_pid:,} with NULL pid; {len(src_cols)} columns)') |
59 | 162 |
|
| 163 | + # --- one thumbnail per pid, chosen deterministically --------------------------- |
60 | 164 | t0 = time.time() |
61 | 165 | con.execute(f""" |
62 | | - COPY ( |
63 | | - SELECT p.* REPLACE (COALESCE(oc.thumbnail_url, p.thumbnail_url) AS thumbnail_url) |
64 | | - FROM read_parquet('{args.src}') p |
65 | | - LEFT JOIN oc_thumbs oc ON p.pid = oc.pid |
66 | | - ) |
67 | | - TO '{args.out}' (FORMAT PARQUET, COMPRESSION ZSTD) |
| 166 | + CREATE TEMP TABLE oc_thumbs AS |
| 167 | + SELECT pid, |
| 168 | + min(url) AS thumbnail_url, -- lexically smallest = stable choice |
| 169 | + COUNT(DISTINCT url) AS n_distinct |
| 170 | + FROM (SELECT pid, NULLIF(TRIM(thumbnail_url), '') AS url FROM read_parquet({OC})) |
| 171 | + WHERE url IS NOT NULL AND pid IS NOT NULL |
| 172 | + GROUP BY pid |
68 | 173 | """) |
69 | | - print(f'[{time.time()-t0:.1f}s] wrote enriched parquet') |
70 | | - |
71 | | - # Verify |
72 | | - r = con.sql(f""" |
73 | | - SELECT COUNT(*) AS rows, |
74 | | - COUNT(*) FILTER (WHERE thumbnail_url IS NOT NULL AND thumbnail_url <> '') AS with_thumb |
75 | | - FROM read_parquet('{args.out}') |
76 | | - """).df() |
77 | | - print(r.to_string(index=False)) |
78 | | - print(f'output size: {os.path.getsize(args.out)/1024/1024:.1f} MB') |
| 174 | + n_pids, n_multi = con.execute( |
| 175 | + "SELECT COUNT(*), COUNT(*) FILTER (WHERE n_distinct > 1) FROM oc_thumbs").fetchone() |
| 176 | + print(f'[{time.time()-t0:.1f}s] oc thumbnails: {n_pids:,} pids, {n_multi:,} with >1 distinct URL') |
| 177 | + if n_multi and not args.allow_multi: |
| 178 | + print(f'ERROR: {n_multi:,} OC pids carry more than one distinct thumbnail_url; ' |
| 179 | + f'pass --allow-multi to keep the lexically smallest', file=sys.stderr) |
| 180 | + return 4 |
| 181 | + |
| 182 | + # --- the policy, as one SQL expression used for BOTH the build and the check --- |
| 183 | + # OC wins for MaterialSampleRecord rows; every other row keeps its value. |
| 184 | + POLICY = "CASE WHEN p.otype = 'MaterialSampleRecord' AND oc.thumbnail_url IS NOT NULL " \ |
| 185 | + "THEN oc.thumbnail_url ELSE p.thumbnail_url END" |
| 186 | + |
| 187 | + # --- join + write in row_id order, to a unique temp file in the output dir ----- |
| 188 | + fd, tmp_out = tempfile.mkstemp(prefix=os.path.basename(out_abs) + '.', suffix='.tmp', dir=out_dir) |
| 189 | + TMP = q(tmp_out) |
| 190 | + try: |
| 191 | + os.close(fd) |
| 192 | + t0 = time.time() |
| 193 | + con.execute(f""" |
| 194 | + COPY ( |
| 195 | + SELECT p.* REPLACE ({POLICY} AS thumbnail_url) |
| 196 | + FROM read_parquet({SRC}) p |
| 197 | + LEFT JOIN oc_thumbs oc ON p.pid = oc.pid |
| 198 | + ORDER BY p.row_id |
| 199 | + ) |
| 200 | + TO {TMP} (FORMAT PARQUET, COMPRESSION ZSTD) |
| 201 | + """) |
| 202 | + print(f'[{time.time()-t0:.1f}s] wrote {tmp_out}') |
| 203 | + |
| 204 | + # --- verify before moving into place ---------------------------------------- |
| 205 | + # (a) output row_id non-null/unique, same count as src, physically ascending |
| 206 | + # (file_row_number is DuckDB's explicit file order); |
| 207 | + # (b) every src row joins exactly one out row on row_id (with (a) and equal |
| 208 | + # counts this proves the row_id sets are identical); |
| 209 | + # (c) per row: thumbnail_url == POLICY exactly, and EVERY other column is |
| 210 | + # unchanged (IS DISTINCT FROM on each, list columns included). |
| 211 | + t0 = time.time() |
| 212 | + n_out, n_out_rowid, n_out_distinct, n_order_breaks = con.execute(f""" |
| 213 | + SELECT COUNT(*), COUNT(row_id), COUNT(DISTINCT row_id), |
| 214 | + COUNT(*) FILTER (WHERE prev IS NOT NULL AND row_id <= prev) |
| 215 | + FROM (SELECT row_id, lag(row_id) OVER (ORDER BY file_row_number) AS prev |
| 216 | + FROM read_parquet({TMP}, file_row_number = true)) |
| 217 | + """).fetchone() |
| 218 | + other_cols = [c for c in src_cols if c != 'thumbnail_url'] |
| 219 | + qi = lambda name: '"' + name.replace('"', '""') + '"' # quote an identifier |
| 220 | + unchanged_pred = " OR ".join(f'p.{qi(c)} IS DISTINCT FROM o.{qi(c)}' for c in other_cols) |
| 221 | + joined, mismatched, other_changed, replaced, overlap, changed, with_thumb = con.execute(f""" |
| 222 | + SELECT |
| 223 | + COUNT(*), |
| 224 | + COUNT(*) FILTER (WHERE o.thumbnail_url IS DISTINCT FROM ({POLICY})), |
| 225 | + COUNT(*) FILTER (WHERE {unchanged_pred}), |
| 226 | + COUNT(*) FILTER (WHERE p.otype = 'MaterialSampleRecord' AND oc.thumbnail_url IS NOT NULL), |
| 227 | + COUNT(*) FILTER (WHERE p.otype = 'MaterialSampleRecord' AND oc.thumbnail_url IS NOT NULL |
| 228 | + AND NULLIF(TRIM(p.thumbnail_url), '') IS NOT NULL), |
| 229 | + COUNT(*) FILTER (WHERE o.thumbnail_url IS DISTINCT FROM p.thumbnail_url), |
| 230 | + COUNT(*) FILTER (WHERE NULLIF(TRIM(o.thumbnail_url), '') IS NOT NULL) |
| 231 | + FROM read_parquet({SRC}) p |
| 232 | + JOIN read_parquet({TMP}) o ON p.row_id = o.row_id |
| 233 | + LEFT JOIN oc_thumbs oc ON p.pid = oc.pid |
| 234 | + """).fetchone() |
| 235 | + problems = [] |
| 236 | + if not (n_out == n_out_rowid == n_out_distinct == n_src): |
| 237 | + problems.append(f'row_id count/uniqueness: out {n_out:,} (non-null {n_out_rowid:,}, distinct {n_out_distinct:,}) vs src {n_src:,}') |
| 238 | + if joined != n_src: problems.append(f'row_id sets differ: {joined:,} of {n_src:,} src rows found in out') |
| 239 | + if n_order_breaks: problems.append(f'{n_order_breaks:,} rows out of ascending row_id order') |
| 240 | + if mismatched: problems.append(f'{mismatched:,} rows whose thumbnail_url != policy') |
| 241 | + if other_changed: problems.append(f'{other_changed:,} rows with a non-thumbnail column changed') |
| 242 | + print(f'[{time.time()-t0:.1f}s] verified: {n_out:,} rows, same row_id set, ascending, {len(other_cols)} other columns unchanged; ' |
| 243 | + f'replaced {replaced:,} (of which {overlap:,} already had a value), {changed:,} rows changed, ' |
| 244 | + f'{with_thumb:,} rows with a thumbnail') |
| 245 | + if problems: |
| 246 | + print('ERROR: verification failed: ' + '; '.join(problems), file=sys.stderr) |
| 247 | + return 5 |
| 248 | + # Publish order: hash the verified temp, drop any old sidecar, THEN rename — |
| 249 | + # so a fresh output can never sit next to a stale manifest (losing the |
| 250 | + # sidecar on a failed rename is the safer failure). |
| 251 | + out_bytes = os.path.getsize(tmp_out) |
| 252 | + out_sha = sha256_file(tmp_out) |
| 253 | + if os.path.exists(mpath): |
| 254 | + os.remove(mpath) |
| 255 | + os.replace(tmp_out, out_abs) |
| 256 | + finally: |
| 257 | + if os.path.exists(tmp_out): |
| 258 | + os.remove(tmp_out) |
| 259 | + print(f'output: {out_bytes/1e6:.1f} MB sha256 {out_sha}') |
| 260 | + |
| 261 | + if not args.no_manifest: |
| 262 | + manifest = { |
| 263 | + "script": os.path.basename(__file__), |
| 264 | + "script_sha256": sha256_file(os.path.abspath(__file__)), |
| 265 | + "argv": sys.argv, |
| 266 | + "git_sha": git_sha(), |
| 267 | + "script_dirty_vs_git_sha": git_dirty(), |
| 268 | + "environment": { |
| 269 | + "duckdb_version": duckdb.__version__, |
| 270 | + "python": platform.python_version(), |
| 271 | + "platform": platform.platform(), |
| 272 | + "threads": args.threads, |
| 273 | + "parquet": {"compression": "ZSTD", "row_group_size": "duckdb default"}, |
| 274 | + }, |
| 275 | + "policy": ("OC thumbnails: OC is authoritative for MaterialSampleRecord rows (thumbnail_url " |
| 276 | + "replaced by the pid's OC URL); other entity types untouched; one URL per pid " |
| 277 | + "(lexically smallest; fails on conflicts unless --allow-multi); rows emitted in " |
| 278 | + "ascending row_id; output verified row-by-row (policy + every other column) before rename. " |
| 279 | + "Reproducible chain step 2 (REPRODUCIBLE_PIPELINE_PLAN_2026-08-25.md)"), |
| 280 | + "inputs": { |
| 281 | + "src": {"path": args.src, "bytes": src_bytes, "sha256": src_sha}, |
| 282 | + "oc": {"path": args.oc, "bytes": oc_bytes, "sha256": oc_sha}, |
| 283 | + }, |
| 284 | + "counts": { |
| 285 | + "src_rows": n_src, |
| 286 | + "src_rows_null_pid": n_null_pid, |
| 287 | + "oc_pids_with_thumbnail": n_pids, |
| 288 | + "oc_pids_multi_thumbnail": n_multi, |
| 289 | + "msr_rows_replaced": replaced, |
| 290 | + "msr_rows_replaced_that_had_a_value": overlap, |
| 291 | + "rows_changed": changed, |
| 292 | + "out_rows": n_out, |
| 293 | + "out_rows_with_thumbnail": with_thumb, |
| 294 | + }, |
| 295 | + "output": {"path": args.out, "bytes": out_bytes, "sha256": out_sha}, |
| 296 | + } |
| 297 | + fd, mtmp = tempfile.mkstemp(prefix=os.path.basename(mpath) + '.', suffix='.tmp', dir=out_dir) |
| 298 | + try: |
| 299 | + with os.fdopen(fd, 'w') as fh: |
| 300 | + json.dump(manifest, fh, indent=2) |
| 301 | + os.replace(mtmp, mpath) |
| 302 | + finally: |
| 303 | + if os.path.exists(mtmp): |
| 304 | + os.remove(mtmp) |
| 305 | + print(f'manifest -> {mpath}') |
79 | 306 | return 0 |
80 | 307 |
|
81 | 308 |
|
|
0 commit comments