Skip to content

Commit f5fb805

Browse files
authored
Merge pull request #355
Reproducible 202609 chain, step 6: build vocab_labels from archived, pinned TTLs (--ttl-dir), explicit order, manifest
2 parents d99cd8d + 8c0129d commit f5fb805

1 file changed

Lines changed: 187 additions & 20 deletions

File tree

scripts/build_vocab_labels.py

Lines changed: 187 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
alt_labels list skos:altLabel values plus prefLabels from any
2323
cross-vocab redeclarations of the same URI.
2424
source_ttl str URL of the TTL the canonical row came from.
25+
broader str? canonical skos:broader parent (lexicographically first
26+
when a concept has several; see broader_count)
27+
broader_count int number of skos:broader parents declared
2528
2629
The dual-form (vocab + data_v1) emission is a workaround for a known
2730
mismatch: the vocabulary TTLs declare concepts without a version segment,
@@ -38,7 +41,13 @@
3841
from __future__ import annotations
3942

4043
import argparse
44+
import hashlib
45+
import json
46+
import platform
47+
import re
48+
import subprocess
4149
import sys
50+
import time
4251
from pathlib import Path
4352

4453
import pandas as pd
@@ -153,25 +162,31 @@ def _prefers(ttl_url: str, concept_uri: str) -> int:
153162

154163
def _pick_definition(g: rdflib.Graph, c: rdflib.term.Node) -> str | None:
155164
"""Return one definition string, preferring English when present."""
156-
defs = list(g.objects(c, SKOS.definition))
157-
if not defs:
158-
return None
159-
for d in defs:
160-
if getattr(d, "language", None) == PREFERRED_LANG:
161-
return str(d)
162-
return str(defs[0])
165+
# Lexical order within each language tier so the choice is a function of
166+
# the TTL content, not of rdflib's traversal order.
167+
defs = sorted(g.objects(c, SKOS.definition),
168+
key=lambda d: (getattr(d, "language", None) != PREFERRED_LANG, str(d)))
169+
return str(defs[0]) if defs else None
163170

164171

165172
def _pick_scheme(g: rdflib.Graph, c: rdflib.term.Node) -> str | None:
166173
"""Return the skos:inScheme URI for a concept, if declared."""
167-
for s in g.objects(c, SKOS.inScheme):
168-
return str(s)
169-
return None
174+
schemes = sorted(str(s) for s in g.objects(c, SKOS.inScheme))
175+
return schemes[0] if schemes else None
170176

171177

172-
def extract_rows(ttl_url: str) -> list[dict]:
178+
def extract_rows(ttl_url: str, data: bytes | None = None) -> list[dict]:
179+
"""Parse one TTL. `ttl_url` is always the canonical (main) URL recorded as
180+
`source_ttl`; when `data` is given those exact bytes are parsed instead of
181+
fetching the network (reproducible builds, --ttl-dir) — the caller hashes
182+
the same buffer, so the manifest's sha256 is of what was parsed."""
173183
g = rdflib.Graph()
174-
g.parse(ttl_url, format="turtle")
184+
if data is not None:
185+
# publicID keeps the canonical URL as the base IRI, so any relative
186+
# IRI in the file resolves exactly as it does when fetched from main.
187+
g.parse(data=data, format="turtle", publicID=ttl_url)
188+
else:
189+
g.parse(ttl_url, format="turtle")
175190

176191
rows: list[dict] = []
177192
for c in g.subjects(RDF.type, SKOS.Concept):
@@ -189,9 +204,11 @@ def extract_rows(ttl_url: str) -> list[dict]:
189204
broader_count = len(broaders)
190205

191206
# One row per language of skos:prefLabel; fall back to rdfs:label.
192-
pref_labels = list(g.objects(c, SKOS.prefLabel))
207+
# Sorted by (language, text): a concept with two same-language labels
208+
# then yields a deterministic winner in _dedupe (see its tiebreak).
209+
pref_labels = sorted(g.objects(c, SKOS.prefLabel), key=lambda l: (str(getattr(l, "language", None) or ""), str(l)))
193210
if not pref_labels:
194-
pref_labels = list(g.objects(c, RDFS.label))
211+
pref_labels = sorted(g.objects(c, RDFS.label), key=lambda l: (str(getattr(l, "language", None) or ""), str(l)))
195212

