1use 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
76pub const DISTRIBUTION_TOLERANCE: &str = "0.000001";
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum DynamicRequestError {
84 Parse(DocumentError),
86 Bounds(String),
88 PropositionLabels,
90 LabelCount {
91 operation: DynamicOperation,
92 count: usize,
93 max: usize,
94 },
95 OutputKind {
97 operation: DynamicOperation,
98 output: DynamicOutputKind,
99 },
100 Abstention(String),
103 ZeroDeadline,
104 ZeroCostCap,
105 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#[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
160pub 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
169pub 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
269pub fn request_identity(
272 q: &ValidatedQuestion,
273 capability: &DynamicCapability,
274) -> Result<DynamicRequestId, CanonicalError> {
275 DynamicIdentity::of(&q.q, capability).id()
276}
277
278pub 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
295pub 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
343pub 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
357pub fn qualifiable(capability: &DynamicCapability) -> bool {
360 capability.hidden_state == HiddenState::None
361}
362
363#[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
386pub 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 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
514pub 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}