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}