Skip to main content

vyre_libs/rule/
builder.rs

1// Rule set program builder.
2
3use crate::rule::ast::{RuleCondition, RuleFormula};
4use std::sync::LazyLock;
5use vyre_foundation::ir::{BufferDecl, DataType, Expr, Node, Program};
6/// `WORKGROUP_SIZE` constant.
7pub const WORKGROUP_SIZE: [u32; 3] = [64, 1, 1];
8
9/// Stable op id for the wrapping rule-set Region. Every `build_rule_program`
10/// call emits one region under this generator so the optimizer + the
11/// universal region-chain discipline test treat the whole rule set as an
12/// atomic compile unit.
13pub const RULE_SET_OP_ID: &str = "vyre-libs::rule::rule_set";
14
15/// Error returned when rule construction cannot lower a condition truthfully.
16#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
17pub enum RuleBuildError {
18    /// The core rule builder has no IR lowering for this frozen condition.
19    #[error(
20        "RuleCondition::{condition} is not lowerable by the core rule builder for rule {rule_id}. Fix: {fix}"
21    )]
22    UnsupportedCondition {
23        /// Rule id whose formula contains the condition.
24        rule_id: u32,
25        /// Condition variant name.
26        condition: &'static str,
27        /// Actionable remediation for callers.
28        fix: &'static str,
29    },
30    /// Extension conditions require an extension-aware builder.
31    #[error(
32        "RuleCondition::Opaque extension {extension_id:#010x} is not lowerable by the core rule builder for rule {rule_id}. Fix: use an extension-aware rule builder that maps this extension to concrete IR, or pre-evaluate the opaque condition before building a core rule program."
33    )]
34    OpaqueCondition {
35        /// Rule id whose formula contains the extension condition.
36        rule_id: u32,
37        /// Raw extension id.
38        extension_id: u32,
39    },
40}
41
42/// Build one IR program for an entire rule set.
43///
44/// Each tuple is `(formula, rule_id)`. The generated program writes each rule's
45/// boolean verdict as `0` or `1` into `verdicts[rule_id]`.
46///
47/// # Errors
48///
49/// Returns [`RuleBuildError`] when a formula contains a condition the core
50/// builder cannot lower truthfully.
51///
52/// # Examples
53///
54/// ```
55/// use vyre_libs::rule::{build_rule_program, RuleCondition, RuleFormula};
56///
57/// let formula = RuleFormula::condition(RuleCondition::LiteralTrue);
58/// let program = build_rule_program(&[(formula, 3)]).expect("Fix: literal rule lowers");
59/// assert!(program.has_buffer("verdicts"));
60/// ```
61#[must_use]
62pub fn build_rule_program(rules: &[(RuleFormula, u32)]) -> Result<Program, RuleBuildError> {
63    let nodes = rule_nodes(rules)?;
64    Ok(Program::wrapped(
65        rule_buffers(),
66        WORKGROUP_SIZE,
67        vec![crate::region::wrap_anonymous(RULE_SET_OP_ID, nodes)],
68    ))
69}
70
71/// Try to build one IR program for an entire rule set.
72///
73/// Returns [`RuleBuildError`] instead of emitting constant-success calls for
74/// conditions that need an external text source or extension-owned buffers.
75///
76/// # Errors
77///
78/// Returns [`RuleBuildError::UnsupportedCondition`] for frozen condition
79/// variants without a core IR lowering and [`RuleBuildError::OpaqueCondition`]
80/// for extension conditions that require an extension-aware builder.
81pub fn try_build_rule_program(rules: &[(RuleFormula, u32)]) -> Result<Program, RuleBuildError> {
82    build_rule_program(rules)
83}
84
85/// Canonical buffer declarations every rule-set program starts from:
86/// six read-only inputs (`rule_ids`, `pattern_ids`, `rule_bitmaps`,
87/// `rule_counts`, `file_size`) plus one output (`verdicts`).
88///
89/// The core builder does not append extension buffers because
90/// `RuleCondition::Opaque` is not lowerable without an extension-aware
91/// builder.
92#[must_use]
93pub fn rule_buffers() -> Vec<BufferDecl> {
94    static TEMPLATE: LazyLock<Vec<BufferDecl>> = LazyLock::new(|| {
95        vec![
96            BufferDecl::read("rule_ids", 0, DataType::U32),
97            BufferDecl::read("pattern_ids", 1, DataType::U32),
98            BufferDecl::read("rule_bitmaps", 2, DataType::U32),
99            BufferDecl::read("rule_counts", 3, DataType::U32),
100            BufferDecl::read("file_size", 4, DataType::U32),
101            BufferDecl::output("verdicts", 5, DataType::U32),
102        ]
103    });
104    TEMPLATE.clone()
105}
106
107/// Emit one `Node::Store` per rule into the shared `verdicts` buffer.
108/// The store is guarded by `rule_id < buf_len("verdicts")` so callers
109/// can pack extra slots without corrupting memory.
110///
111/// # Errors
112///
113/// Returns [`RuleBuildError`] when a formula contains a condition the core
114/// builder cannot lower truthfully.
115#[must_use]
116pub fn rule_nodes(rules: &[(RuleFormula, u32)]) -> Result<Vec<Node>, RuleBuildError> {
117    rules
118        .iter()
119        .map(|(formula, rule_id)| {
120            Ok(Node::if_then(
121                Expr::lt(Expr::u32(*rule_id), Expr::buf_len("verdicts")),
122                vec![Node::store(
123                    "verdicts",
124                    Expr::u32(*rule_id),
125                    formula_expr(formula, *rule_id)?,
126                )],
127            ))
128        })
129        .collect()
130}
131
132/// Try to emit one `Node::Store` per rule into the shared `verdicts` buffer.
133///
134/// # Errors
135///
136/// Returns [`RuleBuildError`] when a formula contains a condition the core
137/// builder cannot lower truthfully.
138pub fn try_rule_nodes(rules: &[(RuleFormula, u32)]) -> Result<Vec<Node>, RuleBuildError> {
139    rule_nodes(rules)
140}
141
142/// Lower a [`RuleFormula`] to a boolean `Expr` tree  -
143/// `Condition` → single predicate, `And`/`Or`/`Not` → bool combinators.
144///
145/// # Errors
146///
147/// Returns [`RuleBuildError`] when the formula contains a condition the core
148/// builder cannot lower truthfully.
149#[must_use]
150pub fn formula_expr(formula: &RuleFormula, rule_id: u32) -> Result<Expr, RuleBuildError> {
151    match formula {
152        RuleFormula::Condition(condition) => condition_expr(condition, rule_id),
153        RuleFormula::And(left, right) => Ok(Expr::and(
154            formula_expr(left, rule_id)?,
155            formula_expr(right, rule_id)?,
156        )),
157        RuleFormula::Or(left, right) => Ok(Expr::or(
158            formula_expr(left, rule_id)?,
159            formula_expr(right, rule_id)?,
160        )),
161        RuleFormula::Not(formula) => Ok(Expr::not(formula_expr(formula, rule_id)?)),
162    }
163}
164
165/// Try to lower a [`RuleFormula`] to a boolean `Expr` tree.
166///
167/// # Errors
168///
169/// Returns [`RuleBuildError`] when any contained condition lacks a truthful
170/// core IR lowering.
171pub fn try_formula_expr(formula: &RuleFormula, rule_id: u32) -> Result<Expr, RuleBuildError> {
172    formula_expr(formula, rule_id)
173}
174
175/// Lower a [`RuleCondition`] to the scalar boolean `Expr` the rule-set
176/// program stores into `verdicts[rule_id]`.
177///
178/// # Errors
179///
180/// Returns [`RuleBuildError`] when the condition has no truthful core IR
181/// lowering.
182#[must_use]
183pub fn condition_expr(condition: &RuleCondition, rule_id: u32) -> Result<Expr, RuleBuildError> {
184    try_condition_expr(condition, rule_id)
185}
186
187/// Try to lower a [`RuleCondition`] to the scalar boolean `Expr` the rule-set
188/// program stores into `verdicts[rule_id]`.
189///
190/// # Errors
191///
192/// Returns [`RuleBuildError`] for condition variants that need a runtime text
193/// source or an extension-aware lowering that the core rule builder does not
194/// own.
195pub fn try_condition_expr(condition: &RuleCondition, rule_id: u32) -> Result<Expr, RuleBuildError> {
196    match condition {
197        RuleCondition::PatternExists { pattern_id } => {
198            Ok(Expr::ne(pattern_state(*pattern_id), Expr::u32(0)))
199        }
200        RuleCondition::PatternCountGt {
201            pattern_id,
202            threshold,
203        } => Ok(Expr::gt(pattern_count(*pattern_id), Expr::u32(*threshold))),
204        RuleCondition::PatternCountGte {
205            pattern_id,
206            threshold,
207        } => Ok(Expr::ge(pattern_count(*pattern_id), Expr::u32(*threshold))),
208        RuleCondition::FileSizeLt(threshold) => Ok(file_size_cmp(Expr::lt, *threshold, true)),
209        RuleCondition::FileSizeLte(threshold) => Ok(file_size_cmp(Expr::le, *threshold, true)),
210        RuleCondition::FileSizeGt(threshold) => Ok(file_size_cmp(Expr::gt, *threshold, false)),
211        RuleCondition::FileSizeGte(threshold) => Ok(file_size_cmp(Expr::ge, *threshold, false)),
212        RuleCondition::FileSizeEq(threshold) => Ok(file_size_cmp(Expr::eq, *threshold, false)),
213        RuleCondition::FileSizeNe(threshold) => Ok(file_size_cmp(Expr::ne, *threshold, true)),
214        RuleCondition::LiteralTrue => Ok(Expr::u32(1)),
215        RuleCondition::LiteralFalse => Ok(Expr::u32(0)),
216        RuleCondition::RegexMatch { .. } => Err(unsupported_rule_condition(
217            rule_id,
218            "RegexMatch",
219            "lower the regex against a concrete buffer in an extension-aware builder, or pre-evaluate the regex condition before calling the core builder.",
220        )),
221        RuleCondition::SubstringMatch { .. } => Err(unsupported_rule_condition(
222            rule_id,
223            "SubstringMatch",
224            "lower the substring predicate against a concrete buffer in an extension-aware builder, or pre-evaluate the text condition before calling the core builder.",
225        )),
226        RuleCondition::PrefixMatch { .. } => Err(unsupported_rule_condition(
227            rule_id,
228            "PrefixMatch",
229            "lower the prefix predicate against a concrete buffer in an extension-aware builder, or pre-evaluate the text condition before calling the core builder.",
230        )),
231        RuleCondition::SuffixMatch { .. } => Err(unsupported_rule_condition(
232            rule_id,
233            "SuffixMatch",
234            "lower the suffix predicate against a concrete buffer in an extension-aware builder, or pre-evaluate the text condition before calling the core builder.",
235        )),
236        RuleCondition::RangeMatch { value, min, max } => {
237            Ok(bool_expr(min <= value && value <= max))
238        }
239        RuleCondition::SetMembership { value, set } => Ok(bool_expr(
240            set.iter()
241                .any(|candidate| candidate.as_ref() == value.as_ref()),
242        )),
243        RuleCondition::FieldInSet { .. } => Err(unsupported_rule_condition(
244            rule_id,
245            "FieldInSet",
246            "FieldInSet requires per-record field lookup; it is supported only by the reference evaluator (`vyre_libs::rule::reference_eval`). Lower against a concrete buffer in an extension-aware builder before calling the core lowering.",
247        )),
248        RuleCondition::Opaque(ext) => Err(RuleBuildError::OpaqueCondition {
249            rule_id,
250            extension_id: ext.extension_id().as_u32(),
251        }),
252    }
253}
254
255fn unsupported_rule_condition(
256    rule_id: u32,
257    condition: &'static str,
258    fix: &'static str,
259) -> RuleBuildError {
260    RuleBuildError::UnsupportedCondition {
261        rule_id,
262        condition,
263        fix,
264    }
265}
266
267fn bool_expr(value: bool) -> Expr {
268    Expr::u32(u32::from(value))
269}
270
271/// Emit a `file_size` comparison that guards against `threshold` values
272/// wider than u32. On overflow the result collapses to the constant
273/// `overflow_is_true` so semantics stay well-defined.
274#[must_use]
275pub fn file_size_cmp<F>(cmp_fn: F, threshold: u64, overflow_is_true: bool) -> Expr
276where
277    F: FnOnce(Expr, Expr) -> Expr,
278{
279    match u32::try_from(threshold) {
280        Ok(t) => cmp_fn(Expr::load("file_size", Expr::u32(0)), Expr::u32(t)),
281        Err(_) => {
282            if overflow_is_true {
283                Expr::u32(1)
284            } else {
285                Expr::u32(0)
286            }
287        }
288    }
289}
290
291/// Safe load from the `rule_bitmaps` buffer  -  returns 0 when
292/// `pattern_id` is out of range so the rule predicate stays defined.
293#[must_use]
294pub fn pattern_state(pattern_id: u32) -> Expr {
295    pattern_buffer_value(pattern_id, "rule_bitmaps")
296}
297
298/// Safe load from the `rule_counts` buffer  -  returns 0 when
299/// `pattern_id` is out of range so the rule predicate stays defined.
300#[must_use]
301pub fn pattern_count(pattern_id: u32) -> Expr {
302    pattern_buffer_value(pattern_id, "rule_counts")
303}
304
305fn pattern_buffer_value(pattern_id: u32, buffer: &str) -> Expr {
306    Expr::select(
307        Expr::lt(Expr::u32(pattern_id), Expr::buf_len(buffer)),
308        Expr::load(buffer, Expr::u32(pattern_id)),
309        Expr::u32(0),
310    )
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use smallvec::smallvec;
317    use std::any::Any;
318    use std::sync::Arc;
319    use vyre_foundation::extension::RuleConditionExt;
320    use vyre_spec::extension::ExtensionRuleConditionId;
321
322    #[derive(Debug)]
323    struct TestOpaqueCondition;
324
325    impl RuleConditionExt for TestOpaqueCondition {
326        fn extension_id(&self) -> ExtensionRuleConditionId {
327            ExtensionRuleConditionId::from_name("vyre.test.rule.opaque")
328        }
329
330        fn evaluate_opaque(&self, _ctx: &dyn Any) -> bool {
331            true
332        }
333
334        fn stable_fingerprint(&self) -> [u8; 32] {
335            [7; 32]
336        }
337    }
338
339    #[test]
340    fn try_build_rule_program_preserves_supported_conditions() {
341        let formula = RuleFormula::and(
342            RuleFormula::condition(RuleCondition::PatternExists { pattern_id: 3 }),
343            RuleFormula::not(RuleFormula::condition(RuleCondition::FileSizeLt(4096))),
344        );
345
346        let program = try_build_rule_program(&[(formula, 5)]).expect("Fix: supported rule lowers");
347
348        assert!(program.has_buffer("rule_bitmaps"));
349        assert!(program.has_buffer("rule_counts"));
350        assert!(program.has_buffer("file_size"));
351        assert!(program.has_buffer("verdicts"));
352    }
353
354    #[test]
355    fn unsupported_conditions_return_actionable_errors() {
356        let unsupported = vec![
357            RuleCondition::RegexMatch {
358                field: Arc::from("path"),
359                pattern: Arc::from(".*\\.rs"),
360            },
361            RuleCondition::SubstringMatch {
362                haystack: Arc::from("path"),
363                needle: Arc::from("src/"),
364            },
365            RuleCondition::PrefixMatch {
366                value: Arc::from("path"),
367                prefix: Arc::from("src/"),
368            },
369            RuleCondition::SuffixMatch {
370                value: Arc::from("path"),
371                suffix: Arc::from(".rs"),
372            },
373        ];
374
375        for condition in unsupported {
376            let error = try_condition_expr(&condition, 42).expect_err("condition must reject");
377            let message = error.to_string();
378
379            assert!(
380                matches!(
381                    error,
382                    RuleBuildError::UnsupportedCondition { rule_id: 42, .. }
383                ),
384                "wrong error: {message}"
385            );
386            assert!(message.contains("Fix:"), "missing fix: {message}");
387            assert!(
388                !message.contains("rule.unsupported"),
389                "error must not expose constant-success calls: {message}"
390            );
391        }
392    }
393
394    #[test]
395    fn static_range_and_set_conditions_lower_to_constants() {
396        assert_eq!(
397            try_condition_expr(
398                &RuleCondition::RangeMatch {
399                    value: 12,
400                    min: 10,
401                    max: 20,
402                },
403                7,
404            )
405            .expect("Fix: range condition lowers"),
406            Expr::u32(1)
407        );
408        assert_eq!(
409            try_condition_expr(
410                &RuleCondition::SetMembership {
411                    value: Arc::from("critical"),
412                    set: smallvec![Arc::from("critical"), Arc::from("high")],
413                },
414                7,
415            )
416            .expect("Fix: set membership condition lowers"),
417            Expr::u32(1)
418        );
419    }
420
421    #[test]
422    fn opaque_condition_returns_construction_error() {
423        let condition = RuleCondition::Opaque(Arc::new(TestOpaqueCondition));
424        let error = try_condition_expr(&condition, 9).expect_err("opaque must reject");
425        let message = error.to_string();
426
427        assert!(
428            matches!(
429                error,
430                RuleBuildError::OpaqueCondition {
431                    rule_id: 9,
432                    extension_id
433                } if extension_id == ExtensionRuleConditionId::from_name("vyre.test.rule.opaque").as_u32()
434            ),
435            "wrong error: {message}"
436        );
437        assert!(message.contains("extension-aware rule builder"));
438    }
439
440    #[test]
441    fn condition_expr_returns_error_instead_of_panicking_or_constant_success() {
442        let condition = RuleCondition::RegexMatch {
443            field: Arc::from("path"),
444            pattern: Arc::from(".*"),
445        };
446
447        let error = condition_expr(&condition, 1).expect_err("regex condition must reject");
448
449        assert!(
450            matches!(
451                error,
452                RuleBuildError::UnsupportedCondition { rule_id: 1, .. }
453            ),
454            "wrong error: {error}"
455        );
456    }
457}