ooroo 0.3.0

A fast, compiled rule engine with a text-based DSL
Documentation
# src/types/ — Type System

All core types live here. The files divide cleanly by concern.

## File Responsibilities

| File | Types |
|------|-------|
| `expr.rs` | `Expr`, `CompiledExpr`, `Bound`, `CompiledBound`, `CompareOp`, `FieldExpr`, `field()`, `rule_ref()`, `bound_field()` |
| `value.rs` | `Value`, `like_match()` |
| `rule.rs` | `Rule`, `CompiledRule`, `Terminal` |
| `ruleset.rs` | `RuleSetBuilder`, `RuleBuilder`, `RuleSet` |
| `context.rs` | `Context` |
| `indexed_context.rs` | `IndexedContext`, `ContextBuilder` |
| `field_registry.rs` | `FieldRegistry` |
| `verdict.rs` | `Verdict` |
| `evaluation_report.rs` | `EvaluationReport` |
| `error.rs` | `CompileError` |

## Type Hierarchy

### Expression types

There are two parallel expression enums: the user-facing `Expr` (string paths, string rule names) and the internal `CompiledExpr` (usize indices). `compile::compile_expr()` transforms one into the other.

```
Expr (user-facing)                 CompiledExpr (internal)
─────────────────                  ────────────────────────
Compare { field: String, ... }  →  Compare { field_index: usize, ... }
And(Box<Expr>, Box<Expr>)       →  And(Box<CompiledExpr>, Box<CompiledExpr>)
Or(...)                         →  Or(...)
Not(Box<Expr>)                  →  Not(Box<CompiledExpr>)
RuleRef(String)                 →  RuleRef(usize)   ← topological sort index
In { field: String, members: Vec<Bound> }
                                →  In { field_index, members: Vec<CompiledBound> }
NotIn { ... }                   →  NotIn { ... }
Between { field, low, high: Bound }
                                →  Between { field_index, low, high: CompiledBound }
Like { field: String, pattern } →  Like { field_index, pattern }
NotLike { ... }                 →  NotLike { ... }
IsNull(String)                  →  IsNull(usize)
IsNotNull(String)               →  IsNotNull(usize)
```

### Bound types

`Bound` is used in `IN` and `BETWEEN` expressions. Both the field being tested and the list/range bounds can independently be either a static literal or a runtime field reference.

```rust
pub enum Bound {
    Literal(Value),      // static: 18, "US", true
    Field(String),       // dynamic: "tier.max_score" resolved at eval time
}

pub(crate) enum CompiledBound {
    Literal(Value),
    FieldIndex(usize),   // resolved from FieldRegistry
}
```

Use `bound_field("path")` in the builder API to create a `Bound::Field`. Bare values convert automatically via `Into<Bound>`.

### Value types

```rust
pub enum Value {
    Int(i64),
    Float(f64),
    Bool(bool),
    String(String),
}
```

All primitive Rust types convert via `Into<Value>`:
- `i64``Value::Int`
- `f64``Value::Float`
- `bool``Value::Bool`
- `&str`, `String``Value::String`

Type mismatches in comparisons return `false` at eval time — no panic, no error.

`like_match(text, pattern)` in `value.rs` implements SQL LIKE semantics: `%` = zero or more chars, `_` = exactly one char, case-sensitive.

### Rule types

```rust
pub struct Rule {
    pub name: String,
    pub condition: Option<Expr>,   // None until .when() is called
}

pub(crate) struct CompiledRule {
    pub name: String,
    pub condition: CompiledExpr,
    pub index: usize,              // position in topological sort
}

pub struct Terminal {
    pub rule_name: String,
    pub priority: u32,             // lower = checked first
}
```

## Builder API

### RuleSetBuilder

```rust
RuleSetBuilder::new()
    .rule("name", |r| r.when(expr))    // define a rule
    .terminal("name", priority)         // mark a rule as a terminal
    .compile()?                         // validate and compile → RuleSet
```

