Skip to content
Merged
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ The entire specification is defined through comprehensive test cases in `test/fi
- **Array Length**: `.length` accessor for array size checks
- **List Quantifiers**: `any`, `all`, `none` operators for element-level conditions
- **List Filtering**: `where (predicate)` operator to filter arrays before applying `.length` or a quantifier (proprietary extension; not part of `nikunjy/rules`)
- **Lenient Mode**: opt-in neutral semantics for missing attributes via `NewEngineWithOptions(WithLenientMode())` (proprietary extension; not part of `nikunjy/rules`)

### Rule Syntax Examples

Expand Down Expand Up @@ -120,6 +121,20 @@ All functionality is validated through the comprehensive test suite in `test/fix
- **String Comparisons**: Lexicographic ordering for string relational operations
- **Membership Operations**: Use strict type checking (no cross-type matching)
- **Large Integer Support**: Preserve precision for integers > 2^53 using dual storage
- **Missing Attributes**: In strict (default) mode, any comparison involving a
missing attribute returns `false`. In lenient mode
(`NewEngineWithOptions(WithLenientMode())`), a comparison predicate
involving a missing attribute returns `true` (neutral) β€” the identity
element for `and`-chains, the dominant pattern in betting lifecycle rules,
so a missing optional field drops out of the conjunction instead of
failing it. Neutrality is uniform across all comparison operators
(`eq`, `ne`, `lt`, `gt`, `le`, `ge`, `co`, `sw`, `ew`, `in`, `not in`,
datetime). Presence (`pr`), logical (`and`/`or`/`not`) and quantifier
operators are unaffected. Neutrality is only neutral inside an `and`-chain:
`not (x eq 10)` -> `false`, `or` short-circuits to `true`, `none` sees
`true`. Opt-in only; `NewEngine()` is strict. Only missing attributes
(key absent from the map) trigger the neutral path; explicit `nil` is
considered present and dispatched to the normal comparison path.

