1use crate::{
5 Document, DocumentError,
6 bounded::{ParseError, ParseLimits, parse_bounded},
7 ids::{ContentDigest, PlanId, RedactionPolicyId},
8 judgment::Derivation,
9 limits::REPLAY_V1,
10 redaction::FieldClass,
11 replay::CaptureStatus,
12 schema,
13};
14use serde::{Deserialize, Deserializer, Serialize};
15use serde_json::Value;
16
17pub const EXPLAINER: &str = "rustev-explain/1";
18pub const MAX_FACTS: usize = 10_000;
19
20fn required<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<T, D::Error> {
22 T::deserialize(d)
23}
24macro_rules! record {
25 ($name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
26 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
27 #[serde(deny_unknown_fields)]
28 pub struct $name { $(#[serde(deserialize_with = "required")] pub $field: $ty),* }
29 };
30}
31macro_rules! vocabulary {
32 ($name:ident, $case:literal, $($variant:ident),+ $(,)?) => {
33 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
34 #[serde(rename_all = $case)]
35 pub enum $name { $($variant),+ }
36 impl<'de> Deserialize<'de> for $name {
37 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
38 #[derive(Deserialize)]
39 #[serde(rename_all = $case)]
40 #[allow(clippy::enum_variant_names)]
42 enum Wire { $($variant),+ }
43 let name = String::deserialize(d)?;
44 let wire = Wire::deserialize(serde::de::value::StringDeserializer::<D::Error>::new(name))?;
45 Ok(match wire { $(Wire::$variant => Self::$variant),+ })
46 }
47 }
48 };
49}
50vocabulary!(SideName, "snake_case", A, B);
51vocabulary!(
55 ItemAvailability,
56 "snake_case",
57 Missing,
58 Expired,
59 Erased,
60 Inaccessible,
61 DigestOnly
62);
63vocabulary!(EvidenceForm, "snake_case", Record, Bundle);
64vocabulary!(WithholdingAction, "snake_case", Drop, Digest);
65vocabulary!(DerivationBound, "snake_case", Exact, Judgment);
66vocabulary!(LineageBound, "snake_case", Exact, Judgment, Plan);
67vocabulary!(
68 Facet,
69 "snake_case",
70 Tier,
71 Relaxation,
72 Presence,
73 AddressedTo,
74 Remediation,
75 CausedBy,
76 FieldOrigin,
77 Origin,
78 ServedIdentity
79);
80vocabulary!(
81 Claim,
82 "snake_case",
83 Membership,
84 RankPosition,
85 Score,
86 Components,
87 Weights,
88 Operator,
89 ShortlistPosition,
90 OrderingKey,
91 FiltersPassed,
92 ProposingRule,
93 Adjustments,
94 JudgmentDerivation,
95 JudgmentLineage,
96 Exclusion,
97 RuleDeclaration,
98 CutByK,
99 Pool,
100 PositionA,
101 PositionB,
102 ScoreA,
103 ScoreB,
104 ComponentsA,
105 ComponentsB,
106 OrderBy,
107 Consistent,
108 Outcome,
109 Matched,
110 Notices,
111 Termination,
112 Cause,
113 TextField,
114 Request,
115 Attempt,
116 Transitions,
117 CostSummary,
118 BackendCost,
119 Record,
120 Difference,
121 ObservationDifference,
122 OutputNotCompared,
123 Tier,
124 Relaxation,
125 Presence,
126 AddressedTo,
127 Remediation,
128 CausedBy,
129 FieldOrigin,
130 Origin,
131 ServedIdentity
132);
133vocabulary!(
134 RefusalCode,
135 "kebab-case",
136 PlanMismatch,
137 PlanAmbiguous,
138 ScopeMismatch,
139 EvidenceIntegrity,
140 InvalidQuery,
141 InvalidOptions,
142 ExplanationTooLarge,
143 Parse
144);
145impl RefusalCode {
146 pub const fn as_str(self) -> &'static str {
147 match self {
148 Self::PlanMismatch => "plan-mismatch",
149 Self::PlanAmbiguous => "plan-ambiguous",
150 Self::ScopeMismatch => "scope-mismatch",
151 Self::EvidenceIntegrity => "evidence-integrity",
152 Self::InvalidQuery => "invalid-query",
153 Self::InvalidOptions => "invalid-options",
154 Self::ExplanationTooLarge => "explanation-too-large",
155 Self::Parse => "parse",
156 }
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
162#[serde(transparent)]
163pub struct EvidencePath(String);
164impl EvidencePath {
165 pub fn parse(s: &str) -> Result<Self, DocumentError> {
166 let unprefixed = s
167 .strip_prefix("a.")
168 .or_else(|| s.strip_prefix("b."))
169 .unwrap_or(s);
170 let (role, pointer) = unprefixed
171 .split_once(':')
172 .ok_or_else(|| invalid("evidence path lacks colon"))?;
173 let supply = role.strip_prefix("supply/").is_some_and(|n| {
174 !n.is_empty()
175 && n.bytes().all(|b| b.is_ascii_digit())
176 && (n == "0" || !n.starts_with('0'))
177 && n.parse::<u64>().is_ok()
178 });
179 if !matches!(role, "record" | "plan" | "bundle" | "snapshot") && !supply {
180 return Err(invalid("invalid evidence role"));
181 }
182 if !pointer.is_empty() && !pointer.starts_with('/') {
183 return Err(invalid("invalid JSON pointer"));
184 }
185 let mut bytes = pointer.bytes();
186 while let Some(b) = bytes.next() {
187 if b == b'~' && !matches!(bytes.next(), Some(b'0' | b'1')) {
188 return Err(invalid("invalid JSON pointer escape"));
189 }
190 }
191 Ok(Self(s.into()))
192 }
193 pub fn as_str(&self) -> &str {
194 &self.0
195 }
196 pub fn side(&self) -> Option<SideName> {
197 if self.0.starts_with("a.") {
198 Some(SideName::A)
199 } else if self.0.starts_with("b.") {
200 Some(SideName::B)
201 } else {
202 None
203 }
204 }
205 pub fn resolve<'a>(&self, document: &'a Value) -> Option<&'a Value> {
206 document.pointer(self.0.split_once(':')?.1)
207 }
208}
209impl std::str::FromStr for EvidencePath {
210 type Err = DocumentError;
211 fn from_str(s: &str) -> Result<Self, Self::Err> {
212 Self::parse(s)
213 }
214}
215impl<'de> Deserialize<'de> for EvidencePath {
216 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
217 Self::parse(&String::deserialize(d)?).map_err(serde::de::Error::custom)
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
222#[serde(tag = "reason", rename_all = "kebab-case", deny_unknown_fields)]
223pub enum Unavailable {
224 NotRecorded {
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 facet: Option<Facet>,
227 },
228 PlanNotSupplied {},
229 Retention {
230 item: EvidencePath,
231 availability: ItemAvailability,
232 },
233 NotProduced {},
234 Redacted {
235 policy: RedactionPolicyId,
236 field_class: FieldClass,
237 action: WithholdingAction,
238 },
239 NotInRecord {},
240}
241record!(Withheld {
242 not_available: Unavailable
243});
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
245#[serde(untagged)]
246pub enum Item {
247 String(String),
248 Withheld(Withheld),
249}
250record!(DigestValue {
251 digest: ContentDigest
252});
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
256#[serde(untagged)]
257pub enum DigestRef {
258 Digest(DigestValue),
259 Withheld(Withheld),
260}
261#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
262#[serde(rename_all = "snake_case", deny_unknown_fields)]
263pub enum Availability {
264 Available,
265 NotAvailable(Unavailable),
266}
267impl<'de> Deserialize<'de> for Availability {
268 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
269 let value = Value::deserialize(d)?;
270 if value == Value::String("available".into()) {
271 return Ok(Self::Available);
272 }
273 let withheld: Withheld = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
274 Ok(Self::NotAvailable(withheld.not_available))
275 }
276}
277record!(Side { name: SideName, form: EvidenceForm, record: DigestRef, plan: Option<PlanId>,
278 bundle: Option<ContentDigest>, capture: Option<CaptureStatus>, decision_id: Item, plan_id: Item });
279record!(FacetSource {
280 name: Facet,
281 spec: String,
282 path: String
283});
284record!(Redaction {
285 policy: RedactionPolicyId
286});
287record!(Truncation { total: u64 });
288record!(FactDerivation {
289 class: Derivation,
290 bound: DerivationBound
291});
292record!(FactLineage { inputs: Vec<String>, steps: Vec<String>, bound: LineageBound });
293record!(Truncated {
294 policy: RedactionPolicyId,
295 bytes: u64
296});
297record!(Fact { id: String, claim: Claim, value: Value, availability: Availability,
298 derivation: Option<FactDerivation>, lineage: Option<FactLineage>,
299 truncated: Option<Truncated>, cites: Vec<EvidencePath> });
300
301#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
302#[serde(rename_all = "snake_case", deny_unknown_fields)]
303pub enum Query {
304 WhySelected { item: Item },
305 WhyExcluded { item: Item },
306 WhyOrder { a: Item, b: Item },
307 WhyOutcome,
308 TextOrigin,
309 Models,
310 Diff,
311}
312#[derive(Deserialize)]
313#[serde(rename_all = "snake_case", deny_unknown_fields)]
314enum ItemQuery {
315 #[serde(rename = "why_selected")]
316 Selected { item: Item },
317 #[serde(rename = "why_excluded")]
318 Excluded { item: Item },
319 #[serde(rename = "why_order")]
320 Order { a: Item, b: Item },
321}
322impl<'de> Deserialize<'de> for Query {
323 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
324 let value = Value::deserialize(d)?;
325 if let Value::String(name) = &value {
326 return match name.as_str() {
327 "why_outcome" => Ok(Self::WhyOutcome),
328 "text_origin" => Ok(Self::TextOrigin),
329 "models" => Ok(Self::Models),
330 "diff" => Ok(Self::Diff),
331 _ => Err(serde::de::Error::custom("unknown query")),
332 };
333 }
334 match serde_json::from_value::<ItemQuery>(value).map_err(serde::de::Error::custom)? {
335 ItemQuery::Selected { item } => Ok(Self::WhySelected { item }),
336 ItemQuery::Excluded { item } => Ok(Self::WhyExcluded { item }),
337 ItemQuery::Order { a, b } => Ok(Self::WhyOrder { a, b }),
338 }
339 }
340}
341impl Query {
342 pub fn validate(&self) -> Result<(), RefusalCode> {
343 let valid = |item: &Item| match item {
344 Item::String(s) => (1..=256).contains(&s.len()),
345 Item::Withheld(w) => matches!(w.not_available, Unavailable::Redacted { .. }),
346 };
347 let ok = match self {
348 Self::WhySelected { item } | Self::WhyExcluded { item } => valid(item),
349 Self::WhyOrder { a, b } => {
351 valid(a)
352 && valid(b)
353 && !matches!((a, b), (Item::String(a), Item::String(b)) if a == b)
354 }
355 _ => true,
356 };
357 if ok {
358 Ok(())
359 } else {
360 Err(RefusalCode::InvalidQuery)
361 }
362 }
363}
364
365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
368#[serde(transparent)]
369pub struct FactId(pub String);
370vocabulary!(ValueKind, "snake_case", Filtered, Shortlist, Ranking);
371vocabulary!(SeeExcluded, "snake_case", WhyExcluded);
372vocabulary!(SeeSelected, "snake_case", WhySelected);
373vocabulary!(OrderBy, "snake_case", Score, TieBreak, Position);
374vocabulary!(ComparisonBy, "snake_case", Score, TieBreak);
375vocabulary!(
376 Divergence,
377 "kebab-case",
378 PlanChanged,
379 SnapshotChanged,
380 EvaluationTimeChanged,
381 InputChanged,
382 BackendOutputChanged,
383 BackendChanged,
384 RequestResultChanged,
385 StepChanged,
386 OutcomeChanged
387);
388record!(SelectedScope { param: String, value_kind: ValueKind, facts: Vec<FactId> });
389record!(ExcludedScope {
390 param: String,
391 value_kind: ValueKind,
392 exclusion: FactId,
393 rule_declaration: FactId
394});
395record!(Cut {
396 param: String,
397 cut_by_k: FactId
398});
399record!(OrderScope { param: String, first: Option<Item>, by: OrderBy, consistent: Option<bool>,
400 facts: Vec<FactId> });
401record!(DisagreeingScope { param: String, first: Option<Item>, by: OrderBy, consistent: bool,
403 disagrees_with: ComparisonBy, facts: Vec<FactId> });
404#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
405#[serde(untagged)]
406pub enum OrderingScope {
407 Disagrees(DisagreeingScope),
408 Other(OrderScope),
409}
410record!(TextField {
411 text_field: FactId,
412 field_origin: FactId
413});
414record!(ModelRequest { request: FactId, attempts: Vec<FactId>, transitions: FactId });
415record!(ModelBackend { backend: Item, attempts: Vec<FactId>, served_identity: FactId, backend_cost: FactId });
416
417macro_rules! answer {
418 ($name:ident { $($body:tt)* }) => {
419 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
420 #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
421 pub enum $name { $($body)* }
422 };
423}
424answer!(SelectedAnswer {
425 NotAvailable { outcome: FactId }, NoProposal { outcome: FactId },
426 NotSelected { see: SeeExcluded },
427 Selected { scopes: Vec<SelectedScope>, proposing_rule: FactId, adjustments: FactId,
428 judgment_derivation: FactId, judgment_lineage: FactId, facets: Vec<FactId> },
429});
430answer!(ExcludedAnswer {
431 NotAvailable { outcome: FactId }, NoProposal { outcome: FactId },
432 Excluded { scopes: Vec<ExcludedScope>, cuts: Vec<Cut>, selected_in: Vec<String>, facets: Vec<FactId> },
433 Cut { cuts: Vec<Cut>, selected_in: Vec<String>, facets: Vec<FactId> },
434 Selected { see: SeeSelected }, NotInPool { pool: FactId, facets: Vec<FactId> },
435 Unknown { pool: FactId, facets: Vec<FactId> },
436});
437#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
438#[serde(tag = "reason", rename_all = "kebab-case", deny_unknown_fields)]
439pub enum Incomparable {
440 DifferentScope {},
441 NotRanked { items: Vec<Item> },
442}
443answer!(OrderAnswer {
444 NotAvailable { outcome: FactId }, Ordered { scopes: Vec<OrderingScope> },
445 Incomparable(Incomparable),
446});
447record!(OutcomeFacts { outcome: FactId, matched: FactId, adjustments: FactId, notices: FactId,
448 judgment_derivation: FactId, judgment_lineage: FactId, termination: FactId,
449 causes: Vec<FactId>, facets: Vec<FactId> });
450answer!(OutcomeAnswer {
451 NotAvailable { outcome: FactId }, Proposed(OutcomeFacts), Escalated(OutcomeFacts), Unresolved(OutcomeFacts),
452});
453answer!(TextAnswer { NotAvailable { outcome: FactId }, TextFields { fields: Vec<TextField> }, NoText {} });
454answer!(ModelsAnswer {
455 NotAvailable { record: FactId }, NoModelCalls { cost_summary: FactId },
456 Models { requests: Vec<ModelRequest>, backends: Vec<ModelBackend>, cost_summary: FactId },
457});
458answer!(DiffAnswer {
459 Identical { observations: Vec<FactId>, not_compared: Vec<FactId> },
460 Diverged { first: FactId, why: Divergence, differences: Vec<FactId>, total: u64,
461 truncated: u64, observations: Vec<FactId>, not_compared: Vec<FactId> },
462 Withheld { redacted: Unavailable },
463});
464
465#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
466#[serde(untagged)]
467pub enum Answer {
468 WhySelected(SelectedAnswer),
469 WhyExcluded(ExcludedAnswer),
470 WhyOrder(OrderAnswer),
471 WhyOutcome(OutcomeAnswer),
472 TextOrigin(TextAnswer),
473 Models(ModelsAnswer),
474 Diff(DiffAnswer),
475}
476impl Answer {
477 fn parse(query: &Query, value: Value) -> Result<Self, serde_json::Error> {
478 Ok(match query {
479 Query::WhySelected { .. } => Self::WhySelected(serde_json::from_value(value)?),
480 Query::WhyExcluded { .. } => Self::WhyExcluded(serde_json::from_value(value)?),
481 Query::WhyOrder { .. } => Self::WhyOrder(serde_json::from_value(value)?),
482 Query::WhyOutcome => Self::WhyOutcome(serde_json::from_value(value)?),
483 Query::TextOrigin => Self::TextOrigin(serde_json::from_value(value)?),
484 Query::Models => Self::Models(serde_json::from_value(value)?),
485 Query::Diff => Self::Diff(serde_json::from_value(value)?),
486 })
487 }
488}
489
490#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
492#[serde(deny_unknown_fields)]
493pub struct Explanation {
494 pub schema: String,
495 pub explainer: String,
496 pub facet_sources: Vec<FacetSource>,
497 pub sides: Vec<Side>,
498 pub redaction: Option<Redaction>,
499 pub query: Query,
500 pub answer: Answer,
501 pub truncation: Option<Truncation>,
502 pub facts: Vec<Fact>,
503}
504record!(ExplanationWire { schema: String, explainer: String, facet_sources: Vec<FacetSource>,
505 sides: Vec<Side>, redaction: Option<Redaction>, query: Query, answer: Value,
506 truncation: Option<Truncation>, facts: Vec<Fact> });
507impl<'de> Deserialize<'de> for Explanation {
508 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
509 let w = ExplanationWire::deserialize(d)?;
510 let answer = Answer::parse(&w.query, w.answer).map_err(serde::de::Error::custom)?;
511 Ok(Self {
512 schema: w.schema,
513 explainer: w.explainer,
514 facet_sources: w.facet_sources,
515 sides: w.sides,
516 redaction: w.redaction,
517 query: w.query,
518 answer,
519 truncation: w.truncation,
520 facts: w.facts,
521 })
522 }
523}
524impl Document for Explanation {
525 const SCHEMA: &'static str = schema::EXPLANATION;
526 const LIMITS: ParseLimits = REPLAY_V1;
527 fn schema(&self) -> &str {
528 &self.schema
529 }
530 fn parse(bytes: &[u8]) -> Result<Self, DocumentError> {
531 let doc: Self = parse_bounded(bytes, &Self::LIMITS).map_err(DocumentError::Parse)?;
532 doc.validate()?;
533 Ok(doc)
534 }
535}
536fn invalid(message: &str) -> DocumentError {
537 DocumentError::Parse(ParseError::Schema {
538 message: message.into(),
539 })
540}
541impl Explanation {
542 pub fn validate(&self) -> Result<(), DocumentError> {
544 if self.schema != schema::EXPLANATION {
545 return Err(DocumentError::Schema {
546 expected: schema::EXPLANATION,
547 found: self.schema.chars().take(80).collect(),
548 });
549 }
550 if self.explainer != EXPLAINER {
551 return Err(invalid("invalid explainer"));
552 }
553 self.query.validate().map_err(|e| invalid(e.as_str()))?;
554 let diff = matches!(self.query, Query::Diff);
555 let names: Vec<_> = self.sides.iter().map(|s| s.name).collect();
556 if names
557 != if diff {
558 vec![SideName::A, SideName::B]
559 } else {
560 vec![SideName::A]
561 }
562 {
563 return Err(invalid("invalid sides for query"));
564 }
565 for side in &self.sides {
566 if let Item::String(id) = &side.plan_id {
567 PlanId::parse(id).map_err(|e| invalid(&e.to_string()))?;
568 }
569 }
570 if self.facts.len() > MAX_FACTS {
571 return Err(invalid("explanation-too-large"));
572 }
573 for (i, fact) in self.facts.iter().enumerate() {
574 if fact.id != format!("f{}", i + 1) {
575 return Err(invalid("facts must be numbered f1..fn"));
576 }
577 if matches!(fact.availability, Availability::NotAvailable(_))
578 && (!fact.value.is_null() || fact.truncated.is_some())
579 {
580 return Err(invalid("unavailable fact has value or truncation"));
581 }
582 if !fact.cites.windows(2).all(|w| w[0] < w[1]) {
583 return Err(invalid("citations must be sorted and unique"));
584 }
585 if fact.cites.iter().any(|p| p.side().is_some() != diff) {
586 return Err(invalid("citation side does not match query"));
587 }
588 }
589 if !self.facet_sources.windows(2).all(|w| w[0].name < w[1].name) {
590 return Err(invalid("facet sources must be in reserved order"));
591 }
592 let value = serde_json::to_value(&self.answer).map_err(|e| invalid(&e.to_string()))?;
593 let reparsed =
594 Answer::parse(&self.query, value.clone()).map_err(|e| invalid(&e.to_string()))?;
595 if reparsed != self.answer {
596 return Err(invalid("answer does not belong to query"));
597 }
598 fn references(v: &Value, count: usize) -> bool {
601 match v {
602 Value::String(id) => id
603 .strip_prefix('f')
604 .and_then(|s| s.parse::<usize>().ok())
605 .is_some_and(|n| n > 0 && n <= count && *id == format!("f{n}")),
606 Value::Array(a) => a.iter().all(|v| references(v, count)),
607 Value::Object(o) => o.iter().all(|(k, v)| {
608 matches!(
609 k.as_str(),
610 "kind"
611 | "param"
612 | "value_kind"
613 | "see"
614 | "selected_in"
615 | "reason"
616 | "items"
617 | "backend"
618 | "by"
619 | "consistent"
620 | "disagrees_with"
621 | "why"
622 | "total"
623 | "truncated"
624 | "redacted"
625 ) || (k == "first" && o.contains_key("param"))
626 || references(v, count)
627 }),
628 _ => false,
629 }
630 }
631 if !references(&value, self.facts.len()) {
632 return Err(invalid("answer names missing fact"));
633 }
634 if let Answer::WhyOrder(OrderAnswer::Ordered { scopes }) = &self.answer {
635 for scope in scopes {
636 let valid = match scope {
637 OrderingScope::Disagrees(s) => s.by == OrderBy::Position && !s.consistent,
638 OrderingScope::Other(s) => match s.by {
639 OrderBy::Position => s.consistent.is_none(),
640 OrderBy::Score | OrderBy::TieBreak => {
641 s.consistent == Some(true) && s.first.is_some()
642 }
643 },
644 };
645 if !valid {
646 return Err(invalid("invalid ordering scope combination"));
647 }
648 }
649 }
650 let bytes = self
651 .record_canonical()
652 .map_err(|e| invalid(&e.to_string()))?;
653 crate::bounded::scan(&bytes, &REPLAY_V1)
654 .map_err(|e| DocumentError::Parse(ParseError::Bound(e)))?;
655 Ok(())
656 }
657}