1use std::collections::BTreeSet;
22
23use rustev_contract::Identified;
24use rustev_contract::ids::{ArtifactId, CalibrationId};
25use rustev_contract::judgment::Judgment;
26use rustev_contract::live::{
27 CapturePosture, Change, ComparisonDefinition, ComparisonOutcome, ComparisonSection,
28 CostComparison, DeliveryObservation, Historical, HistoricalLink, HistoricalRetention,
29 IdentityChanges, ItemObservation, LiveComparison, LiveReexecution, ObservationChanges,
30 OtherChanges, ResultChange, RuntimeObservations,
31};
32use rustev_contract::plan::{BoundCalibration, Plan, PlanExecution, PlanStepDetail};
33use rustev_contract::replay::{CaptureStatus, ReplayBundle};
34use rustev_contract::run::{
35 Cancellation, CoreEvidence, RemoteState, RequestResult, RunRecord, Termination, Transition,
36};
37
38use crate::config::Agreement;
39use crate::dataset::AdapterRef;
40use crate::metrics::CaseOutcome;
41use crate::replay::{Incomparable, ReplayConfig, ReplayOutcome, reproduce};
42use crate::resolve::Resolver;
43
44pub const LIMITATIONS: [&str; 5] = [
46 "agreement holds for the named adapter and fields only; it is not determinism, calibration, \
47 quality, correctness or authorization",
48 "a served identity is not recorded by rustev.run/1, so served-identity change is unknown",
49 "historical latency, attempts and cost are copied observations, never remeasured",
50 "a digest proves integrity relative to expected bytes, never truth or producer identity",
51 "cost units are logical units of each backend, not money",
52];
53
54pub const EXCLUSIONS: [&str; 2] = [
56 "full judgment bytes: decision, plan, snapshot and time may legitimately differ",
57 "proposal payload fields the adapter does not name",
58];
59
60pub struct LiveCase<'a> {
62 pub bundle: &'a ReplayBundle,
63 pub resolver: &'a dyn Resolver,
64 pub config: &'a ReplayConfig,
66 pub live: &'a LiveReexecution,
67 pub live_run: &'a RunRecord,
69 pub live_plan: &'a Plan,
71 pub adapter: &'a AdapterRef,
73 pub agreement: &'a Agreement,
74}
75
76fn incomparable(reason: &str) -> ComparisonOutcome {
77 ComparisonOutcome::Incomparable {
78 reason: reason.into(),
79 }
80}
81
82fn observations(run: &RunRecord) -> RuntimeObservations {
83 let attempts = run.requests.iter().flat_map(|r| &r.attempts);
84 let transitions = run.requests.iter().flat_map(|r| &r.transitions);
85 RuntimeObservations {
86 attempts: attempts.clone().count() as u64,
87 retries: transitions
88 .clone()
89 .filter(|t| matches!(t, Transition::Retry { .. }))
90 .count() as u64,
91 fallbacks: transitions
92 .filter(|t| matches!(t, Transition::Fallback { .. }))
93 .count() as u64,
94 cancellations_requested: attempts
95 .clone()
96 .filter(|a| matches!(a.cancellation, Cancellation::Requested { .. }))
97 .count() as u64,
98 possibly_continuing: attempts
99 .filter(|a| a.remote == RemoteState::PossiblyContinuing)
100 .count() as u64,
101 deadline_expired: run.timing.deadline_expired,
102 }
103}
104
105fn bound_artifact(plan: &Plan, step: &str, target: u32) -> Option<ArtifactId> {
107 let Some(PlanStepDetail::Semantic(b)) =
108 plan.steps.iter().find(|s| s.id == step).map(|s| &s.detail)
109 else {
110 return None;
111 };
112 if target == 0 {
113 return Some(b.artifact.clone());
114 }
115 match &plan.execution {
116 PlanExecution::Declared(d) => d
117 .fallbacks
118 .iter()
119 .find(|f| f.step == step && f.target == target)
120 .map(|f| f.artifact.clone()),
121 PlanExecution::None => None,
122 }
123}
124
125fn output_artifacts(plan: &Plan, run: &RunRecord) -> Option<BTreeSet<ArtifactId>> {
127 run.requests
128 .iter()
129 .filter_map(|r| match r.result {
130 RequestResult::Output { target } => Some(bound_artifact(plan, &r.step, target)),
131 _ => None,
132 })
133 .collect()
134}
135
136fn backends(run: &RunRecord) -> BTreeSet<&str> {
137 run.requests
138 .iter()
139 .flat_map(|r| &r.attempts)
140 .map(|a| a.backend_id.as_str())
141 .collect()
142}
143
144fn calibrations(plan: &Plan) -> BTreeSet<CalibrationId> {
145 plan.steps
146 .iter()
147 .filter_map(|s| match &s.detail {
148 PlanStepDetail::Semantic(b) => match &b.calibration {
149 BoundCalibration::Bound { id, .. } => Some(id.clone()),
150 BoundCalibration::None => None,
151 },
152 _ => None,
153 })
154 .collect()
155}
156
157fn judgment(run: &RunRecord) -> Option<&Judgment> {
158 match (&run.termination, &run.core) {
159 (Termination::Judged, CoreEvidence::Recorded(e)) => Some(&e.judgment),
160 _ => None,
161 }
162}
163
164fn result_change(historical: &CaseOutcome, live: &CaseOutcome) -> ResultChange {
165 match (historical, live) {
166 _ if historical.name() == live.name() => ResultChange::Unchanged,
167 (CaseOutcome::Abstention { .. }, CaseOutcome::Abstention { .. }) => {
168 ResultChange::UnresolvedCategoryChanged
169 }
170 _ => ResultChange::ProposalChanged,
171 }
172}
173
174fn delivery_change(live: &DeliveryObservation) -> Change {
175 match live {
177 DeliveryObservation::Acknowledged { .. } => Change::Unchanged,
178 DeliveryObservation::Queued => Change::Unknown,
179 DeliveryObservation::Dropped { .. } | DeliveryObservation::NotDelivered { .. } => {
180 Change::Changed
181 }
182 }
183}
184
185fn retention_change(live: &CapturePosture) -> Change {
186 match live {
188 CapturePosture::On {
189 status: CaptureStatus::Complete,
190 ..
191 } => Change::Unchanged,
192 _ => Change::Changed,
193 }
194}
195
196pub fn compare_live(case: &LiveCase<'_>) -> (LiveComparison, HistoricalRetention) {
199 let live = case.live;
200 let live_obs = observations(case.live_run);
201 let observed_retention = |unavailable: Vec<ItemObservation>| HistoricalRetention::Observed {
202 observed_at_ms: case.config.now_ms,
203 bundle_expires_at_ms: case.bundle.expires_at_ms,
204 unavailable,
205 };
206 let link = match &live.historical {
207 Historical::Linked(l) => Some(l),
208 Historical::None => None,
209 };
210 let change_or_unknown = |f: &dyn Fn(&HistoricalLink) -> Change| link.map_or(Change::Unknown, f);
211 let mut comparison = LiveComparison {
212 definition: ComparisonDefinition {
213 adapter: case.adapter.name.clone(),
214 adapter_version: case.adapter.version.clone(),
215 fields: case.agreement.params.clone(),
216 },
217 compared_at_ms: case.config.now_ms,
218 outcome: incomparable("not-compared"),
219 historical_result: None,
220 live_result: judgment(case.live_run).map(|j| CaseOutcome::of(j, case.agreement).name()),
221 result: ResultChange::NotCompared,
222 identities: IdentityChanges {
223 snapshot: change_or_unknown(&|l| Change::of(&l.snapshot_id, &live.snapshot_id)),
224 plan: change_or_unknown(&|l| Change::of(&l.plan_id, &live.plan_id)),
225 evaluation_time: change_or_unknown(&|l| {
226 Change::of(&l.evaluation_time_ms, &live.evaluation_time_ms)
227 }),
228 artifacts: Change::Unknown,
229 served_identity: Change::Unknown,
230 },
231 observations: ObservationChanges {
232 backends: Change::Unknown,
233 policy: Change::Unknown,
234 execution_policy: Change::Unknown,
235 calibration: Change::Unknown,
236 historical_runtime: None,
237 live_runtime: live_obs,
238 },
239 cost: CostComparison {
240 historical: None,
241 live: case.live_run.cost,
242 },
243 other: OtherChanges {
244 termination: Change::Unknown,
245 cancellation: Change::Unknown,
246 remote_state: Change::Unknown,
247 retention: Change::Unknown,
248 delivery: Change::Unknown,
249 },
250 exclusions: EXCLUSIONS.iter().map(|s| s.to_string()).collect(),
251 limitations: LIMITATIONS.iter().map(|s| s.to_string()).collect(),
252 };
253
254 if live.check().is_err() {
256 comparison.outcome = incomparable("live-document-invalid");
257 return (comparison, HistoricalRetention::NotObserved);
258 }
259 if !live.binds(case.live_run) {
260 comparison.outcome = incomparable("live-run-mismatch");
261 return (comparison, HistoricalRetention::NotObserved);
262 }
263 if case.live_plan.id().ok().as_ref() != Some(&live.plan_id) {
264 comparison.outcome = incomparable("live-plan-mismatch");
265 return (comparison, HistoricalRetention::NotObserved);
266 }
267 let Some(link) = link else {
269 comparison.outcome = incomparable("no-historical-case");
270 return (comparison, HistoricalRetention::NoHistory);
271 };
272 if HistoricalLink::of(case.bundle).ok().as_ref() != Some(link) {
273 comparison.outcome = incomparable("historical-link-mismatch");
274 return (comparison, HistoricalRetention::NotObserved);
275 }
276 let reproduced = match reproduce(case.bundle, case.resolver, case.config) {
278 ReplayOutcome::Reproduced(r) => r,
279 ReplayOutcome::Diverged { .. } => {
280 comparison.outcome = incomparable("historical-diverged");
281 return (comparison, observed_retention(vec![]));
282 }
283 ReplayOutcome::Incomparable(Incomparable::Unavailable {
284 location,
285 availability,
286 }) => {
287 let location = location.to_string();
288 comparison.outcome = ComparisonOutcome::Unavailable {
289 location: location.clone(),
290 availability,
291 };
292 return (
293 comparison,
294 observed_retention(vec![ItemObservation {
295 location,
296 availability,
297 }]),
298 );
299 }
300 ReplayOutcome::Incomparable(i) => {
301 comparison.outcome = incomparable(&format!("historical-{}", i.code()));
302 return (comparison, observed_retention(vec![]));
303 }
304 };
305 let historical_run = reproduced.run();
306 let historical_plan = reproduced.plan();
307 let hist_obs = observations(historical_run);
308
309 comparison.identities.artifacts = match (
310 output_artifacts(historical_plan, historical_run),
311 output_artifacts(case.live_plan, case.live_run),
312 ) {
313 (Some(h), Some(l)) => Change::of(&h, &l),
314 _ => Change::Unknown,
315 };
316 comparison.observations = ObservationChanges {
317 backends: Change::of(&backends(historical_run), &backends(case.live_run)),
318 policy: Change::of(
319 &historical_plan.definition_id,
320 &case.live_plan.definition_id,
321 ),
322 execution_policy: Change::of(&historical_run.execution, &case.live_run.execution),
323 calibration: Change::of(
324 &calibrations(historical_plan),
325 &calibrations(case.live_plan),
326 ),
327 historical_runtime: Some(hist_obs),
328 live_runtime: live_obs,
329 };
330 comparison.cost.historical = Some(historical_run.cost);
331 comparison.other = OtherChanges {
332 termination: Change::of(&historical_run.termination, &case.live_run.termination),
333 cancellation: Change::of(
334 &(hist_obs.cancellations_requested > 0),
335 &(live_obs.cancellations_requested > 0),
336 ),
337 remote_state: Change::of(
338 &(hist_obs.possibly_continuing > 0),
339 &(live_obs.possibly_continuing > 0),
340 ),
341 retention: retention_change(&live.retention.capture),
342 delivery: delivery_change(&live.delivery),
343 };
344 let historical = CaseOutcome::of(reproduced.judgment(), case.agreement);
345 comparison.historical_result = Some(historical.name());
346 match judgment(case.live_run) {
347 None => comparison.outcome = incomparable("live-not-judged"),
350 Some(j) => {
351 let live_outcome = CaseOutcome::of(j, case.agreement);
352 comparison.result = result_change(&historical, &live_outcome);
353 comparison.outcome = match comparison.result {
354 ResultChange::Unchanged => ComparisonOutcome::Agreement,
355 _ => ComparisonOutcome::LiveDiverged,
356 };
357 }
358 }
359 (comparison, observed_retention(vec![]))
360}
361
362pub fn with_comparison(
365 live: &LiveReexecution,
366 comparison: LiveComparison,
367 historical: HistoricalRetention,
368) -> LiveReexecution {
369 let mut out = live.clone();
370 out.retention.historical = historical;
371 out.comparison = ComparisonSection::Completed(Box::new(comparison));
372 out
373}