Skip to main content

turnframe_runtime/
divergence.rs

1//! A shared vocabulary for what two turn paths disagreed about.
2//!
3//! # Why a type and not a report format
4//!
5//! Classifying divergences is where the value of a shadow stage is. An adopter
6//! who runs [`TurnPlanner`](crate::planning::TurnPlanner) beside their existing
7//! tool loop ends up with two descriptions of the same turn and a pile of
8//! differences, and what they need next is a name for each kind of difference
9//! — the same name the next adopter uses, and the same name the next release
10//! uses, so a report can be read across both.
11//!
12//! Each side describes its turn as a [`TurnSummary`]. The Turnframe side gets
13//! one for free from [`PlannedTurn::summary`](crate::planning::PlannedTurn::summary);
14//! the authoritative side is filled in by the adopter from their own logs, and
15//! the fields are deliberately shallow enough that a free tool-calling agent
16//! can be described in them.
17//!
18//! # The asymmetry, in the type
19//!
20//! Not every difference is a regression, and a comparison that cannot say so
21//! will be read as though it were. This module states the case
22//! that matters: when this library **refuses a mutation because the target was
23//! ambiguous** and the previous path performed it anyway, the finding is
24//! against the previous path. It picked one of several records the user might
25//! have meant, and being right most of the time is not the same as being
26//! correct.
27//!
28//! So that case is its own variant —
29//! [`Divergence::RefusedUnresolvedTargetThatRan`] — carrying
30//! [`Attribution::Authoritative`], and it is subtracted from the plain
31//! [`Divergence::Mutations`] difference rather than counted twice. Every other
32//! finding carries the attribution the comparison can actually justify, which
33//! is usually [`Attribution::Undetermined`]: a difference a human still has to
34//! judge is reported as one.
35
36use std::fmt;
37
38use turnframe_core::case::CaseKey;
39use turnframe_core::ids::OperationKey;
40use turnframe_core::interaction::InteractionKind;
41use turnframe_core::reduce::{CommandRef, PlannedActResult};
42use turnframe_core::response::ClaimClass;
43use turnframe_core::target::TargetResolution;
44
45use crate::planning::PlannedTurn;
46
47/// Which of the two paths a summary or a finding is about.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
49pub enum Side {
50    /// This library, running without authority.
51    Shadow,
52    /// The existing path, whose answer the user actually receives.
53    Authoritative,
54}
55
56impl Side {
57    /// The other one.
58    #[must_use]
59    pub const fn other(self) -> Self {
60        match self {
61            Self::Shadow => Self::Authoritative,
62            Self::Authoritative => Self::Shadow,
63        }
64    }
65
66    /// A stable lower-case name, for a report or a metric label.
67    #[must_use]
68    pub const fn as_str(self) -> &'static str {
69        match self {
70            Self::Shadow => "shadow",
71            Self::Authoritative => "authoritative",
72        }
73    }
74}
75
76impl fmt::Display for Side {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        f.write_str(self.as_str())
79    }
80}
81
82/// One act a side extracted from the turn.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ActSummary {
85    /// The shape of the act, as `ProposedAct::kind_name` spells it
86    /// (`apply_operation`, `start_workflow`, …). An adopter describing a tool
87    /// loop normally uses `apply_operation`.
88    pub kind: String,
89    /// The operation, when the act names one.
90    pub operation: Option<OperationKey>,
91    /// The case the act ended up aimed at, when the side resolved exactly one.
92    pub case: Option<CaseKey>,
93}
94
95/// One mutation a side would run.
96///
97/// It is described by operation and case rather than by a domain payload,
98/// because that is the level at which two different implementations of the same
99/// intent are comparable at all.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct MutationSummary {
102    /// What it does.
103    pub operation: OperationKey,
104    /// What it changes, when the side resolved a case.
105    pub case: Option<CaseKey>,
106    /// The command inside the reduction, for the side that has one.
107    pub command_ref: Option<CommandRef>,
108}
109
110/// One question a side asked before doing anything.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ClarificationSummary {
113    /// The card's stable key within the turn.
114    pub key: String,
115    /// What kind of card it is.
116    pub kind: InteractionKind,
117    /// The case it belongs to.
118    pub case: CaseKey,
119}
120
121/// Why a side did not run a mutation it had understood.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123#[non_exhaustive]
124pub enum RefusalReason {
125    /// Several authorized records matched, and picking one is never allowed
126    /// (I8).
127    AmbiguousTarget,
128    /// The token was issued this turn and the record no longer exists.
129    MissingTarget,
130    /// Unknown token, or another tenant's.
131    UnauthorizedTarget,
132    /// The record moved past the revision the token was issued at.
133    StaleTarget,
134    /// Policy required a confirmation the turn did not have.
135    ConfirmationRequired,
136    /// The domain refused it deterministically.
137    DomainRejected,
138}
139
140impl RefusalReason {
141    /// Whether the refusal was about *which record was meant*.
142    ///
143    /// This is the class of refusal that makes a difference a finding against
144    /// the other path: the mutation was understood, and the target was not.
145    #[must_use]
146    pub const fn is_unresolved_target(self) -> bool {
147        matches!(
148            self,
149            Self::AmbiguousTarget
150                | Self::MissingTarget
151                | Self::UnauthorizedTarget
152                | Self::StaleTarget
153        )
154    }
155
156    /// A stable lower-case name.
157    #[must_use]
158    pub const fn as_str(self) -> &'static str {
159        match self {
160            Self::AmbiguousTarget => "ambiguous_target",
161            Self::MissingTarget => "missing_target",
162            Self::UnauthorizedTarget => "unauthorized_target",
163            Self::StaleTarget => "stale_target",
164            Self::ConfirmationRequired => "confirmation_required",
165            Self::DomainRejected => "domain_rejected",
166        }
167    }
168}
169
170impl fmt::Display for RefusalReason {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.write_str(self.as_str())
173    }
174}
175
176/// A mutation a side understood and did not perform.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct RefusedMutation {
179    /// What it would have done, when the act named an operation.
180    pub operation: Option<OperationKey>,
181    /// Why it did not.
182    pub reason: RefusalReason,
183}
184
185/// One side's account of a turn.
186///
187/// Build the Turnframe side with
188/// [`PlannedTurn::summary`](crate::planning::PlannedTurn::summary). Build the
189/// other side by hand, from whatever the existing path logs — which is why this
190/// struct is deliberately *not* `#[non_exhaustive]`: an adopter has to be able
191/// to construct one. Prefer the `with_*` methods, or struct-update syntax over
192/// [`TurnSummary::new`], so a later field arrives as a default rather than as a
193/// compile error.
194#[derive(Debug, Clone, Default, PartialEq, Eq)]
195pub struct TurnSummary {
196    /// The cases this side addressed, in the order it considered them.
197    pub cases: Vec<CaseKey>,
198    /// The acts it extracted from the turn.
199    pub acts: Vec<ActSummary>,
200    /// The mutations it would run.
201    pub mutations: Vec<MutationSummary>,
202    /// The questions it asked before running anything.
203    pub clarifications: Vec<ClarificationSummary>,
204    /// The outcome classes it stated, or would have been entitled to state.
205    pub claims: Vec<ClaimClass>,
206    /// The outcome classes an event of this side actually backs. Empty for a
207    /// planned turn, which by construction committed nothing.
208    pub evidenced_claims: Vec<ClaimClass>,
209    /// The mutations it understood and did not perform.
210    pub refusals: Vec<RefusedMutation>,
211}
212
213impl TurnSummary {
214    /// An empty summary, for a turn on which a side did nothing at all.
215    #[must_use]
216    pub fn new() -> Self {
217        Self::default()
218    }
219
220    /// Adds a case this side addressed.
221    #[must_use]
222    pub fn with_case(mut self, case: CaseKey) -> Self {
223        self.cases.push(case);
224        self
225    }
226
227    /// Adds an act this side extracted.
228    #[must_use]
229    pub fn with_act(mut self, act: ActSummary) -> Self {
230        self.acts.push(act);
231        self
232    }
233
234    /// Adds a mutation this side would run.
235    #[must_use]
236    pub fn with_mutation(mut self, mutation: MutationSummary) -> Self {
237        self.mutations.push(mutation);
238        self
239    }
240
241    /// Adds a question this side asked before running anything.
242    #[must_use]
243    pub fn with_clarification(mut self, clarification: ClarificationSummary) -> Self {
244        self.clarifications.push(clarification);
245        self
246    }
247
248    /// Adds an outcome class this side stated.
249    #[must_use]
250    pub fn with_claim(mut self, class: ClaimClass) -> Self {
251        self.claims.push(class);
252        self
253    }
254
255    /// Adds an outcome class an event of this side backs.
256    #[must_use]
257    pub fn with_evidenced_claim(mut self, class: ClaimClass) -> Self {
258        self.evidenced_claims.push(class);
259        self
260    }
261
262    /// Adds a mutation this side understood and did not perform.
263    #[must_use]
264    pub fn with_refusal(mut self, refusal: RefusedMutation) -> Self {
265        self.refusals.push(refusal);
266        self
267    }
268
269    /// This library's side of the comparison, read off a planned turn.
270    ///
271    /// `evidenced_claims` is empty and stays empty: planning commits nothing,
272    /// so there is no event to cite. `claims` carries
273    /// [`PlannedTurn::would_claim`](crate::planning::PlannedTurn::would_claim),
274    /// which is an upper bound.
275    #[must_use]
276    pub fn from_planned(planned: &PlannedTurn) -> Self {
277        let mut mutations: Vec<MutationSummary> = Vec::new();
278        let mut acts: Vec<ActSummary> = Vec::new();
279        let mut refusals: Vec<RefusedMutation> = Vec::new();
280
281        for planned_act in &planned.reduction.acts {
282            let case = planned_act
283                .target
284                .as_ref()
285                .and_then(TargetResolution::exact)
286                .map(turnframe_core::case::CaseRef::key);
287            let operation = planned_act.act.operation().cloned();
288            acts.push(ActSummary {
289                kind: planned_act.act.kind_name().to_owned(),
290                operation: operation.clone(),
291                case: case.clone(),
292            });
293            match &planned_act.result {
294                PlannedActResult::ReadyToExecute { command_refs } => {
295                    if let Some(operation) = operation.clone() {
296                        for command_ref in command_refs {
297                            mutations.push(MutationSummary {
298                                operation: operation.clone(),
299                                case: case.clone(),
300                                command_ref: Some(*command_ref),
301                            });
302                        }
303                    }
304                }
305                PlannedActResult::AwaitingConfirmation { .. } => refusals.push(RefusedMutation {
306                    operation: operation.clone(),
307                    reason: RefusalReason::ConfirmationRequired,
308                }),
309                PlannedActResult::Rejected { .. } => refusals.push(RefusedMutation {
310                    operation: operation.clone(),
311                    reason: unresolved_reason(planned_act.target.as_ref())
312                        .unwrap_or(RefusalReason::DomainRejected),
313                }),
314                PlannedActResult::NeedsClarification { .. } => {
315                    if let Some(reason) = unresolved_reason(planned_act.target.as_ref()) {
316                        refusals.push(RefusedMutation {
317                            operation: operation.clone(),
318                            reason,
319                        });
320                    }
321                }
322                _ => {}
323            }
324        }
325
326        Self {
327            cases: planned
328                .views
329                .iter()
330                .map(|view| view.case_ref.key())
331                .collect(),
332            acts,
333            mutations,
334            clarifications: planned
335                .would_persist
336                .iter()
337                .map(|spec| ClarificationSummary {
338                    key: spec.key.clone(),
339                    kind: spec.kind,
340                    case: spec.case_ref.key(),
341                })
342                .collect(),
343            claims: planned.would_claim.clone(),
344            evidenced_claims: Vec::new(),
345            refusals,
346        }
347    }
348}
349
350/// The refusal reason a target resolution implies, when it implies one.
351fn unresolved_reason(resolution: Option<&TargetResolution>) -> Option<RefusalReason> {
352    match resolution? {
353        TargetResolution::Ambiguous { .. } => Some(RefusalReason::AmbiguousTarget),
354        TargetResolution::Missing => Some(RefusalReason::MissingTarget),
355        TargetResolution::Unauthorized => Some(RefusalReason::UnauthorizedTarget),
356        TargetResolution::Stale { .. } => Some(RefusalReason::StaleTarget),
357        TargetResolution::Exact { .. } => None,
358        // `TargetResolution` is `#[non_exhaustive]`: a resolution this release
359        // does not know is not evidence that the target was unresolved, so it
360        // is not turned into a refusal reason.
361        _ => None,
362    }
363}
364
365/// Which path a finding is against.
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
367#[non_exhaustive]
368pub enum Attribution {
369    /// This library behaved worse, or at least differently in a way it has to
370    /// answer for.
371    Shadow,
372    /// The existing path did. A mutation on an ambiguous target is the case
373    /// this module names.
374    Authoritative,
375    /// The comparison cannot say, and a person has to look.
376    Undetermined,
377}
378
379impl Attribution {
380    /// A stable lower-case name.
381    #[must_use]
382    pub const fn as_str(self) -> &'static str {
383        match self {
384            Self::Shadow => "shadow",
385            Self::Authoritative => "authoritative",
386            Self::Undetermined => "undetermined",
387        }
388    }
389}
390
391impl fmt::Display for Attribution {
392    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
393        f.write_str(self.as_str())
394    }
395}
396
397/// One way two paths disagreed about the same turn.
398#[derive(Debug, Clone, PartialEq, Eq)]
399#[non_exhaustive]
400pub enum Divergence {
401    /// The two sides addressed different records.
402    CaseSelection {
403        /// What this library selected.
404        shadow: Vec<CaseKey>,
405        /// What the existing path selected.
406        authoritative: Vec<CaseKey>,
407    },
408    /// The two sides read different acts out of the same turn.
409    ActsExtracted {
410        /// What this library extracted.
411        shadow: Vec<ActSummary>,
412        /// What the existing path extracted.
413        authoritative: Vec<ActSummary>,
414    },
415    /// The two sides would run different mutations, for a reason the
416    /// comparison cannot attribute on its own.
417    Mutations {
418        /// What this library would run.
419        shadow: Vec<MutationSummary>,
420        /// What the existing path would run.
421        authoritative: Vec<MutationSummary>,
422    },
423    /// One side asked the user something while the other acted.
424    ClarificationVersusAction {
425        /// The side that asked.
426        asked: Side,
427        /// What it asked.
428        clarifications: Vec<ClarificationSummary>,
429        /// What the other side did instead.
430        mutations: Vec<MutationSummary>,
431    },
432    /// One side stated an outcome that no committed event, on either side,
433    /// backs.
434    ///
435    /// Evidence from *either* side counts, because in a shadow stage only one
436    /// side executes anything: a claim the authoritative path committed an
437    /// event for is a backed claim, whoever else also made it. Only classes an
438    /// event can back are checked — "you can see the card below" is backed by a
439    /// persisted card and "I will notify you" by nothing at all, so neither is
440    /// judged here.
441    ClaimWithoutEvent {
442        /// The side that stated it.
443        claimant: Side,
444        /// What it stated.
445        class: ClaimClass,
446    },
447    /// This library refused a mutation because it could not tell which record
448    /// was meant, and the existing path performed it anyway.
449    ///
450    /// This is the asymmetry this module exists to carry, and it is a finding
451    /// against the existing path: it chose one of several records the user
452    /// might have meant.
453    RefusedUnresolvedTargetThatRan {
454        /// The mutation the existing path performed.
455        performed: MutationSummary,
456        /// Why this library would not.
457        reason: RefusalReason,
458    },
459}
460
461impl Divergence {
462    /// A stable lower-case name of the kind, for a metric label or a column.
463    #[must_use]
464    pub const fn kind(&self) -> &'static str {
465        match self {
466            Self::CaseSelection { .. } => "case_selection",
467            Self::ActsExtracted { .. } => "acts_extracted",
468            Self::Mutations { .. } => "mutations",
469            Self::ClarificationVersusAction { .. } => "clarification_versus_action",
470            Self::ClaimWithoutEvent { .. } => "claim_without_event",
471            Self::RefusedUnresolvedTargetThatRan { .. } => "refused_unresolved_target_that_ran",
472        }
473    }
474}
475
476/// A divergence together with the side it counts against.
477#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct Finding {
479    /// What differed.
480    pub divergence: Divergence,
481    /// Whose problem it is, as far as the comparison can tell.
482    pub attribution: Attribution,
483}
484
485impl fmt::Display for Finding {
486    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487        write!(f, "{} ({})", self.divergence.kind(), self.attribution)
488    }
489}
490
491/// Everything two summaries of one turn disagreed about.
492#[derive(Debug, Clone, Default, PartialEq, Eq)]
493pub struct DivergenceReport {
494    /// The findings, in the order [`compare`] produces them.
495    pub findings: Vec<Finding>,
496}
497
498impl DivergenceReport {
499    /// Whether the two sides agreed on everything the vocabulary covers.
500    #[must_use]
501    pub fn agreed(&self) -> bool {
502        self.findings.is_empty()
503    }
504
505    /// The findings against one side.
506    pub fn against(&self, side: Side) -> impl Iterator<Item = &Finding> {
507        let wanted = match side {
508            Side::Shadow => Attribution::Shadow,
509            Side::Authoritative => Attribution::Authoritative,
510        };
511        self.findings
512            .iter()
513            .filter(move |finding| finding.attribution == wanted)
514    }
515
516    /// Whether anything at all counts against this library.
517    ///
518    /// This is the question a migration actually asks, and it is not
519    /// "were there differences": a run whose only findings are against the
520    /// existing path is a run that went well.
521    #[must_use]
522    pub fn any_against_shadow(&self) -> bool {
523        self.against(Side::Shadow).next().is_some()
524    }
525
526    /// The findings of one kind, by the name [`Divergence::kind`] gives.
527    pub fn of_kind<'a>(&'a self, kind: &'a str) -> impl Iterator<Item = &'a Finding> {
528        self.findings
529            .iter()
530            .filter(move |finding| finding.divergence.kind() == kind)
531    }
532}
533
534impl fmt::Display for DivergenceReport {
535    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
536        if self.findings.is_empty() {
537            return f.write_str("no divergence");
538        }
539        for (index, finding) in self.findings.iter().enumerate() {
540            if index > 0 {
541                f.write_str("; ")?;
542            }
543            write!(f, "{finding}")?;
544        }
545        Ok(())
546    }
547}
548
549/// Compares the two sides of one turn.
550///
551/// The order of the findings is stable: the attributable asymmetry first, then
552/// case selection, acts, mutations, clarification-versus-action, and claims.
553#[must_use]
554pub fn compare(shadow: &TurnSummary, authoritative: &TurnSummary) -> DivergenceReport {
555    let mut findings = Vec::new();
556
557    // A mutation the existing path ran that this library refused because it
558    // could not tell which record was meant. Reported first, and subtracted
559    // from the plain mutation difference below so it is not counted twice.
560    let mut explained: Vec<&MutationSummary> = Vec::new();
561    for performed in &authoritative.mutations {
562        if shadow
563            .mutations
564            .iter()
565            .any(|ours| same_mutation(ours, performed))
566        {
567            continue;
568        }
569        let Some(refusal) = shadow.refusals.iter().find(|refusal| {
570            refusal.reason.is_unresolved_target()
571                && refusal
572                    .operation
573                    .as_ref()
574                    .is_none_or(|operation| *operation == performed.operation)
575        }) else {
576            continue;
577        };
578        explained.push(performed);
579        findings.push(Finding {
580            divergence: Divergence::RefusedUnresolvedTargetThatRan {
581                performed: performed.clone(),
582                reason: refusal.reason,
583            },
584            attribution: Attribution::Authoritative,
585        });
586    }
587
588    if shadow.cases != authoritative.cases {
589        findings.push(Finding {
590            divergence: Divergence::CaseSelection {
591                shadow: shadow.cases.clone(),
592                authoritative: authoritative.cases.clone(),
593            },
594            attribution: Attribution::Undetermined,
595        });
596    }
597
598    if shadow.acts != authoritative.acts {
599        findings.push(Finding {
600            divergence: Divergence::ActsExtracted {
601                shadow: shadow.acts.clone(),
602                authoritative: authoritative.acts.clone(),
603            },
604            attribution: Attribution::Undetermined,
605        });
606    }
607
608    let remaining: Vec<MutationSummary> = authoritative
609        .mutations
610        .iter()
611        .filter(|performed| !explained.iter().any(|done| same_mutation(done, performed)))
612        .cloned()
613        .collect();
614    if !same_mutation_set(&shadow.mutations, &remaining) {
615        findings.push(Finding {
616            divergence: Divergence::Mutations {
617                shadow: shadow.mutations.clone(),
618                authoritative: remaining,
619            },
620            attribution: Attribution::Undetermined,
621        });
622    }
623
624    for asked in [Side::Shadow, Side::Authoritative] {
625        let (asker, actor) = match asked {
626            Side::Shadow => (shadow, authoritative),
627            Side::Authoritative => (authoritative, shadow),
628        };
629        if asker.clarifications.is_empty()
630            || !asker.mutations.is_empty()
631            || actor.mutations.is_empty()
632        {
633            continue;
634        }
635        // Asking which record was meant, while the other side picked one, is
636        // that asymmetry again. The other direction — the
637        // existing path asked and this library acted — depends on what the user
638        // meant, which the comparison does not know.
639        let attribution = if asked == Side::Shadow
640            && asker
641                .refusals
642                .iter()
643                .any(|refusal| refusal.reason.is_unresolved_target())
644        {
645            Attribution::Authoritative
646        } else {
647            Attribution::Undetermined
648        };
649        findings.push(Finding {
650            divergence: Divergence::ClarificationVersusAction {
651                asked,
652                clarifications: asker.clarifications.clone(),
653                mutations: actor.mutations.clone(),
654            },
655            attribution,
656        });
657    }
658
659    for (side, summary) in [(Side::Shadow, shadow), (Side::Authoritative, authoritative)] {
660        for class in &summary.claims {
661            if !is_event_backable(*class)
662                || shadow.evidenced_claims.contains(class)
663                || authoritative.evidenced_claims.contains(class)
664            {
665                continue;
666            }
667            findings.push(Finding {
668                divergence: Divergence::ClaimWithoutEvent {
669                    claimant: side,
670                    class: *class,
671                },
672                attribution: match side {
673                    Side::Shadow => Attribution::Shadow,
674                    Side::Authoritative => Attribution::Authoritative,
675                },
676            });
677        }
678    }
679
680    DivergenceReport { findings }
681}
682
683/// Whether a committed event is the kind of thing that could back this class.
684///
685/// [`ClaimClass::InteractionVisibility`] is backed by a persisted card and
686/// [`ClaimClass::FutureNotification`] by nothing the library has, so neither
687/// can be judged against the ledger.
688const fn is_event_backable(class: ClaimClass) -> bool {
689    !matches!(
690        class,
691        ClaimClass::InteractionVisibility | ClaimClass::FutureNotification
692    )
693}
694
695/// Two mutations are the same when they do the same thing to the same record.
696/// The command reference is deliberately ignored: only one side has one.
697fn same_mutation(left: &MutationSummary, right: &MutationSummary) -> bool {
698    left.operation == right.operation && left.case == right.case
699}
700
701fn same_mutation_set(left: &[MutationSummary], right: &[MutationSummary]) -> bool {
702    left.len() == right.len()
703        && left
704            .iter()
705            .zip(right)
706            .all(|(one, other)| same_mutation(one, other))
707}