1use std::collections::{BTreeMap, BTreeSet};
6use std::fmt;
7
8use rustev_contract::canonical::{CanonicalError, tagged_digest};
9use rustev_contract::decimal::Decimal;
10use rustev_contract::definition::Operation;
11use rustev_contract::ids::ArtifactId;
12use rustev_contract::limits::DESCRIPTOR_V1;
13use rustev_contract::{Document, DocumentError};
14use serde::{Deserialize, Serialize};
15
16pub const SCHEMA: &str = "rustev.rules/1";
18
19pub mod limits {
21 pub const BACKEND_ID_BYTES: usize = 64;
22 pub const MAX_PROJECTION_BYTES: u64 = 65_536;
23 pub const TASKS: usize = 32;
24 pub const TASK_BYTES: usize = 256;
25 pub const QUESTION_BYTES: usize = 4_096;
26 pub const OPTIONS_MIN: usize = 2;
27 pub const OPTIONS: usize = 64;
28 pub const OPTION_BYTES: usize = 256;
29 pub const INTERPRETATION_BYTES: usize = 1_024;
30 pub const FIELDS: usize = 16;
31 pub const NAME_BYTES: usize = 64;
32 pub const RULES: usize = 128;
33 pub const CONDITION_NODES: usize = 32;
34 pub const CONDITION_DEPTH: usize = 4;
35 pub const CONDITION_MEMBERS: usize = 16;
36 pub const TERMS: usize = 32;
37 pub const TERM_BYTES: usize = 64;
38 pub const TERMS_PER_TASK: usize = 1_024;
39 pub const CONTRIBUTIONS: usize = 8;
40 pub const LOOKUP_ENTRIES: usize = 256;
41 pub const LOOKUP_KEY_BYTES: usize = 256;
42 pub const LOOKUP_ENTRIES_PER_TASK: usize = 1_024;
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct RulesProgram {
48 pub schema: String,
49 pub backend_id: String,
50 pub limits: ProgramLimits,
51 pub cost: ProgramCost,
52 pub tasks: Vec<Task>,
53}
54
55impl Document for RulesProgram {
56 const SCHEMA: &'static str = SCHEMA;
57 const LIMITS: rustev_contract::bounded::ParseLimits = DESCRIPTOR_V1;
58 fn schema(&self) -> &str {
59 &self.schema
60 }
61}
62
63impl RulesProgram {
64 pub fn artifact_id(&self) -> Result<ArtifactId, CanonicalError> {
68 let digest = tagged_digest(SCHEMA, &self.canonical()?);
69 ArtifactId::parse(&digest).map_err(|e| CanonicalError::Serialize {
70 message: e.to_string(),
71 })
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct ProgramLimits {
78 pub max_projection_bytes: u64,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct ProgramCost {
85 pub units_per_call: u64,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct Task {
92 pub operation: Operation,
93 pub task: String,
94 pub question: String,
95 pub options: Vec<String>,
96 pub interpretation: String,
98 pub fields: Vec<Field>,
99 pub base: BTreeMap<String, Decimal>,
100 pub rules: Vec<Rule>,
101 pub on_no_match: NoMatch,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct Field {
107 pub name: String,
108 #[serde(rename = "ref")]
109 pub reference: String,
110 #[serde(rename = "type")]
111 pub ty: FieldType,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116pub enum FieldType {
117 Text,
118 Bool,
119 Integer,
120 Decimal,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum NoMatch {
126 Base,
127 Fail,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct Rule {
133 pub name: String,
134 pub when: Condition,
135 pub then: Vec<Contribution>,
136 pub stop: bool,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case", deny_unknown_fields)]
141pub enum Condition {
142 Always,
143 ContainsAny {
144 field: String,
145 terms: Vec<String>,
146 },
147 Equals {
148 field: String,
149 value: String,
150 },
151 Compare {
152 field: String,
153 op: CompareOp,
154 value: Decimal,
155 },
156 All(Vec<Condition>),
157 Any(Vec<Condition>),
158 Not(Box<Condition>),
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum CompareOp {
164 Lt,
165 Le,
166 Ge,
167 Gt,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case", deny_unknown_fields)]
172pub enum Contribution {
173 Add(BTreeMap<String, Decimal>),
174 Linear {
175 field: String,
176 coefficients: BTreeMap<String, Decimal>,
177 },
178 Lookup {
179 field: String,
180 entries: Vec<LookupEntry>,
181 on_missing: OnMissing,
182 },
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct LookupEntry {
188 pub key: String,
189 pub add: BTreeMap<String, Decimal>,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194pub enum OnMissing {
195 Skip,
196 Fail,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct ProgramError {
203 pub at: String,
204 pub kind: ProgramErrorKind,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum ProgramErrorKind {
209 Document(DocumentError),
211 Canonical(CanonicalError),
213 Bound {
215 what: &'static str,
216 min: usize,
217 max: usize,
218 found: usize,
219 },
220 BackendId,
221 UnsupportedOperation,
223 PropositionOptions,
225 Duplicate {
226 what: &'static str,
227 name: String,
228 },
229 UnknownOption(String),
230 UnknownField(String),
231 Reference(String),
234 FieldType {
236 field: String,
237 found: FieldType,
238 },
239 EqualsValue(String),
241 Interpretation,
243}
244
245impl fmt::Display for ProgramError {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 write!(f, "{}: {:?}", self.at, self.kind)
248 }
249}
250
251impl std::error::Error for ProgramError {}
252
253fn err(at: impl Into<String>, kind: ProgramErrorKind) -> ProgramError {
254 ProgramError {
255 at: at.into(),
256 kind,
257 }
258}
259
260fn bound(
261 at: &str,
262 what: &'static str,
263 found: usize,
264 min: usize,
265 max: usize,
266) -> Result<(), ProgramError> {
267 if found < min || found > max {
268 return Err(err(
269 at,
270 ProgramErrorKind::Bound {
271 what,
272 min,
273 max,
274 found,
275 },
276 ));
277 }
278 Ok(())
279}
280
281#[derive(Debug, Clone)]
286pub(crate) struct CTask {
287 pub operation: Operation,
288 pub name: String,
289 pub question: String,
290 pub options: Vec<String>,
291 pub fields: Vec<CField>,
292 pub base: Vec<Decimal>,
293 pub rules: Vec<CRule>,
294 pub on_no_match: NoMatch,
295}
296
297#[derive(Debug, Clone)]
298pub(crate) struct CField {
299 pub name: String,
300 pub key: String,
302 pub member: Option<String>,
303 pub ty: FieldType,
304}
305
306#[derive(Debug, Clone)]
307pub(crate) struct CRule {
308 pub name: String,
309 pub when: CCond,
310 pub then: Vec<CContrib>,
311 pub stop: bool,
312}
313
314#[derive(Debug, Clone)]
315pub(crate) enum CCond {
316 Always,
317 ContainsAny {
319 field: usize,
320 terms: Vec<String>,
321 },
322 Equals {
323 field: usize,
324 value: String,
325 },
326 Compare {
327 field: usize,
328 op: CompareOp,
329 value: Decimal,
330 },
331 All(Vec<CCond>),
332 Any(Vec<CCond>),
333 Not(Box<CCond>),
334}
335
336pub(crate) type Adds = Vec<(usize, Decimal)>;
338
339#[derive(Debug, Clone)]
340pub(crate) enum CContrib {
341 Add(Adds),
342 Linear {
343 field: usize,
344 coefficients: Adds,
345 },
346 Lookup {
347 field: usize,
348 table: BTreeMap<String, Adds>,
349 on_missing: OnMissing,
350 },
351}
352
353pub(crate) fn check(p: &RulesProgram) -> Result<Vec<CTask>, ProgramError> {
355 if p.schema != SCHEMA {
356 return Err(err(
357 "schema",
358 ProgramErrorKind::Document(DocumentError::Schema {
359 expected: SCHEMA,
360 found: p.schema.chars().take(80).collect(),
361 }),
362 ));
363 }
364 let id_ok = !p.backend_id.is_empty()
365 && p.backend_id.len() <= limits::BACKEND_ID_BYTES
366 && p.backend_id
367 .bytes()
368 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b));
369 if !id_ok {
370 return Err(err("backend_id", ProgramErrorKind::BackendId));
371 }
372 let max = p.limits.max_projection_bytes;
373 if max == 0 || max > limits::MAX_PROJECTION_BYTES {
374 return Err(err(
375 "limits.max_projection_bytes",
376 ProgramErrorKind::Bound {
377 what: "max_projection_bytes",
378 min: 1,
379 max: limits::MAX_PROJECTION_BYTES as usize,
380 found: usize::try_from(max).unwrap_or(usize::MAX),
381 },
382 ));
383 }
384 bound("tasks", "tasks", p.tasks.len(), 1, limits::TASKS)?;
385 let mut names = BTreeSet::new();
386 let mut out = Vec::with_capacity(p.tasks.len());
387 for (i, t) in p.tasks.iter().enumerate() {
388 let at = format!("tasks[{i}]");
389 if !names.insert(t.task.as_str()) {
390 return Err(err(
391 at,
392 ProgramErrorKind::Duplicate {
393 what: "task",
394 name: t.task.clone(),
395 },
396 ));
397 }
398 out.push(check_task(t, &at)?);
399 }
400 Ok(out)
401}
402
403fn check_task(t: &Task, at: &str) -> Result<CTask, ProgramError> {
404 if t.operation == Operation::Rank {
405 return Err(err(
406 format!("{at}.operation"),
407 ProgramErrorKind::UnsupportedOperation,
408 ));
409 }
410 bound(at, "task bytes", t.task.len(), 1, limits::TASK_BYTES)?;
411 bound(
412 at,
413 "question bytes",
414 t.question.len(),
415 1,
416 limits::QUESTION_BYTES,
417 )?;
418 bound(
419 &format!("{at}.options"),
420 "options",
421 t.options.len(),
422 limits::OPTIONS_MIN,
423 limits::OPTIONS,
424 )?;
425 let mut options = BTreeMap::new();
426 for (k, o) in t.options.iter().enumerate() {
427 bound(
428 &format!("{at}.options[{k}]"),
429 "option bytes",
430 o.len(),
431 1,
432 limits::OPTION_BYTES,
433 )?;
434 if options.insert(o.clone(), k).is_some() {
435 return Err(err(
436 format!("{at}.options"),
437 ProgramErrorKind::Duplicate {
438 what: "option",
439 name: o.clone(),
440 },
441 ));
442 }
443 }
444 if t.operation == Operation::Proposition && t.options != ["false", "true"] {
445 return Err(err(
446 format!("{at}.options"),
447 ProgramErrorKind::PropositionOptions,
448 ));
449 }
450 bound(
451 &format!("{at}.interpretation"),
452 "interpretation bytes",
453 t.interpretation.len(),
454 1,
455 limits::INTERPRETATION_BYTES,
456 )?;
457 if t.interpretation.trim().is_empty() {
458 return Err(err(
459 format!("{at}.interpretation"),
460 ProgramErrorKind::Interpretation,
461 ));
462 }
463 bound(
464 &format!("{at}.fields"),
465 "fields",
466 t.fields.len(),
467 0,
468 limits::FIELDS,
469 )?;
470 let mut fields = Vec::with_capacity(t.fields.len());
471 let mut field_index = BTreeMap::new();
472 for (k, f) in t.fields.iter().enumerate() {
473 let fat = format!("{at}.fields[{k}]");
474 bound(
475 &fat,
476 "field name bytes",
477 f.name.len(),
478 1,
479 limits::NAME_BYTES,
480 )?;
481 if field_index.insert(f.name.clone(), k).is_some() {
482 return Err(err(
483 fat,
484 ProgramErrorKind::Duplicate {
485 what: "field",
486 name: f.name.clone(),
487 },
488 ));
489 }
490 let (key, member) = parse_ref(&f.reference)
491 .ok_or_else(|| err(&fat, ProgramErrorKind::Reference(f.reference.clone())))?;
492 fields.push(CField {
493 name: f.name.clone(),
494 key,
495 member,
496 ty: f.ty,
497 });
498 }
499 let base = {
500 let mut v = vec![Decimal::from_i64(0); t.options.len()];
501 for (k, d) in adds(&t.base, &options, &format!("{at}.base"), true)? {
502 v[k] = d;
503 }
504 v
505 };
506 bound(
507 &format!("{at}.rules"),
508 "rules",
509 t.rules.len(),
510 0,
511 limits::RULES,
512 )?;
513 let mut cx = TaskCx {
514 options: &options,
515 fields: &fields,
516 field_index: &field_index,
517 terms: 0,
518 entries: 0,
519 };
520 let mut rule_names = BTreeSet::new();
521 let mut rules = Vec::with_capacity(t.rules.len());
522 for (k, r) in t.rules.iter().enumerate() {
523 let rat = format!("{at}.rules[{k}]");
524 bound(&rat, "rule name bytes", r.name.len(), 1, limits::NAME_BYTES)?;
525 if !rule_names.insert(r.name.as_str()) {
526 return Err(err(
527 rat,
528 ProgramErrorKind::Duplicate {
529 what: "rule",
530 name: r.name.clone(),
531 },
532 ));
533 }
534 let mut nodes = 0;
535 let when = cx.condition(&r.when, &format!("{rat}.when"), 0, &mut nodes)?;
536 bound(
537 &format!("{rat}.when"),
538 "condition nodes",
539 nodes,
540 1,
541 limits::CONDITION_NODES,
542 )?;
543 bound(
544 &format!("{rat}.then"),
545 "contributions",
546 r.then.len(),
547 0,
548 limits::CONTRIBUTIONS,
549 )?;
550 let mut then = Vec::with_capacity(r.then.len());
551 for (c, contribution) in r.then.iter().enumerate() {
552 then.push(cx.contribution(contribution, &format!("{rat}.then[{c}]"))?);
553 }
554 rules.push(CRule {
555 name: r.name.clone(),
556 when,
557 then,
558 stop: r.stop,
559 });
560 }
561 bound(
562 at,
563 "contains_any terms per task",
564 cx.terms,
565 0,
566 limits::TERMS_PER_TASK,
567 )?;
568 bound(
569 at,
570 "lookup entries per task",
571 cx.entries,
572 0,
573 limits::LOOKUP_ENTRIES_PER_TASK,
574 )?;
575 Ok(CTask {
576 operation: t.operation,
577 name: t.task.clone(),
578 question: t.question.clone(),
579 options: t.options.clone(),
580 fields,
581 base,
582 rules,
583 on_no_match: t.on_no_match,
584 })
585}
586
587fn parse_ref(r: &str) -> Option<(String, Option<String>)> {
589 let (key, member) = match r.split_once('/') {
590 Some((k, m)) => (k, Some(m)),
591 None => (r, None),
592 };
593 let name = key
594 .strip_prefix("input:")
595 .or_else(|| key.strip_prefix("bind:"))?;
596 let simple = |s: &str| !s.is_empty() && !s.contains('/') && !s.contains(':');
597 if !simple(name) || member.is_some_and(|m| !simple(m)) {
598 return None;
599 }
600 Some((key.to_string(), member.map(str::to_string)))
601}
602
603fn adds(
604 m: &BTreeMap<String, Decimal>,
605 options: &BTreeMap<String, usize>,
606 at: &str,
607 may_be_empty: bool,
608) -> Result<Adds, ProgramError> {
609 if m.is_empty() && !may_be_empty {
610 return Err(err(
611 at,
612 ProgramErrorKind::Bound {
613 what: "option constants",
614 min: 1,
615 max: limits::OPTIONS,
616 found: 0,
617 },
618 ));
619 }
620 m.iter()
621 .map(|(o, d)| match options.get(o) {
622 Some(k) => Ok((*k, *d)),
623 None => Err(err(at, ProgramErrorKind::UnknownOption(o.clone()))),
624 })
625 .collect()
626}
627
628struct TaskCx<'a> {
629 options: &'a BTreeMap<String, usize>,
630 fields: &'a [CField],
631 field_index: &'a BTreeMap<String, usize>,
632 terms: usize,
633 entries: usize,
634}
635
636impl TaskCx<'_> {
637 fn field(&self, name: &str, at: &str, accepts: &[FieldType]) -> Result<usize, ProgramError> {
638 let k = *self
639 .field_index
640 .get(name)
641 .ok_or_else(|| err(at, ProgramErrorKind::UnknownField(name.to_string())))?;
642 let found = self.fields[k].ty;
643 if !accepts.contains(&found) {
644 return Err(err(
645 at,
646 ProgramErrorKind::FieldType {
647 field: name.to_string(),
648 found,
649 },
650 ));
651 }
652 Ok(k)
653 }
654
655 fn condition(
656 &mut self,
657 c: &Condition,
658 at: &str,
659 depth: usize,
660 nodes: &mut usize,
661 ) -> Result<CCond, ProgramError> {
662 *nodes += 1;
663 if *nodes > limits::CONDITION_NODES {
664 return Err(err(
665 at,
666 ProgramErrorKind::Bound {
667 what: "condition nodes",
668 min: 1,
669 max: limits::CONDITION_NODES,
670 found: *nodes,
671 },
672 ));
673 }
674 Ok(match c {
675 Condition::Always => CCond::Always,
676 Condition::ContainsAny { field, terms } => {
677 let field = self.field(field, at, &[FieldType::Text])?;
678 bound(at, "terms", terms.len(), 1, limits::TERMS)?;
679 for t in terms {
680 bound(at, "term bytes", t.len(), 1, limits::TERM_BYTES)?;
681 }
682 self.terms += terms.len();
683 CCond::ContainsAny {
684 field,
685 terms: terms.iter().map(|t| t.to_ascii_lowercase()).collect(),
686 }
687 }
688 Condition::Equals { field, value } => {
689 let k = self.field(
690 field,
691 at,
692 &[FieldType::Text, FieldType::Bool, FieldType::Integer],
693 )?;
694 if !canonical_for(self.fields[k].ty, value) {
695 return Err(err(at, ProgramErrorKind::EqualsValue(value.clone())));
696 }
697 CCond::Equals {
698 field: k,
699 value: value.clone(),
700 }
701 }
702 Condition::Compare { field, op, value } => CCond::Compare {
703 field: self.field(field, at, &[FieldType::Integer, FieldType::Decimal])?,
704 op: *op,
705 value: *value,
706 },
707 Condition::All(members) | Condition::Any(members) => {
708 if depth + 1 > limits::CONDITION_DEPTH {
709 return Err(err(
710 at,
711 ProgramErrorKind::Bound {
712 what: "condition depth",
713 min: 0,
714 max: limits::CONDITION_DEPTH,
715 found: depth + 1,
716 },
717 ));
718 }
719 bound(
720 at,
721 "condition members",
722 members.len(),
723 1,
724 limits::CONDITION_MEMBERS,
725 )?;
726 let mut v = Vec::with_capacity(members.len());
727 for (k, m) in members.iter().enumerate() {
728 v.push(self.condition(m, &format!("{at}[{k}]"), depth + 1, nodes)?);
729 }
730 if matches!(c, Condition::All(_)) {
731 CCond::All(v)
732 } else {
733 CCond::Any(v)
734 }
735 }
736 Condition::Not(inner) => {
737 if depth + 1 > limits::CONDITION_DEPTH {
738 return Err(err(
739 at,
740 ProgramErrorKind::Bound {
741 what: "condition depth",
742 min: 0,
743 max: limits::CONDITION_DEPTH,
744 found: depth + 1,
745 },
746 ));
747 }
748 CCond::Not(Box::new(self.condition(
749 inner,
750 &format!("{at}.not"),
751 depth + 1,
752 nodes,
753 )?))
754 }
755 })
756 }
757
758 fn contribution(&mut self, c: &Contribution, at: &str) -> Result<CContrib, ProgramError> {
759 Ok(match c {
760 Contribution::Add(m) => CContrib::Add(adds(m, self.options, at, false)?),
761 Contribution::Linear {
762 field,
763 coefficients,
764 } => CContrib::Linear {
765 field: self.field(field, at, &[FieldType::Integer, FieldType::Decimal])?,
766 coefficients: adds(coefficients, self.options, at, false)?,
767 },
768 Contribution::Lookup {
769 field,
770 entries,
771 on_missing,
772 } => {
773 let k = self.field(
774 field,
775 at,
776 &[FieldType::Text, FieldType::Bool, FieldType::Integer],
777 )?;
778 bound(
779 at,
780 "lookup entries",
781 entries.len(),
782 1,
783 limits::LOOKUP_ENTRIES,
784 )?;
785 self.entries += entries.len();
786 let mut table = BTreeMap::new();
787 for (e, entry) in entries.iter().enumerate() {
788 let eat = format!("{at}.entries[{e}]");
789 bound(
790 &eat,
791 "lookup key bytes",
792 entry.key.len(),
793 1,
794 limits::LOOKUP_KEY_BYTES,
795 )?;
796 let a = adds(&entry.add, self.options, &eat, false)?;
797 if table.insert(entry.key.clone(), a).is_some() {
799 return Err(err(
800 eat,
801 ProgramErrorKind::Duplicate {
802 what: "lookup key",
803 name: entry.key.clone(),
804 },
805 ));
806 }
807 }
808 CContrib::Lookup {
809 field: k,
810 table,
811 on_missing: *on_missing,
812 }
813 }
814 })
815 }
816}
817
818fn canonical_for(ty: FieldType, v: &str) -> bool {
820 match ty {
821 FieldType::Text => true,
822 FieldType::Bool => v == "true" || v == "false",
823 FieldType::Integer => v.parse::<i64>().is_ok_and(|i| i.to_string() == v),
824 FieldType::Decimal => false,
825 }
826}