Skip to main content

rustev_contract/
dynamic.rs

1//! Dynamic questions (spec 019): a separate, explicitly invoked class for
2//! one bounded `proposition`, `classify` or `rubric` question over
3//! caller-selected structured context.
4//!
5//! Three documents live here: the request, `rustev.dynamic-question/1`; a
6//! backend's opt-in declaration, `rustev.dynamic-capability/1`, distinct
7//! from its registered-plan descriptor; and the outcome,
8//! `rustev.dynamic-outcome/1`. None of them is a plan document, and no type
9//! here converts into a plan value, a backend output or a judgment (spec
10//! 019, 3.1).
11//!
12//! Every caller-controlled byte (question, labels, levels, context) is
13//! untrusted data. Nothing here interprets it; a type only bounds it.
14//!
15//! The request and the capability are checked against their structural
16//! bounds when parsed ([`DynamicQuestion::check_bounds`],
17//! [`DynamicCapability::check_bounds`]); the bounded scan runs first, so an
18//! oversized document is refused before typed deserialization allocates
19//! anything. Cross-field rules (which labels an operation takes, which
20//! output kinds it allows, the abstention posture) are the core's request
21//! validation.
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::fmt;
25
26use serde::{Deserialize, Serialize};
27
28use crate::bounded::{ParseError, ParseLimits, parse_bounded};
29use crate::canonical::{CanonicalError, canonical_bytes, tagged_digest};
30use crate::decimal::Decimal;
31use crate::definition::ProvenanceClass;
32use crate::descriptor::InputExcess;
33use crate::execution::FailureClass;
34use crate::ids::{ArtifactId, DynamicCapabilityId, DynamicRequestId, TemplateId};
35use crate::judgment::Derivation;
36use crate::limits::{DESCRIPTOR_V1, DYNAMIC_QUESTION_V1, RECORD_V1};
37use crate::remote::{CancelSupport, Disclosure, ServedIdentity, ServedTerms};
38use crate::run::{AttemptCost, AttemptEnd, Cancellation, CostModel, CostSummary, RemoteState};
39use crate::schema;
40use crate::scope::Scope;
41use crate::time::Timestamp;
42use crate::{Document, DocumentError, Identified};
43
44const KIB: usize = 1024;
45
46/// The most bytes of the question (3.2).
47pub const MAX_QUESTION_BYTES: usize = 4 * KIB;
48/// The most bytes of one label or level (3.2).
49pub const MAX_LABEL_BYTES: usize = 128;
50/// The fewest labels or levels of `classify` and `rubric` (3.2).
51pub const MIN_LABELS: usize = 2;
52/// The most labels of `classify` (3.2).
53pub const MAX_CLASSIFY_LABELS: usize = 32;
54/// The most levels of `rubric` (3.2).
55pub const MAX_RUBRIC_LEVELS: usize = 16;
56/// The fixed ordered labels of `proposition` (3.2).
57pub const PROPOSITION_LABELS: [&str; 2] = ["false", "true"];
58/// The most context fields (3.2).
59pub const MAX_CONTEXT_FIELDS: usize = 64;
60/// The most context value bytes, summed over every field (3.2).
61pub const MAX_CONTEXT_VALUE_BYTES: usize = 64 * KIB;
62/// The most canonical bytes of a whole request (3.2).
63pub const MAX_CANONICAL_BYTES: usize = 128 * KIB;
64/// The most bytes of a name: a context field, a task family name or
65/// version, a template name or version, a preprocessing policy, a privacy
66/// option, a backend id.
67pub const MAX_NAME_BYTES: usize = 128;
68/// The most bytes of bounded metadata: the request id, a context field's
69/// source and source revision.
70pub const MAX_META_BYTES: usize = 256;
71/// The most bytes of free text Rustev writes into an outcome (a detail or a
72/// response excerpt), cut at a character boundary.
73pub const MAX_DETAIL_BYTES: usize = 256;
74/// The most task families, privacy options or operations a capability
75/// lists.
76pub const MAX_CAPABILITY_ENTRIES: usize = 64;
77
78/// The three dynamic operations (3.2). Their names are R-05's; `rank`,
79/// `generate` and `extract` are not dynamic operations in version 1.
80#[derive(
81    Debug,
82    Clone,
83    Copy,
84    PartialEq,
85    Eq,
86    PartialOrd,
87    Ord,
88    Hash,
89    Serialize,
90    Deserialize,
91    schemars::JsonSchema,
92)]
93#[serde(rename_all = "snake_case")]
94pub enum DynamicOperation {
95    Proposition,
96    Classify,
97    Rubric,
98}
99
100impl DynamicOperation {
101    pub fn name(self) -> &'static str {
102        match self {
103            DynamicOperation::Proposition => "proposition",
104            DynamicOperation::Classify => "classify",
105            DynamicOperation::Rubric => "rubric",
106        }
107    }
108}
109
110/// The output kind a request requires (3.2): `label` for any operation,
111/// `distribution` for `proposition` and `classify`, `ordinal_distribution`
112/// for `rubric`. A distribution is never a calibrated probability, and an
113/// ordinal distribution is never an interval score (3.6).
114#[derive(
115    Debug,
116    Clone,
117    Copy,
118    PartialEq,
119    Eq,
120    PartialOrd,
121    Ord,
122    Hash,
123    Serialize,
124    Deserialize,
125    schemars::JsonSchema,
126)]
127#[serde(rename_all = "snake_case")]
128pub enum DynamicOutputKind {
129    Label,
130    Distribution,
131    OrdinalDistribution,
132}
133
134impl DynamicOutputKind {
135    pub fn name(self) -> &'static str {
136        match self {
137            DynamicOutputKind::Label => "label",
138            DynamicOutputKind::Distribution => "distribution",
139            DynamicOutputKind::OrdinalDistribution => "ordinal_distribution",
140        }
141    }
142}
143
144/// A task family and its version, selected from a host allowlist (3.2).
145#[derive(
146    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, schemars::JsonSchema,
147)]
148#[serde(deny_unknown_fields)]
149pub struct TaskFamily {
150    pub name: String,
151    pub version: String,
152}
153
154/// A scalar or text context value in canonical form (3.2). Decimals are
155/// strings, so a request holds no binary floating-point number.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
157#[serde(rename_all = "snake_case", deny_unknown_fields)]
158pub enum ContextValue {
159    Bool(bool),
160    Integer(i64),
161    Decimal(Decimal),
162    Text(String),
163    Timestamp(Timestamp),
164}
165
166impl ContextValue {
167    /// The bytes this value counts against [`MAX_CONTEXT_VALUE_BYTES`]: a
168    /// text's UTF-8 bytes, otherwise the canonical bytes of the scalar.
169    pub fn value_bytes(&self) -> usize {
170        match self {
171            ContextValue::Bool(b) => {
172                if *b {
173                    4
174                } else {
175                    5
176                }
177            }
178            ContextValue::Integer(i) => i.to_string().len(),
179            ContextValue::Decimal(d) => d.to_canonical_string().len(),
180            ContextValue::Text(t) => t.len(),
181            ContextValue::Timestamp(t) => t.as_ms().to_string().len(),
182        }
183    }
184}
185
186/// One named context field (3.2). `provenance` and `source` state who
187/// asserted the value; they do not make it true or trusted (3.3).
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
189#[serde(deny_unknown_fields)]
190pub struct ContextField {
191    pub name: String,
192    pub value: ContextValue,
193    pub provenance: ProvenanceClass,
194    /// Who asserted it; recorded, never trusted as authority.
195    pub source: String,
196    pub derivation: Derivation,
197    /// The revision of the source the value was read at.
198    pub source_revision: String,
199}
200
201/// How a request's result may be accepted (3.5). Thresholds exist only when
202/// the required result is a distribution; label-only acceptance exists only
203/// when the request permits it explicitly. Neither is a calibration claim.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
205#[serde(rename_all = "snake_case", deny_unknown_fields)]
206pub enum AbstentionPolicy {
207    /// Accept a distribution only if its top mass is at least
208    /// `min_top_mass` and the top mass exceeds the second by at least
209    /// `min_margin`. Both are in `[0, 1]`.
210    Thresholds {
211        min_top_mass: Decimal,
212        min_margin: Decimal,
213    },
214    /// The request explicitly permits a label with no mass.
215    LabelOnly,
216}
217
218/// A dynamic question, `rustev.dynamic-question/1` (3.2).
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
220#[serde(deny_unknown_fields)]
221pub struct DynamicQuestion {
222    pub schema: String,
223    /// Unique per request, 1 to 256 bytes. Not part of the canonical
224    /// request identity.
225    pub request_id: String,
226    /// The opaque authorized scope: an isolation key, never a grant.
227    pub scope: Scope,
228    pub operation: DynamicOperation,
229    pub task_family: TaskFamily,
230    /// One UTF-8 question, 1 to 4096 bytes.
231    pub question: String,
232    /// The ordered labels (`proposition`, `classify`) or levels, low to
233    /// high (`rubric`). Unique by byte-exact comparison.
234    pub labels: Vec<String>,
235    pub context: Vec<ContextField>,
236    pub output: DynamicOutputKind,
237    /// The end-to-end deadline, from the moment the runtime starts the
238    /// request; positive.
239    pub deadline_ms: u64,
240    /// The hard cost cap of the one attempt; positive.
241    pub max_cost_units: u64,
242    /// The artifact the backend must declare.
243    pub artifact: ArtifactId,
244    /// The privacy options requested; each must be declared by the
245    /// capability. A request is evidence of what was asked, never that the
246    /// options were honored.
247    pub privacy: Vec<String>,
248    pub abstention: AbstentionPolicy,
249}
250
251impl Document for DynamicQuestion {
252    const SCHEMA: &'static str = schema::DYNAMIC_QUESTION;
253    const LIMITS: ParseLimits = DYNAMIC_QUESTION_V1;
254    fn schema(&self) -> &str {
255        &self.schema
256    }
257
258    /// Scan, deserialize, check the schema string and the structural bounds
259    /// of 3.2.
260    fn parse(bytes: &[u8]) -> Result<Self, DocumentError> {
261        parse_checked(bytes, DynamicQuestion::check_bounds)
262    }
263}
264
265/// Why a document is outside its structural bounds. The message names the
266/// member and the bound; it never echoes caller bytes beyond 64 characters.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct BoundsError(pub String);
269
270impl fmt::Display for BoundsError {
271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272        f.write_str(&self.0)
273    }
274}
275
276impl std::error::Error for BoundsError {}
277
278fn bounds<T>(msg: impl Into<String>) -> Result<T, BoundsError> {
279    Err(BoundsError(msg.into()))
280}
281
282fn short(s: &str) -> String {
283    s.chars().take(64).collect()
284}
285
286fn sized(what: &str, s: &str, min: usize, max: usize) -> Result<(), BoundsError> {
287    if s.len() < min || s.len() > max {
288        return bounds(format!(
289            "{what} is {} bytes; {min} to {max} are allowed",
290            s.len()
291        ));
292    }
293    Ok(())
294}
295
296fn unique<'a>(what: &str, items: impl Iterator<Item = &'a str>) -> Result<(), BoundsError> {
297    let mut seen = BTreeSet::new();
298    for s in items {
299        if !seen.insert(s) {
300            return bounds(format!("duplicate {what} {:?}", short(s)));
301        }
302    }
303    Ok(())
304}
305
306fn parse_checked<T: Document>(
307    bytes: &[u8],
308    check: fn(&T) -> Result<(), BoundsError>,
309) -> Result<T, DocumentError> {
310    let doc: T = parse_bounded(bytes, &T::LIMITS).map_err(DocumentError::Parse)?;
311    if doc.schema() != T::SCHEMA {
312        return Err(DocumentError::Schema {
313            expected: T::SCHEMA,
314            found: doc.schema().chars().take(80).collect(),
315        });
316    }
317    check(&doc).map_err(|e| DocumentError::Parse(ParseError::Schema { message: e.0 }))?;
318    Ok(doc)
319}
320
321impl DynamicQuestion {
322    /// The structural bounds of 3.2: sizes, counts, byte-exact uniqueness
323    /// of labels and context names, the context value budget and the
324    /// canonical size. Rustev performs no linguistic normalization: two
325    /// labels are duplicates only when their bytes are equal.
326    pub fn check_bounds(&self) -> Result<(), BoundsError> {
327        sized("request_id", &self.request_id, 1, MAX_META_BYTES)?;
328        sized(
329            "task_family.name",
330            &self.task_family.name,
331            1,
332            MAX_NAME_BYTES,
333        )?;
334        sized(
335            "task_family.version",
336            &self.task_family.version,
337            1,
338            MAX_NAME_BYTES,
339        )?;
340        sized("question", &self.question, 1, MAX_QUESTION_BYTES)?;
341        let max_labels = MAX_CLASSIFY_LABELS.max(MAX_RUBRIC_LEVELS);
342        if self.labels.len() < MIN_LABELS || self.labels.len() > max_labels {
343            return bounds(format!(
344                "{} labels; {MIN_LABELS} to {max_labels} are allowed",
345                self.labels.len()
346            ));
347        }
348        for l in &self.labels {
349            sized("a label", l, 1, MAX_LABEL_BYTES)?;
350        }
351        unique("label", self.labels.iter().map(String::as_str))?;
352        if self.context.len() > MAX_CONTEXT_FIELDS {
353            return bounds(format!(
354                "{} context fields; at most {MAX_CONTEXT_FIELDS} are allowed",
355                self.context.len()
356            ));
357        }
358        let mut value_bytes = 0usize;
359        for c in &self.context {
360            sized("a context field name", &c.name, 1, MAX_NAME_BYTES)?;
361            sized("a context source", &c.source, 1, MAX_META_BYTES)?;
362            sized(
363                "a context source revision",
364                &c.source_revision,
365                1,
366                MAX_META_BYTES,
367            )?;
368            value_bytes = value_bytes.saturating_add(c.value.value_bytes());
369        }
370        unique(
371            "context field",
372            self.context.iter().map(|c| c.name.as_str()),
373        )?;
374        if value_bytes > MAX_CONTEXT_VALUE_BYTES {
375            return bounds(format!(
376                "context values are {value_bytes} bytes; at most {MAX_CONTEXT_VALUE_BYTES} are allowed"
377            ));
378        }
379        if self.privacy.len() > MAX_CAPABILITY_ENTRIES {
380            return bounds("too many privacy options");
381        }
382        for p in &self.privacy {
383            sized("a privacy option", p, 1, MAX_NAME_BYTES)?;
384        }
385        unique("privacy option", self.privacy.iter().map(String::as_str))?;
386        let canonical = self
387            .canonical()
388            .map_err(|e| BoundsError(format!("no canonical form: {e}")))?;
389        if canonical.len() > MAX_CANONICAL_BYTES {
390            return bounds(format!(
391                "the request is {} canonical bytes; at most {MAX_CANONICAL_BYTES} are allowed",
392                canonical.len()
393            ));
394        }
395        Ok(())
396    }
397
398    /// The context value bytes, summed over every field.
399    pub fn context_value_bytes(&self) -> usize {
400        self.context
401            .iter()
402            .map(|c| c.value.value_bytes())
403            .fold(0, usize::saturating_add)
404    }
405}
406
407/// A structured template, identified by its producer (3.3, 3.4): the
408/// versioned mapping from request fields to the provider's request, with
409/// fixed control instructions no caller value can enter.
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
411#[serde(deny_unknown_fields)]
412pub struct TemplateRef {
413    pub name: String,
414    pub version: String,
415    pub id: TemplateId,
416}
417
418/// What a backend declares for one dynamic operation.
419#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
420#[serde(deny_unknown_fields)]
421pub struct DynamicOperationSupport {
422    pub operation: DynamicOperation,
423    pub outputs: Vec<DynamicOutputKind>,
424    /// The most labels or levels one request may carry.
425    pub max_labels: u64,
426}
427
428/// Whether output-affecting state exists that the request does not carry
429/// (3.4). Declared `present` makes the backend ineligible for
430/// qualification.
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
432#[serde(rename_all = "snake_case")]
433pub enum HiddenState {
434    None,
435    Present,
436}
437
438/// A backend's opt-in dynamic capability, `rustev.dynamic-capability/1`
439/// (3.4), distinct from its registered-plan descriptor: `classify` there
440/// never implies dynamic classification here.
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
442#[serde(deny_unknown_fields)]
443pub struct DynamicCapability {
444    pub schema: String,
445    pub backend_id: String,
446    pub artifact: ArtifactId,
447    pub operations: Vec<DynamicOperationSupport>,
448    pub max_question_bytes: u64,
449    pub max_context_bytes: u64,
450    pub max_context_fields: u64,
451    /// The task families this backend answers, by name and version.
452    pub task_families: Vec<TaskFamily>,
453    pub template: TemplateRef,
454    /// The named, versioned preprocessing policy.
455    pub preprocessing: String,
456    /// The declared truncation policy. The runtime never relies on it: a
457    /// request must fit every declared limit without truncation (3.4).
458    pub truncation: InputExcess,
459    pub served: ServedTerms,
460    /// Privacy options the backend accepts, by name.
461    pub privacy: Vec<String>,
462    pub cancellation: CancelSupport,
463    /// The cost-bound mode; only `bounded` can be admitted (3.5).
464    pub cost: CostModel,
465    pub hidden_state: HiddenState,
466}
467
468impl Document for DynamicCapability {
469    const SCHEMA: &'static str = schema::DYNAMIC_CAPABILITY;
470    const LIMITS: ParseLimits = DESCRIPTOR_V1;
471    fn schema(&self) -> &str {
472        &self.schema
473    }
474
475    fn parse(bytes: &[u8]) -> Result<Self, DocumentError> {
476        parse_checked(bytes, DynamicCapability::check_bounds)
477    }
478}
479
480impl Identified for DynamicCapability {
481    type Id = DynamicCapabilityId;
482    fn wrap(digest: String) -> DynamicCapabilityId {
483        DynamicCapabilityId::from_digest(digest)
484    }
485}
486
487impl DynamicCapability {
488    /// Structural bounds: names, unique entries, and `pinned` exactly when
489    /// a pinned identity is given.
490    pub fn check_bounds(&self) -> Result<(), BoundsError> {
491        sized("backend_id", &self.backend_id, 1, MAX_NAME_BYTES)?;
492        sized("template.name", &self.template.name, 1, MAX_NAME_BYTES)?;
493        sized(
494            "template.version",
495            &self.template.version,
496            1,
497            MAX_NAME_BYTES,
498        )?;
499        sized("preprocessing", &self.preprocessing, 1, MAX_NAME_BYTES)?;
500        if self.operations.is_empty() || self.operations.len() > 3 {
501            return bounds("one to three operations are declared");
502        }
503        let mut ops = BTreeSet::new();
504        for o in &self.operations {
505            if !ops.insert(o.operation) {
506                return bounds(format!("duplicate operation {}", o.operation.name()));
507            }
508            if o.outputs.is_empty() || o.outputs.len() > 3 {
509                return bounds("one to three output kinds per operation");
510            }
511            let set: BTreeSet<_> = o.outputs.iter().collect();
512            if set.len() != o.outputs.len() {
513                return bounds("duplicate output kind");
514            }
515        }
516        if self.task_families.is_empty() || self.task_families.len() > MAX_CAPABILITY_ENTRIES {
517            return bounds("one to 64 task families are declared");
518        }
519        for t in &self.task_families {
520            sized("a task family name", &t.name, 1, MAX_NAME_BYTES)?;
521            sized("a task family version", &t.version, 1, MAX_NAME_BYTES)?;
522        }
523        let set: BTreeSet<_> = self.task_families.iter().collect();
524        if set.len() != self.task_families.len() {
525            return bounds("duplicate task family");
526        }
527        if self.privacy.len() > MAX_CAPABILITY_ENTRIES {
528            return bounds("too many privacy options");
529        }
530        for p in &self.privacy {
531            sized("a privacy option", p, 1, MAX_NAME_BYTES)?;
532        }
533        unique("privacy option", self.privacy.iter().map(String::as_str))?;
534        let pinned = self.served.disclosure == Disclosure::Pinned;
535        match &self.served.identity {
536            Some(i) if pinned => sized("served.identity", i, 1, MAX_META_BYTES)?,
537            None if !pinned => {}
538            _ => return bounds("served.identity is present exactly when disclosure is pinned"),
539        }
540        Ok(())
541    }
542
543    /// The support declared for `operation`, if any.
544    pub fn support(&self, operation: DynamicOperation) -> Option<&DynamicOperationSupport> {
545        self.operations.iter().find(|o| o.operation == operation)
546    }
547}
548
549/// The canonical request identity's preimage (3.4): everything that can
550/// change what a backend computes for a question. The request id, deadline,
551/// cost cap and abstention policy are deliberately absent; they bound or
552/// judge the computation, they do not define it. Its digest, tagged with
553/// [`schema::DYNAMIC_QUESTION`], is the [`DynamicRequestId`].
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
555#[serde(deny_unknown_fields)]
556pub struct DynamicIdentity {
557    pub scope: Scope,
558    pub operation: DynamicOperation,
559    pub task_family: TaskFamily,
560    pub question: String,
561    pub labels: Vec<String>,
562    pub context: Vec<ContextField>,
563    pub output: DynamicOutputKind,
564    pub artifact: ArtifactId,
565    pub privacy: Vec<String>,
566    pub template: TemplateRef,
567    pub preprocessing: String,
568}
569
570impl DynamicIdentity {
571    /// The identity of a request served under `capability`'s template and
572    /// preprocessing.
573    pub fn of(q: &DynamicQuestion, capability: &DynamicCapability) -> DynamicIdentity {
574        DynamicIdentity {
575            scope: q.scope.clone(),
576            operation: q.operation,
577            task_family: q.task_family.clone(),
578            question: q.question.clone(),
579            labels: q.labels.clone(),
580            context: q.context.clone(),
581            output: q.output,
582            artifact: q.artifact.clone(),
583            privacy: q.privacy.clone(),
584            template: capability.template.clone(),
585            preprocessing: capability.preprocessing.clone(),
586        }
587    }
588
589    /// `sha256(rustev.dynamic-question/1 || 0x00 || canonical bytes)`.
590    pub fn id(&self) -> Result<DynamicRequestId, CanonicalError> {
591        Ok(DynamicRequestId::from_digest(tagged_digest(
592            schema::DYNAMIC_QUESTION,
593            &canonical_bytes(self)?,
594        )))
595    }
596}
597
598/// The bytes a dynamic backend receives (3.3): the request's data fields as
599/// canonical JSON, every value a length-delimited, escaped JSON string or
600/// scalar under a fixed member name. An adapter maps these members through
601/// its versioned template; no member names a system, developer, tool,
602/// function, role, header, endpoint, model or artifact field, so no caller
603/// value can enter one. Context provenance, the scope and the bounds stay
604/// with Rustev.
605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
606#[serde(deny_unknown_fields)]
607pub struct DynamicProjection {
608    pub operation: DynamicOperation,
609    pub task_family: TaskFamily,
610    pub question: String,
611    pub labels: Vec<String>,
612    pub context: Vec<ProjectedField>,
613    pub output: DynamicOutputKind,
614}
615
616/// One context field as projected: its name and value only.
617#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
618#[serde(deny_unknown_fields)]
619pub struct ProjectedField {
620    pub name: String,
621    pub value: ContextValue,
622}
623
624impl DynamicProjection {
625    pub fn of(q: &DynamicQuestion) -> DynamicProjection {
626        DynamicProjection {
627            operation: q.operation,
628            task_family: q.task_family.clone(),
629            question: q.question.clone(),
630            labels: q.labels.clone(),
631            context: q
632                .context
633                .iter()
634                .map(|c| ProjectedField {
635                    name: c.name.clone(),
636                    value: c.value.clone(),
637                })
638                .collect(),
639            output: q.output,
640        }
641    }
642}
643
644/// What a dynamic backend returned, before validation (3.3). Material that
645/// is not a label or a distribution (a tool call, instructions, a schema, a
646/// URL to fetch, an action) is carried as `unrecognized`, recorded as
647/// invalid output and never executed.
648#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
649#[serde(rename_all = "snake_case", deny_unknown_fields)]
650pub enum DynamicRawOutput {
651    Label(String),
652    /// Masses keyed by label or level.
653    Distribution(BTreeMap<String, f64>),
654    Unrecognized {
655        shape: ResponseShape,
656        /// At most 256 bytes of the material, as data.
657        excerpt: String,
658    },
659}
660
661/// The shape of response material that is not an answer (3.3).
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
663#[serde(rename_all = "snake_case")]
664pub enum ResponseShape {
665    ToolCall,
666    Instruction,
667    Schema,
668    UrlFetch,
669    Action,
670    FreeText,
671    /// A distribution with a NaN or infinite mass: JSON cannot carry the
672    /// masses, so the outcome records them as an excerpt (3.3, 3.6).
673    NonFiniteMass,
674    Other,
675}
676
677impl ResponseShape {
678    pub fn name(self) -> &'static str {
679        match self {
680            ResponseShape::ToolCall => "tool_call",
681            ResponseShape::Instruction => "instruction",
682            ResponseShape::Schema => "schema",
683            ResponseShape::UrlFetch => "url_fetch",
684            ResponseShape::Action => "action",
685            ResponseShape::FreeText => "free_text",
686            ResponseShape::NonFiniteMass => "non_finite_mass",
687            ResponseShape::Other => "other",
688        }
689    }
690}
691
692/// A dynamic outcome, `rustev.dynamic-outcome/1` (3.6): evidence of what
693/// Rustev observed for one request, bounded by `RECORD_V1`. It is not proof
694/// that the context was true, the backend was correct, privacy options were
695/// honored, or an action was permitted.
696#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
697#[serde(deny_unknown_fields)]
698pub struct DynamicOutcome {
699    pub schema: String,
700    pub request_id: String,
701    /// The canonical request identity; absent only when no capability was
702    /// declared to bind its template and preprocessing.
703    pub request: Option<DynamicRequestId>,
704    pub scope: Scope,
705    pub task_family: TaskFamily,
706    pub operation: DynamicOperation,
707    pub output: DynamicOutputKind,
708    pub backend_id: String,
709    /// The artifact the request required; never replaced by a served one.
710    pub requested_artifact: ArtifactId,
711    /// The served identity as the backend reported it, or `unknown`.
712    pub served: ServedIdentity,
713    /// Absent when the backend declares no dynamic capability.
714    pub capability: Option<DynamicCapabilityId>,
715    pub template: Option<TemplateRef>,
716    /// The privacy options requested; recorded, never proof of handling.
717    pub privacy: Vec<String>,
718    /// The one attempt; absent when nothing was dispatched.
719    pub attempt: Option<DynamicAttempt>,
720    pub timing: DynamicTiming,
721    /// The request's own ledger: mode `hard`, limit the cap.
722    pub cost: CostSummary,
723    pub raw: DynamicRaw,
724    pub validation: DynamicValidation,
725    pub abstention: AbstentionDecision,
726    pub result: DynamicResult,
727}
728
729impl Document for DynamicOutcome {
730    const SCHEMA: &'static str = schema::DYNAMIC_OUTCOME;
731    const LIMITS: ParseLimits = RECORD_V1;
732    fn schema(&self) -> &str {
733        &self.schema
734    }
735}
736
737/// The one dispatched attempt (3.5).
738#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
739#[serde(deny_unknown_fields)]
740pub struct DynamicAttempt {
741    pub attempt_id: String,
742    pub dispatched_ms: u64,
743    pub ended_ms: u64,
744    pub end: AttemptEnd,
745    pub cancellation: Cancellation,
746    pub remote: RemoteState,
747    pub cost: AttemptCost,
748}
749
750/// Monotonic milliseconds from the start of the request.
751#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
752#[serde(deny_unknown_fields)]
753pub struct DynamicTiming {
754    pub elapsed_ms: u64,
755    pub deadline_ms: u64,
756    pub deadline_expired: bool,
757}
758
759/// The raw output or failure (3.6).
760#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
761#[serde(rename_all = "snake_case", deny_unknown_fields)]
762pub enum DynamicRaw {
763    /// Nothing was dispatched.
764    NotDispatched,
765    /// The backend's answer, exactly as returned.
766    Output(DynamicRawOutput),
767    /// The adapter reported a failure.
768    Failed { class: FailureClass, detail: String },
769    /// No answer was received: deadline or cancellation.
770    NoAnswer,
771}
772
773/// Exact validation of the raw output (3.3, 3.6). Exact processing: it
774/// never relabels the proposal exact-derived.
775#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
776#[serde(rename_all = "snake_case", deny_unknown_fields)]
777pub enum DynamicValidation {
778    NotRun,
779    Valid,
780    Invalid { detail: String },
781}
782
783/// The abstention rule that refused a distribution.
784#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
785#[serde(rename_all = "snake_case")]
786pub enum AbstentionRule {
787    MinTopMass,
788    MinMargin,
789}
790
791impl AbstentionRule {
792    pub fn name(self) -> &'static str {
793        match self {
794            AbstentionRule::MinTopMass => "min_top_mass",
795            AbstentionRule::MinMargin => "min_margin",
796        }
797    }
798}
799
800/// How the abstention policy was applied (3.5). Exact processing over
801/// model-derived masses; the thresholds are not calibration claims.
802#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
803#[serde(rename_all = "snake_case", deny_unknown_fields)]
804pub enum AbstentionDecision {
805    NotApplied,
806    /// A label accepted under the request's explicit label-only posture.
807    LabelOnly,
808    Passed {
809        top_mass: f64,
810        margin: f64,
811    },
812    Abstained {
813        rule: AbstentionRule,
814        top_mass: f64,
815        margin: f64,
816    },
817}
818
819/// A proposal or a named unresolved outcome (3.5, 3.6).
820#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
821#[serde(rename_all = "snake_case", deny_unknown_fields)]
822pub enum DynamicResult {
823    Proposal(DynamicProposal),
824    Unresolved(DynamicUnresolved),
825}
826
827/// A dynamic proposal (3.6): always `model-derived`, with the request and
828/// backend output in its lineage. Never a grant, never a plan value.
829#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
830#[serde(deny_unknown_fields)]
831pub struct DynamicProposal {
832    pub answer: DynamicAnswer,
833    pub derivation: Derivation,
834    pub lineage: DynamicLineage,
835}
836
837/// One label's or level's mass, in declared order.
838#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
839#[serde(deny_unknown_fields)]
840pub struct LabelMass {
841    pub label: String,
842    pub mass: f64,
843}
844
845/// What was proposed (3.6). A label is only a label; a distribution is
846/// uncalibrated mass, not a calibrated probability; an ordinal distribution
847/// is mass over ordered levels, not an interval score.
848#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
849#[serde(rename_all = "snake_case", deny_unknown_fields)]
850pub enum DynamicAnswer {
851    Label {
852        label: String,
853    },
854    /// `label` is the top label; ties go to the earlier declared label.
855    UncalibratedDistribution {
856        label: String,
857        masses: Vec<LabelMass>,
858    },
859    /// `level` is the top level; ties go to the lower level.
860    OrdinalDistribution {
861        level: String,
862        masses: Vec<LabelMass>,
863    },
864}
865
866/// What a proposal was computed from (3.6).
867#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
868#[serde(deny_unknown_fields)]
869pub struct DynamicLineage {
870    pub request: DynamicRequestId,
871    /// The context field names, in request order.
872    pub context: Vec<String>,
873    pub backend_id: String,
874    pub artifact: ArtifactId,
875    pub attempt_id: String,
876    /// Exact processing applied after the backend, in order (`validate`,
877    /// then `abstention`); visible, never a relabeling.
878    pub exact_processing: Vec<String>,
879}
880
881/// A named unresolved outcome (3.5). No neutral score, first label,
882/// majority label, empty distribution or provider confidence replaces it.
883#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
884#[serde(rename_all = "snake_case", deny_unknown_fields)]
885pub enum DynamicUnresolved {
886    /// Refused before dispatch.
887    Refused {
888        refusal: DynamicRefusal,
889    },
890    /// The adapter reported a failure; no second request follows.
891    BackendFailed {
892        class: FailureClass,
893        detail: String,
894    },
895    /// The output is outside the declared labels, levels, kind or support,
896    /// or not an answer at all.
897    InvalidOutput {
898        detail: String,
899    },
900    /// The pinned served identity differs from the one served.
901    ServedIdentityMismatch {
902        pinned: String,
903        served: Option<String>,
904    },
905    Abstained {
906        rule: AbstentionRule,
907    },
908    DeadlineExceeded,
909    Cancelled,
910}
911
912impl DynamicUnresolved {
913    /// The reason's name, as reports count it.
914    pub fn name(&self) -> &'static str {
915        match self {
916            DynamicUnresolved::Refused { .. } => "refused",
917            DynamicUnresolved::BackendFailed { .. } => "backend_failed",
918            DynamicUnresolved::InvalidOutput { .. } => "invalid_output",
919            DynamicUnresolved::ServedIdentityMismatch { .. } => "served_identity_mismatch",
920            DynamicUnresolved::Abstained { .. } => "abstained",
921            DynamicUnresolved::DeadlineExceeded => "deadline_exceeded",
922            DynamicUnresolved::Cancelled => "cancelled",
923        }
924    }
925}
926
927/// Why a request was refused before dispatch (3.4, 3.5).
928#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
929#[serde(rename_all = "snake_case", deny_unknown_fields)]
930pub enum DynamicRefusal {
931    /// The backend declares no dynamic capability; a registered-plan
932    /// descriptor never suffices.
933    NoDynamicCapability,
934    /// The declared capability fails its own structural bounds.
935    CapabilityInvalid {
936        detail: String,
937    },
938    /// The declaration is for another backend or artifact than the request
939    /// requires.
940    ArtifactMismatch {
941        declared: ArtifactId,
942    },
943    OperationUnsupported {
944        operation: DynamicOperation,
945        output: DynamicOutputKind,
946    },
947    TaskFamilyUndeclared,
948    /// The request does not fit a declared limit without truncation.
949    ExceedsDeclaredLimit {
950        limit: String,
951        declared: u64,
952        requested: u64,
953    },
954    PrivacyUnsupported {
955        option: String,
956    },
957    /// The cost model is not `bounded`, so no enforceable bound exists.
958    CostUnenforceable {
959        model: CostModel,
960    },
961    /// The per-call disclosure is not an enforceable bound.
962    CostUnknown,
963    /// The enforceable bound is above the cap.
964    CostAboveCap {
965        bound: u64,
966        cap: u64,
967    },
968    /// A host ledger cannot hold the reservation.
969    BudgetExhausted,
970}