Skip to content

Commit d99cd8d

Browse files
authored
Merge pull request #354
Reproducible 202609 chain, step 2: deterministic, verified, manifest-stamped OC thumbnail enrichment
2 parents 350bbd4 + 3f6f482 commit d99cd8d

1 file changed

Lines changed: 273 additions & 46 deletions

File tree

scripts/enrich_wide_with_oc_thumbnails.py

Lines changed: 273 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,308 @@
11
#!/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.
33
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).
89
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.
1533
1634
Usage:
1735
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
2139
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.)
2443
"""
2544
import argparse
45+
import hashlib
46+
import json
2647
import os
48+
import platform
49+
import subprocess
2750
import sys
51+
import tempfile
2852
import time
53+
2954
import duckdb
3055

3156

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+
32103
def main():
33104
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')
37113
args = p.parse_args()
38114

39115
for f in (args.src, args.oc):
40116
if not os.path.exists(f):
41117
print(f'ERROR: missing {f}', file=sys.stderr)
42118
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')
43135

44136
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)
45140

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}')
49145

146+
# --- preconditions on --src: row_id is the total order we emit in -------------
50147
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)')
59162

163+
# --- one thumbnail per pid, chosen deterministically ---------------------------
60164
t0 = time.time()
61165
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
68173
""")
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}')
79306
return 0
80307

81308

0 commit comments

Comments
 (0)