### List Operations
- **Array Length**: Access `.length` on array properties (e.g., `items.length gt 3`)
Expand Down
106 changes: 101 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ A **blazingly fast**, **zero-allocation** rule engine for Go that evaluates logi
2. [πŸ“š API](#-api)
3. [🎯 Context](#-context)
4. [πŸ”€ Rule Language](#-rule-language)
5. [πŸ’Ύ Query Caching](#-query-caching)
6. [⚑ Benchmarks](#-benchmarks)
7. [🀝 Contributing](#-contributing)
8. [πŸ“„ License](#-license)
5. [🟒 Lenient Mode (Neutral Semantics)](#-lenient-mode-neutral-semantics)
6. [πŸ’Ύ Query Caching](#-query-caching)
7. [⚑ Benchmarks](#-benchmarks)
8. [🀝 Contributing](#-contributing)
9. [πŸ“„ License](#-license)

---

Expand Down Expand Up @@ -81,6 +82,19 @@ Creates a new rule engine instance. Each engine maintains its own query cache fo
engine := rule.NewEngine()
```

#### `NewEngineWithOptions(opts ...Option) *Engine`
Creates a new rule engine configured with functional options. `NewEngine()` is equivalent to `NewEngineWithOptions()` with no options (strict mode).

```go
// Strict (default, same as NewEngine()):
engine := rule.NewEngineWithOptions()

// Lenient (neutral semantics) mode:
engine := rule.NewEngineWithOptions(rule.WithLenientMode())
```

See [Lenient Mode (Neutral Semantics)](#-lenient-mode-neutral-semantics) for details.

#### `Evaluate(query string, context rule.D) (bool, error)`
Evaluates a rule expression against the provided context. Returns `true`/`false` and any parsing/evaluation errors.

Expand Down Expand Up @@ -610,6 +624,87 @@ engine.Evaluate(`selections where (odd ge 1.4) all (is_live eq true)`, context)

---

## 🟒 Lenient Mode (Neutral Semantics)

By default the engine runs in **strict mode**: when an attribute is missing
from the context, *every* comparison involving it returns `false`. This is
fast and predictable but unsuitable for rule chains where a missing field
should mean "no constraint" rather than "the rule fails".

**Lenient mode** is an opt-in alternative where a comparison predicate
involving a missing attribute returns **`true`** (neutral). This is the
identity element for `and`-chains β€” the dominant pattern in betting
lifecycle rules β€” so a missing optional field simply drops out of the
conjunction instead of failing it.

| Operator | missing operand (either side) |
|------------------------|--------------------------------|
| `eq` / `==` | `true` |
| `ne` / `!=` | `true` |
| `lt`, `gt`, `le`, `ge` | `true` |
| `co`, `sw`, `ew` | `true` |
| `in` | `true` |
| `not in` | `true` |
| datetime (`dq`…`dg`) | `true` |
| `pr` | unchanged (`false` if missing / `true` if present, incl. `nil`) |

`and`, `or`, `not` and the list quantifiers (`any`/`all`/`none`) are
**unaffected** by lenient mode β€” they keep their normal truthiness
behavior, which leads to intentional composition consequences:

- `not (x eq 10)` with `x` absent β†’ `not true` β†’ `false`
- `x eq 10 or y eq 20` with `x` absent β†’ `or` short-circuits to `true`
- `selections none (r gt 0)` per element where `r` is absent β†’ `none` sees
`true` β†’ returns `false`

These are deliberate: neutrality is neutral only inside an `and`-chain.
For negation/quantifier-heavy rules, prefer strict mode or keep the
relevant fields present.

### Enabling lenient mode

```go
engine := rule.NewEngineWithOptions(rule.WithLenientMode())
```

`NewEngine()` and `NewEngineWithOptions()` with no options keep the original
strict behavior, so existing code is unchanged.

### Example

```go
strict := rule.NewEngine() // default
lenient := rule.NewEngineWithOptions(rule.WithLenientMode())

ctx := rule.D{} // "age" is absent

strict.Evaluate(`age eq 18`, ctx) // -> false
lenient.Evaluate(`age eq 18`, ctx) // -> true (neutral)
lenient.Evaluate(`age ne 18`, ctx) // -> true (neutral)
lenient.Evaluate(`a eq b`, ctx) // -> true (both missing)
lenient.Evaluate(`role not in ["admin","user"]`, ctx) // -> true

// Real constraints still apply: a present field that is false wins.
lenient.Evaluate(`age eq 18 and status eq "settled"`, rule.D{"status": "pending"})
// -> false (age eq 18 is neutral/true, but status eq "settled" is false)
```

### Notes

- Only **missing attributes** (key absent from the context map) trigger the
neutral path. An explicit `nil` value is still considered present
(`IsValid=true`) and is dispatched to the normal comparison path; it does
not go through the lenient branch.
- Lenient mode has **no allocation cost** (a single bool flag on the
evaluator) and does not affect strict-mode performance.
- Lenient mode is a proprietary extension, not part of `nikunjy/rules`.
- The neutral behavior is uniform across all comparison operators. A
per-operator SQL-ish variant (`null eq value -> false`, `null ne value ->
true`, …) is intentionally not provided; it can be added as a separate
option later if needed, without changing this mode.

---

## πŸ’Ύ Query Caching

The rule engine is smart about performance! 🧠 Here's how caching works:
Expand Down Expand Up @@ -832,7 +927,7 @@ This section provides a comprehensive compatibility analysis between NSXBet/rule
|--------|-------|-------|
| **Core Functionality Compatibility** | **77.3%** (17/22 scenarios) | Identical behavior for standard use cases |
| **Migration Code Compatibility** | **90%+** | Percentage of existing code that works without changes |
| **Enhanced Features** | **6 major extensions** | New capabilities not available in nikunjy/rules |
| **Enhanced Features** | **7 major extensions** | New capabilities not available in nikunjy/rules |
| **Breaking Changes** | **1 significant** | Unquoted strings not supported |

**Bottom Line**: NSXBet/rule is highly compatible for standard use cases, provides significant performance improvements, and adds powerful new features while maintaining the same API structure.
Expand Down Expand Up @@ -869,6 +964,7 @@ This section provides a comprehensive compatibility analysis between NSXBet/rule
| **List Quantifiers** | `any`, `all`, `none` over arrays | `selections any (status eq "live")` | Element-level checks on arrays |
| **Array Length** | `.length` accessor on arrays | `items.length ge 3` | Size constraints |
| **`where` Filter** | Filter arrays by predicate before count or quantifier | `selections where (odd ge 1.4).length ge 4` | Counting / quantifying over filtered subsets. **Reserves the keyword `where`.** |
| **Lenient Mode** | Opt-in neutral semantics: missing-attribute comparisons return `true` | `NewEngineWithOptions(rule.WithLenientMode())` | `and`-chains where an optional field should impose no constraint |
| **rule.D Type Alias** | Cleaner syntax | `rule.D{"key": "value"}` | Developer experience |

### πŸ”§ Migration Assessment
Expand Down
44 changes: 44 additions & 0 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,50 @@ func NewEngine() *Engine {
}
}

// Option configures an Engine created with NewEngineWithOptions.
type Option func(*Engine)

// WithLenientMode enables lenient (neutral) evaluation semantics.
//
// In lenient mode, a comparison predicate involving a missing attribute
// returns true (neutral), imposing no constraint. This is the identity
// element for AND-chains (the dominant pattern in betting lifecycle rules),
// so a missing optional field drops out of the conjunction instead of
// failing it:
//
// x eq 10 (x absent) -> true x ne 10 (x absent) -> true
// x lt 10 (x absent) -> true x in [...] (x absent) -> true
// x co "a" (x absent) -> true x dl 30 (x absent) -> true
//
// Composition is intentional: neutral only inside an AND-chain. Under
// negation or quantifiers neutrality becomes decisive, e.g.
// `not (x eq 10)` -> false and `selections none (r gt 0)` sees true per
// element where r is absent and returns false. For such rules prefer strict
// mode (the default).
//
// Presence (pr), logical (and/or/not) and quantifier operators are
// unaffected. The default (strict) mode is unchanged. Opt-in only.
func WithLenientMode() Option {
return func(e *Engine) {
e.evaluator.lenient = true
}
}

// NewEngineWithOptions creates a new rule engine configured with the given
// options. NewEngine() is equivalent to NewEngineWithOptions() with no
// options (strict mode).
func NewEngineWithOptions(opts ...Option) *Engine {
e := NewEngine()

for _, opt := range opts {
if opt != nil {
opt(e)
}
}

return e
}

func (e *Engine) AddQuery(rule string) error {
if _, exists := e.compiledRules.Load(rule); exists {
return nil // Already compiled
Expand Down
21 changes: 19 additions & 2 deletions evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ type EvalResult struct {
}

// Evaluator is an optimized evaluator that avoids allocations during evaluation.
type Evaluator struct{}
//
// When lenient is true, the evaluator applies SQL-ish null-aware semantics
// for comparisons involving missing attributes (see WithLenientMode).
type Evaluator struct {
lenient bool
}

func NewEvaluator() *Evaluator {
return &Evaluator{}
Expand Down Expand Up @@ -600,7 +605,19 @@ func (e *Evaluator) evaluateComparisonOperator(node *ASTNode, context D, result

// If either operand is invalid (missing attribute), comparison is false
if !leftResult.IsValid || !rightResult.IsValid {
result.Bool = false
if e.lenient {
// Neutral semantics: a comparison predicate over a missing attribute
// returns true, imposing no constraint. This is the identity element
// for AND-chains (the dominant pattern in betting lifecycle rules).
// Neutrality only holds inside an AND-chain: under `not`, `or`, or
// list quantifiers it becomes decisive (e.g. `not (x eq 10)` -> false,
// `or` short-circuits to true, `none` sees true per element). For
// those rules prefer strict mode.
result.Bool = true
} else {
result.Bool = false
}

return nil
}

Expand Down
46 changes: 46 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -701,3 +701,49 @@ func Example_ecommerce() {
// Free shipping eligible: true
// Weekend discount eligible: true
}

// Example_lenientMode demonstrates the opt-in lenient (neutral) evaluation
// mode. A comparison predicate involving a missing attribute returns true
// (neutral), imposing no constraint β€” the identity element for AND-chains.
func Example_lenientMode() {
// Default engine: strict mode (missing attribute -> any comparison is false).
strict := rule.NewEngine()

// Opt-in lenient mode via functional options.
lenient := rule.NewEngineWithOptions(rule.WithLenientMode())

// Context with no "age" key.
ctx := rule.D{}

// In strict mode, "age eq 18" is false because age is missing.
rStrict, _ := strict.Evaluate("age eq 18", ctx)

// In lenient mode, a missing attribute makes the comparison neutral (true).
rLenientEq, _ := lenient.Evaluate("age eq 18", ctx)

// Neutrality holds for every comparison operator, so "age ne 18" is also true.
rLenientNe, _ := lenient.Evaluate("age ne 18", ctx)

// And for both-sides-missing: "a eq b" is neutral -> true.
rLenientBothMissing, _ := lenient.Evaluate("a eq b", ctx)

// And for membership: "role not in [...]" is neutral -> true.
rLenientNotIn, _ := lenient.Evaluate(`role not in ["admin","user"]`, ctx)

// AND-chains keep real constraints: a present field that is false still wins.
rAndChain, _ := lenient.Evaluate(`age eq 18 and status eq "settled"`, rule.D{"status": "pending"})

fmt.Printf("strict age eq 18: %t\n", rStrict)
fmt.Printf("lenient age eq 18: %t\n", rLenientEq)
fmt.Printf("lenient age ne 18: %t\n", rLenientNe)
fmt.Printf("lenient a eq b (both missing): %t\n", rLenientBothMissing)
fmt.Printf("lenient role not in [...]: %t\n", rLenientNotIn)
fmt.Printf("lenient age eq 18 and status eq settled (status=pending): %t", rAndChain)
// Output:
// strict age eq 18: false
// lenient age eq 18: true
// lenient age ne 18: true
// lenient a eq b (both missing): true
// lenient role not in [...]: true
// lenient age eq 18 and status eq settled (status=pending): false
}
Loading