Skip to main content

rustev_core/
dynamic.rs

1//! Dynamic questions (spec 019): pure request validation, canonical
2//! identity, capability fit, cost admission and response judgment.
3//!
4//! Nothing here reads a clock, performs I/O or dispatches: the runtime's
5//! `ask_dynamic` is the only execution entry point (3.1). No type here
6//! converts into a plan value, a [`crate::evaluate::Supplied`] output or a
7//! judgment, so a dynamic result cannot enter staged evaluation:
8//!
9//! ```compile_fail
10//! # use rustev_core::evaluate::Supplied;
11//! # use rustev_contract::dynamic::DynamicOutcome;
12//! fn launder(o: DynamicOutcome) -> Supplied { o.into() }
13//! ```
14//!
15//! ```compile_fail
16//! # use rustev_core::evaluate::Supplied;
17//! # use rustev_contract::dynamic::DynamicRawOutput;
18//! fn launder(raw: DynamicRawOutput) -> Supplied { Supplied::Output(raw) }
19//! ```
20//!
21//! ```compile_fail
22//! # use rustev_core::evaluate::{Evaluation, Supplied};
23//! # use rustev_contract::dynamic::DynamicProposal;
24//! fn launder(ev: &mut Evaluation<'_>, p: DynamicProposal) {
25//!     let _ = ev.supply("step", &[], p);
26//! }
27//! ```
28//!
29//! ```compile_fail
30//! # use rustev_contract::judgment::Judgment;
31//! # use rustev_contract::dynamic::DynamicOutcome;
32//! fn launder(o: DynamicOutcome) -> Judgment { Judgment::from(o) }
33//! ```
34//!
35//! ```compile_fail
36//! # use rustev_core::dynamic::ValidatedQuestion;
37//! # use rustev_contract::dynamic::DynamicQuestion;
38//! // A validated question is built only by `validate`.
39//! fn forge(q: DynamicQuestion) -> ValidatedQuestion { ValidatedQuestion { q } }
40//! ```
41//!
42//! Negative control: the same shapes compile with a plan's own types, so
43//! each refusal above is the type boundary, not a typo.
44//!
45//! ```
46//! # use rustev_core::evaluate::{Evaluation, Supplied};
47//! # use rustev_contract::dynamic::{DynamicOutcome, DynamicProposal, DynamicRawOutput};
48//! # use rustev_contract::output::RawOutput;
49//! fn plan_output(raw: RawOutput) -> Supplied { Supplied::Output(raw) }
50//! fn supply(ev: &mut Evaluation<'_>, s: Supplied) {
51//!     let _ = ev.supply("step", &[], s);
52//! }
53//! fn dynamic_types_exist(_: DynamicOutcome, _: DynamicRawOutput, _: DynamicProposal) {}
54//! ```
55
56use std::collections::BTreeMap;
57use std::fmt;
58
59use rustev_contract::canonical::{CanonicalError, canonical_bytes};
60use rustev_contract::decimal::Decimal;
61use rustev_contract::dynamic::{
62    AbstentionDecision, AbstentionPolicy, AbstentionRule, DynamicAnswer, DynamicCapability,
63    DynamicIdentity, DynamicLineage, DynamicOperation, DynamicOutputKind, DynamicProjection,
64    DynamicProposal, DynamicQuestion, DynamicRawOutput, DynamicRefusal, DynamicUnresolved,
65    DynamicValidation, HiddenState, LabelMass, MAX_CLASSIFY_LABELS, MAX_RUBRIC_LEVELS, MIN_LABELS,
66    PROPOSITION_LABELS, TaskFamily,
67};
68use rustev_contract::ids::DynamicRequestId;
69use rustev_contract::judgment::Derivation;
70use rustev_contract::remote::{Disclosure, ServedIdentity};
71use rustev_contract::run::{CostBound, CostModel};
72use rustev_contract::{Document, DocumentError};
73
74use crate::kinds::Distribution;
75
76/// The tolerance a returned distribution's masses must sum to 1 within.
77/// Fixed by this implementation (spec 019 declares no per-request
78/// tolerance); it equals the reference plans' `distribution_tolerance`.
79pub const DISTRIBUTION_TOLERANCE: &str = "0.000001";
80
81/// Why a request failed validation (3.2). Nothing was dispatched.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum DynamicRequestError {
84    /// The document was refused by the bounded parser or its schema.
85    Parse(DocumentError),
86    /// A structural bound of 3.2.
87    Bounds(String),
88    /// `proposition` takes exactly the ordered labels `false`, `true`.
89    PropositionLabels,
90    LabelCount {
91        operation: DynamicOperation,
92        count: usize,
93        max: usize,
94    },
95    /// The operation does not allow the required output kind.
96    OutputKind {
97        operation: DynamicOperation,
98        output: DynamicOutputKind,
99    },
100    /// The abstention policy does not fit the output kind, or a threshold is
101    /// outside `[0, 1]`.
102    Abstention(String),
103    ZeroDeadline,
104    ZeroCostCap,
105    /// The task family is not on the host's allowlist.
106    TaskFamilyNotAllowed(TaskFamily),
107}
108
109impl fmt::Display for DynamicRequestError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            DynamicRequestError::Parse(e) => write!(f, "{e}"),
113            DynamicRequestError::Bounds(m) => f.write_str(m),
114            DynamicRequestError::PropositionLabels => {
115                f.write_str("proposition takes exactly the labels [\"false\", \"true\"]")
116            }
117            DynamicRequestError::LabelCount {
118                operation,
119                count,
120                max,
121            } => write!(
122                f,
123                "{} takes {MIN_LABELS} to {max} labels, not {count}",
124                operation.name()
125            ),
126            DynamicRequestError::OutputKind { operation, output } => {
127                write!(f, "{} does not produce {}", operation.name(), output.name())
128            }
129            DynamicRequestError::Abstention(m) => write!(f, "abstention: {m}"),
130            DynamicRequestError::ZeroDeadline => f.write_str("the deadline must be positive"),
131            DynamicRequestError::ZeroCostCap => f.write_str("the hard cost cap must be positive"),
132            DynamicRequestError::TaskFamilyNotAllowed(t) => write!(
133                f,
134                "task family {:?} version {:?} is not allowed",
135                t.name, t.version
136            ),
137        }
138    }
139}
140
141impl std::error::Error for DynamicRequestError {}
142
143/// A request that passed validation against a host allowlist. Only
144/// [`validate`] builds one, so the runtime accepts nothing else (3.1).
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ValidatedQuestion {
147    q: DynamicQuestion,
148}
149
150impl ValidatedQuestion {
151    pub fn question(&self) -> &DynamicQuestion {
152        &self.q
153    }
154
155    pub fn into_question(self) -> DynamicQuestion {
156        self.q
157    }
158}
159
160/// Parse and validate request bytes (3.2).
161pub fn parse_question(
162    bytes: &[u8],
163    allowlist: &[TaskFamily],
164) -> Result<ValidatedQuestion, DynamicRequestError> {
165    let q = DynamicQuestion::parse(bytes).map_err(DynamicRequestError::Parse)?;
166    validate(q, allowlist)
167}
168
169/// Validate a request (3.2): its structural bounds, the labels its
170/// operation takes, the output kinds the operation allows, the abstention
171/// posture, positive bounds, and the host's task-family allowlist.
172pub fn validate(
173    q: DynamicQuestion,
174    allowlist: &[TaskFamily],
175) -> Result<ValidatedQuestion, DynamicRequestError> {
176    if q.schema != rustev_contract::schema::DYNAMIC_QUESTION {
177        return Err(DynamicRequestError::Parse(DocumentError::Schema {
178            expected: rustev_contract::schema::DYNAMIC_QUESTION,
179            found: q.schema.chars().take(80).collect(),
180        }));
181    }
182    q.check_bounds()
183        .map_err(|e| DynamicRequestError::Bounds(e.0))?;
184    match q.operation {
185        DynamicOperation::Proposition => {
186            if q.labels.len() != PROPOSITION_LABELS.len()
187                || q.labels.iter().zip(PROPOSITION_LABELS).any(|(l, p)| l != p)
188            {
189                return Err(DynamicRequestError::PropositionLabels);
190            }
191        }
192        op @ (DynamicOperation::Classify | DynamicOperation::Rubric) => {
193            let max = if op == DynamicOperation::Classify {
194                MAX_CLASSIFY_LABELS
195            } else {
196                MAX_RUBRIC_LEVELS
197            };
198            if q.labels.len() < MIN_LABELS || q.labels.len() > max {
199                return Err(DynamicRequestError::LabelCount {
200                    operation: op,
201                    count: q.labels.len(),
202                    max,
203                });
204            }
205        }
206    }
207    let allowed = match q.operation {
208        DynamicOperation::Proposition | DynamicOperation::Classify => {
209            matches!(
210                q.output,
211                DynamicOutputKind::Label | DynamicOutputKind::Distribution
212            )
213        }
214        DynamicOperation::Rubric => matches!(
215            q.output,
216            DynamicOutputKind::Label | DynamicOutputKind::OrdinalDistribution
217        ),
218    };
219    if !allowed {
220        return Err(DynamicRequestError::OutputKind {
221            operation: q.operation,
222            output: q.output,
223        });
224    }
225    match (&q.abstention, q.output) {
226        (AbstentionPolicy::LabelOnly, DynamicOutputKind::Label) => {}
227        (AbstentionPolicy::LabelOnly, _) => {
228            return Err(DynamicRequestError::Abstention(
229                "label-only posture with a distribution output".into(),
230            ));
231        }
232        (AbstentionPolicy::Thresholds { .. }, DynamicOutputKind::Label) => {
233            return Err(DynamicRequestError::Abstention(
234                "a label output is accepted only under an explicit label-only posture".into(),
235            ));
236        }
237        (
238            AbstentionPolicy::Thresholds {
239                min_top_mass,
240                min_margin,
241            },
242            _,
243        ) => {
244            let zero = Decimal::parse("0").expect("literal");
245            let one = Decimal::parse("1").expect("literal");
246            for (name, t) in [("min_top_mass", min_top_mass), ("min_margin", min_margin)] {
247                if *t < zero || *t > one {
248                    return Err(DynamicRequestError::Abstention(format!(
249                        "{name} {t} is outside [0, 1]"
250                    )));
251                }
252            }
253        }
254    }
255    if q.deadline_ms == 0 {
256        return Err(DynamicRequestError::ZeroDeadline);
257    }
258    if q.max_cost_units == 0 {
259        return Err(DynamicRequestError::ZeroCostCap);
260    }
261    if !allowlist.contains(&q.task_family) {
262        return Err(DynamicRequestError::TaskFamilyNotAllowed(
263            q.task_family.clone(),
264        ));
265    }
266    Ok(ValidatedQuestion { q })
267}
268
269/// The canonical request identity under `capability`'s template and
270/// preprocessing (3.4).
271pub fn request_identity(
272    q: &ValidatedQuestion,
273    capability: &DynamicCapability,
274) -> Result<DynamicRequestId, CanonicalError> {
275    DynamicIdentity::of(&q.q, capability).id()
276}
277
278/// The canonical projection bytes a dynamic backend receives (3.3).
279pub fn projection(q: &ValidatedQuestion) -> Vec<u8> {
280    canonical_bytes(&DynamicProjection::of(&q.q)).expect("a validated request has a canonical form")
281}
282
283fn exceeds(limit: &str, declared: u64, requested: usize) -> Result<(), DynamicRefusal> {
284    let requested = requested as u64;
285    if requested > declared {
286        return Err(DynamicRefusal::ExceedsDeclaredLimit {
287            limit: limit.into(),
288            declared,
289            requested,
290        });
291    }
292    Ok(())
293}
294
295/// Whether a declared capability can answer the request exactly as asked,
296/// without truncation (3.4). `None` is a backend with no dynamic
297/// capability: its registered-plan descriptor never suffices.
298pub fn admit<'c>(
299    q: &ValidatedQuestion,
300    capability: Option<&'c DynamicCapability>,
301) -> Result<&'c DynamicCapability, DynamicRefusal> {
302    let q = &q.q;
303    let c = capability.ok_or(DynamicRefusal::NoDynamicCapability)?;
304    if c.schema != rustev_contract::schema::DYNAMIC_CAPABILITY {
305        return Err(DynamicRefusal::CapabilityInvalid {
306            detail: "not a rustev.dynamic-capability/1 declaration".into(),
307        });
308    }
309    c.check_bounds()
310        .map_err(|e| DynamicRefusal::CapabilityInvalid { detail: e.0 })?;
311    if c.artifact != q.artifact {
312        return Err(DynamicRefusal::ArtifactMismatch {
313            declared: c.artifact.clone(),
314        });
315    }
316    let support = c
317        .support(q.operation)
318        .filter(|s| s.outputs.contains(&q.output))
319        .ok_or(DynamicRefusal::OperationUnsupported {
320            operation: q.operation,
321            output: q.output,
322        })?;
323    if !c.task_families.contains(&q.task_family) {
324        return Err(DynamicRefusal::TaskFamilyUndeclared);
325    }
326    exceeds("labels", support.max_labels, q.labels.len())?;
327    exceeds("question_bytes", c.max_question_bytes, q.question.len())?;
328    exceeds("context_fields", c.max_context_fields, q.context.len())?;
329    exceeds(
330        "context_bytes",
331        c.max_context_bytes,
332        q.context_value_bytes(),
333    )?;
334    if let Some(p) = q.privacy.iter().find(|p| !c.privacy.contains(p)) {
335        return Err(DynamicRefusal::PrivacyUnsupported { option: p.clone() });
336    }
337    if c.cost != CostModel::Bounded {
338        return Err(DynamicRefusal::CostUnenforceable { model: c.cost });
339    }
340    Ok(c)
341}
342
343/// The units to reserve for the one attempt, or why it cannot be
344/// dispatched (3.5): only an enforceable bound at or below the hard cap is
345/// admitted. Unknown exposure and estimates are refused.
346pub fn admit_cost(bound: CostBound, cap: u64) -> Result<u64, DynamicRefusal> {
347    match bound {
348        CostBound::Bounded { max_units } if max_units <= cap => Ok(max_units),
349        CostBound::Bounded { max_units } => Err(DynamicRefusal::CostAboveCap {
350            bound: max_units,
351            cap,
352        }),
353        CostBound::Estimated { .. } | CostBound::Unknown => Err(DynamicRefusal::CostUnknown),
354    }
355}
356
357/// Whether a backend is eligible for qualification under its declaration
358/// (3.4): hidden output-affecting state makes it ineligible.
359pub fn qualifiable(capability: &DynamicCapability) -> bool {
360    capability.hidden_state == HiddenState::None
361}
362
363/// The exact judgment of one raw output (3.3, 3.5): validation, abstention,
364/// and an answer or a named unresolved reason.
365#[derive(Debug, Clone, PartialEq)]
366pub struct Judged {
367    pub validation: DynamicValidation,
368    pub abstention: AbstentionDecision,
369    pub result: Result<DynamicAnswer, DynamicUnresolved>,
370}
371
372fn invalid(detail: String) -> Judged {
373    Judged {
374        validation: DynamicValidation::Invalid {
375            detail: detail.clone(),
376        },
377        abstention: AbstentionDecision::NotApplied,
378        result: Err(DynamicUnresolved::InvalidOutput { detail }),
379    }
380}
381
382fn short(s: &str) -> String {
383    s.chars().take(64).collect()
384}
385
386/// Judge what the backend returned against the request and the capability
387/// (3.3, 3.5). Anything outside the declared labels, levels, value kind
388/// and exact support is invalid; response material that is not an answer
389/// is invalid and never acted on; a pinned served identity that differs is
390/// unresolved. Nothing is substituted for an unresolved result.
391pub fn judge(
392    q: &ValidatedQuestion,
393    capability: &DynamicCapability,
394    served: &ServedIdentity,
395    raw: &DynamicRawOutput,
396) -> Judged {
397    let q = &q.q;
398    if capability.served.disclosure == Disclosure::Pinned
399        && (served.disclosure != Disclosure::Pinned
400            || served.identity != capability.served.identity)
401    {
402        let pinned = capability.served.identity.clone().unwrap_or_default();
403        return Judged {
404            validation: DynamicValidation::Invalid {
405                detail: "served identity differs from the pinned identity".into(),
406            },
407            abstention: AbstentionDecision::NotApplied,
408            result: Err(DynamicUnresolved::ServedIdentityMismatch {
409                pinned,
410                served: served.identity.as_ref().map(|s| short(s)),
411            }),
412        };
413    }
414    match raw {
415        DynamicRawOutput::Unrecognized { shape, .. } => invalid(format!(
416            "response material of shape {} is not an answer; it was not acted on",
417            shape.name()
418        )),
419        DynamicRawOutput::Label(l) => {
420            if q.output != DynamicOutputKind::Label {
421                return invalid(format!(
422                    "a label where {} is required, and label-only posture is not permitted",
423                    q.output.name()
424                ));
425            }
426            if !q.labels.contains(l) {
427                return invalid(format!("undeclared label {:?}", short(l)));
428            }
429            Judged {
430                validation: DynamicValidation::Valid,
431                abstention: AbstentionDecision::LabelOnly,
432                result: Ok(DynamicAnswer::Label { label: l.clone() }),
433            }
434        }
435        DynamicRawOutput::Distribution(map) => judge_distribution(q, map),
436    }
437}
438
439fn judge_distribution(q: &DynamicQuestion, map: &BTreeMap<String, f64>) -> Judged {
440    if q.output == DynamicOutputKind::Label {
441        return invalid("a distribution where a label is required".into());
442    }
443    let tolerance = Decimal::parse(DISTRIBUTION_TOLERANCE).expect("literal");
444    let d = match Distribution::new(&q.labels, map, tolerance) {
445        Ok(d) => d,
446        Err(e) => {
447            let detail: String = e.0.chars().take(200).collect();
448            return invalid(format!("outside the declared support: {detail}"));
449        }
450    };
451    let (top_label, top_mass) = d.top();
452    let top_label = top_label.to_string();
453    let second = d
454        .options()
455        .iter()
456        .zip(d.masses())
457        .filter(|(o, _)| **o != top_label)
458        .map(|(_, m)| *m)
459        .fold(0.0_f64, f64::max);
460    let margin = top_mass - second;
461    let AbstentionPolicy::Thresholds {
462        min_top_mass,
463        min_margin,
464    } = &q.abstention
465    else {
466        // Validation pairs a distribution output with thresholds.
467        return invalid("no thresholds for a distribution output".into());
468    };
469    let masses: Vec<LabelMass> = d
470        .options()
471        .iter()
472        .zip(d.masses())
473        .map(|(l, m)| LabelMass {
474            label: l.clone(),
475            mass: *m,
476        })
477        .collect();
478    let rule = if top_mass < min_top_mass.to_f64_nearest() {
479        Some(AbstentionRule::MinTopMass)
480    } else if margin < min_margin.to_f64_nearest() {
481        Some(AbstentionRule::MinMargin)
482    } else {
483        None
484    };
485    if let Some(rule) = rule {
486        return Judged {
487            validation: DynamicValidation::Valid,
488            abstention: AbstentionDecision::Abstained {
489                rule,
490                top_mass,
491                margin,
492            },
493            result: Err(DynamicUnresolved::Abstained { rule }),
494        };
495    }
496    let answer = if q.output == DynamicOutputKind::OrdinalDistribution {
497        DynamicAnswer::OrdinalDistribution {
498            level: top_label,
499            masses,
500        }
501    } else {
502        DynamicAnswer::UncalibratedDistribution {
503            label: top_label,
504            masses,
505        }
506    };
507    Judged {
508        validation: DynamicValidation::Valid,
509        abstention: AbstentionDecision::Passed { top_mass, margin },
510        result: Ok(answer),
511    }
512}
513
514/// A proposal for an accepted answer (3.6): always `model-derived`, with
515/// the request and the backend output in its lineage; the exact steps that
516/// ran are listed, never a relabeling. `validate` always ran; `abstention`
517/// is listed only when thresholds were applied, so a label accepted under
518/// the explicit `label_only` posture, where no abstention rule runs
519/// (`AbstentionDecision::LabelOnly`), lists `validate` alone.
520pub fn proposal(
521    q: &ValidatedQuestion,
522    request: DynamicRequestId,
523    capability: &DynamicCapability,
524    attempt_id: &str,
525    answer: DynamicAnswer,
526) -> DynamicProposal {
527    let mut exact_processing = vec!["validate".to_string()];
528    if !matches!(answer, DynamicAnswer::Label { .. }) {
529        exact_processing.push("abstention".into());
530    }
531    DynamicProposal {
532        answer,
533        derivation: Derivation::ModelDerived,
534        lineage: DynamicLineage {
535            request,
536            context: q.q.context.iter().map(|c| c.name.clone()).collect(),
537            backend_id: capability.backend_id.clone(),
538            artifact: capability.artifact.clone(),
539            attempt_id: attempt_id.to_string(),
540            exact_processing,
541        },
542    }
543}