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