Skip to main content

shifty_engine/
sharing.rs

1//! How much an [`EvidenceRun`] shares across independently addressable
2//! validation results.
3//!
4//! [`compact`](crate::compact) answers a question about *bytes*: how much of a
5//! serialized run collapses under hash-consing. This module answers a question
6//! about *work*: how much of the derivation is reached from more than one
7//! addressable result, and whether a judgment determines its own evidence. The
8//! two are measured differently on purpose — this one never touches JSON, so
9//! its counts are an independent check on the encoder's.
10//!
11//! Three definitions carry the measurement.
12//!
13//! **The address of a result.** Interior results are addressed by
14//! `(constraint, node, polarity)` — the shape memo's `(ShapeId, Term)` key
15//! (`validate.rs`) plus which way it came out. Every `Witness` and `SatTrace`
16//! carries the first two; the enum itself carries the third. *Constraint* and
17//! *node*, not *shape* and *focus*: `focus` is reserved for the top-level
18//! selected node, and interior judgments are usually about some other node
19//! reached from it.
20//!
21//! **The unit that is independently addressable.** A run holds one record per
22//! *authored* `(statement, focus)` pair, because a report must name the
23//! statement its reader wrote. But two authored statements that normalize
24//! together are one *request*: the engine evaluates it once, and materializing
25//! it twice is duplication, not sharing. Sharing is therefore counted across
26//! normalized requests — [`normalized_requests`](ResultSharing::normalized_requests)
27//! — while the authored records are retained and counted separately, so
28//! source traceability costs a number rather than hiding inside the sharing
29//! one.
30//!
31//! **Payload divergence.** A key is only an *address* if it determines what is
32//! stored there. It may not: `Witness::Atom` carries `reached_by` and
33//! `produced_by`, which describe how the node was reached rather than what was
34//! decided about it, so one key can occur with several payloads.
35//! [`divergent_keys`](ResultSharing::divergent_keys) measures how often that
36//! happens, which is what decides whether evidence can be memoized on the
37//! judgment the way conformance already is.
38//!
39//! That gives a bracket rather than a single number:
40//! [`payload_redundancy`](ResultSharing::payload_redundancy) is what structure
41//! sharing collapses losslessly today, and
42//! [`key_redundancy`](ResultSharing::key_redundancy) is what a memo keyed like
43//! the conformance memo could collapse if divergence were zero. The gap between
44//! them is the evidence that depends on its derivation context.
45
46use crate::witness::{EvaluationStatus, Evidence, EvidenceNodeRef, EvidenceRun};
47use oxrdf::Term;
48use rustc_hash::FxHashMap;
49use shifty_algebra::ShapeId;
50use std::hash::{Hash, Hasher};
51
52/// Distinct payloads retained per key before the count saturates.
53///
54/// Divergence is detected by comparing a payload against the ones already seen
55/// for its key, which is exact but quadratic in the number of *distinct*
56/// payloads one key accumulates. The cap bounds that; whether a key diverges at
57/// all is still exact, since the first disagreement is enough. Only
58/// [`distinct_payloads_per_key`](ResultSharing::distinct_payloads_per_key)
59/// saturates, and [`keys_over_payload_cap`](ResultSharing::keys_over_payload_cap)
60/// reports when it did.
61const PAYLOAD_CAP: usize = 32;
62
63/// Which statement a record answers, after normalization has had its say.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65enum Statement {
66    /// The normalized statement this record was evaluated against. Authored
67    /// statements that normalize together share one of these.
68    Normalized(usize),
69    /// No normalized counterpart, so the authored statement is its own request.
70    Authored(usize),
71}
72
73/// Sharing and divergence over one run's evidence.
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
75pub struct ResultSharing {
76    /// `(authored statement, focus)` records the run reports.
77    pub authored_records: usize,
78    /// Distinct `(normalized statement, focus)` requests behind them.
79    pub normalized_requests: usize,
80    /// Records answering a request some earlier record already answered.
81    pub duplicate_records: usize,
82    /// Duplicates whose evidence is *not* equal to the first record's — which
83    /// would mean the duplication is not pure and cannot simply be shared.
84    pub divergent_duplicates: usize,
85
86    /// Judgment nodes over every authored record. Equals `run.walk().len()`.
87    pub occurrences: usize,
88    /// Judgment nodes over one record per request: the materialization view,
89    /// with source-preserving duplication removed.
90    pub canonical_occurrences: usize,
91    /// Structurally distinct judgment nodes.
92    pub distinct_payloads: usize,
93    /// Distinct payloads reached from two or more requests.
94    pub shared_payloads: usize,
95    /// Canonical occurrences of payloads reached from two or more requests.
96    pub shared_canonical_occurrences: usize,
97    /// Sum over distinct payloads of the requests reaching each.
98    pub request_reaches: usize,
99    /// Requests reaching the most widely shared payload.
100    pub max_payload_requests: usize,
101
102    /// Distinct `(constraint, node, polarity)` keys.
103    pub distinct_keys: usize,
104    /// Keys occurring more than once — the ones that *could* diverge.
105    pub multi_occurrence_keys: usize,
106    /// Keys occurring with two or more distinct payloads.
107    pub divergent_keys: usize,
108    /// Occurrences belonging to a divergent key.
109    pub divergent_occurrences: usize,
110    /// Distinct payloads summed over keys, saturating at [`PAYLOAD_CAP`].
111    pub distinct_payloads_per_key: usize,
112    /// Keys whose distinct-payload count saturated.
113    pub keys_over_payload_cap: usize,
114    /// `(constraint, node)` addresses appearing with *both* polarities in one
115    /// run. A judgment has one truth value per run, so this should be zero;
116    /// it is measured rather than assumed.
117    pub both_polarity_addresses: usize,
118}
119
120impl ResultSharing {
121    /// Occurrences per structurally distinct judgment node: what hash-consing
122    /// collapses losslessly, and the lower bound of the bracket.
123    pub fn payload_redundancy(&self) -> f64 {
124        ratio(self.occurrences, self.distinct_payloads)
125    }
126
127    /// Occurrences per distinct key: what a memo keyed on the judgment could
128    /// collapse, and the upper bound of the bracket. Reachable only to the
129    /// extent that [`divergent_keys`](Self::divergent_keys) is small.
130    pub fn key_redundancy(&self) -> f64 {
131        ratio(self.occurrences, self.distinct_keys)
132    }
133
134    /// Share of distinct payloads reached from more than one request.
135    pub fn shared_payload_fraction(&self) -> f64 {
136        fraction(self.shared_payloads, self.distinct_payloads)
137    }
138
139    /// Share of canonical occurrences that are of a payload more than one
140    /// request reaches.
141    pub fn shared_occurrence_fraction(&self) -> f64 {
142        fraction(
143            self.shared_canonical_occurrences,
144            self.canonical_occurrences,
145        )
146    }
147
148    /// Requests reaching the average distinct payload; 1.0 when nothing is
149    /// shared across requests.
150    pub fn requests_per_payload(&self) -> f64 {
151        ratio(self.request_reaches, self.distinct_payloads)
152    }
153
154    /// Share of the keys that occur more than once whose payloads disagree.
155    /// The quantity that decides whether evidence can be memoized on the
156    /// judgment; 0.0 when no key occurs twice, since none can diverge.
157    pub fn divergence_fraction(&self) -> f64 {
158        fraction(self.divergent_keys, self.multi_occurrence_keys)
159    }
160}
161
162fn ratio(occurrences: usize, distinct: usize) -> f64 {
163    if distinct == 0 {
164        1.0
165    } else {
166        occurrences as f64 / distinct as f64
167    }
168}
169
170fn fraction(part: usize, whole: usize) -> f64 {
171    if whole == 0 {
172        0.0
173    } else {
174        part as f64 / whole as f64
175    }
176}
177
178/// A judgment node compared and hashed by the structure it holds, so that
179/// identical derivations collapse to one payload id.
180///
181/// `EvidenceNodeRef` is `Copy` and deliberately not `Eq`: it is a cursor, and
182/// two cursors onto equal trees are not the same node for most purposes. Here
183/// they are exactly what should merge, so the equality lives on this wrapper
184/// rather than on the public cursor.
185#[derive(Clone, Copy)]
186struct Payload<'a>(EvidenceNodeRef<'a>);
187
188impl PartialEq for Payload<'_> {
189    fn eq(&self, other: &Self) -> bool {
190        match (self.0, other.0) {
191            (EvidenceNodeRef::Satisfaction(left), EvidenceNodeRef::Satisfaction(right)) => {
192                left == right
193            }
194            (EvidenceNodeRef::Failure(left), EvidenceNodeRef::Failure(right)) => left == right,
195            _ => false,
196        }
197    }
198}
199
200impl Eq for Payload<'_> {}
201
202impl Hash for Payload<'_> {
203    fn hash<H: Hasher>(&self, state: &mut H) {
204        match self.0 {
205            EvidenceNodeRef::Satisfaction(value) => {
206                state.write_u8(0);
207                value.hash(state);
208            }
209            EvidenceNodeRef::Failure(value) => {
210                state.write_u8(1);
211                value.hash(state);
212            }
213        }
214    }
215}
216
217/// The address of an interior judgment: the memo's key plus its polarity.
218type Key<'a> = (ShapeId, Option<&'a Term>, EvaluationStatus);
219
220#[derive(Default)]
221struct KeyStats {
222    occurrences: usize,
223    payloads: Vec<u32>,
224    over_cap: bool,
225}
226
227/// Per-payload accumulators, indexed by payload id.
228#[derive(Default)]
229struct Payloads {
230    occurrences: Vec<u32>,
231    canonical_occurrences: Vec<u32>,
232    /// The last request that reached this payload, so that a request reaching
233    /// it many times counts once. Sound only because records are visited
234    /// grouped by request.
235    last_request: Vec<u32>,
236    requests: Vec<u32>,
237}
238
239impl Payloads {
240    fn push(&mut self) {
241        self.occurrences.push(0);
242        self.canonical_occurrences.push(0);
243        self.last_request.push(u32::MAX);
244        self.requests.push(0);
245    }
246}
247
248/// Measure sharing and divergence over a run's evidence.
249///
250/// One pass over the typed evidence; no serialization, and nothing is retained
251/// beyond the counts.
252pub fn result_sharing(run: &EvidenceRun) -> ResultSharing {
253    // Address every authored record first, so requests can be numbered before
254    // anything is counted against them.
255    let mut request_ids: FxHashMap<(Statement, &Term), u32> = FxHashMap::default();
256    let mut records: Vec<(u32, &Evidence)> = Vec::new();
257    for statement in &run.statements {
258        let which = statement
259            .normalized_statement_id
260            .map_or(Statement::Authored(statement.source_statement_id), |id| {
261                Statement::Normalized(id)
262            });
263        for focus in &statement.selected_foci {
264            let next = request_ids.len() as u32;
265            let id = *request_ids.entry((which, &focus.focus)).or_insert(next);
266            records.push((id, &focus.evidence));
267        }
268    }
269    // Grouping by request is what makes the per-payload request count exact
270    // with a single `last_request` slot instead of a set per payload. The sort
271    // is stable, so the first record of each request stays first and remains
272    // the canonical one.
273    records.sort_by_key(|(request, _)| *request);
274
275    let mut payload_ids: FxHashMap<Payload<'_>, u32> = FxHashMap::default();
276    let mut payloads = Payloads::default();
277    let mut keys: FxHashMap<Key<'_>, KeyStats> = FxHashMap::default();
278
279    let mut measured = ResultSharing {
280        authored_records: records.len(),
281        normalized_requests: request_ids.len(),
282        ..ResultSharing::default()
283    };
284    let mut canonical: Vec<&Evidence> = Vec::with_capacity(request_ids.len());
285    let mut current = None;
286
287    for (request, evidence) in records {
288        let first = current != Some(request);
289        current = Some(request);
290        if first {
291            canonical.push(evidence);
292        } else {
293            measured.duplicate_records += 1;
294            if canonical[request as usize] != evidence {
295                measured.divergent_duplicates += 1;
296            }
297        }
298
299        for node in evidence.walk() {
300            let next = payload_ids.len() as u32;
301            let payload = *payload_ids.entry(Payload(node)).or_insert_with(|| {
302                payloads.push();
303                next
304            });
305            let slot = payload as usize;
306
307            payloads.occurrences[slot] += 1;
308            if first {
309                payloads.canonical_occurrences[slot] += 1;
310            }
311            if payloads.last_request[slot] != request {
312                payloads.last_request[slot] = request;
313                payloads.requests[slot] += 1;
314            }
315
316            let stats = keys
317                .entry((node.constraint_id(), node.node(), node.status()))
318                .or_default();
319            stats.occurrences += 1;
320            if !stats.payloads.contains(&payload) {
321                if stats.payloads.len() < PAYLOAD_CAP {
322                    stats.payloads.push(payload);
323                } else {
324                    stats.over_cap = true;
325                }
326            }
327        }
328    }
329
330    measured.distinct_payloads = payloads.occurrences.len();
331    measured.occurrences = payloads
332        .occurrences
333        .iter()
334        .map(|&count| count as usize)
335        .sum();
336    measured.canonical_occurrences = payloads
337        .canonical_occurrences
338        .iter()
339        .map(|&count| count as usize)
340        .sum();
341    for (slot, &requests) in payloads.requests.iter().enumerate() {
342        measured.request_reaches += requests as usize;
343        measured.max_payload_requests = measured.max_payload_requests.max(requests as usize);
344        if requests >= 2 {
345            measured.shared_payloads += 1;
346            measured.shared_canonical_occurrences += payloads.canonical_occurrences[slot] as usize;
347        }
348    }
349
350    // Polarity is part of the key, so a `(constraint, node)` reached with both
351    // polarities shows up as two keys. Fold them back to check for it.
352    let mut polarities: FxHashMap<(ShapeId, Option<&Term>), u8> = FxHashMap::default();
353    measured.distinct_keys = keys.len();
354    for ((constraint, node, status), stats) in &keys {
355        if stats.occurrences >= 2 {
356            measured.multi_occurrence_keys += 1;
357        }
358        if stats.payloads.len() >= 2 {
359            measured.divergent_keys += 1;
360            measured.divergent_occurrences += stats.occurrences;
361        }
362        measured.distinct_payloads_per_key += stats.payloads.len();
363        measured.keys_over_payload_cap += usize::from(stats.over_cap);
364
365        let seen = polarities.entry((*constraint, *node)).or_default();
366        *seen |= match status {
367            EvaluationStatus::Pass => 1,
368            EvaluationStatus::Fail => 2,
369        };
370    }
371    measured.both_polarity_addresses = polarities.values().filter(|&&seen| seen == 3).count();
372
373    measured
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::validate_with_evidence;
380    use shifty_parse::{load_turtle, parse_turtle};
381
382    // Two shapes declare the same property constraint and select the same
383    // focus nodes, so identical judgments are reached from separate authored
384    // statements — the sharing this module exists to count.
385    const TTL: &str = r#"
386        @prefix sh: <http://www.w3.org/ns/shacl#> .
387        @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
388        @prefix ex: <http://ex/> .
389        ex:S a sh:NodeShape ; sh:targetClass ex:T ;
390          sh:property [ sh:path ex:p ; sh:minCount 1 ; sh:class ex:C ] ;
391          sh:property [ sh:path ex:n ; sh:datatype xsd:integer ] .
392        ex:T2 a sh:NodeShape ; sh:targetClass ex:T ;
393          sh:property [ sh:path ex:p ; sh:minCount 1 ; sh:class ex:C ] .
394        ex:good a ex:T ; ex:p ex:c1 ; ex:n 3 .
395        ex:bad a ex:T ; ex:n "not a number" .
396        ex:c1 a ex:C .
397    "#;
398
399    fn run() -> EvidenceRun {
400        let parsed = parse_turtle(TTL.as_bytes(), None).unwrap();
401        let loaded = load_turtle(TTL.as_bytes(), None).unwrap();
402        validate_with_evidence(&loaded.graph, &parsed.schema).unwrap()
403    }
404
405    // The identity that ties this module to the encoder: two traversals — one
406    // over the types here, one over serialized JSON there — must see the same
407    // judgment nodes and the same number of distinct ones.
408    #[test]
409    fn the_typed_pass_agrees_with_the_encoder() {
410        let original = run();
411        let measured = result_sharing(&original);
412        let encoded = crate::compact::sharing(&original).unwrap();
413
414        assert_eq!(measured.occurrences, original.walk().len());
415        assert_eq!(measured.occurrences, encoded.result_occurrences);
416        assert_eq!(measured.distinct_payloads, encoded.distinct_results);
417    }
418
419    #[test]
420    fn requests_collapse_authored_records_without_losing_them() {
421        let original = run();
422        let measured = result_sharing(&original);
423
424        assert_eq!(
425            measured.authored_records,
426            original
427                .statements
428                .iter()
429                .map(|statement| statement.selected_foci.len())
430                .sum::<usize>()
431        );
432        assert!(
433            measured.normalized_requests <= measured.authored_records,
434            "normalizing cannot invent requests"
435        );
436        assert_eq!(
437            measured.authored_records - measured.normalized_requests,
438            measured.duplicate_records
439        );
440        assert_eq!(
441            measured.divergent_duplicates, 0,
442            "records answering one request must agree"
443        );
444    }
445
446    #[test]
447    fn the_bracket_is_ordered() {
448        let measured = result_sharing(&run());
449
450        // Structural equality implies key equality but not the reverse, so
451        // distinct keys can only be fewer, and the collapse a key-addressed
452        // memo could reach can only be larger.
453        assert!(measured.distinct_keys <= measured.distinct_payloads);
454        assert!(measured.key_redundancy() >= measured.payload_redundancy());
455        assert!(measured.payload_redundancy() >= 1.0);
456
457        assert!(measured.canonical_occurrences <= measured.occurrences);
458        assert!(measured.shared_payloads <= measured.distinct_payloads);
459        assert!(measured.divergent_keys <= measured.multi_occurrence_keys);
460        assert_eq!(
461            measured.both_polarity_addresses, 0,
462            "one truth value per (constraint, node) per run"
463        );
464    }
465
466    #[test]
467    fn the_fixture_actually_shares() {
468        let measured = result_sharing(&run());
469        assert!(
470            measured.shared_payloads > 0,
471            "ex:S and ex:T2 reach the same judgments about the same nodes"
472        );
473        assert!(measured.requests_per_payload() > 1.0);
474    }
475
476    #[test]
477    fn sharing_of_nothing_is_one() {
478        let empty = ResultSharing::default();
479        assert_eq!(empty.payload_redundancy(), 1.0);
480        assert_eq!(empty.key_redundancy(), 1.0);
481        assert_eq!(empty.requests_per_payload(), 1.0);
482        assert_eq!(empty.divergence_fraction(), 0.0);
483        assert_eq!(empty.shared_payload_fraction(), 0.0);
484    }
485}