Skip to content

feat: add opt-in lenient mode with neutral semantics - #12

Merged
matheosu merged 10 commits into
mainfrom
feat/lenient-mode
Aug 6, 2026
Merged

matheosu merged 10 commits into
mainfrom
feat/lenient-mode

Conversation

@matheosu

@matheosu matheosu commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

📝 Summary

This PR adds an opt-in lenient mode to the rule engine where a comparison predicate involving a missing attribute returns true (neutral), instead of being coerced to false unconditionally. 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.

Motivation

In the current strict (default) mode, when an attribute is missing from the context, every comparison involving it returns false. This is fast and predictable but fails and-chains as soon as any optional field is absent:

status eq "settled" and settled_at dl 30    // settled_at absent -> whole rule false (strict)

For a betting lifecycle, "settled_at absent" should mean "no time constraint", not "the rule fails". Lenient mode fixes this.

Note: an earlier iteration of this PR used SQL-ish (2-valued) per-operator semantics (null eq value -> false, null ne value -> true, …). On review that was wrong for the AND-chain lifecycle use case (an absent settled_at would still make settled_at dl 30 false and fail the chain the same way). This PR now implements neutral = true uniformly.

API

// Default (unchanged, strict):
engine := rule.NewEngine()

// Opt-in lenient:
engine := rule.NewEngineWithOptions(rule.WithLenientMode())

NewEngine() and NewEngineWithOptions() with no options keep the original strict behavior, so existing code is 100% unaffected.

Semantics (neutral)

The table applies symmetrically — "missing operand" covers null on either side (left or right). The lenient path is only triggered when an attribute is missing from the context (key absent from the map); an explicit nil value is still considered present (see Notes).

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 — they keep their normal truthiness behavior. This 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.

Example

strict  := rule.NewEngine()
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)

Implementation

  • Evaluator gains a lenient bool field (zero allocation cost — field read by value).
  • The single semantic change is in evaluateComparisonOperator: when an operand is missing and e.lenient is set, result.Bool = true. The neutral behavior is uniform across all comparison operators.
  • pr (presence) is handled in a separate code path and is intentionally unchanged.
  • The lenient path is only triggered by missing attributes (key absent from the context map). Explicit nil values are still considered present (IsValid=true) and dispatched to the normal comparison path — they do not go through the lenient branch.

What we did (commits)

d0926af feat: switch lenient mode from SQL-ish to neutral semantics
0d1103e test: add explicit-nil vs absent lenient comparison cases
48d1d16 chore: remove internal planning doc from branch
8984256 docs: update CLAUDE.md with lenient mode spec
901c5a0 docs: document lenient mode in README
2f5a0ec test: add lenient mode example in example_test.go
8765c99 test: add lenient mode null semantics test suite
051d402 feat: implement SQL-ish null comparison in lenient mode
e2c32eb feat: add lenient mode option to Engine and Evaluator
  • engine.go — Option, WithLenientMode(), NewEngineWithOptions()
  • evaluator.go — lenient bool field + neutral branch in evaluateComparisonOperator
  • test/lenient_fixtures.go — ~70 cases covering eq/ne/lt/gt/le/ge/co/sw/ew/in/not_in with missing attributes, both-sides-missing, missing-on-right, nil-vs-concrete, and/or/not composition, nested missing chains, datetime operators and presence
  • test/rule_engine_test.go — TestRulesLenient runner using NewEngineWithOptions(WithLenientMode())
  • example_test.go — Example_lenientMode with verified // Output: block
  • README.md — new "🟢 Lenient Mode (Neutral Semantics)" section, TOC, API entry, Exclusive Features table
  • CLAUDE.md — lenient mode spec under Type System Compliance + features list

⚡ Performance Impact

  • No performance impact

Strict mode is bit-for-bit identical (the new branch only runs when e.lenient is set). Lenient mode adds a single predictable branch in one hot-path function and a field read — no allocations.

Benchmarks (Apple M4 Pro), strict path unchanged:

BenchmarkOptimizedEngineSimple-12        56673189    21.19 ns/op    0 B/op    0 allocs/op
BenchmarkOptimizedEngineComplex-12       21660681    55.23 ns/op    0 B/op    0 allocs/op
BenchmarkOptimizedEngineStringOps-12     25200244    47.70 ns/op    0 B/op    0 allocs/op
BenchmarkOptimizedEngineInOperator-12    23959527    50.11 ns/op   24 B/op    1 allocs/op  (existing []any runtime alloc)
BenchmarkOptimizedEngineNestedProps-12   26810374    41.22 ns/op    0 B/op    0 allocs/op
BenchmarkOptimizedEngineArrayLength-12   53156539    22.48 ns/op    0 B/op    0 allocs/op

All core evaluations remain sub-100ns with 0 allocs/op (the In 1-alloc and ComplexBetting 2-allocs are pre-existing Go runtime []any handling, documented in CLAUDE.md, and untouched by this PR).