`.rule()` takes a closure `FnOnce(RuleBuilder) -> RuleBuilder`. The closure must call `.when(expr)` — if it doesn't, `.compile()` returns `CompileError::MissingCondition`.

### FieldExpr (expression building)

`field("path")` returns a `FieldExpr`. Call a comparison method on it to get an `Expr`:

```rust
field("user.age").gte(18_i64)               // Expr::Compare { op: Gte }
field("status").eq("active")                // Expr::Compare { op: Eq }
field("country").is_in(["US", "CA"])        // Expr::In
field("status").not_in(["banned"])          // Expr::NotIn
field("age").between(18_i64, 65_i64)        // Expr::Between (both literal)
field("score").between(0_i64, bound_field("tier.max"))  // Expr::Between (mixed)
field("email").like("%@gmail.com")          // Expr::Like
field("email").not_like("%@test.%")         // Expr::NotLike
field("profile").is_null()                  // Expr::IsNull
field("profile").is_not_null()              // Expr::IsNotNull
```

All `FieldExpr` methods carry `#[must_use]`. A `FieldExpr` with no comparison method is a logic bug — the compiler will warn.

### Composing expressions

```rust
expr1.and(expr2)    // Expr::And — short-circuits on false
expr1.or(expr2)     // Expr::Or  — short-circuits on true
!expr               // Expr::Not — via std::ops::Not
rule_ref("name")    // Expr::RuleRef — references another rule
```

`.and()` and `.or()` are left-associative when chained: `a.and(b).and(c)` → `And(And(a, b), c)`.

## Context vs IndexedContext

### Context (simple, string-based)

```rust
let ctx = Context::new()
    .set("user.age", 25_i64)
    .set("user.status", "active");

ruleset.evaluate(&ctx)
```

`Context` stores values in a nested `HashMap`. At evaluation time, `flatten_context()` allocates a `Vec<Option<Value>>` keyed by the field registry. Use for tests and low-frequency code.

### IndexedContext (fast path)

```rust
let builder = ruleset.context_builder();   // borrows FieldRegistry from RuleSet
let ctx = builder
    .set("user.age", 25_i64)
    .set("user.status", "active")
    .build();

ruleset.evaluate_indexed(&ctx)
```

`ContextBuilder` is tied to a specific `RuleSet`'s `FieldRegistry`. `.set()` resolves the field path to its index immediately. `.build()` produces an `IndexedContext` which is just a `Vec<Option<Value>>` — no string lookups at eval time.

Unknown field paths passed to `.set()` are silently ignored (the field isn't in the registry, so there's no slot to write to).

### FieldRegistry

`FieldRegistry` is built during compilation by walking all `Expr` trees. It maps each unique field path to a stable `usize` index. The registry is immutable after `RuleSet` is built.

`context_builder()` returns a `ContextBuilder<'_>` that borrows the registry — this enforces that `IndexedContext` values are only used with the ruleset they were built for.

## Evaluation Results

### Verdict

```rust
pub struct Verdict {
    terminal: String,
    result: bool,   // always true (evaluation stops at first match)
}

verdict.terminal()  // which terminal matched
verdict.matched()   // always true
```

### EvaluationReport

Returned by `evaluate_detailed()` / `evaluate_detailed_indexed()`. Contains:
- The `Verdict` (or `None` if no terminal matched)
- Which rule names evaluated to `true`
- Evaluation order (topological)
- Wall-clock duration

Use for diagnostics and debugging only — it has more overhead than standard evaluation.

## CompileError

```rust
pub enum CompileError {
    MissingCondition { rule: String },        // .when() not called
    DuplicateRule { name: String },           // two rules with same name
    UndefinedTerminal { terminal: String },   // terminal references missing rule
    DuplicateTerminal { terminal: String },   // rule registered as terminal twice
    NoTerminals,                              // no .terminal() calls at all
    UndefinedRuleRef { rule, reference },     // rule_ref("missing")
    CyclicDependency { path: Vec<String> },   // a → b → a
}
```

All errors are checked eagerly at `.compile()`. Evaluation never returns an error.