Skip to main content

probe_evidence_cost/
probe_evidence_cost.rs

1//! Where does evidence tracing spend its time? A counting probe, not a timer.
2//!
3//! Runs both arms over one model from one prepared snapshot and reports the
4//! work each does, so the latency gap in `bench_evidence` can be attributed:
5//!
6//!   * memo hits/misses/entries — how much sharing the conformance evaluator
7//!     gets from its `(ShapeId, Term) -> bool` cache;
8//!   * evidence node visits — how many nodes `evaluate_node` *enters*, versus
9//!     how many survive into the retained evidence.
10//!
11//! Visits far above both the retained node count and the memo's entry count is
12//! the signature of a shared shape DAG being re-expanded into a tree, per pair,
13//! with no cache to collapse it.
14//!
15//! Usage:
16//!   cargo run --release -p shifty-engine --example probe_evidence_cost -- \
17//!       --shapes benchmark/brick/Brick-closure.ttl \
18//!       --data benchmark/brick/models/bldg1.ttl
19
20use shifty_engine::{
21    CompiledShapes, ConformanceOptions, PreparedEvidenceValidator, SessionData, SessionOptions,
22    ValidationGraphMode, ValidationOptions, profile,
23};
24use std::fs;
25use std::path::PathBuf;
26use std::time::Instant;
27
28fn main() {
29    let mut shapes_path = PathBuf::new();
30    let mut data_path = PathBuf::new();
31    let mut no_infer = false;
32    let mut top = 15usize;
33    let mut dump_json: Option<PathBuf> = None;
34    let mut argv = std::env::args().skip(1);
35    while let Some(flag) = argv.next() {
36        let mut value = || {
37            argv.next()
38                .unwrap_or_else(|| panic!("{flag} needs a value"))
39        };
40        match flag.as_str() {
41            "--shapes" => shapes_path = PathBuf::from(value()),
42            "--data" => data_path = PathBuf::from(value()),
43            "--no-infer" => no_infer = true,
44            "--top" => top = value().parse().expect("--top must be an integer"),
45            "--dump-json" => dump_json = Some(PathBuf::from(value())),
46            other => panic!("unknown flag {other}"),
47        }
48    }
49    assert!(!shapes_path.as_os_str().is_empty(), "--shapes is required");
50    assert!(!data_path.as_os_str().is_empty(), "--data is required");
51
52    let shapes_bytes = fs::read(&shapes_path).expect("cannot read --shapes");
53    let shapes = shifty_parse::load_turtle(&shapes_bytes, None).expect("cannot parse --shapes");
54    let compiled = CompiledShapes::compile(shapes).expect("cannot compile shapes");
55
56    let data_bytes = fs::read(&data_path).expect("cannot read --data");
57    let data = shifty_parse::load_turtle(&data_bytes, None).expect("cannot parse --data");
58    let data_graph = if no_infer {
59        data.graph.clone()
60    } else {
61        compiled
62            .session(
63                SessionData::Separate(data.graph.clone()),
64                SessionOptions {
65                    inference: true,
66                    ..SessionOptions::default()
67                },
68            )
69            .expect("inference")
70            .data()
71            .clone()
72    };
73
74    let prepared = PreparedEvidenceValidator::with_graphs(
75        &data_graph,
76        &compiled.source().graph,
77        compiled.authored_schema(),
78        ValidationGraphMode::Union,
79    )
80    .expect("stratifiable");
81    let options = ValidationOptions::default();
82    let scan_options = ConformanceOptions::default();
83
84    println!("model: {}", data_path.display());
85    println!("normalized arena nodes: {}", prepared.schema().arena.len());
86    println!(
87        "normalized statements:  {}",
88        prepared.schema().statements.len()
89    );
90    println!();
91
92    // ---- arm A: conformance only ------------------------------------------
93    profile::enable();
94    let _ = profile::take_evidence_visits();
95    let start = Instant::now();
96    let conformance = prepared.validate_conformance(&scan_options);
97    let conformance_ms = start.elapsed().as_secs_f64() * 1e3;
98    let conformance_visits = profile::take_evidence_visits();
99    let conformance_probes = profile::take_path_probes();
100    let conformance_cache = profile::take().expect("profiling enabled");
101    let conformance_cache = conformance_cache.shape_cache().clone();
102
103    // ---- arm B: dual evidence ----------------------------------------------
104    profile::enable();
105    let start = Instant::now();
106    let run = prepared.validate(&options);
107    let evidence_ms = start.elapsed().as_secs_f64() * 1e3;
108    let evidence_visits = profile::take_evidence_visits();
109    let evidence_probes = profile::take_path_probes();
110    let collector = profile::take().expect("profiling enabled");
111    let evidence_cache = collector.shape_cache().clone();
112
113    // ---- arm C: on-demand ---------------------------------------------------
114    // What a caller pays who wants explanations only for what failed: one
115    // conformance pass that also records the failing pairs, then evidence
116    // materialized for those pairs alone.
117    profile::enable();
118    let start = Instant::now();
119    let (_, failures) = prepared.find_failures(&scan_options);
120    let find_ms = start.elapsed().as_secs_f64() * 1e3;
121    let start = Instant::now();
122    let explained: usize = failures
123        .iter()
124        .map(|pair| prepared.explain(pair).len())
125        .sum();
126    let explain_ms = start.elapsed().as_secs_f64() * 1e3;
127    let _ = profile::take();
128    let _ = profile::take_evidence_visits();
129    let _ = profile::take_path_probes();
130
131    let pairs = conformance.selected_pairs.max(1) as f64;
132    let retained = run.walk().len();
133
134    // ---- compact encoding --------------------------------------------------
135    let full_json = run.to_json().expect("evidence run serializes");
136    // Split so the encoding's own cost is separable from the intermediate
137    // `Value` tree it currently has to be handed: compaction is worth doing
138    // inline only if the total stays under the validation it describes.
139    let to_value_start = Instant::now();
140    let run_value = serde_json::to_value(&run).expect("run serializes");
141    let to_value_ms = to_value_start.elapsed().as_secs_f64() * 1e3;
142    let intern_start = Instant::now();
143    let interned = shifty_engine::compact_value(run_value, true);
144    let intern_ms = intern_start.elapsed().as_secs_f64() * 1e3;
145    let emit_start = Instant::now();
146    let compact_json = serde_json::to_string(&interned).expect("compact encoding serializes");
147    let emit_ms = emit_start.elapsed().as_secs_f64() * 1e3;
148    let compact_ms = to_value_ms + intern_ms + emit_ms;
149    drop(interned);
150    let compact_no_catalog =
151        shifty_engine::to_compact_json(&run, false).expect("compact encoding succeeds");
152    println!();
153    println!(
154        "serialized full:           {:>10.2} MB  ({:.0} bytes/pair)",
155        full_json.len() as f64 / 1e6,
156        full_json.len() as f64 / pairs
157    );
158    println!(
159        "compact (with catalog):    {:>10.2} MB  ({:.0} bytes/pair, {:.1}% smaller, {compact_ms:.0} ms \
160         = {to_value_ms:.0} to_value + {intern_ms:.0} intern + {emit_ms:.0} emit)",
161        compact_json.len() as f64 / 1e6,
162        compact_json.len() as f64 / pairs,
163        100.0 * (full_json.len() - compact_json.len()) as f64 / full_json.len() as f64
164    );
165    println!(
166        "compact (catalog elided):  {:>10.2} MB  ({:.0} bytes/pair, {:.1}% smaller)",
167        compact_no_catalog.len() as f64 / 1e6,
168        compact_no_catalog.len() as f64 / pairs,
169        100.0 * (full_json.len() - compact_no_catalog.len()) as f64 / full_json.len() as f64
170    );
171
172    if let Some(path) = &dump_json {
173        fs::write(path, run.to_json().expect("evidence run serializes"))
174            .expect("cannot write --dump-json");
175        eprintln!("wrote {}", path.display());
176    }
177
178    println!("                        conformance        evidence");
179    println!("  wall ms               {conformance_ms:>11.1}  {evidence_ms:>14.1}");
180    println!(
181        "  memo lookups          {:>11}  {:>14}",
182        conformance_cache.hits + conformance_cache.misses,
183        evidence_cache.hits + evidence_cache.misses
184    );
185    println!(
186        "  memo hits             {:>11}  {:>14}",
187        conformance_cache.hits, evidence_cache.hits
188    );
189    println!(
190        "  memo misses           {:>11}  {:>14}",
191        conformance_cache.misses, evidence_cache.misses
192    );
193    println!(
194        "  memo entries (peak)   {:>11}  {:>14}",
195        conformance_cache.peak_entries, evidence_cache.peak_entries
196    );
197    println!(
198        "  back-edges            {:>11}  {:>14}",
199        conformance_cache.recursion_back_edges, evidence_cache.recursion_back_edges
200    );
201    println!(
202        "  uncacheable results   {:>11}  {:>14}",
203        conformance_cache.non_cacheable_results, evidence_cache.non_cacheable_results
204    );
205    println!("  evidence visits       {conformance_visits:>11}  {evidence_visits:>14}");
206    println!("  path_support probes   {conformance_probes:>11}  {evidence_probes:>14}");
207    println!();
208    println!(
209        "path probes per visit:     {:.1}",
210        evidence_probes as f64 / evidence_visits.max(1) as f64
211    );
212
213    println!("selected pairs:            {}", conformance.selected_pairs);
214    println!("retained evidence nodes:   {retained}");
215    println!(
216        "visited / retained:        {:.1}x",
217        evidence_visits as f64 / retained.max(1) as f64
218    );
219    println!(
220        "visits per pair:           {:.0}",
221        evidence_visits as f64 / pairs
222    );
223    println!(
224        "distinct (shape,node) that a cache could collapse to at most: {}",
225        evidence_cache.peak_entries
226    );
227    println!(
228        "visits / distinct pairs:   {:.1}x",
229        evidence_visits as f64 / evidence_cache.peak_entries.max(1) as f64
230    );
231    println!();
232    println!(
233        "per-visit cost:            {:.2} us",
234        evidence_ms * 1e3 / evidence_visits.max(1) as f64
235    );
236    println!(
237        "per-memo-lookup cost:      {:.2} us",
238        conformance_ms * 1e3 / (conformance_cache.hits + conformance_cache.misses).max(1) as f64
239    );
240
241    // ---- attribution: which statements own the time ------------------------
242    let (selection, materialization): (Vec<_>, Vec<_>) = collector
243        .shape_records()
244        .iter()
245        .partition(|record| record.label.starts_with("select:"));
246    let selection_us: u64 = selection.iter().map(|record| record.total_us).sum();
247    let materialization_us: u64 = materialization.iter().map(|record| record.total_us).sum();
248
249    println!();
250    println!(
251        "target selection total:    {:.1} ms over {} statement(s)",
252        selection_us as f64 / 1e3,
253        selection.len()
254    );
255    println!(
256        "materialization total:     {:.1} ms over {} statement(s)",
257        materialization_us as f64 / 1e3,
258        materialization.len()
259    );
260    println!();
261    println!(
262        "on-demand: {} failing pair(s) of {} selected -> {} explanation(s)",
263        failures.len(),
264        conformance.selected_pairs,
265        explained
266    );
267    println!(
268        "  find_failures {find_ms:.1} ms + explain {explain_ms:.1} ms = {:.1} ms  \
269         ({:.2}x the full evidence run, {:.2}x conformance alone)",
270        find_ms + explain_ms,
271        (find_ms + explain_ms) / evidence_ms.max(f64::MIN_POSITIVE),
272        (find_ms + explain_ms) / conformance_ms.max(f64::MIN_POSITIVE)
273    );
274
275    let mut ranked = materialization;
276    ranked.sort_by_key(|record| std::cmp::Reverse(record.total_us));
277    println!();
278    println!("top statements by evidence-materialization time:");
279    println!(
280        "  {:>8}  {:>7}  {:>6}  {:>10}  {:>12}  {:>10}  shape",
281        "ms", "share", "foci", "us/focus", "visits", "visits/foci"
282    );
283    for record in ranked.iter().take(top) {
284        println!(
285            "  {:>8.1}  {:>6.1}%  {:>6}  {:>10.1}  {:>12}  {:>10.0}  {}",
286            record.total_us as f64 / 1e3,
287            100.0 * record.total_us as f64 / materialization_us.max(1) as f64,
288            record.invocations,
289            record.total_us as f64 / record.invocations.max(1) as f64,
290            record.visits,
291            record.visits as f64 / record.invocations.max(1) as f64,
292            record.label,
293        );
294    }
295
296    let mut ranked_selection = selection;
297    ranked_selection.sort_by_key(|record| std::cmp::Reverse(record.total_us));
298    println!();
299    println!("top statements by target-selection time:");
300    for record in ranked_selection.iter().take(5) {
301        println!(
302            "  {:>8.1} ms  {}",
303            record.total_us as f64 / 1e3,
304            record.label
305        );
306    }
307}