✅ Checklist

  • Code follows project style
  • Self-reviewed the code
  • Tests added for new functionality
  • README updated (if required)
  • Linter passes 100% clean (golangci-lint run ./... → 0 issues)
  • gofmt clean
  • Fuzz tests pass (FuzzRuleExecution, 20s, no panics)
  • Zero allocations maintained in hot paths
  • Core evaluation remains under 100ns
  • Thread-safe for concurrent usage (lenient flag is set once at construction; Evaluator otherwise stateless per evaluation)
  • NewEngine() API-compatible — lenient is opt-in only

Summary by CodeRabbit

  • New Features

    • Added an opt-in lenient evaluation mode for comparisons involving missing attributes.
    • Added configurable engine options to enable lenient mode while preserving strict behavior by default.
    • Documented neutral comparison semantics across operators, logical expressions, nested properties, and explicit nil values.
    • Added examples demonstrating strict and lenient evaluation.
  • Tests

    • Added comprehensive coverage for comparison, membership, logical, datetime, presence, and nested-property scenarios.

Introduce NewEngineWithOptions and WithLenientMode Option. The Evaluator
gains a lenient bool field (zero allocation cost) wired from the Engine.
NewEngine() behavior is unchanged (strict mode); lenient is opt-in only.
Add lenientCompare() that applies null-aware semantics when at least one
operand is missing: eq null<->value is false (null<->null is true), ne flips
accordingly, not in becomes true, and ordering/string/datetime ops stay false.
Strict mode is unchanged: the new branch only runs when e.lenient is set.
Add Lenient* fixtures covering eq/ne/lt/gt/le/ge/co/sw/ew/in/not_in with
missing attributes, both-sides-missing, nil-vs-concrete, and/or/not,
nested missing chains, datetime operators and presence (unaffected).
Wire a TestRulesLenient runner using NewEngineWithOptions(WithLenientMode).
Add Example_lenientMode with a verified // Output block that contrasts strict
vs lenient behavior for missing attributes, demonstrating ne/not in/eq null.
Add a 'Lenient Mode (Null-Aware Semantics)' section with the SQL-ish
operator table, enabling snippet, example and notes. Register
NewEngineWithOptions in the API section, add the feature to the Exclusive
Features table, and link it from the TOC.
Add lenient mode bullet to the supported operations list and document
SQL-ish null semantics under Type System Compliance. Also fix linter
findings (exhaustive nolint, wsl_v5 whitespace) introduced by the new
branch.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@matheosu, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 56 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d3d3e044-c59c-4ffa-a363-671867369958

📥 Commits

Reviewing files that changed from the base of the PR and between d0926af and 90a3e9b.

📒 Files selected for processing (1)
  • README.md
📝 Walkthrough

Walkthrough

Adds an opt-in lenient mode for missing-attribute comparisons. Strict mode remains the default. The change adds engine options, evaluator behavior, operator fixtures, executable coverage, examples, and documentation.

Changes

Lenient comparison mode