196213
if not pref_labels:
197214
# Concept with no label at all — emit a row with NULL label so
@@ -245,7 +262,7 @@ def _dedupe(rows: list[dict]) -> list[dict]:
245262
if len(candidates) == 1:
246263
out.append(candidates[0])
247264
continue
248-
candidates.sort(key=lambda r: (_prefers(r["source_ttl"], r["uri"]), r["source_ttl"]))
265+
candidates.sort(key=lambda r: (_prefers(r["source_ttl"], r["uri"]), r["source_ttl"], r["pref_label"] or ""))
249266
keep = dict(candidates[0])
250267
extra = []
251268
for loser in candidates[1:]:
@@ -280,6 +297,38 @@ def _emit_data_form_aliases(rows: list[dict]) -> list[dict]:
280297
return aliases
281298

282299

300+
def _sha256(path: Path) -> str:
301+
h = hashlib.sha256()
302+
with open(path, "rb") as f:
303+
for chunk in iter(lambda: f.read(1 << 20), b""):
304+
h.update(chunk)
305+
return h.hexdigest()
306+
307+
308+
def _git_sha() -> str | None:
309+
try:
310+
return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=Path(__file__).parent,
311+
stderr=subprocess.DEVNULL).decode().strip()
312+
except Exception:
313+
return None
314+
315+
316+
def _git_dirty() -> bool | None:
317+
try:
318+
return bool(subprocess.check_output(["git", "status", "--porcelain", "--", str(Path(__file__).resolve())],
319+
cwd=Path(__file__).parent, stderr=subprocess.DEVNULL).decode().strip())
320+
except Exception:
321+
return None
322+
323+
324+
def _local_ttl_path(ttl_dir: Path, url: str) -> Path:
325+
"""Archived layout: <ttl_dir>/<repo-name>/<file>.ttl for
326+
https://raw.githubusercontent.com/isamplesorg/<repo-name>/main/vocabulary/<file>.ttl"""
327+
parts = url.split("/")
328+
repo = parts[4]
329+
return ttl_dir / repo / parts[-1]
330+
331+
283332
def main(argv: list[str] | None = None) -> int:
284333
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
285334
ap.add_argument(
@@ -302,15 +351,88 @@ def main(argv: list[str] | None = None) -> int:
302351
"artifact is intended for publishing."
303352
),
304353
)
354+
ap.add_argument(
355+
"--ttl-dir",
356+
type=Path,
357+
default=None,
358+
help=(
359+
"Read the TTLs from this archive directory (layout <dir>/<repo>/<file>.ttl, "
360+
"as written by the 202609 provenance freeze) instead of fetching main. "
361+
"source_ttl still records the canonical main URL. Required for a reproducible build."
362+
),
363+
)
364+
ap.add_argument(
365+
"--ttl-archive",
366+
type=Path,
367+
default=None,
368+
help="Optional ttl_archive.json (per-file commit + sha256) to copy into the manifest and to VERIFY the archived bytes against.",
369+
)
370+
ap.add_argument("--no-manifest", action="store_true", help="skip writing {output}.manifest.json")
305371
args = ap.parse_args(argv)
306-
372+
effective_args = list(argv) if argv is not None else sys.argv[1:]
373+
374+
t_start = time.time()
375+
# ttl_inputs_pinned = every expected TTL was read from the archive, its bytes
376+
# verified against ttl_archive.json (sha256) and attributed to a 40-hex git
377+
# commit. Rows from MANUAL_LABEL_OVERRIDES come from this script, not from a
378+
# TTL, and are reported separately.
379+
if (args.ttl_dir is None) != (args.ttl_archive is None):
380+
print("ERROR: --ttl-dir and --ttl-archive must be given together", file=sys.stderr)
381+
return 2
382+
archive_index: dict[str, dict] = {}
383+
if args.ttl_archive:
384+
recs = json.loads(args.ttl_archive.read_text())
385+
for rec in recs:
386+
if rec["source_ttl"] in archive_index:
387+
print(f"ERROR: duplicate record in {args.ttl_archive}: {rec['source_ttl']}", file=sys.stderr)
388+
return 2
389+
if not (isinstance(rec.get("commit"), str) and re.fullmatch(r"[0-9a-f]{40}", rec["commit"])):
390+
print(f"ERROR: {args.ttl_archive}: record for {rec['source_ttl']} lacks a 40-hex commit", file=sys.stderr)
391+
return 2
392+
if not (isinstance(rec.get("sha256"), str) and re.fullmatch(r"[0-9a-f]{64}", rec["sha256"])):
393+
print(f"ERROR: {args.ttl_archive}: record for {rec['source_ttl']} lacks a sha256", file=sys.stderr)
394+
return 2
395+
archive_index[rec["source_ttl"]] = rec
396+
missing = [u for u in VOCAB_TTLS if u not in archive_index]
397+
extra = [u for u in archive_index if u not in VOCAB_TTLS]
398+
if missing or extra:
399+
print(f"ERROR: {args.ttl_archive} does not describe exactly the {len(VOCAB_TTLS)} expected TTLs "
400+
f"(missing {len(missing)}, unexpected {len(extra)})", file=sys.stderr)
401+
for u in missing: print(f" missing: {u}", file=sys.stderr)
402+
for u in extra: print(f" unexpected: {u}", file=sys.stderr)
403+
return 2
404+
405+
ttl_inputs: list[dict] = [] # only TTLs whose rows are IN the output
307406
all_rows: list[dict] = []
308407
failures: list[tuple[str, str]] = []
309408
for url in VOCAB_TTLS:
409+
local = None
410+
data = None
411+
if args.ttl_dir is not None:
412+
# Archive integrity is never "partial": a missing or altered file is fatal.
413+
# Read ONCE; hash and parse the same buffer.
414+
local = _local_ttl_path(args.ttl_dir, url)
415+
if not local.exists():
416+
print(f"ERROR: archived TTL missing: {local}", file=sys.stderr)
417+
return 2
418+
data = local.read_bytes()
419+
digest = hashlib.sha256(data).hexdigest()
420+
rec = archive_index[url]
421+
if rec["sha256"] != digest:
422+
print(f"ERROR: archived TTL sha256 {digest[:12]}… != ttl_archive.json {rec['sha256'][:12]}… for {local}",
423+
file=sys.stderr)
424+
return 2
310425
try:
311426
n_before = len(all_rows)
312-
all_rows.extend(extract_rows(url))
313-
print(f" {len(all_rows) - n_before:>4} rows {url}")
427+
rows = extract_rows(url, data)
428+
all_rows.extend(rows)
429+
if data is not None:
430+
ttl_inputs.append({"source_ttl": url, "local_path": str(local), "bytes": len(data),
431+
"sha256": digest, "commit": archive_index[url]["commit"], "rows": len(rows)})
432+
else:
433+
ttl_inputs.append({"source_ttl": url, "local_path": None, "rows": len(rows),
434+
"note": "fetched live from main (NOT pinned)"})
435+
print(f" {len(all_rows) - n_before:>4} rows {url}{' [archived]' if local else ''}")
314436
except Exception as e:
315437
print(f"WARN: failed to parse {url}: {e}", file=sys.stderr)
316438
failures.append((url, str(e)))
@@ -358,10 +480,23 @@ def main(argv: list[str] | None = None) -> int:
358480
all_rows.extend(aliases)
359481

