Planner & Evaluator¶
Celly doesn't interpret the AST directly. A planner compiles it once into a tree of
IInterpretable objects; evaluation walks that tree. The split pays for itself when a
program is evaluated many times (the normal case for policies): name-resolution decisions,
constant materialization, and macro structure are all settled at plan time.
The interpretable tree¶
public interface IInterpretable
{
CelValue Eval(IActivation activation);
}
One small class per behavior (src/Celly/Interpreter/Interpretables.cs):
| Node | Built from | Eval behavior |
|---|---|---|
ConstEval |
literals | returns a pre-materialized CelValue |
IdentEval |
identifiers | tries container-qualified candidates against the activation |
ScopeIdentEval |
comprehension vars | direct single-name lookup (shadowing) |
SelectEval |
a.b |
qualified-variable candidates first, then field access |
TestOnlySelectEval |
has(a.b) |
presence check |
CallEval |
function calls | strict arg eval → dispatch |
AndEval / OrEval / ConditionalEval / NotStrictlyFalseEval |
logic | non-strict (below) |
ListEval / MapEval / StructEval |
aggregates | build values; optional-entry handling |
ComprehensionEval |
macros | the fold loop |
Strict calls, error/unknown protocol¶
CallEval evaluates all arguments first, then:
- any unknowns → merge them and return (partial evaluation wins over errors),
- else any error → return the first one,
- else invoke the function implementation (a
Func<CelValue[], CelValue>from the registry, resolved at plan time).
Exceptions from implementations are caught at this single point and converted to
ErrorValue — the evaluator itself never throws for expression-level failures.
The non-strict four¶
&&, ||, ?:, and @not_strictly_false get dedicated nodes because their semantics
are about not evaluating or not propagating:
// OrEval, abbreviated — commutative absorption in ~10 lines:
var l = left.Eval(activation);
if (l is BoolValue { Value: true }) return BoolValue.True; // short-circuit
var r = right.Eval(activation);
if (r is BoolValue { Value: true }) return BoolValue.True; // absorption: r decides
if (l is BoolValue && r is BoolValue) return BoolValue.False;
return MergeNonBool(l, r); // unknown ∪ unknown → merged; unknown > error; else error
The ternary is strict only in its condition — untaken branches never run.
Comprehensions and scoping¶
ComprehensionEval implements the fold from Macros. Scoping uses
ScopedActivation — a one-name binding layered over the parent activation:
activation ▸ ScopedActivation(@result = accu) ▸ ScopedActivation(x = element)
The accumulator cell is mutable across iterations (rebound each step); the iteration
variable is rewritten per element. Early exit falls out of the loop condition: exists
stops as soon as @not_strictly_false(!@result) is false, i.e. once a true lands.
Map iteration yields keys; two-variable comprehensions bind (index, value) for lists and
(key, value) for maps via IterVar2.
Parse-only name resolution ("maybe attributes")¶
Here's the subtlest part of the evaluator. In checked mode, the checker decides what
a.b.c means. In parse-only mode nobody has — it could be:
- a variable literally named
"a.b.c"(bindings may use qualified names), - variable
"a.b"with field.c, or variable"a"with.b.c, - any of those prefixed by the container (
ns.a.b.c, …).
The planner therefore gives SelectEval the full candidate list (longest first,
container-expanded). At eval, the first candidate bound in the activation wins; only then
does it fall back to evaluating the operand and selecting the field. Three refinements:
- Comprehension variables shadow candidates — the planner tracks in-scope variable
names while planning loop bodies, so
y.zinsideexists(y, …)compiles to a plain field select with no candidate list. - Absolute references (
.y) unwrap allScopedActivationlayers before looking up. - Static fallbacks: if a candidate names a standard type ident (
int,google.protobuf.Duration) or a provider ident (enum constant, message type), the planner resolves it to a constant at plan time andIdentEval/SelectEvalfall back to it after activation misses.
The same candidate logic makes namespaced functions work: optional.of(x) is
receiver-syntax on the identifier optional, but the planner sees a registered function
named "optional.of" and compiles a plain global call.
What's deliberately not here¶
No bytecode, no expression-tree/IL compilation, no constant-folding pass beyond literal
materialization. A tree of virtual Eval calls is straightforward to verify against a
2,456-case suite, and CEL expressions are short — tree-walk overhead is rarely the
bottleneck. The IInterpretable boundary is where an optimizing backend would slot in
without touching anything upstream.
Next: Protobuf Integration.