Layer / File(s) Summary
Engine option and evaluator comparison flow
engine.go, evaluator.go
Adds Option, WithLenientMode, and NewEngineWithOptions. Lenient evaluation returns true for invalid comparison operands.
Lenient operator fixtures and executable coverage
test/lenient_fixtures.go, test/rule_engine_test.go, example_test.go
Adds coverage for comparison, logical, nested, datetime, membership, presence, explicit nil, and strict present-value behavior.
API and semantic documentation
CLAUDE.md, README.md
Documents configuration, strict defaults, neutral comparison semantics, operator interactions, explicit nil, compatibility, and examples.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: heynemann, mzaqueu

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: adding opt-in lenient mode with neutral comparison semantics.
Description check ✅ Passed The description covers the summary, performance impact, API, semantics, tests, documentation, validation, and checklist requirements.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/lenient-mode

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@matheosu
matheosu requested review from heynemann and mzaqueu and removed request for mzaqueu August 4, 2026 22:05
PLAN.md was a working draft of the implementation plan and should not
ship in the repository.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@PLAN.md`:
- Around line 5-7: Atualize o objetivo em PLAN.md para alinhar a descrição do
modo “lenient” ao contrato explícito de nil: remova “ou nil” da regra que
transforma campos ausentes em null, ou declare claramente a exceção de que nil
explícito permanece presente, mantendo a definição detalhada das linhas
posteriores consistente.

In `@README.md`:
- Line 946: Reconcile the exclusive-feature count in README.md: the table now
lists seven capability rows when “rule.D Type Alias” is excluded, while the
summary reports six. Update the summary to reflect the actual count, or
explicitly define a counting rule that explains the exclusion.

In `@test/lenient_fixtures.go`:
- Around line 149-156: Extend LenientPresenceTests with explicit-nil comparison
cases that execute lenientCompare: add x eq y using rule.D{"x": nil} with y
absent and expect false, and x ne y with the same data and expect true. Keep the
existing pr cases unchanged and use the established test-case structure.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 54e83c91-12c5-47e7-9277-13654b4ca0b6

📥 Commits

Reviewing files that changed from the base of the PR and between d0b44bf and 8984256.

📒 Files selected for processing (8)
  • CLAUDE.md
  • PLAN.md
  • README.md
  • engine.go
  • evaluator.go
  • example_test.go
  • test/lenient_fixtures.go
  • test/rule_engine_test.go

Comment thread PLAN.md Outdated
Comment thread README.md Outdated
Comment thread test/lenient_fixtures.go
Address CodeRabbit review: extend LenientPresenceTests with x eq y /
x ne y cases using rule.D{"x": nil} (present) vs y absent, expecting
false and true respectively. These exercise lenientCompare when only
one operand is missing. Also fix the README exclusive-features count
(6 -> 7 major extensions) now that Lenient Mode is listed.
@matheosu

matheosu commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Addressing CodeRabbit review (3 actionable findings) per the "verify against current code, fix only still-valid issues" guidance:

1. PLAN.md (lines 5-7) — "remova 'ou nil'..."
⏭️ Skip: PLAN.md was removed from the PR in commit 48d1d16 (it was an internal planning draft that should not ship). The finding no longer applies to the current tree.

2. README.md (line 909) — exclusive-feature count mismatch
✅ Fixed: the summary said "6 major extensions" but the table now lists 7 (excluding rule.D Type Alias, which is a syntactic convenience rather than a feature — consistent with the original count convention). Updated to "7 major extensions". Commit 0d1103e.

3. test/lenient_fixtures.go (lines 149-156) — add explicit-nil comparison cases
✅ Fixed: added two cases to LenientPresenceTests keeping the existing pr cases untouched:

  • lenient_nil_vs_absent_eq: x eq y with rule.D{"x": nil} (y absent) → false
  • lenient_nil_vs_absent_ne: x ne y with rule.D{"x": nil} (y absent) → true

These exercise lenientCompare when only one operand is missing (explicit nil is present → IsValid=true and does not route through lenientCompare on its own; it only triggers the null-aware path because y is absent). Expected values verified against the implementation before adding.

Validation: go test ./... ✅, golangci-lint run ./... → 0 issues ✅, gofmt clean ✅.

A comparison predicate over a missing attribute now returns true (neutral)
across all comparison operators (eq, ne, lt, gt, le, ge, co, sw, ew, in,
not in, datetime). 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.

The SQL-ish per-operator logic (null eq value -> false, null ne value ->
true, ...) is removed in favor of a single result.Bool = true branch.
Neutrality only holds inside an AND-chain: under not, or, or list
quantifiers it becomes decisive (documented in code + tests). pr, logical
and quantifier operators are unaffected.

Tests in test/lenient_fixtures.go and Example_lenientMode are updated to
the neutral truth table; the old SQL-ish expectations are replaced.
@matheosu matheosu changed the title feat: add opt-in lenient mode with SQL-ish null-aware semantics feat: add opt-in lenient mode with neutral semantics Aug 5, 2026
The lenientCompare function was removed when switching to uniform neutral
semantics; update the note to refer to 'the lenient branch' instead.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/lenient_fixtures.go`:
- Around line 20-23: Add executable quantifier fixture cases to TestRulesLenient
for any, all, and none using missing comparison attributes, covering
short-circuit behavior and vacuous truth as documented. Ensure the assertions
match the existing lenient semantics and keep the fixture group integrated with
the current test structure.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 020844e9-b136-4cb1-b400-0e003105e5e6

📥 Commits

Reviewing files that changed from the base of the PR and between 8984256 and d0926af.

📒 Files selected for processing (6)
  • CLAUDE.md
  • README.md
  • engine.go
  • evaluator.go
  • example_test.go
  • test/lenient_fixtures.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • example_test.go
  • CLAUDE.md
  • engine.go

Comment thread test/lenient_fixtures.go
Comment on lines +20 to +23
* Composition consequences (documented, intentional):
* not (x eq 10) -> not true -> false
* x eq 10 or y eq 20 -> OR short-circuits to true
* selections none (r gt 0) -> sees true per element where r is absent -> false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add executable quantifier fixtures.

Lines 20-23 document none behavior, but TestRulesLenient does not run a quantifier fixture group. Add any, all, and none cases with missing comparison attributes. Verify short-circuiting and vacuous truth where applicable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/lenient_fixtures.go` around lines 20 - 23, Add executable quantifier
fixture cases to TestRulesLenient for any, all, and none using missing
comparison attributes, covering short-circuit behavior and vacuous truth as
documented. Ensure the assertions match the existing lenient semantics and keep
the fixture group integrated with the current test structure.

Source: Coding guidelines

@matheosu
matheosu merged commit bd8faac into main Aug 6, 2026
2 checks passed
@matheosu
matheosu deleted the feat/lenient-mode branch August 6, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants