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
2629The dual-form (vocab + data_v1) emission is a workaround for a known
2730mismatch: the vocabulary TTLs declare concepts without a version segment,
3841from __future__ import annotations
3942
4043import argparse
44+ import hashlib
45+ import json
46+ import platform
47+ import re
48+ import subprocess
4149import sys
50+ import time
4251from pathlib import Path
4352
4453import pandas as pd
@@ -153,25 +162,31 @@ def _prefers(ttl_url: str, concept_uri: str) -> int:
153162
154163def _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
165172def _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+
283332def 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