360482
df = pd.DataFrame(all_rows)
483+
# Explicit total order: rows by (uri_form, uri, lang, source_ttl) — unique
484+
# once the duplicate check below passes — and each row's alt_labels sorted.
485+
# Together with the deterministic selectors above (definition, scheme,
486+
# prefLabel, broader) the rows are a function of the TTL bytes + this
487+
# script + the RDF parser (rdflib version recorded; blank-node ids would
488+
# vary between parses, but these vocabularies use absolute IRIs only);
489+
# the Parquet BYTES are additionally a function of the pandas/pyarrow
490+
# writer versions recorded in the manifest.
491+
df["alt_labels"] = df["alt_labels"].apply(lambda v: sorted(v) if isinstance(v, list) else v)
492+
df = df.sort_values(["uri_form", "uri", "lang", "source_ttl"], kind="mergesort", na_position="last").reset_index(drop=True)
361493
# Final sanity check
362-
dupes = df.duplicated(subset=["uri", "lang"], keep=False).sum()
494+
dupes = int(df.duplicated(subset=["uri", "lang"], keep=False).sum())
363495
if dupes:
364-
print(f"WARN: {dupes} duplicate (uri, lang) rows survived dedupe", file=sys.stderr)
496+
# With duplicates the (uri_form, uri, lang, source_ttl) order is no longer
497+
# total and the artifact would not be a function of its inputs — refuse.
498+
print(f"ERROR: {dupes} duplicate (uri, lang) rows survived dedupe; refusing to emit", file=sys.stderr)
499+
return 4
365500

