Skip to main content

lmflow/
metadata.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::status::{Error, Result};
6use crate::timestamp::Timestamp;
7
8pub type Metadata = BTreeMap<String, MetadataValue>;
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11#[serde(untagged)]
12pub enum MetadataValue {
13    Bool(bool),
14    I64(i64),
15    F64(f64),
16    String(String),
17}
18
19impl From<bool> for MetadataValue {
20    fn from(value: bool) -> Self {
21        Self::Bool(value)
22    }
23}
24
25impl From<i64> for MetadataValue {
26    fn from(value: i64) -> Self {
27        Self::I64(value)
28    }
29}
30
31impl From<f64> for MetadataValue {
32    fn from(value: f64) -> Self {
33        Self::F64(value)
34    }
35}
36
37impl From<String> for MetadataValue {
38    fn from(value: String) -> Self {
39        Self::String(value)
40    }
41}
42
43impl From<&str> for MetadataValue {
44    fn from(value: &str) -> Self {
45        Self::String(value.to_string())
46    }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum ComparisonOp {
52    Eq,
53    Ne,
54    Gt,
55    Gte,
56    Lt,
57    Lte,
58    Exists,
59    Contains,
60}
61
62#[derive(Debug, Clone, PartialEq, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct MetadataCondition {
65    pub metadata: String,
66    pub op: ComparisonOp,
67    #[serde(default)]
68    pub value: Option<MetadataValue>,
69}
70
71#[derive(Debug, Clone, PartialEq, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct TimestampComparison {
74    pub op: ComparisonOp,
75    #[serde(default)]
76    pub value: Option<i64>,
77}
78
79#[derive(Debug, Clone, PartialEq, Deserialize)]
80#[serde(untagged)]
81pub enum MetadataPredicate {
82    All { all: Vec<MetadataPredicate> },
83    Any { any: Vec<MetadataPredicate> },
84    Not { not: Box<MetadataPredicate> },
85    Timestamp { timestamp: TimestampComparison },
86    Metadata(MetadataCondition),
87}
88
89impl MetadataPredicate {
90    pub fn summary(&self) -> String {
91        match self {
92            Self::All { all } => format!(
93                "all({})",
94                all.iter().map(Self::summary).collect::<Vec<_>>().join(", ")
95            ),
96            Self::Any { any } => format!(
97                "any({})",
98                any.iter().map(Self::summary).collect::<Vec<_>>().join(", ")
99            ),
100            Self::Not { not } => format!("not({})", not.summary()),
101            Self::Timestamp { timestamp } => format!(
102                "timestamp {} {}",
103                op_name(timestamp.op),
104                timestamp
105                    .value
106                    .map_or_else(|| "?".into(), |value| value.to_string())
107            ),
108            Self::Metadata(condition) => {
109                let value = condition
110                    .value
111                    .as_ref()
112                    .map_or_else(String::new, |value| format!(" {}", value_summary(value)));
113                format!("{} {}{}", condition.metadata, op_name(condition.op), value)
114            }
115        }
116    }
117
118    pub(crate) fn missing_metadata_count(&self, metadata: &Metadata) -> u64 {
119        match self {
120            Self::All { all } => all
121                .iter()
122                .map(|predicate| predicate.missing_metadata_count(metadata))
123                .sum(),
124            Self::Any { any } => any
125                .iter()
126                .map(|predicate| predicate.missing_metadata_count(metadata))
127                .sum(),
128            Self::Not { not } => not.missing_metadata_count(metadata),
129            Self::Timestamp { .. } => 0,
130            Self::Metadata(condition) => u64::from(!metadata.contains_key(&condition.metadata)),
131        }
132    }
133
134    pub fn validate(&self) -> Result<()> {
135        match self {
136            Self::All { all } => validate_group("all", all),
137            Self::Any { any } => validate_group("any", any),
138            Self::Not { not } => not.validate(),
139            Self::Timestamp { timestamp } => {
140                if matches!(timestamp.op, ComparisonOp::Exists | ComparisonOp::Contains) {
141                    return Err(Error::InvalidArg(format!(
142                        "timestamp condition does not support `{}`",
143                        op_name(timestamp.op)
144                    )));
145                }
146                if timestamp.value.is_none() {
147                    return Err(Error::InvalidArg(
148                        "timestamp condition requires `value`".into(),
149                    ));
150                }
151                Ok(())
152            }
153            Self::Metadata(condition) => {
154                if condition.metadata.is_empty() {
155                    return Err(Error::InvalidArg(
156                        "metadata condition key must not be empty".into(),
157                    ));
158                }
159                if condition.op == ComparisonOp::Exists {
160                    if condition.value.is_some() {
161                        return Err(Error::InvalidArg(
162                            "metadata `exists` condition must not declare `value`".into(),
163                        ));
164                    }
165                } else if condition.value.is_none() {
166                    return Err(Error::InvalidArg(format!(
167                        "metadata `{}` condition requires `value`",
168                        op_name(condition.op)
169                    )));
170                }
171                if condition.op == ComparisonOp::Contains
172                    && !matches!(condition.value, Some(MetadataValue::String(_)))
173                {
174                    return Err(Error::InvalidArg(
175                        "metadata `contains` condition requires a string value".into(),
176                    ));
177                }
178                Ok(())
179            }
180        }
181    }
182
183    pub fn evaluate(&self, metadata: &Metadata, timestamp: Timestamp) -> Result<bool> {
184        match self {
185            Self::All { all } => {
186                for predicate in all {
187                    if !predicate.evaluate(metadata, timestamp)? {
188                        return Ok(false);
189                    }
190                }
191                Ok(true)
192            }
193            Self::Any { any } => {
194                for predicate in any {
195                    if predicate.evaluate(metadata, timestamp)? {
196                        return Ok(true);
197                    }
198                }
199                Ok(false)
200            }
201            Self::Not { not } => Ok(!not.evaluate(metadata, timestamp)?),
202            Self::Timestamp {
203                timestamp: condition,
204            } => compare_i64(
205                timestamp.0,
206                condition.op,
207                condition.value.expect("validated timestamp condition"),
208            ),
209            Self::Metadata(condition) => {
210                let actual = metadata.get(&condition.metadata);
211                if condition.op == ComparisonOp::Exists {
212                    return Ok(actual.is_some());
213                }
214                let Some(actual) = actual else {
215                    return Ok(false);
216                };
217                compare_metadata(
218                    actual,
219                    condition.op,
220                    condition
221                        .value
222                        .as_ref()
223                        .expect("validated metadata condition"),
224                )
225            }
226        }
227    }
228}
229
230fn validate_group(name: &str, predicates: &[MetadataPredicate]) -> Result<()> {
231    if predicates.is_empty() {
232        return Err(Error::InvalidArg(format!(
233            "metadata `{name}` condition must not be empty"
234        )));
235    }
236    predicates.iter().try_for_each(MetadataPredicate::validate)
237}
238
239fn compare_metadata(
240    actual: &MetadataValue,
241    op: ComparisonOp,
242    expected: &MetadataValue,
243) -> Result<bool> {
244    match (actual, expected) {
245        (MetadataValue::Bool(left), MetadataValue::Bool(right)) => compare_ord(left, op, right),
246        (MetadataValue::I64(left), MetadataValue::I64(right)) => compare_ord(left, op, right),
247        (MetadataValue::F64(left), MetadataValue::F64(right)) => compare_float(*left, op, *right),
248        (MetadataValue::I64(left), MetadataValue::F64(right)) => {
249            compare_float(*left as f64, op, *right)
250        }
251        (MetadataValue::F64(left), MetadataValue::I64(right)) => {
252            compare_float(*left, op, *right as f64)
253        }
254        (MetadataValue::String(left), MetadataValue::String(right)) => {
255            if op == ComparisonOp::Contains {
256                Ok(left.contains(right))
257            } else {
258                compare_ord(left, op, right)
259            }
260        }
261        _ => Err(Error::InvalidArg(format!(
262            "metadata comparison type mismatch for `{}`",
263            op_name(op)
264        ))),
265    }
266}
267
268fn compare_i64(left: i64, op: ComparisonOp, right: i64) -> Result<bool> {
269    compare_ord(&left, op, &right)
270}
271
272fn compare_float(left: f64, op: ComparisonOp, right: f64) -> Result<bool> {
273    Ok(match op {
274        ComparisonOp::Eq => left == right,
275        ComparisonOp::Ne => left != right,
276        ComparisonOp::Gt => left > right,
277        ComparisonOp::Gte => left >= right,
278        ComparisonOp::Lt => left < right,
279        ComparisonOp::Lte => left <= right,
280        ComparisonOp::Exists | ComparisonOp::Contains => {
281            return Err(Error::InvalidArg(format!(
282                "numeric comparison does not support `{}`",
283                op_name(op)
284            )));
285        }
286    })
287}
288
289fn compare_ord<T: PartialOrd + PartialEq>(left: &T, op: ComparisonOp, right: &T) -> Result<bool> {
290    Ok(match op {
291        ComparisonOp::Eq => left == right,
292        ComparisonOp::Ne => left != right,
293        ComparisonOp::Gt => left > right,
294        ComparisonOp::Gte => left >= right,
295        ComparisonOp::Lt => left < right,
296        ComparisonOp::Lte => left <= right,
297        ComparisonOp::Exists | ComparisonOp::Contains => {
298            return Err(Error::InvalidArg(format!(
299                "comparison does not support `{}`",
300                op_name(op)
301            )));
302        }
303    })
304}
305
306fn op_name(op: ComparisonOp) -> &'static str {
307    match op {
308        ComparisonOp::Eq => "eq",
309        ComparisonOp::Ne => "ne",
310        ComparisonOp::Gt => "gt",
311        ComparisonOp::Gte => "gte",
312        ComparisonOp::Lt => "lt",
313        ComparisonOp::Lte => "lte",
314        ComparisonOp::Exists => "exists",
315        ComparisonOp::Contains => "contains",
316    }
317}
318
319fn value_summary(value: &MetadataValue) -> String {
320    match value {
321        MetadataValue::Bool(value) => value.to_string(),
322        MetadataValue::I64(value) => value.to_string(),
323        MetadataValue::F64(value) => value.to_string(),
324        MetadataValue::String(value) => format!("\"{value}\""),
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn nested_predicate_evaluates() {
334        let predicate: MetadataPredicate = serde_yaml::from_str(
335            r#"
336all:
337  - { metadata: confidence, op: gte, value: 0.8 }
338  - any:
339      - { metadata: category, op: eq, value: person }
340      - timestamp: { op: gte, value: 100 }
341"#,
342        )
343        .unwrap();
344        predicate.validate().unwrap();
345        let metadata = Metadata::from([
346            ("confidence".into(), MetadataValue::F64(0.9)),
347            ("category".into(), MetadataValue::String("vehicle".into())),
348        ]);
349        assert!(predicate.evaluate(&metadata, Timestamp(100)).unwrap());
350    }
351}