1use crate::witness::EvidenceRun;
36use rustc_hash::{FxHashMap, FxHasher};
37use serde_json::{Map, Value, json};
38use std::hash::Hasher;
39
40const NODE_REF: &str = "#";
42const TERM_REF: &str = "~";
44const VERSION: u64 = 1;
46
47pub fn compact(run: &EvidenceRun, include_catalog: bool) -> serde_json::Result<Value> {
53 Ok(compact_value(serde_json::to_value(run)?, include_catalog))
54}
55
56pub fn compact_value(value: Value, include_catalog: bool) -> Value {
62 encode(value, include_catalog).0
63}
64
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
85pub struct Sharing {
86 pub node_occurrences: usize,
88 pub distinct_nodes: usize,
90 pub result_occurrences: usize,
92 pub distinct_results: usize,
94 pub support_occurrences: usize,
96 pub distinct_support: usize,
98 pub term_occurrences: usize,
100 pub distinct_terms: usize,
102}
103
104impl Sharing {
105 pub fn node_redundancy(&self) -> f64 {
111 ratio(self.node_occurrences, self.distinct_nodes)
112 }
113
114 pub fn result_redundancy(&self) -> f64 {
116 ratio(self.result_occurrences, self.distinct_results)
117 }
118
119 pub fn support_redundancy(&self) -> f64 {
121 ratio(self.support_occurrences, self.distinct_support)
122 }
123
124 pub fn term_redundancy(&self) -> f64 {
126 ratio(self.term_occurrences, self.distinct_terms)
127 }
128
129 pub fn support_share(&self) -> f64 {
132 if self.node_occurrences == 0 {
133 0.0
134 } else {
135 self.support_occurrences as f64 / self.node_occurrences as f64
136 }
137 }
138}
139
140fn ratio(occurrences: usize, distinct: usize) -> f64 {
141 if distinct == 0 {
142 1.0
143 } else {
144 occurrences as f64 / distinct as f64
145 }
146}
147
148pub fn sharing(run: &EvidenceRun) -> serde_json::Result<Sharing> {
153 Ok(encode(serde_json::to_value(run)?, false).1)
154}
155
156fn encode(mut value: Value, include_catalog: bool) -> (Value, Sharing) {
157 let conforms = value.get("conforms").cloned().unwrap_or(json!(false));
158 let catalog = value
159 .get_mut("constraints")
160 .map(Value::take)
161 .unwrap_or(Value::Null);
162
163 let mut terms = Interner::default();
164 let mut nodes = Interner::default();
165 let mut families = Families::default();
166 let statements = intern(
167 value
168 .get_mut("statements")
169 .map(Value::take)
170 .unwrap_or(json!([])),
171 &mut terms,
172 &mut nodes,
173 &mut families,
174 );
175 let sharing = Sharing {
178 node_occurrences: nodes.occurrences,
179 distinct_nodes: nodes.table.len(),
180 result_occurrences: families.result_occurrences,
181 distinct_results: families.distinct_results,
182 support_occurrences: families.support_occurrences,
183 distinct_support: families.distinct_support,
184 term_occurrences: terms.occurrences,
185 distinct_terms: terms.table.len(),
186 };
187 let catalog = include_catalog.then(|| intern(catalog, &mut terms, &mut nodes, &mut families));
191
192 let mut out = Map::new();
193 out.insert("v".into(), json!(VERSION));
194 out.insert("conforms".into(), conforms);
195 out.insert("terms".into(), Value::Array(terms.table));
196 out.insert("nodes".into(), Value::Array(nodes.table));
197 out.insert("statements".into(), statements);
198 if let Some(catalog) = catalog {
199 out.insert("constraints".into(), catalog);
200 }
201 (Value::Object(out), sharing)
202}
203
204pub fn to_compact_json(run: &EvidenceRun, include_catalog: bool) -> serde_json::Result<String> {
206 serde_json::to_string(&compact(run, include_catalog)?)
207}
208
209pub fn expand(value: &Value) -> Result<EvidenceRun, CompactError> {
211 let catalog = value
212 .get("constraints")
213 .cloned()
214 .ok_or(CompactError::MissingCatalog)?;
215 expand_with_catalog(value, catalog)
216}
217
218pub fn expand_with_catalog(value: &Value, catalog: Value) -> Result<EvidenceRun, CompactError> {
220 serde_json::from_value(expand_value(value, catalog)?).map_err(CompactError::Decode)
221}
222
223pub fn expand_value(value: &Value, catalog: Value) -> Result<Value, CompactError> {
227 let version = value.get("v").and_then(Value::as_u64);
228 if version != Some(VERSION) {
229 return Err(CompactError::Version(version));
230 }
231 let terms = array(value, "terms")?;
232 let nodes = array(value, "nodes")?;
233 let statements = value.get("statements").ok_or(CompactError::Malformed)?;
234
235 let mut expanded: Vec<Value> = Vec::with_capacity(nodes.len());
238 for node in nodes {
239 let value = restore(node, terms, &expanded)?;
240 expanded.push(value);
241 }
242
243 let mut out = Map::new();
244 out.insert(
245 "conforms".into(),
246 value.get("conforms").cloned().unwrap_or(json!(false)),
247 );
248 out.insert("constraints".into(), restore(&catalog, terms, &expanded)?);
251 out.insert("statements".into(), restore(statements, terms, &expanded)?);
252 Ok(Value::Object(out))
253}
254
255#[derive(Debug)]
257pub enum CompactError {
258 MissingCatalog,
261 Version(Option<u64>),
263 Malformed,
265 InvalidReference { table: &'static str, id: usize },
267 Decode(serde_json::Error),
269}
270
271impl std::fmt::Display for CompactError {
272 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273 match self {
274 Self::MissingCatalog => {
275 write!(f, "compact evidence omits its constraint catalog")
276 }
277 Self::Version(found) => {
278 write!(f, "compact evidence version {found:?}, expected {VERSION}")
279 }
280 Self::Malformed => write!(f, "compact evidence is missing its tables"),
281 Self::InvalidReference { table, id } => {
282 write!(f, "compact evidence has invalid {table} reference {id}")
283 }
284 Self::Decode(error) => write!(f, "compact evidence does not decode: {error}"),
285 }
286 }
287}
288
289impl std::error::Error for CompactError {}
290
291fn array<'a>(value: &'a Value, key: &str) -> Result<&'a [Value], CompactError> {
292 value
293 .get(key)
294 .and_then(Value::as_array)
295 .map(Vec::as_slice)
296 .ok_or(CompactError::Malformed)
297}
298
299#[derive(Default)]
300struct Interner {
301 table: Vec<Value>,
302 index: FxHashMap<u64, Vec<u32>>,
306 occurrences: usize,
309}
310
311impl Interner {
312 fn intern(&mut self, value: Value) -> (usize, bool) {
321 self.occurrences += 1;
322 let hash = structural_hash(&value);
323 if let Some(bucket) = self.index.get(&hash) {
324 for &id in bucket {
325 if self.table[id as usize] == value {
326 return (id as usize, false);
327 }
328 }
329 }
330 let id = self.table.len();
331 self.table.push(value);
332 self.index.entry(hash).or_default().push(id as u32);
333 (id, true)
334 }
335}
336
337#[derive(Default)]
341struct Families {
342 result_occurrences: usize,
343 distinct_results: usize,
344 support_occurrences: usize,
345 distinct_support: usize,
346}
347
348impl Families {
349 fn record(&mut self, result: bool, fresh: bool) {
350 let (occurrences, distinct) = if result {
351 (&mut self.result_occurrences, &mut self.distinct_results)
352 } else {
353 (&mut self.support_occurrences, &mut self.distinct_support)
354 };
355 *occurrences += 1;
356 *distinct += usize::from(fresh);
357 }
358}
359
360fn structural_hash(value: &Value) -> u64 {
370 let mut hasher = FxHasher::default();
371 hash_into(value, &mut hasher);
372 hasher.finish()
373}
374
375fn hash_into(value: &Value, hasher: &mut FxHasher) {
376 match value {
377 Value::Null => hasher.write_u8(0),
378 Value::Bool(flag) => {
379 hasher.write_u8(1);
380 hasher.write_u8(u8::from(*flag));
381 }
382 Value::Number(number) => {
383 hasher.write_u8(2);
384 if let Some(unsigned) = number.as_u64() {
385 hasher.write_u8(0);
386 hasher.write_u64(unsigned);
387 } else if let Some(signed) = number.as_i64() {
388 hasher.write_u8(1);
389 hasher.write_i64(signed);
390 } else {
391 hasher.write_u8(2);
392 hasher.write_u64(number.as_f64().map_or(0, f64::to_bits));
393 }
394 }
395 Value::String(text) => {
396 hasher.write_u8(3);
397 hasher.write(text.as_bytes());
398 }
399 Value::Array(items) => {
400 hasher.write_u8(4);
401 hasher.write_usize(items.len());
402 items.iter().for_each(|item| hash_into(item, hasher));
403 }
404 Value::Object(map) => {
405 hasher.write_u8(5);
406 hasher.write_usize(map.len());
407 for (key, child) in map {
408 hasher.write(key.as_bytes());
409 hash_into(child, hasher);
410 }
411 }
412 }
413}
414
415fn is_term(map: &Map<String, Value>) -> bool {
419 !map.contains_key("details")
420 && map
421 .get("type")
422 .and_then(Value::as_str)
423 .is_some_and(|kind| matches!(kind, "uri" | "bnode" | "literal"))
424 && map.contains_key("value")
425}
426
427fn is_node(map: &Map<String, Value>) -> bool {
429 map.len() == 2 && map.contains_key("type") && map.contains_key("details")
430}
431
432fn is_result(value: &Value) -> bool {
444 value
445 .get("details")
446 .and_then(Value::as_object)
447 .is_some_and(|details| details.contains_key("shape"))
448}
449
450fn intern(
452 mut value: Value,
453 terms: &mut Interner,
454 nodes: &mut Interner,
455 families: &mut Families,
456) -> Value {
457 let (term, tagged) = match &value {
458 Value::Object(map) => (is_term(map), is_node(map)),
459 _ => (false, false),
460 };
461 if term {
462 let (id, _) = terms.intern(value);
463 return json!({ TERM_REF: id });
464 }
465 match &mut value {
470 Value::Object(map) => {
471 for child in map.values_mut() {
472 let taken = std::mem::replace(child, Value::Null);
473 *child = intern(taken, terms, nodes, families);
474 }
475 }
476 Value::Array(items) => {
477 for item in items.iter_mut() {
478 let taken = std::mem::replace(item, Value::Null);
479 *item = intern(taken, terms, nodes, families);
480 }
481 }
482 _ => {}
483 }
484 if tagged {
485 let result = is_result(&value);
489 let (id, fresh) = nodes.intern(value);
490 families.record(result, fresh);
491 json!({ NODE_REF: id })
492 } else {
493 value
494 }
495}
496
497fn restore(value: &Value, terms: &[Value], nodes: &[Value]) -> Result<Value, CompactError> {
499 match value {
500 Value::Object(map) => {
501 if let Some(id) = reference(map, TERM_REF) {
502 return terms
503 .get(id)
504 .cloned()
505 .ok_or(CompactError::InvalidReference { table: "term", id });
506 }
507 if let Some(id) = reference(map, NODE_REF) {
508 return nodes
509 .get(id)
510 .cloned()
511 .ok_or(CompactError::InvalidReference { table: "node", id });
512 }
513 Ok(Value::Object(
514 map.iter()
515 .map(|(key, child)| Ok((key.clone(), restore(child, terms, nodes)?)))
516 .collect::<Result<Map<_, _>, CompactError>>()?,
517 ))
518 }
519 Value::Array(items) => Ok(Value::Array(
520 items
521 .iter()
522 .map(|item| restore(item, terms, nodes))
523 .collect::<Result<Vec<_>, _>>()?,
524 )),
525 other => Ok(other.clone()),
526 }
527}
528
529fn reference(map: &Map<String, Value>, key: &str) -> Option<usize> {
530 (map.len() == 1)
531 .then(|| map.get(key))
532 .flatten()
533 .and_then(Value::as_u64)
534 .map(|id| id as usize)
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use crate::validate_with_evidence;
541 use crate::witness::{PathSupport, SatTrace};
542 use oxrdf::{NamedNode, Triple};
543 use shifty_parse::{load_turtle, parse_turtle};
544
545 const TTL: &str = r#"
548 @prefix sh: <http://www.w3.org/ns/shacl#> .
549 @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
550 @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
551 @prefix ex: <http://ex/> .
552 ex:S a sh:NodeShape ; sh:targetClass ex:T ;
553 sh:property [ sh:path ex:p ; sh:minCount 1 ; sh:class ex:C ] ;
554 sh:property [ sh:path ex:q ; sh:maxCount 1 ] ;
555 sh:property [ sh:path ex:n ; sh:datatype xsd:integer ] ;
556 sh:property [ sh:path ex:label ; sh:minCount 1 ] .
557 ex:T2 a sh:NodeShape ; sh:targetClass ex:T ;
558 sh:property [ sh:path ex:p ; sh:minCount 1 ; sh:class ex:C ] .
559 ex:good a ex:T ; ex:p ex:c1 ; ex:q ex:z ;
560 ex:n 3 ; ex:label "hello"@en ; ex:child [ ex:p ex:c1 ] .
561 ex:bad a ex:T ; ex:q ex:z ; ex:q ex:y ; ex:n "not a number" .
562 ex:c1 a ex:C .
563 "#;
564
565 fn run() -> EvidenceRun {
566 let parsed = parse_turtle(TTL.as_bytes(), None).unwrap();
567 let loaded = load_turtle(TTL.as_bytes(), None).unwrap();
568 validate_with_evidence(&loaded.graph, &parsed.schema).unwrap()
569 }
570
571 #[test]
572 fn compact_round_trips_exactly() {
573 let original = run();
574 let encoded = compact(&original, true).unwrap();
575 let restored = expand(&encoded).unwrap();
576 assert_eq!(restored, original);
577 }
578
579 #[test]
580 fn catalog_can_be_carried_out_of_band() {
581 let original = run();
582 let encoded = compact(&original, false).unwrap();
583 assert!(encoded.get("constraints").is_none());
584
585 let catalog = serde_json::to_value(&original.constraints).unwrap();
586 let restored = expand_with_catalog(&encoded, catalog).unwrap();
587 assert_eq!(restored, original);
588 assert!(matches!(
589 expand(&encoded),
590 Err(CompactError::MissingCatalog)
591 ));
592 }
593
594 #[test]
595 fn identical_subtrees_are_stored_once() {
596 let original = run();
599 let encoded = compact(&original, true).unwrap();
600 let nodes = encoded.get("nodes").unwrap().as_array().unwrap().len();
601 let emitted = original.walk().len();
602 assert!(
603 nodes < emitted,
604 "expected sharing: {nodes} distinct vs {emitted} emitted"
605 );
606 }
607
608 #[test]
609 fn compact_is_smaller_than_the_full_run() {
610 let original = run();
611 let full = original.to_json().unwrap().len();
612 let packed = to_compact_json(&original, true).unwrap().len();
613 assert!(
614 packed < full,
615 "compact {packed} not smaller than full {full}"
616 );
617 }
618
619 #[test]
620 fn sharing_counts_the_evidence_the_encoder_interns() {
621 let original = run();
622 let measured = sharing(&original).unwrap();
623
624 let encoded = compact(&original, false).unwrap();
629 let table = |key: &str| encoded.get(key).unwrap().as_array().unwrap().len();
630 assert_eq!(measured.distinct_nodes, table("nodes"));
631 assert_eq!(measured.distinct_terms, table("terms"));
632 assert_eq!(
633 table("nodes"),
634 compact(&original, true)
635 .unwrap()
636 .get("nodes")
637 .unwrap()
638 .as_array()
639 .unwrap()
640 .len()
641 .min(table("nodes")),
642 "the catalog only ever adds entries"
643 );
644
645 assert!(measured.node_occurrences > measured.distinct_nodes);
648 assert!(measured.term_occurrences > measured.distinct_terms);
649 assert!(measured.node_redundancy() > 1.0);
650 assert!(measured.term_redundancy() > 1.0);
651 }
652
653 #[test]
659 fn the_node_families_partition_and_agree_with_the_typed_walk() {
660 let original = run();
661 let measured = sharing(&original).unwrap();
662
663 assert_eq!(
664 measured.result_occurrences + measured.support_occurrences,
665 measured.node_occurrences,
666 "every tagged node belongs to exactly one family"
667 );
668 assert_eq!(
669 measured.distinct_results + measured.distinct_support,
670 measured.distinct_nodes,
671 "the families partition the node table"
672 );
673
674 assert_eq!(
677 measured.result_occurrences,
678 original.walk().len(),
679 "result occurrences are exactly the evidence nodes"
680 );
681 assert!(
682 measured.support_occurrences > 0,
683 "the fixture reaches values by a path, so it has certificates"
684 );
685 }
686
687 #[test]
688 fn path_support_is_not_mistaken_for_a_judgment() {
689 let edge = serde_json::to_value(PathSupport::Edge(Triple::new(
692 NamedNode::new("urn:s").unwrap(),
693 NamedNode::new("urn:p").unwrap(),
694 NamedNode::new("urn:o").unwrap(),
695 )))
696 .unwrap();
697 assert!(is_node(edge.as_object().unwrap()));
698 assert!(!is_result(&edge));
699
700 assert!(!is_result(
701 &serde_json::to_value(PathSupport::Chain(vec![])).unwrap()
702 ));
703
704 let judgment = serde_json::to_value(SatTrace::Irrefutable {
705 shape: shifty_algebra::ShapeId(0),
706 })
707 .unwrap();
708 assert!(is_node(judgment.as_object().unwrap()));
709 assert!(is_result(&judgment));
710 }
711
712 #[test]
716 fn interning_gives_one_entry_per_distinct_value() {
717 let distinct = [
718 json!(null),
719 json!(true),
720 json!(false),
721 json!(0),
722 json!(-1),
723 json!(1.5),
724 json!(""),
725 json!("0"),
726 json!([]),
727 json!([json!(0)]),
728 json!([json!(0), json!(0)]),
729 json!({}),
730 json!({ "a": 0 }),
731 json!({ "a": 0, "b": 1 }),
732 json!({ "b": 0, "a": 1 }),
733 json!({ "a": { "a": 0 } }),
734 ];
735
736 let mut interner = Interner::default();
737 let first: Vec<(usize, bool)> = distinct
738 .iter()
739 .map(|value| interner.intern(value.clone()))
740 .collect();
741 assert_eq!(
742 first,
743 (0..distinct.len()).map(|id| (id, true)).collect::<Vec<_>>(),
744 "every distinct value is a fresh insertion"
745 );
746
747 let again: Vec<(usize, bool)> = distinct
750 .iter()
751 .map(|value| interner.intern(value.clone()))
752 .collect();
753 assert_eq!(
754 again,
755 first.iter().map(|&(id, _)| (id, false)).collect::<Vec<_>>()
756 );
757 assert_eq!(interner.table.len(), distinct.len());
758 assert_eq!(interner.occurrences, 2 * distinct.len());
759
760 for (value, &(id, _)) in distinct.iter().zip(first.iter()) {
762 assert_eq!(&interner.table[id], value);
763 }
764 }
765
766 #[test]
767 fn colliding_values_stay_distinct() {
768 let mut interner = Interner::default();
771 let a = interner.intern(json!({ "type": "uri", "value": "urn:a" }));
772 let b = interner.intern(json!({ "type": "uri", "value": "urn:b" }));
773 assert_ne!(a, b);
774
775 let bucket: Vec<u32> = interner.index.values().flatten().copied().collect();
776 assert_eq!(bucket.len(), 2, "both ids are reachable from the index");
777 }
778
779 #[test]
780 fn sharing_of_nothing_is_one() {
781 assert_eq!(Sharing::default().node_redundancy(), 1.0);
782 assert_eq!(Sharing::default().term_redundancy(), 1.0);
783 }
784
785 #[test]
786 fn a_foreign_version_is_rejected() {
787 let mut encoded = compact(&run(), true).unwrap();
788 encoded["v"] = json!(VERSION + 1);
789 assert!(matches!(expand(&encoded), Err(CompactError::Version(_))));
790 }
791
792 #[test]
793 fn dangling_and_forward_references_are_rejected() {
794 let dangling = json!({
795 "v": VERSION,
796 "conforms": false,
797 "terms": [],
798 "nodes": [],
799 "statements": { (NODE_REF): 0 },
800 "constraints": [],
801 });
802 assert!(matches!(
803 expand_value(&dangling, json!([])),
804 Err(CompactError::InvalidReference {
805 table: "node",
806 id: 0
807 })
808 ));
809
810 let forward = json!({
811 "v": VERSION,
812 "conforms": false,
813 "terms": [],
814 "nodes": [{ (NODE_REF): 0 }],
815 "statements": [],
816 "constraints": [],
817 });
818 assert!(matches!(
819 expand_value(&forward, json!([])),
820 Err(CompactError::InvalidReference {
821 table: "node",
822 id: 0
823 })
824 ));
825 }
826}