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 && t.options != ["score"] {
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 if t.operation == Operation::Rank {
423 1
424 } else {
425 limits::OPTIONS_MIN
426 },
427 limits::OPTIONS,
428 )?;
429 let mut options = BTreeMap::new();
430 for (k, o) in t.options.iter().enumerate() {
431 bound(
432 &format!("{at}.options[{k}]"),
433 "option bytes",
434 o.len(),
435 1,
436 limits::OPTION_BYTES,
437 )?;
438 if options.insert(o.clone(), k).is_some() {
439 return Err(err(
440 format!("{at}.options"),
441 ProgramErrorKind::Duplicate {
442 what: "option",
443 name: o.clone(),
444 },
445 ));
446 }
447 }
448 if t.operation == Operation::Proposition && t.options != ["false", "true"] {
449 return Err(err(
450 format!("{at}.options"),
451 ProgramErrorKind::PropositionOptions,
452 ));
453 }
454 bound(
455 &format!("{at}.interpretation"),
456 "interpretation bytes",
457 t.interpretation.len(),
458 1,
459 limits::INTERPRETATION_BYTES,
460 )?;
461 if t.interpretation.trim().is_empty() {
462 return Err(err(
463 format!("{at}.interpretation"),
464 ProgramErrorKind::Interpretation,
465 ));
466 }
467 bound(
468 &format!("{at}.fields"),
469 "fields",
470 t.fields.len(),
471 0,
472 limits::FIELDS,
473 )?;
474 let mut fields = Vec::with_capacity(t.fields.len());
475 let mut field_index = BTreeMap::new();
476 for (k, f) in t.fields.iter().enumerate() {
477 let fat = format!("{at}.fields[{k}]");
478 bound(
479 &fat,
480 "field name bytes",
481 f.name.len(),
482 1,
483 limits::NAME_BYTES,
484 )?;
485 if field_index.insert(f.name.clone(), k).is_some() {
486 return Err(err(
487 fat,
488 ProgramErrorKind::Duplicate {
489 what: "field",
490 name: f.name.clone(),
491 },
492 ));
493 }
494 let (key, member) = parse_ref(&f.reference)
495 .ok_or_else(|| err(&fat, ProgramErrorKind::Reference(f.reference.clone())))?;
496 fields.push(CField {
497 name: f.name.clone(),
498 key,
499 member,
500 ty: f.ty,
501 });
502 }
503 let base = {
504 let mut v = vec![Decimal::from_i64(0); t.options.len()];
505 for (k, d) in adds(&t.base, &options, &format!("{at}.base"), true)? {
506 v[k] = d;
507 }
508 v
509 };
510 bound(
511 &format!("{at}.rules"),
512 "rules",
513 t.rules.len(),
514 0,
515 limits::RULES,
516 )?;
517 let mut cx = TaskCx {
518 options: &options,
519 fields: &fields,
520 field_index: &field_index,
521 terms: 0,
522 entries: 0,
523 };
524 let mut rule_names = BTreeSet::new();
525 let mut rules = Vec::with_capacity(t.rules.len());
526 for (k, r) in t.rules.iter().enumerate() {
527 let rat = format!("{at}.rules[{k}]");
528 bound(&rat, "rule name bytes", r.name.len(), 1, limits::NAME_BYTES)?;
529 if !rule_names.insert(r.name.as_str()) {
530 return Err(err(
531 rat,
532 ProgramErrorKind::Duplicate {
533 what: "rule",
534 name: r.name.clone(),
535 },
536 ));
537 }
538 let mut nodes = 0;
539 let when = cx.condition(&r.when, &format!("{rat}.when"), 0, &mut nodes)?;
540 bound(
541 &format!("{rat}.when"),
542 "condition nodes",
543 nodes,
544 1,
545 limits::CONDITION_NODES,
546 )?;
547 bound(
548 &format!("{rat}.then"),
549 "contributions",
550 r.then.len(),
551 0,
552 limits::CONTRIBUTIONS,
553 )?;
554 let mut then = Vec::with_capacity(r.then.len());
555 for (c, contribution) in r.then.iter().enumerate() {
556 then.push(cx.contribution(contribution, &format!("{rat}.then[{c}]"))?);
557 }
558 rules.push(CRule {
559 name: r.name.clone(),
560 when,
561 then,
562 stop: r.stop,
563 });
564 }
565 bound(
566 at,
567 "contains_any terms per task",
568 cx.terms,
569 0,
570 limits::TERMS_PER_TASK,
571 )?;
572 bound(
573 at,
574 "lookup entries per task",
575 cx.entries,
576 0,
577 limits::LOOKUP_ENTRIES_PER_TASK,
578 )?;
579 Ok(CTask {
580 operation: t.operation,
581 name: t.task.clone(),
582 question: t.question.clone(),
583 options: t.options.clone(),
584 fields,
585 base,
586 rules,
587 on_no_match: t.on_no_match,
588 })
589}
590
591fn parse_ref(r: &str) -> Option<(String, Option<String>)> {
593 let (key, member) = match r.split_once('/') {
594 Some((k, m)) => (k, Some(m)),
595 None => (r, None),
596 };
597 let name = key
598 .strip_prefix("input:")
599 .or_else(|| key.strip_prefix("bind:"))?;
600 let simple = |s: &str| !s.is_empty() && !s.contains('/') && !s.contains(':');
601 if !simple(name) || member.is_some_and(|m| !simple(m)) {
602 return None;
603 }
604 Some((key.to_string(), member.map(str::to_string)))
605}
606
607fn adds(
608 m: &BTreeMap<String, Decimal>,
609 options: &BTreeMap<String, usize>,
610 at: &str,
611 may_be_empty: bool,
612) -> Result<Adds, ProgramError> {
613 if m.is_empty() && !may_be_empty {
614 return Err(err(
615 at,
616 ProgramErrorKind::Bound {
617 what: "option constants",
618 min: 1,
619 max: limits::OPTIONS,
620 found: 0,
621 },
622 ));
623 }
624 m.iter()
625 .map(|(o, d)| match options.get(o) {
626 Some(k) => Ok((*k, *d)),
627 None => Err(err(at, ProgramErrorKind::UnknownOption(o.clone()))),
628 })
629 .collect()
630}
631
632struct TaskCx<'a> {
633 options: &'a BTreeMap<String, usize>,
634 fields: &'a [CField],
635 field_index: &'a BTreeMap<String, usize>,
636 terms: usize,
637 entries: usize,
638}
639
640impl TaskCx<'_> {
641 fn field(&self, name: &str, at: &str, accepts: &[FieldType]) -> Result<usize, ProgramError> {
642 let k = *self
643 .field_index
644 .get(name)
645 .ok_or_else(|| err(at, ProgramErrorKind::UnknownField(name.to_string())))?;
646 let found = self.fields[k].ty;
647 if !accepts.contains(&found) {
648 return Err(err(
649 at,
650 ProgramErrorKind::FieldType {
651 field: name.to_string(),
652 found,
653 },
654 ));
655 }
656 Ok(k)
657 }
658
659 fn condition(
660 &mut self,
661 c: &Condition,
662 at: &str,
663 depth: usize,
664 nodes: &mut usize,
665 ) -> Result<CCond, ProgramError> {
666 *nodes += 1;
667 if *nodes > limits::CONDITION_NODES {
668 return Err(err(
669 at,
670 ProgramErrorKind::Bound {
671 what: "condition nodes",
672 min: 1,
673 max: limits::CONDITION_NODES,
674 found: *nodes,
675 },
676 ));
677 }
678 Ok(match c {
679 Condition::Always => CCond::Always,
680 Condition::ContainsAny { field, terms } => {
681 let field = self.field(field, at, &[FieldType::Text])?;
682 bound(at, "terms", terms.len(), 1, limits::TERMS)?;
683 for t in terms {
684 bound(at, "term bytes", t.len(), 1, limits::TERM_BYTES)?;
685 }
686 self.terms += terms.len();
687 CCond::ContainsAny {
688 field,
689 terms: terms.iter().map(|t| t.to_ascii_lowercase()).collect(),
690 }
691 }
692 Condition::Equals { field, value } => {
693 let k = self.field(
694 field,
695 at,
696 &[FieldType::Text, FieldType::Bool, FieldType::Integer],
697 )?;
698 if !canonical_for(self.fields[k].ty, value) {
699 return Err(err(at, ProgramErrorKind::EqualsValue(value.clone())));
700 }
701 CCond::Equals {
702 field: k,
703 value: value.clone(),
704 }
705 }
706 Condition::Compare { field, op, value } => CCond::Compare {
707 field: self.field(field, at, &[FieldType::Integer, FieldType::Decimal])?,
708 op: *op,
709 value: *value,
710 },
711 Condition::All(members) | Condition::Any(members) => {
712 if depth + 1 > limits::CONDITION_DEPTH {
713 return Err(err(
714 at,
715 ProgramErrorKind::Bound {
716 what: "condition depth",
717 min: 0,
718 max: limits::CONDITION_DEPTH,
719 found: depth + 1,
720 },
721 ));
722 }
723 bound(
724 at,
725 "condition members",
726 members.len(),
727 1,
728 limits::CONDITION_MEMBERS,
729 )?;
730 let mut v = Vec::with_capacity(members.len());
731 for (k, m) in members.iter().enumerate() {
732 v.push(self.condition(m, &format!("{at}[{k}]"), depth + 1, nodes)?);
733 }
734 if matches!(c, Condition::All(_)) {
735 CCond::All(v)
736 } else {
737 CCond::Any(v)
738 }
739 }
740 Condition::Not(inner) => {
741 if depth + 1 > limits::CONDITION_DEPTH {
742 return Err(err(
743 at,
744 ProgramErrorKind::Bound {
745 what: "condition depth",
746 min: 0,
747 max: limits::CONDITION_DEPTH,
748 found: depth + 1,
749 },
750 ));
751 }
752 CCond::Not(Box::new(self.condition(
753 inner,
754 &format!("{at}.not"),
755 depth + 1,
756 nodes,
757 )?))
758 }
759 })
760 }
761
762 fn contribution(&mut self, c: &Contribution, at: &str) -> Result<CContrib, ProgramError> {
763 Ok(match c {
764 Contribution::Add(m) => CContrib::Add(adds(m, self.options, at, false)?),
765 Contribution::Linear {
766 field,
767 coefficients,
768 } => CContrib::Linear {
769 field: self.field(field, at, &[FieldType::Integer, FieldType::Decimal])?,
770 coefficients: adds(coefficients, self.options, at, false)?,
771 },
772 Contribution::Lookup {
773 field,
774 entries,
775 on_missing,
776 } => {
777 let k = self.field(
778 field,
779 at,
780 &[FieldType::Text, FieldType::Bool, FieldType::Integer],
781 )?;
782 bound(
783 at,
784 "lookup entries",
785 entries.len(),
786 1,
787 limits::LOOKUP_ENTRIES,
788 )?;
789 self.entries += entries.len();
790 let mut table = BTreeMap::new();
791 for (e, entry) in entries.iter().enumerate() {
792 let eat = format!("{at}.entries[{e}]");
793 bound(
794 &eat,
795 "lookup key bytes",
796 entry.key.len(),
797 1,
798 limits::LOOKUP_KEY_BYTES,
799 )?;
800 let a = adds(&entry.add, self.options, &eat, false)?;
801 if table.insert(entry.key.clone(), a).is_some() {
803 return Err(err(
804 eat,
805 ProgramErrorKind::Duplicate {
806 what: "lookup key",
807 name: entry.key.clone(),
808 },
809 ));
810 }
811 }
812 CContrib::Lookup {
813 field: k,
814 table,
815 on_missing: *on_missing,
816 }
817 }
818 })
819 }
820}
821
822fn canonical_for(ty: FieldType, v: &str) -> bool {
824 match ty {
825 FieldType::Text => true,
826 FieldType::Bool => v == "true" || v == "false",
827 FieldType::Integer => v.parse::<i64>().is_ok_and(|i| i.to_string() == v),
828 FieldType::Decimal => false,
829 }
830}