Skip to content

Reuse the cel environment built from a Context across evaluations - #51

Open
hardbyte wants to merge 3 commits into
mainfrom
brian/jolly-franklin-75fkzx-context-reuse
Open

hardbyte wants to merge 3 commits into
mainfrom
brian/jolly-franklin-75fkzx-context-reuse

Conversation

@hardbyte

Copy link
Copy Markdown
Owner

Why

Every evaluate() and Program.execute() call rebuilt the cel-rust context from scratch: each variable re-boxed into a Box<dyn Val>, each registered Python function re-wrapped in a fresh closure. So the per-call cost scaled with the size of the Python Context, not the expression. The CLI, which registers all 47 extended-stdlib functions, paid ~7.5 µs of setup to evaluate 1 + 2.

What

Context now builds its cel environment on first use and caches it (Mutex<Option<Arc<cel::Context<'static>>>>). add_variable, add_function and update drop the cache. set_variable_resolver does not need to: the resolver is bound per call in a child scope via cel-rust's Context::new_inner_scope, which keeps exactly the lookup order the resolver had on the root (resolver → registered variables → type identifiers), and leaves the root untouched and shareable.

The evaluation borrows an Arc clone rather than the Python object, so a callback may mutate, or re-enter evaluation with, the Context it was registered on; the mutation applies from the next evaluation, which is also what happened before (the old code copied variables and functions out before executing).

Dict contexts are still materialised per call, because a dict can change between calls without notice.

Also in this PR, because the same code was being rewritten: the Python-function wrapper and the compile step are factored into helpers shared by evaluate()/compile(), and the panic message for an execution-time panic no longer calls itself a parser error.

Numbers

min of 3 × 50 000 iterations, release build, same machine. "Before" is main's Rust (built from the #44 branch, which only touches Python).

Program.execute() against… before after
no context 0.13 µs 0.15 µs
dict, 2 vars 0.43 µs 0.54 µs
Context, 2 vars 0.31 µs 0.18 µs
Context, 2 vars + 1 Python fn (called) 0.70 µs 0.37 µs
Context, 2 vars + resolver 0.43 µs 0.24 µs
Context with the 47 extended-stdlib functions 7.48 µs 0.16 µs
Context with 200 vars 31.85 µs 0.13 µs

The no-context and dict rows are within run-to-run noise (an alternating A/B of 7 × 100 000 iterations gave 0.13–0.14 vs 0.13–0.14 and 0.45–0.48 vs 0.42–0.48).

Tests

New tests/test_context_reuse.py pins the contract: every mutator is visible on the next evaluation (including replacing a function and replacing the resolver); resolver precedence over registered variables is unchanged; a callback can mutate the context it runs under and can call evaluate()/execute() with that same context; one Context serves many programs; a Context shared across 8 threads returns correct results. 528 passed, 1 skipped, 5 xfailed. cargo fmt --check, cargo clippy -D warnings, cargo test, ruff and mypy clean.

Note for merging: this and #44 both add to the CHANGELOG's Unreleased section, so whichever lands second will need a trivial conflict resolution there.

https://claude.ai/code/session_019WbvXZFm8Nb2LXF2kiWoWW


Generated by Claude Code

Every evaluate() and Program.execute() call rebuilt the cel-rust context from
scratch: each variable was re-boxed and each registered Python function was
re-wrapped in a closure, so the per-call cost scaled with the size of the
Python Context rather than the expression. Executing a pre-compiled `1 + 2`
against a Context carrying the 47 extended-stdlib functions (the CLI's setup)
cost ~7.5 us against ~0.13 us with no context; a Context with 200 variables
cost ~32 us.

The Context now builds that environment on first use and caches it behind a
Mutex as an Arc<cel::Context<'static>>. add_variable, add_function and update
drop the cache; set_variable_resolver does not need to, because the resolver
is bound per call in a child scope (cel-rust's Context::new_inner_scope), which
keeps the same lookup order as before (resolver, then registered variables).
Handing out an Arc rather than borrowing the Python object means a callback
may mutate, or re-enter evaluation with, the Context it is registered on; the
mutation applies from the next evaluation. Dict contexts are still built per
call, since a dict can change without notice.

Both cases above now execute in ~0.15 us. The dict and no-context paths are
unchanged within noise.

Also factors the Python-function wrapper and the compile step into helpers
shared by evaluate() and compile(), and corrects the panic message for an
execution-time panic, which called itself a parser error.

Claude-Session: https://claude.ai/code/session_019WbvXZFm8Nb2LXF2kiWoWW
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-14T02:08:09.039876Z 202397e PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 202397ecd7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/context.rs Outdated
update() applies entries in order and returns early on the first bad key or
value, so with the invalidation at the end a failed call left earlier entries
applied but the cache untouched: evaluations kept returning the old value until
some later successful mutator happened to drop the cache. Every mutator now
drops it first. Nothing can repopulate the cache during the mutator because it
holds &mut self. Tests pin both the failed-update and failed-add_variable
cases.

Claude-Session: https://claude.ai/code/session_019WbvXZFm8Nb2LXF2kiWoWW
Resolves the CHANGELOG Unreleased conflict with #52; src/context.rs merged
cleanly (the getters sit alongside the cache-invalidating mutators).

Claude-Session: https://claude.ai/code/session_019WbvXZFm8Nb2LXF2kiWoWW
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.

2 participants