3131)
3232
3333
34+ def is_triple_line (line : bytes ) -> bool :
35+ """Identify an N-Triples record without parsing or copying its terms."""
36+ stripped = line .strip ()
37+ return bool (stripped ) and not stripped .startswith (b"#" ) and stripped .endswith (b"." )
38+
39+
3440def parse_args () -> argparse .Namespace :
3541 parser = argparse .ArgumentParser (description = "VCF-RDFizer partitioned compression runner" )
3642 parser .add_argument ("--source" , required = True , help = "plain or gzip-compressed N-Triples input" )
@@ -40,6 +46,7 @@ def parse_args() -> argparse.Namespace:
4046 parser .add_argument ("--target-chunk-bytes" , required = True , type = int )
4147 parser .add_argument ("--min-chunk-bytes" , required = True , type = int )
4248 parser .add_argument ("--max-chunk-bytes" , required = True , type = int )
49+ parser .add_argument ("--expected-triples" , type = int )
4350 parser .add_argument ("--result-path" , required = True )
4451 return parser .parse_args ()
4552
@@ -124,7 +131,8 @@ def close_chunk():
124131 handle .write (line )
125132 chunk_size += line_size
126133 logical_offset += line_size
127- record_count += 1
134+ if is_triple_line (line ):
135+ record_count += 1
128136 finally :
129137 close_chunk ()
130138
@@ -306,6 +314,12 @@ def main() -> int:
306314 )
307315 if not chunks :
308316 raise ValueError ("RDF source contains no complete records" )
317+ source_triples = int (plan ["record_count" ])
318+ if args .expected_triples is not None and source_triples != args .expected_triples :
319+ raise ValueError (
320+ "source triple count does not match the upstream conversion count: "
321+ f"source={ source_triples } , expected={ args .expected_triples } "
322+ )
309323 hdt_paths : list [Path ] = []
310324 cottas_paths : list [Path ] = []
311325 needs_hdt = any (method in HDT_METHODS for method in methods )
@@ -326,6 +340,65 @@ def main() -> int:
326340 if any (method in COTTAS_METHODS for method in methods ) and not cottas_python :
327341 raise RuntimeError ("Missing Python runtime for COTTAS" )
328342
343+ def validate_artifact (
344+ * ,
345+ name : str ,
346+ artifact : Path ,
347+ artifact_format : str ,
348+ python_bin : str ,
349+ skip_index_check : bool = False ,
350+ ) -> dict :
351+ """Decode/count one final artifact and return its validation report."""
352+ validation_path = work_dir / f".{ name } .validation.json"
353+ command = [
354+ python_bin ,
355+ "/opt/vcf-rdfizer/validate_compression.py" ,
356+ "--source" ,
357+ str (source ),
358+ "--artifact" ,
359+ str (artifact ),
360+ "--format" ,
361+ artifact_format ,
362+ "--source-triples" ,
363+ str (source_triples ),
364+ "--result-path" ,
365+ str (validation_path ),
366+ ]
367+ if args .expected_triples is not None :
368+ command .extend (["--expected-triples" , str (args .expected_triples )])
369+ if skip_index_check :
370+ command .append ("--skip-index-check" )
371+ stage = runner .run (name , command , validation_path )
372+ try :
373+ report = json .loads (validation_path .read_text (encoding = "utf-8" ))
374+ except (FileNotFoundError , json .JSONDecodeError ) as exc :
375+ report = {
376+ "valid" : False ,
377+ "count_match" : False ,
378+ "error" : f"validator did not produce a valid report: { exc } " ,
379+ }
380+ finally :
381+ validation_path .unlink (missing_ok = True )
382+ report .setdefault (
383+ "timing" ,
384+ {
385+ "wall_seconds" : stage .get ("wall_seconds" ),
386+ "user_seconds" : stage .get ("user_seconds" ),
387+ "sys_seconds" : stage .get ("sys_seconds" ),
388+ "max_rss_kb" : stage .get ("max_rss_kb" ),
389+ },
390+ )
391+ if (
392+ stage ["exit_code" ] != 0
393+ or not report .get ("valid" )
394+ or not report .get ("count_match" )
395+ ):
396+ raise RuntimeError (
397+ f"{ artifact_format .upper ()} validation failed for { artifact .name } : "
398+ f"{ report .get ('error' , 'decoded triple count mismatch' )} "
399+ )
400+ return report
401+
329402 for index , chunk in enumerate (chunks ):
330403 if needs_hdt :
331404 chunk_hdt = work_dir / f"chunk-{ index :05d} .hdt"
@@ -369,6 +442,13 @@ def main() -> int:
369442 add_totals (hdt_total , index_stage )
370443 if index_stage ["exit_code" ] != 0 :
371444 raise RuntimeError ("final HDT index initialization failed" )
445+ hdt_validation = validate_artifact (
446+ name = "hdt-validate" ,
447+ artifact = output_hdt ,
448+ artifact_format = "hdt" ,
449+ python_bin = sys .executable ,
450+ skip_index_check = True ,
451+ )
372452 results ["hdt" ] = {
373453 ** finalize_totals (hdt_total ),
374454 "output_path" : str (output_hdt ),
@@ -379,6 +459,7 @@ def main() -> int:
379459 "merge_rounds" : hdt_rounds ,
380460 "index_path" : str (output_index ),
381461 "index_size_bytes" : output_index .stat ().st_size ,
462+ "validation" : hdt_validation ,
382463 },
383464 }
384465
@@ -395,12 +476,23 @@ def main() -> int:
395476 raise RuntimeError ("COTTAS merge failed" )
396477 shutil .copyfile (final_cottas , output_cottas )
397478 final_cottas .unlink (missing_ok = True )
479+ cottas_validation = validate_artifact (
480+ name = "cottas-validate" ,
481+ artifact = output_cottas ,
482+ artifact_format = "cottas" ,
483+ python_bin = cottas_python ,
484+ )
398485 results ["cottas" ] = {
399486 ** finalize_totals (cottas_total ),
400487 "output_path" : str (output_cottas ),
401488 "output_size_bytes" : output_cottas .stat ().st_size ,
402489 "source" : "partitioned_generated" ,
403- "details" : {** plan , "merge_rounds" : cottas_rounds , "index" : "spo" },
490+ "details" : {
491+ ** plan ,
492+ "merge_rounds" : cottas_rounds ,
493+ "index" : "spo" ,
494+ "validation" : cottas_validation ,
495+ },
404496 }
405497
406498 for method in methods :
0 commit comments