1use shifty_engine::{
30 ConformanceOptions, PreparedEvidenceValidator, ValidationGraphMode, ValidationOptions,
31};
32use std::collections::{HashMap, HashSet};
33use std::fs;
34use std::path::{Path, PathBuf};
35use std::time::{Duration, Instant};
36
37struct Args {
38 shapes: PathBuf,
39 models: PathBuf,
40 suite: String,
41 iters: usize,
42 min_iters: usize,
43 budget_ms: f64,
44 header: bool,
45 no_infer: bool,
46}
47
48#[derive(Clone, Copy)]
49enum Arm {
50 Conformance,
51 FullEvidence,
52 OnDemand,
53}
54
55const ARM_ORDERS: [[Arm; 3]; 3] = [
56 [Arm::Conformance, Arm::FullEvidence, Arm::OnDemand],
57 [Arm::FullEvidence, Arm::OnDemand, Arm::Conformance],
58 [Arm::OnDemand, Arm::Conformance, Arm::FullEvidence],
59];
60
61fn parse_args() -> Args {
62 let mut args = Args {
63 shapes: PathBuf::new(),
64 models: PathBuf::new(),
65 suite: "suite".to_string(),
66 iters: 5,
67 min_iters: 3,
68 budget_ms: 0.0,
69 header: true,
70 no_infer: false,
71 };
72 let mut argv = std::env::args().skip(1);
73 while let Some(flag) = argv.next() {
74 let mut value = || {
75 argv.next()
76 .unwrap_or_else(|| panic!("{flag} needs a value"))
77 };
78 match flag.as_str() {
79 "--shapes" => args.shapes = PathBuf::from(value()),
80 "--models" => args.models = PathBuf::from(value()),
81 "--suite" => args.suite = value(),
82 "--iters" => args.iters = value().parse().expect("--iters must be an integer"),
83 "--budget-ms" => {
84 args.budget_ms = value().parse().expect("--budget-ms must be a number")
85 }
86 "--min-iters" => {
87 args.min_iters = value().parse().expect("--min-iters must be an integer")
88 }
89 "--no-header" => args.header = false,
90 "--no-infer" => args.no_infer = true,
91 other => panic!("unknown flag {other}"),
92 }
93 }
94 assert!(!args.shapes.as_os_str().is_empty(), "--shapes is required");
95 assert!(!args.models.as_os_str().is_empty(), "--models is required");
96 assert!(args.iters > 0, "--iters must be positive");
97 args
98}
99
100const COLUMNS: &str = "suite,model,data_triples,iters,\
101conformance_ms_median,conformance_ms_mad,\
102full_evidence_ms_median,full_evidence_ms_mad,\
103full_overhead_median,full_overhead_mad,\
104failure_discovery_ms_median,failure_discovery_ms_mad,\
105failure_explanation_ms_median,failure_explanation_ms_mad,\
106on_demand_ms_median,on_demand_ms_mad,\
107on_demand_overhead_median,on_demand_overhead_mad,\
108serialize_ms_median,serialize_ms_mad,\
109conforms_conformance,conforms_evidence,conforms_on_demand,\
110evaluated_pairs,pass_pairs,fail_pairs,fail_fraction,\
111authored_pairs,authored_pass_pairs,authored_fail_pairs,\
112statements,\
113evidence_nodes,evidence_nodes_per_authored_pair,\
114evidence_bytes,evidence_bytes_per_authored_pair,\
115full_run_bytes_with_catalog,full_run_bytes_no_catalog,\
116compact_run_bytes_with_catalog,compact_run_bytes_no_catalog,\
117node_occurrences,distinct_nodes,node_redundancy,\
118result_occurrences,distinct_results,result_redundancy,\
119support_occurrences,distinct_support,support_redundancy,support_share,\
120term_occurrences,distinct_terms,term_redundancy,\
121normalized_requests,duplicate_records,divergent_duplicates,\
122canonical_occurrences,shared_payloads,shared_payload_fraction,\
123shared_canonical_occurrences,shared_occurrence_fraction,\
124request_reaches,requests_per_payload,max_payload_requests,\
125distinct_keys,key_redundancy,multi_occurrence_keys,\
126divergent_keys,divergence_fraction,divergent_occurrences,\
127distinct_payloads_per_key,keys_over_payload_cap,both_polarity_addresses,\
128conformance_ms_samples,full_evidence_ms_samples,\
129failure_discovery_ms_samples,failure_explanation_ms_samples,on_demand_ms_samples";
130
131fn main() {
132 let args = parse_args();
133
134 eprintln!("loading shapes {}…", args.shapes.display());
135 let shapes_bytes = fs::read(&args.shapes).expect("cannot read --shapes");
136 let shapes = shifty_parse::load_turtle(&shapes_bytes, None).expect("cannot parse --shapes");
137 let parsed = shifty_parse::parse_loaded(&shapes);
138 let raw_schema = parsed.schema.clone();
139 let inference_schema = shifty_opt::normalize(&raw_schema);
141 drop(shapes_bytes);
142
143 if args.header {
144 println!("{COLUMNS}");
145 }
146
147 for model in models_in(&args.models) {
148 let name = model
149 .file_name()
150 .and_then(|name| name.to_str())
151 .unwrap_or("?")
152 .to_string();
153 eprint!("{name}… ");
154
155 let data_bytes = fs::read(&model).expect("cannot read model");
157 let data = shifty_parse::load_turtle(&data_bytes, None).expect("cannot parse model");
158 drop(data_bytes);
159 let data_graph = if args.no_infer {
160 data.graph.clone()
161 } else {
162 shifty_engine::infer_graphs(&data.graph, &shapes.graph, &inference_schema)
163 .unwrap_or_else(|error| panic!("{name}: inference failed: {error}"))
164 .graph
165 };
166 let data_triples = data_graph.len();
167
168 let prepared = PreparedEvidenceValidator::with_graphs(
169 &data_graph,
170 &shapes.graph,
171 &raw_schema,
172 ValidationGraphMode::Union,
173 )
174 .unwrap_or_else(|error| panic!("{name}: schema is non-stratifiable: {error}"));
175 let options = ValidationOptions::default();
176 let scan_options = ConformanceOptions::default();
177
178 let probe = Instant::now();
182 let _ = prepared.validate_conformance(&scan_options);
183 let _ = prepared.validate_canonical(&options);
184 let (_, warm_failures) = prepared.find_failures(&scan_options);
185 for pair in &warm_failures {
186 std::hint::black_box(prepared.explain_canonical(pair));
187 }
188 let probe_ms = probe.elapsed().as_secs_f64() * 1e3;
189 let iters = if args.budget_ms > 0.0 {
193 ((args.budget_ms / probe_ms.max(f64::MIN_POSITIVE)) as usize)
194 .clamp(args.min_iters.min(args.iters), args.iters)
195 } else {
196 args.iters
197 };
198
199 let mut conformance_samples = Vec::with_capacity(iters);
200 let mut evidence_samples = Vec::with_capacity(iters);
201 let mut discovery_samples = Vec::with_capacity(iters);
202 let mut explanation_samples = Vec::with_capacity(iters);
203 let mut on_demand_samples = Vec::with_capacity(iters);
204 let mut last_run = None;
205 let mut last_conformance = None;
206 let mut last_on_demand_conformance = None;
207 let mut last_failures = Vec::new();
208 let mut last_explanations = Vec::new();
209
210 for iteration in 0..iters {
213 for arm in ARM_ORDERS[iteration % ARM_ORDERS.len()] {
214 match arm {
215 Arm::Conformance => {
216 let start = Instant::now();
217 let conformance_run = prepared.validate_conformance(&scan_options);
218 conformance_samples.push(start.elapsed());
219 std::hint::black_box(conformance_run.selected_pairs);
220 last_conformance = Some(conformance_run);
221 }
222 Arm::FullEvidence => {
223 let start = Instant::now();
224 let run = prepared.validate_canonical(&options);
225 evidence_samples.push(start.elapsed());
226 std::hint::black_box(run.statements.len());
227 last_run = Some(run);
228 }
229 Arm::OnDemand => {
230 let start = Instant::now();
231 let (on_demand_conformance, failures) =
232 prepared.find_failures(&scan_options);
233 let discovery = start.elapsed();
234
235 let start = Instant::now();
236 let explanations: Vec<_> = failures
237 .iter()
238 .flat_map(|pair| prepared.explain_canonical(pair))
239 .collect();
240 let explanation = start.elapsed();
241
242 discovery_samples.push(discovery);
243 explanation_samples.push(explanation);
244 on_demand_samples.push(discovery + explanation);
245 std::hint::black_box(explanations.len());
246 last_on_demand_conformance = Some(on_demand_conformance);
247 last_failures = failures;
248 last_explanations = explanations;
249 }
250 }
251 }
252 }
253
254 let conformance = last_conformance.expect("at least one iteration runs");
257 let on_demand_conformance =
258 last_on_demand_conformance.expect("at least one iteration runs");
259 let run = last_run.expect("at least one iteration runs");
260 let authored_pairs: usize = run
261 .statements
262 .iter()
263 .map(|statement| statement.selected_foci.len())
264 .sum();
265 let authored_fail_pairs: usize = run
266 .statements
267 .iter()
268 .flat_map(|statement| &statement.selected_foci)
269 .filter(|focus| focus.status() == shifty_engine::EvaluationStatus::Fail)
270 .count();
271
272 assert_eq!(
276 conformance, on_demand_conformance,
277 "{name}: on-demand counts"
278 );
279 assert_eq!(
280 conformance.conforms, run.conforms,
281 "{name}: conformance bit"
282 );
283 let full_selected: HashSet<_> = run
284 .statements
285 .iter()
286 .flat_map(|statement| {
287 statement.selected_foci.iter().filter_map(|focus| {
288 statement
289 .normalized_statement_id
290 .map(|id| (id, focus.focus.clone()))
291 })
292 })
293 .collect();
294 let full_failures: HashSet<_> = run
295 .statements
296 .iter()
297 .flat_map(|statement| {
298 statement
299 .selected_foci
300 .iter()
301 .filter(|focus| focus.status() == shifty_engine::EvaluationStatus::Fail)
302 .filter_map(|focus| {
303 statement
304 .normalized_statement_id
305 .map(|id| (id, focus.focus.clone()))
306 })
307 })
308 .collect();
309 let discovered_failures: HashSet<_> = last_failures
310 .iter()
311 .map(|pair| (pair.normalized_statement(), pair.focus().clone()))
312 .collect();
313 assert_eq!(
314 full_selected.len(),
315 conformance.selected_pairs,
316 "{name}: selected pairs"
317 );
318 assert_eq!(
319 full_failures.len(),
320 conformance.failed,
321 "{name}: failed pairs"
322 );
323 assert_eq!(
324 full_selected.len() - full_failures.len(),
325 conformance.passed,
326 "{name}: passed pairs"
327 );
328 assert_eq!(
329 full_failures, discovered_failures,
330 "{name}: failed identities"
331 );
332
333 let full_by_authored: HashMap<_, _> = run
334 .statements
335 .iter()
336 .flat_map(|statement| {
337 statement.selected_foci.iter().map(|focus| {
338 (
339 (statement.source_statement_id, focus.focus.clone()),
340 &focus.evidence,
341 )
342 })
343 })
344 .collect();
345 let mut explained_foci = 0;
346 for statement in &last_explanations {
347 for focus in &statement.selected_foci {
348 explained_foci += 1;
349 assert_eq!(
350 focus.status(),
351 shifty_engine::EvaluationStatus::Fail,
352 "{name}: on-demand explanation polarity"
353 );
354 let key = (statement.source_statement_id, focus.focus.clone());
355 assert_eq!(
356 &focus.evidence,
357 *full_by_authored
358 .get(&key)
359 .unwrap_or_else(|| panic!("{name}: unexplained full-run pair {key:?}")),
360 "{name}: full and on-demand evidence differ for {key:?}"
361 );
362 }
363 }
364 assert_eq!(
365 explained_foci, authored_fail_pairs,
366 "{name}: authored explanations"
367 );
368
369 let evidence_nodes = run.walk().len();
370 let evidence_bytes: usize = run
371 .statements
372 .iter()
373 .flat_map(|statement| &statement.selected_foci)
374 .map(|focus| focus.evidence.to_json().expect("evidence serializes").len())
375 .sum();
376 let full_json = run.to_json().expect("evidence run serializes");
377 let full_run_bytes_with_catalog = full_json.len();
378 let mut full_value_no_catalog =
379 serde_json::to_value(&run).expect("evidence run converts to JSON");
380 full_value_no_catalog
381 .as_object_mut()
382 .expect("evidence run is a JSON object")
383 .remove("constraints");
384 let full_run_bytes_no_catalog = serde_json::to_string(&full_value_no_catalog)
385 .expect("catalog-free evidence run serializes")
386 .len();
387
388 let encoded_with_catalog =
391 shifty_engine::compact(&run, true).expect("compact encoding succeeds");
392 let compact_run_bytes_with_catalog = serde_json::to_string(&encoded_with_catalog)
393 .expect("compact encoding serializes")
394 .len();
395 assert_eq!(
396 shifty_engine::expand(&encoded_with_catalog).expect("compact run expands"),
397 run,
398 "{name}: compact run with catalog is not lossless"
399 );
400 let encoded_no_catalog =
401 shifty_engine::compact(&run, false).expect("compact encoding succeeds");
402 let compact_run_bytes_no_catalog = serde_json::to_string(&encoded_no_catalog)
403 .expect("catalog-free compact encoding serializes")
404 .len();
405 let catalog = serde_json::to_value(&run.constraints).expect("catalog serializes");
406 assert_eq!(
407 shifty_engine::expand_with_catalog(&encoded_no_catalog, catalog)
408 .expect("catalog-free compact run expands"),
409 run,
410 "{name}: compact run without catalog is not lossless"
411 );
412 let sharing = shifty_engine::sharing(&run).expect("sharing measures");
416 let results = shifty_engine::result_sharing(&run);
417
418 assert_eq!(
424 sharing.result_occurrences + sharing.support_occurrences,
425 sharing.node_occurrences,
426 "{name}: node families do not partition occurrences"
427 );
428 assert_eq!(
429 sharing.distinct_results + sharing.distinct_support,
430 sharing.distinct_nodes,
431 "{name}: node families do not partition the table"
432 );
433 assert_eq!(
434 sharing.result_occurrences, evidence_nodes,
435 "{name}: interned judgments disagree with the typed walk"
436 );
437 assert_eq!(
438 results.occurrences, evidence_nodes,
439 "{name}: typed sharing pass disagrees with the typed walk"
440 );
441 assert_eq!(
442 results.distinct_payloads, sharing.distinct_results,
443 "{name}: distinct judgments disagree between the two passes"
444 );
445 assert!(
447 results.distinct_keys <= results.distinct_payloads,
448 "{name}: more keys than payloads"
449 );
450 assert_eq!(
453 results.normalized_requests, conformance.selected_pairs,
454 "{name}: requests disagree with the conformance selection"
455 );
456 assert_eq!(
457 results.authored_records, authored_pairs,
458 "{name}: authored records disagree with the run"
459 );
460
461 let mut serialize_samples = Vec::with_capacity(iters);
464 for _ in 0..iters {
465 let start = Instant::now();
466 let json = run.to_json().expect("evidence run serializes");
467 serialize_samples.push(start.elapsed());
468 std::hint::black_box(json.len());
469 }
470
471 let full_ratios: Vec<f64> = conformance_samples
474 .iter()
475 .zip(evidence_samples.iter())
476 .map(|(conformance, evidence)| {
477 evidence.as_secs_f64() / conformance.as_secs_f64().max(f64::MIN_POSITIVE)
478 })
479 .collect();
480 let on_demand_ratios: Vec<f64> = conformance_samples
481 .iter()
482 .zip(on_demand_samples.iter())
483 .map(|(conformance, on_demand)| {
484 on_demand.as_secs_f64() / conformance.as_secs_f64().max(f64::MIN_POSITIVE)
485 })
486 .collect();
487
488 let conformance_ms = summarize_durations(&conformance_samples);
489 let evidence_ms = summarize_durations(&evidence_samples);
490 let discovery_ms = summarize_durations(&discovery_samples);
491 let explanation_ms = summarize_durations(&explanation_samples);
492 let on_demand_ms = summarize_durations(&on_demand_samples);
493 let serialize_ms = summarize_durations(&serialize_samples);
494 let full_ratio = summarize_values(&full_ratios);
495 let on_demand_ratio = summarize_values(&on_demand_ratios);
496 let evaluated_pairs = conformance.selected_pairs.max(1) as f64;
497 let authored_pairs_denominator = authored_pairs.max(1) as f64;
498
499 println!(
500 "{suite},{name},{data_triples},{iters},\
501{c_med:.4},{c_mad:.4},{e_med:.4},{e_mad:.4},{full_ratio:.4},{full_ratio_mad:.4},\
502{find_med:.4},{find_mad:.4},{explain_med:.4},{explain_mad:.4},\
503{ondemand_med:.4},{ondemand_mad:.4},{ondemand_ratio:.4},{ondemand_ratio_mad:.4},\
504{serialize_med:.4},{serialize_mad:.4},\
505{conforms_c},{conforms_e},{conforms_o},\
506{pairs_eval},{pass_pairs},{fail_pairs},{fail_fraction:.6},\
507{authored_pairs},{authored_pass},{authored_fail},\
508{statements},\
509{evidence_nodes},{nodes_per_authored_pair:.3},\
510{evidence_bytes},{bytes_per_authored_pair:.1},\
511{full_with_catalog},{full_no_catalog},{compact_with_catalog},{compact_no_catalog},\
512{node_occurrences},{distinct_nodes},{node_redundancy:.3},\
513{result_occurrences},{distinct_results},{result_redundancy:.3},\
514{support_occurrences},{distinct_support},{support_redundancy:.3},{support_share:.4},\
515{term_occurrences},{distinct_terms},{term_redundancy:.3},\
516{normalized_requests},{duplicate_records},{divergent_duplicates},\
517{canonical_occurrences},{shared_payloads},{shared_payload_fraction:.4},\
518{shared_canonical_occurrences},{shared_occurrence_fraction:.4},\
519{request_reaches},{requests_per_payload:.3},{max_payload_requests},\
520{distinct_keys},{key_redundancy:.3},{multi_occurrence_keys},\
521{divergent_keys},{divergence_fraction:.4},{divergent_occurrences},\
522{distinct_payloads_per_key},{keys_over_payload_cap},{both_polarity_addresses},\
523{conformance_raw},{evidence_raw},{discovery_raw},{explanation_raw},{ondemand_raw}",
524 suite = args.suite,
525 node_occurrences = sharing.node_occurrences,
526 distinct_nodes = sharing.distinct_nodes,
527 node_redundancy = sharing.node_redundancy(),
528 result_occurrences = sharing.result_occurrences,
529 distinct_results = sharing.distinct_results,
530 result_redundancy = sharing.result_redundancy(),
531 support_occurrences = sharing.support_occurrences,
532 distinct_support = sharing.distinct_support,
533 support_redundancy = sharing.support_redundancy(),
534 support_share = sharing.support_share(),
535 term_occurrences = sharing.term_occurrences,
536 distinct_terms = sharing.distinct_terms,
537 term_redundancy = sharing.term_redundancy(),
538 normalized_requests = results.normalized_requests,
539 duplicate_records = results.duplicate_records,
540 divergent_duplicates = results.divergent_duplicates,
541 canonical_occurrences = results.canonical_occurrences,
542 shared_payloads = results.shared_payloads,
543 shared_payload_fraction = results.shared_payload_fraction(),
544 shared_canonical_occurrences = results.shared_canonical_occurrences,
545 shared_occurrence_fraction = results.shared_occurrence_fraction(),
546 request_reaches = results.request_reaches,
547 requests_per_payload = results.requests_per_payload(),
548 max_payload_requests = results.max_payload_requests,
549 distinct_keys = results.distinct_keys,
550 key_redundancy = results.key_redundancy(),
551 multi_occurrence_keys = results.multi_occurrence_keys,
552 divergent_keys = results.divergent_keys,
553 divergence_fraction = results.divergence_fraction(),
554 divergent_occurrences = results.divergent_occurrences,
555 distinct_payloads_per_key = results.distinct_payloads_per_key,
556 keys_over_payload_cap = results.keys_over_payload_cap,
557 both_polarity_addresses = results.both_polarity_addresses,
558 c_med = conformance_ms.median,
559 c_mad = conformance_ms.mad,
560 e_med = evidence_ms.median,
561 e_mad = evidence_ms.mad,
562 full_ratio = full_ratio.median,
563 full_ratio_mad = full_ratio.mad,
564 find_med = discovery_ms.median,
565 find_mad = discovery_ms.mad,
566 explain_med = explanation_ms.median,
567 explain_mad = explanation_ms.mad,
568 ondemand_med = on_demand_ms.median,
569 ondemand_mad = on_demand_ms.mad,
570 ondemand_ratio = on_demand_ratio.median,
571 ondemand_ratio_mad = on_demand_ratio.mad,
572 serialize_med = serialize_ms.median,
573 serialize_mad = serialize_ms.mad,
574 conforms_c = conformance.conforms,
575 conforms_e = run.conforms,
576 conforms_o = on_demand_conformance.conforms,
577 pairs_eval = conformance.selected_pairs,
578 pass_pairs = conformance.passed,
579 fail_pairs = conformance.failed,
580 fail_fraction = conformance.failed as f64 / evaluated_pairs,
581 authored_pass = authored_pairs - authored_fail_pairs,
582 authored_fail = authored_fail_pairs,
583 statements = run.statements.len(),
584 nodes_per_authored_pair = evidence_nodes as f64 / authored_pairs_denominator,
585 bytes_per_authored_pair = evidence_bytes as f64 / authored_pairs_denominator,
586 full_with_catalog = full_run_bytes_with_catalog,
587 full_no_catalog = full_run_bytes_no_catalog,
588 compact_with_catalog = compact_run_bytes_with_catalog,
589 compact_no_catalog = compact_run_bytes_no_catalog,
590 conformance_raw = samples_string(&conformance_samples),
591 evidence_raw = samples_string(&evidence_samples),
592 discovery_raw = samples_string(&discovery_samples),
593 explanation_raw = samples_string(&explanation_samples),
594 ondemand_raw = samples_string(&on_demand_samples),
595 );
596
597 eprintln!(
598 "median: {:.1}ms conformance, {:.1}ms full ({:.2}x), {:.1}ms on-demand ({:.2}x); {} failures / {} pairs; {iters} rounds",
599 conformance_ms.median,
600 evidence_ms.median,
601 full_ratio.median,
602 on_demand_ms.median,
603 on_demand_ratio.median,
604 conformance.failed,
605 conformance.selected_pairs,
606 );
607 }
608}
609
610struct Stats {
611 median: f64,
612 mad: f64,
613}
614
615fn summarize_durations(samples: &[Duration]) -> Stats {
618 let values: Vec<f64> = samples
619 .iter()
620 .map(|duration| duration.as_secs_f64() * 1e3)
621 .collect();
622 summarize_values(&values)
623}
624
625fn summarize_values(samples: &[f64]) -> Stats {
626 let mut values = samples.to_vec();
627 values.sort_by(f64::total_cmp);
628 let median = median_of(&values);
629 let mut deviations: Vec<f64> = values.iter().map(|value| (value - median).abs()).collect();
630 deviations.sort_by(f64::total_cmp);
631 Stats {
632 median,
633 mad: median_of(&deviations),
634 }
635}
636
637fn samples_string(samples: &[Duration]) -> String {
638 samples
639 .iter()
640 .map(|duration| format!("{:.4}", duration.as_secs_f64() * 1e3))
641 .collect::<Vec<_>>()
642 .join(";")
643}
644
645fn median_of(sorted: &[f64]) -> f64 {
646 let n = sorted.len();
647 if n % 2 == 1 {
648 sorted[n / 2]
649 } else {
650 (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
651 }
652}
653
654fn models_in(dir: &Path) -> Vec<PathBuf> {
656 let mut models: Vec<PathBuf> = fs::read_dir(dir)
657 .expect("cannot read --models")
658 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
659 .filter(|path| path.extension().is_some_and(|ext| ext == "ttl"))
660 .collect();
661 models.sort();
662 models
663}