Skip to main content

shifty_engine/
session.rs

1//! Evaluation over one immutable asserted-data snapshot.
2
3use crate::compiled::CompiledShapes;
4use crate::context;
5use crate::evidence::{
6    ConformanceOptions, ConformanceRun, PreparedEvidenceValidator, SelectedPair,
7};
8use crate::frozen::FrozenIndexedDataset;
9use crate::gate::RepairOutcome;
10use crate::sparql::SparqlDiagnostic;
11use crate::validate::{
12    EngineOptions, Reason, UnsupportedPolicy, ValidationGraphMode, ValidationOptions,
13    ValidationOutcome, Violation,
14};
15use crate::witness::{EvidenceRun, StatementEvaluation};
16use oxrdf::{BlankNode, Graph, NamedOrBlankNode, Term, Triple};
17use shifty_algebra::Severity;
18use shifty_parse::{DiagLevel, Diagnostic};
19use shifty_repair::GraphDelta;
20use std::cell::{OnceCell, RefCell};
21use std::collections::{HashMap, HashSet};
22use std::fmt;
23use std::sync::Arc;
24
25#[expect(
26    clippy::large_enum_variant,
27    reason = "the documented public API consumes an owned Graph"
28)]
29pub enum SessionData {
30    Separate(Graph),
31    Embedded,
32}
33
34#[derive(Debug, Clone, Copy, Default)]
35pub struct SessionOptions {
36    pub graph_mode: ValidationGraphMode,
37    pub inference: bool,
38    pub engine: EngineOptions,
39}
40
41#[derive(Debug, Clone)]
42pub struct FindingOptions {
43    pub entry_shape_names: Vec<String>,
44    pub minimum_severity: Severity,
45    pub sort_results: bool,
46}
47
48impl Default for FindingOptions {
49    fn default() -> Self {
50        Self {
51            entry_shape_names: Vec::new(),
52            minimum_severity: Severity::Info,
53            sort_results: true,
54        }
55    }
56}
57
58impl FindingOptions {
59    fn validation(&self, engine: EngineOptions) -> ValidationOptions {
60        ValidationOptions {
61            entry_shape_names: self.entry_shape_names.clone(),
62            minimum_severity: self.minimum_severity.clone(),
63            sort_results: self.sort_results,
64            engine,
65        }
66    }
67}
68
69#[derive(Debug, Clone, Default)]
70pub struct EvidenceOptions {
71    pub findings: FindingOptions,
72    pub include_progress: bool,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct ExecutionDiagnostic {
77    pub message: String,
78}
79
80#[derive(Debug)]
81pub enum SessionError {
82    EmptyShapes,
83    UnsupportedFeatures(Vec<Diagnostic>),
84    StrictInference(Vec<ExecutionDiagnostic>),
85}
86
87impl fmt::Display for SessionError {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            Self::EmptyShapes => write!(f, "separate input requires a nonempty shapes graph"),
91            Self::UnsupportedFeatures(diagnostics) => write!(
92                f,
93                "unsupported shapes features: {}",
94                diagnostics
95                    .iter()
96                    .map(ToString::to_string)
97                    .collect::<Vec<_>>()
98                    .join("; ")
99            ),
100            Self::StrictInference(diagnostics) => write!(
101                f,
102                "strict inference failed: {}",
103                diagnostics
104                    .iter()
105                    .map(|diagnostic| diagnostic.message.as_str())
106                    .collect::<Vec<_>>()
107                    .join("; ")
108            ),
109        }
110    }
111}
112
113impl std::error::Error for SessionError {}
114
115/// Only `explain` and `explain_canonical` can fail: every other evaluation on a
116/// constructed session is total, so those return their value directly.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum EvaluationError {
119    ForeignPair,
120}
121
122impl fmt::Display for EvaluationError {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        match self {
125            Self::ForeignPair => write!(f, "selected pair belongs to another session"),
126        }
127    }
128}
129
130impl std::error::Error for EvaluationError {}
131
132pub struct EvaluationSession {
133    compiled: CompiledShapes,
134    asserted: Arc<Graph>,
135    evaluated: OnceCell<Arc<Graph>>,
136    separate: bool,
137    options: SessionOptions,
138    inferred: Vec<Triple>,
139    public_identity: OnceCell<PublicIdentity>,
140    public_data: OnceCell<Arc<Graph>>,
141    public_inferred: OnceCell<Vec<Triple>>,
142    inference_ran: bool,
143    diagnostics: Vec<ExecutionDiagnostic>,
144    inference_dataset: RefCell<Option<FrozenIndexedDataset>>,
145    prepared: OnceCell<PreparedEvidenceValidator>,
146    snapshot: Arc<()>,
147}
148
149/// The two inputs may spell different blank nodes with the same label. The
150/// evaluator uses an alias for the data node; public results keep the data
151/// label and give the shapes node a fresh one. This map is built from aliases
152/// captured while the data was encoded, without another graph scan.
153struct PublicIdentity {
154    labels: HashMap<BlankNode, BlankNode>,
155    internal_labels: HashMap<BlankNode, BlankNode>,
156    data_aliases: HashMap<BlankNode, BlankNode>,
157    source_collisions: HashSet<BlankNode>,
158}
159
160#[derive(PartialEq, Eq, Hash)]
161enum ScopedTermKey {
162    Other(Term),
163    DataBlank(BlankNode),
164    ShapesBlank(BlankNode),
165}
166
167impl PublicIdentity {
168    fn from_dataset(dataset: &FrozenIndexedDataset) -> Self {
169        let data_aliases = dataset.data_blank_node_aliases();
170        let source_collisions = data_aliases.values().cloned().collect();
171        let labels = dataset.external_blank_node_map();
172        let internal_labels = labels.iter().map(|(a, b)| (b.clone(), a.clone())).collect();
173        Self {
174            labels,
175            internal_labels,
176            data_aliases,
177            source_collisions,
178        }
179    }
180
181    fn term(&self, term: &Term) -> Term {
182        match term {
183            Term::BlankNode(node) => Term::BlankNode(
184                self.labels
185                    .get(node)
186                    .cloned()
187                    .unwrap_or_else(|| node.clone()),
188            ),
189            _ => term.clone(),
190        }
191    }
192
193    fn internal_term(&self, term: &Term) -> Term {
194        match term {
195            Term::BlankNode(node) => Term::BlankNode(
196                self.internal_labels
197                    .get(node)
198                    .cloned()
199                    .unwrap_or_else(|| node.clone()),
200            ),
201            _ => term.clone(),
202        }
203    }
204
205    fn triple(&self, triple: &Triple) -> Triple {
206        let subject = match &triple.subject {
207            NamedOrBlankNode::BlankNode(node) => NamedOrBlankNode::BlankNode(
208                self.labels
209                    .get(node)
210                    .cloned()
211                    .unwrap_or_else(|| node.clone()),
212            ),
213            named => named.clone(),
214        };
215        Triple::new(subject, triple.predicate.clone(), self.term(&triple.object))
216    }
217
218    fn key(&self, term: &Term) -> ScopedTermKey {
219        match term {
220            Term::BlankNode(node) if self.data_aliases.contains_key(node) => {
221                ScopedTermKey::DataBlank(self.data_aliases[node].clone())
222            }
223            Term::BlankNode(node) if self.source_collisions.contains(node) => {
224                ScopedTermKey::ShapesBlank(node.clone())
225            }
226            _ => ScopedTermKey::Other(term.clone()),
227        }
228    }
229
230    fn diagnostic(&self, diagnostic: &mut SparqlDiagnostic) {
231        for (_, term) in &mut diagnostic.bindings {
232            *term = self.term(term);
233        }
234        for row in &mut diagnostic.results {
235            for (_, term) in row {
236                *term = self.term(term);
237            }
238        }
239    }
240
241    fn reason(&self, reason: &mut Reason) {
242        reason.value = self.term(&reason.value);
243        if let Some(diagnostic) = &mut reason.sparql_diagnostic {
244            self.diagnostic(diagnostic);
245        }
246        for child in &mut reason.sub_reasons {
247            self.reason(child);
248        }
249    }
250
251    fn violation(&self, violation: &mut Violation) {
252        violation.focus = self.term(&violation.focus);
253        for reason in &mut violation.reasons {
254            self.reason(reason);
255        }
256    }
257
258    fn validation(&self, outcome: &mut ValidationOutcome) {
259        for violation in &mut outcome.violations {
260            self.violation(violation);
261        }
262    }
263
264    fn report(&self, report: &mut crate::report::ValidationReport) {
265        for result in &mut report.results {
266            result.focus = self.term(&result.focus);
267            result.path = result.path.as_ref().map(|term| self.term(term));
268            result.value = result.value.as_ref().map(|term| self.term(term));
269            result.source_shape = self.term(&result.source_shape);
270            for message in &mut result.messages {
271                *message = self.term(message);
272            }
273            if let Some(diagnostic) = &mut result.sparql_diagnostic {
274                self.diagnostic(diagnostic);
275            }
276        }
277    }
278
279    fn remap_evidence<T>(&self, value: T) -> T
280    where
281        T: serde::Serialize + serde::de::DeserializeOwned,
282    {
283        if self.labels.is_empty() {
284            return value;
285        }
286        // Evidence is an opt-in, already-serializable tree. Apply the same
287        // alias table to every RDF term in that tree only on the collision
288        // path; the indexed graph itself is never scanned or copied here.
289        let mut encoded = serde_json::to_value(value).expect("evidence serializes");
290        fn rewrite(value: &mut serde_json::Value, labels: &HashMap<String, String>) {
291            match value {
292                serde_json::Value::Object(object) => {
293                    if object.get("type").and_then(serde_json::Value::as_str) == Some("bnode")
294                        && let Some(serde_json::Value::String(label)) = object.get_mut("value")
295                        && let Some(mapped) = labels.get(label)
296                    {
297                        *label = mapped.clone();
298                        return;
299                    }
300                    for child in object.values_mut() {
301                        rewrite(child, labels);
302                    }
303                }
304                serde_json::Value::Array(items) => {
305                    for child in items {
306                        rewrite(child, labels);
307                    }
308                }
309                _ => {}
310            }
311        }
312        let labels = self
313            .labels
314            .iter()
315            .map(|(from, to)| (from.as_str().to_owned(), to.as_str().to_owned()))
316            .collect();
317        rewrite(&mut encoded, &labels);
318        serde_json::from_value(encoded).expect("rewritten evidence deserializes")
319    }
320}
321
322impl CompiledShapes {
323    pub fn session(
324        &self,
325        data: SessionData,
326        options: SessionOptions,
327    ) -> Result<EvaluationSession, SessionError> {
328        let (asserted, separate) = match data {
329            SessionData::Separate(data) => {
330                if self.source().graph.is_empty() {
331                    return Err(SessionError::EmptyShapes);
332                }
333                (data, true)
334            }
335            SessionData::Embedded => (self.source().graph.clone(), false),
336        };
337        self.session_from_asserted(asserted, separate, options)
338    }
339
340    fn session_from_asserted(
341        &self,
342        asserted: Graph,
343        separate: bool,
344        options: SessionOptions,
345    ) -> Result<EvaluationSession, SessionError> {
346        if options.engine.unsupported == UnsupportedPolicy::Error {
347            let unsupported: Vec<_> = self
348                .diagnostics()
349                .iter()
350                .filter(|diagnostic| diagnostic.level == DiagLevel::Unsupported)
351                .cloned()
352                .collect();
353            if !unsupported.is_empty() {
354                return Err(SessionError::UnsupportedFeatures(unsupported));
355            }
356        }
357        let asserted = Arc::new(asserted);
358        // No executable rule can change this snapshot. In particular, avoid
359        // assembling a shapes/data union or encoding the source just to hand
360        // the inference driver an empty schedule.
361        let (evaluated, inferred, diagnostics, inference_dataset, public_identity) =
362            if options.inference && !self.inner.rules.is_empty() {
363                let run = crate::infer::infer_with_compiled_functions(
364                    &asserted,
365                    &self.inner.normalized,
366                    &options.engine,
367                    &self.inner.functions,
368                    &self.inner.rules,
369                    (self.source_storage(), separate, self.parsed_queries()),
370                );
371                let diagnostics: Vec<_> = run
372                    .diagnostics
373                    .into_iter()
374                    .map(|message| ExecutionDiagnostic { message })
375                    .collect();
376                if options.engine.unsupported == UnsupportedPolicy::Error && !diagnostics.is_empty()
377                {
378                    return Err(SessionError::StrictInference(diagnostics));
379                }
380                let public_identity = Some(PublicIdentity::from_dataset(&run.dataset));
381                let mut inference_dataset = Some(run.dataset);
382                if separate
383                    && options.graph_mode == ValidationGraphMode::Data
384                    && let Some(dataset) = &mut inference_dataset
385                {
386                    dataset.select_data_view();
387                }
388                (
389                    None,
390                    run.inferred,
391                    diagnostics,
392                    inference_dataset,
393                    public_identity,
394                )
395            } else {
396                (
397                    Some(Arc::clone(&asserted)),
398                    Vec::new(),
399                    Vec::new(),
400                    None,
401                    None,
402                )
403            };
404        let evaluated_cell = OnceCell::new();
405        if let Some(graph) = evaluated {
406            evaluated_cell.set(graph).expect("new evaluated graph cell");
407        }
408        let identity_cell = OnceCell::new();
409        if let Some(identity) = public_identity {
410            identity_cell.set(identity).ok();
411        }
412        Ok(EvaluationSession {
413            compiled: self.clone(),
414            asserted,
415            evaluated: evaluated_cell,
416            separate,
417            options,
418            inferred,
419            public_identity: identity_cell,
420            public_data: OnceCell::new(),
421            public_inferred: OnceCell::new(),
422            inference_ran: options.inference && !self.inner.rules.is_empty(),
423            diagnostics,
424            inference_dataset: RefCell::new(inference_dataset),
425            prepared: OnceCell::new(),
426            snapshot: Arc::new(()),
427        })
428    }
429}
430
431impl EvaluationSession {
432    /// Resolve a public RDF term to the indexed identity used by evidence
433    /// constraints. This is a constant-time lookup and leaves other terms alone.
434    pub fn internal_term(&self, term: &Term) -> Term {
435        self.identity().internal_term(term)
436    }
437
438    #[cfg(test)]
439    pub(crate) fn has_prepared_dataset(&self) -> bool {
440        self.prepared.get().is_some()
441    }
442
443    fn prepared(&self) -> &PreparedEvidenceValidator {
444        self.prepared.get_or_init(|| {
445            let frozen = self
446                .inference_dataset
447                .borrow_mut()
448                .take()
449                .unwrap_or_else(|| {
450                    context::frozen(
451                        self.evaluated.get().expect("dataset or evaluated graph"),
452                        self.compiled.source_storage(),
453                        self.separate,
454                        self.options.graph_mode,
455                    )
456                });
457            self.public_identity
458                .get_or_init(|| PublicIdentity::from_dataset(&frozen));
459            PreparedEvidenceValidator::from_compiled(
460                self.evaluated.get().cloned(),
461                &self.compiled,
462                frozen,
463                true,
464                self.options.engine.unsupported,
465                if self.separate && self.options.graph_mode != ValidationGraphMode::UnionAll {
466                    crate::focus::FocusScope::Data
467                } else {
468                    crate::focus::FocusScope::Default
469                },
470                self.separate && self.options.graph_mode == ValidationGraphMode::UnionAll,
471            )
472        })
473    }
474
475    fn identity(&self) -> &PublicIdentity {
476        if self.public_identity.get().is_none() {
477            // Preparation constructs the indexed dataset and records its alias
478            // map even when no rule inference ran.
479            self.prepared();
480        }
481        self.public_identity
482            .get()
483            .expect("prepared session has blank-node identity")
484    }
485
486    fn evaluated_graph(&self) -> &Arc<Graph> {
487        self.evaluated.get_or_init(|| {
488            if let Some(dataset) = self.inference_dataset.borrow().as_ref() {
489                return Arc::new(dataset.data_graph_projection());
490            }
491            self.prepared().data_graph_shared()
492        })
493    }
494
495    /// Borrow the prepared evidence view for existing shape-map projections.
496    pub fn prepared_evidence(&self) -> &PreparedEvidenceValidator {
497        self.prepared()
498    }
499
500    fn validate_internal(&self, options: &FindingOptions) -> ValidationOutcome {
501        self.prepared().validate_findings(
502            self.compiled.physical_plan(),
503            &options.validation(self.options.engine),
504        )
505    }
506
507    pub fn validate(&self, options: &FindingOptions) -> ValidationOutcome {
508        let mut outcome = self.validate_internal(options);
509        self.identity().validation(&mut outcome);
510        outcome
511    }
512
513    pub fn report(&self, options: &FindingOptions) -> crate::report::ValidationReport {
514        let mut report = self.prepared().report(
515            self.compiled.source(),
516            &options.validation(self.options.engine),
517        );
518        self.identity().report(&mut report);
519        report
520    }
521
522    pub fn property_witnesses(
523        &self,
524        key_path: Option<&shifty_algebra::Path>,
525        options: &FindingOptions,
526    ) -> Vec<crate::report::PropertyWitness> {
527        let mut witnesses = self.prepared().property_witnesses(
528            self.compiled.source(),
529            key_path,
530            &options.validation(self.options.engine),
531        );
532        let identity = self.identity();
533        for witness in &mut witnesses {
534            witness.focus = identity.term(&witness.focus);
535            witness.shape = identity.term(&witness.shape);
536            witness.key = identity.term(&witness.key);
537            for value in &mut witness.values {
538                *value = identity.term(value);
539            }
540        }
541        witnesses
542    }
543
544    pub fn evidence(&self, options: &EvidenceOptions) -> EvidenceRun {
545        let validation = options.findings.validation(self.options.engine);
546        self.evidence_with_validation(&validation, options.include_progress)
547    }
548
549    pub fn evidence_with_validation(
550        &self,
551        validation: &ValidationOptions,
552        include_progress: bool,
553    ) -> EvidenceRun {
554        let run = if include_progress {
555            self.prepared().validate(validation)
556        } else {
557            self.prepared().validate_canonical(validation)
558        };
559        self.identity().remap_evidence(run)
560    }
561
562    pub fn conformance(&self, options: &ConformanceOptions) -> ConformanceRun {
563        self.prepared().validate_conformance(options)
564    }
565
566    pub fn find_failures(
567        &self,
568        options: &ConformanceOptions,
569    ) -> (ConformanceRun, Vec<SelectedPair>) {
570        let (run, mut pairs) = self.prepared().find_failures(options);
571        let identity = self.identity();
572        for pair in &mut pairs {
573            if !identity.labels.is_empty() {
574                pair.public_focus = Some(identity.term(pair.focus()));
575            }
576            pair.snapshot = Some(Arc::clone(&self.snapshot));
577        }
578        (run, pairs)
579    }
580
581    pub fn explain(
582        &self,
583        pair: &SelectedPair,
584    ) -> Result<Vec<StatementEvaluation>, EvaluationError> {
585        self.check_pair(pair)?;
586        Ok(self
587            .identity()
588            .remap_evidence(self.prepared().explain(pair)))
589    }
590
591    pub fn explain_canonical(
592        &self,
593        pair: &SelectedPair,
594    ) -> Result<Vec<StatementEvaluation>, EvaluationError> {
595        self.check_pair(pair)?;
596        Ok(self
597            .identity()
598            .remap_evidence(self.prepared().explain_canonical(pair)))
599    }
600
601    fn check_pair(&self, pair: &SelectedPair) -> Result<(), EvaluationError> {
602        if !pair
603            .snapshot
604            .as_ref()
605            .is_some_and(|snapshot| Arc::ptr_eq(snapshot, &self.snapshot))
606        {
607            return Err(EvaluationError::ForeignPair);
608        }
609        Ok(())
610    }
611
612    pub fn with_delta(&self, delta: &GraphDelta) -> Result<Self, SessionError> {
613        let patched = crate::gate::apply(&self.asserted, delta);
614        self.compiled
615            .session_from_asserted(patched, self.separate, self.options)
616    }
617
618    /// Revalidate a delta with a different inference setting. Enabling rules
619    /// patches asserted data and derives again; disabling them patches the
620    /// already evaluated graph so previously inferred triples remain present.
621    pub fn with_delta_and_inference(
622        &self,
623        delta: &GraphDelta,
624        inference: bool,
625    ) -> Result<Self, SessionError> {
626        if inference == self.options.inference {
627            return self.with_delta(delta);
628        }
629        let baseline = if inference {
630            self.asserted.as_ref()
631        } else {
632            self.evaluated_graph()
633        };
634        let patched = crate::gate::apply(baseline, delta);
635        let mut options = self.options;
636        options.inference = inference;
637        self.compiled
638            .session_from_asserted(patched, self.separate, options)
639    }
640
641    pub fn data(&self) -> &Graph {
642        self.public_data()
643    }
644
645    /// Share the evaluated data graph without copying it.
646    pub fn data_shared(&self) -> Arc<Graph> {
647        Arc::clone(self.public_data())
648    }
649
650    /// A source graph with only colliding blank nodes relabeled for combining
651    /// with the public data graph. No copy is made when scopes do not collide.
652    pub fn public_shapes_graph_if_relabelled(&self) -> Option<Graph> {
653        let identity = self.identity();
654        if identity.labels.is_empty() {
655            return None;
656        }
657        let mut graph = Graph::new();
658        for triple in self.compiled.source().graph.iter() {
659            graph.insert(&identity.triple(&triple.into_owned()));
660        }
661        Some(graph)
662    }
663
664    /// Relabel shape constants only when a split graph has a blank-node
665    /// collision; otherwise preserve the caller's shared schema allocation.
666    pub fn public_schema(
667        &self,
668        schema: Arc<shifty_algebra::Schema>,
669    ) -> Arc<shifty_algebra::Schema> {
670        if self.identity().labels.is_empty() {
671            schema
672        } else {
673            Arc::new(self.identity().remap_evidence(schema.as_ref().clone()))
674        }
675    }
676
677    fn public_data(&self) -> &Arc<Graph> {
678        if !self.inference_ran || self.identity().labels.is_empty() {
679            return self.evaluated_graph();
680        }
681        self.public_data.get_or_init(|| {
682            let identity = self.identity();
683            if let Some(dataset) = self.inference_dataset.borrow().as_ref() {
684                return Arc::new(dataset.data_graph_projection_mapped(&identity.labels));
685            }
686            Arc::new(
687                self.prepared()
688                    .data_graph_projection_mapped(&identity.labels),
689            )
690        })
691    }
692
693    /// Share the asserted data graph used by `with_delta`.
694    pub fn asserted_shared(&self) -> Arc<Graph> {
695        Arc::clone(&self.asserted)
696    }
697
698    pub fn inferred(&self) -> &[Triple] {
699        if self.identity().labels.is_empty() {
700            return &self.inferred;
701        }
702        self.public_inferred.get_or_init(|| {
703            self.inferred
704                .iter()
705                .map(|triple| self.identity().triple(triple))
706                .collect()
707        })
708    }
709
710    /// Count inferred triples without materializing their public RDF terms.
711    pub fn inferred_count(&self) -> usize {
712        self.inferred.len()
713    }
714
715    /// Inferred triples with input data blank-node labels restored and shapes
716    /// nodes kept distinct, for adding the delta to a caller-owned data graph.
717    pub fn inferred_for_write_back(&self) -> Vec<Triple> {
718        self.inferred().to_vec()
719    }
720
721    pub fn diagnostics(&self) -> &[ExecutionDiagnostic] {
722        &self.diagnostics
723    }
724
725    pub fn gate(&self, delta: &GraphDelta) -> Result<RepairOutcome, SessionError> {
726        let baseline = self.validate_internal(&FindingOptions::default());
727        let additions: HashSet<_> = delta.add.iter().collect();
728        let changed = additions
729            .iter()
730            .any(|triple| !self.asserted.contains(*triple))
731            || delta
732                .delete
733                .iter()
734                .any(|triple| !additions.contains(triple) && self.asserted.contains(triple));
735        if !changed {
736            let mut remaining = baseline.violations;
737            for violation in &mut remaining {
738                self.identity().violation(violation);
739            }
740            return Ok(RepairOutcome {
741                remaining,
742                ..RepairOutcome::default()
743            });
744        }
745        let candidate = self.with_delta(delta)?;
746        let patched = candidate.validate_internal(&FindingOptions::default());
747        let baseline_keys: HashSet<_> = baseline
748            .violations
749            .iter()
750            .map(|violation| (self.identity().key(&violation.focus), violation.statement))
751            .collect();
752        let patched_keys: HashSet<_> = patched
753            .violations
754            .iter()
755            .map(|violation| {
756                (
757                    candidate.identity().key(&violation.focus),
758                    violation.statement,
759                )
760            })
761            .collect();
762        let mut outcome = RepairOutcome::default();
763        for mut violation in baseline.violations {
764            if !patched_keys.contains(&(self.identity().key(&violation.focus), violation.statement))
765            {
766                self.identity().violation(&mut violation);
767                outcome.fixed.push(violation);
768            }
769        }
770        for mut violation in patched.violations {
771            let old = baseline_keys.contains(&(
772                candidate.identity().key(&violation.focus),
773                violation.statement,
774            ));
775            candidate.identity().violation(&mut violation);
776            if old {
777                outcome.remaining.push(violation);
778            } else {
779                outcome.introduced.push(violation);
780            }
781        }
782        Ok(outcome)
783    }
784}