366501
args.output.parent.mkdir(parents=True, exist_ok=True)
367502
df.to_parquet(args.output, index=False)
@@ -382,6 +517,38 @@ def main(argv: list[str] | None = None) -> int:
382517
df.to_csv(csv_path, index=False)
383518
print(f"Also wrote {csv_path}")
384519

520+
if not args.no_manifest:
521+
import pyarrow
522+
manifest = {
523+
"script": Path(__file__).name,
524+
"script_sha256": _sha256(Path(__file__).resolve()),
525+
"args": effective_args,
526+
"git_sha": _git_sha(),
527+
"script_dirty_vs_git_sha": _git_dirty(),
528+
"environment": {"python": platform.python_version(), "platform": platform.platform(),
529+
"rdflib": rdflib.__version__, "pandas": pd.__version__, "pyarrow": pyarrow.__version__},
530+
"policy": ("SKOS prefLabels/altLabels/definitions/broader from the expected vocabulary TTLs; deterministic "
531+
"selectors (lexical tiebreaks); cross-vocab dedupe; /1.0/ data-form aliases; rows sorted by "
532+
"(uri_form, uri, lang, source_ttl), alt_labels sorted; duplicate (uri, lang) is fatal. "
533+
"Rows are a function of the TTL bytes + this script + the recorded rdflib and pandas (final sort); "
534+
"Parquet bytes additionally of the recorded pyarrow writer. This manifest itself (args, paths, "
535+
"elapsed) is not byte-reproducible."),
536+
"ttl_inputs_pinned": args.ttl_dir is not None and not failures and len(ttl_inputs) == len(VOCAB_TTLS),
537+
"archive_verified": args.ttl_dir is not None,
538+
"complete": not failures,
539+
"inputs": {"expected_ttls": len(VOCAB_TTLS), "ttls": ttl_inputs,
540+
"failed": [{"source_ttl": u, "error": e} for u, e in failures],
541+
"script_defined_rows": {"manual_overrides": len(MANUAL_LABEL_OVERRIDES),
542+
"source_ttl_value": "manual_override"}},
543+
"counts": {"rows": int(len(df)), "unique_uris": int(df["uri"].nunique()),
544+
"by_uri_form": {k: int(v) for k, v in df["uri_form"].value_counts().to_dict().items()}},
545+
"output": {"path": str(args.output), "bytes": args.output.stat().st_size, "sha256": _sha256(args.output)},
546+
"elapsed_s": round(time.time() - t_start, 1),
547+
}
548+
mpath = Path(str(args.output) + ".manifest.json")
549+
mpath.write_text(json.dumps(manifest, indent=2))
550+
print(f"manifest -> {mpath}")
551+
385552
return 0
386553

387554

0 commit comments

Comments
 (0)