Skip to content

Evaluation Design

S.Lott edited this page Jun 4, 2025 · 22 revisions

The evaluation module has vaguely monad-like processing. It might make sense to advance this to proper monad processing. A Result monad might be a better design than a Result type which is a celpy.celtypes.Value | CELEvalError | celpy.celtypes.CELType.

See Chapter 13 of Functional Python Programming.

An important note is the way the "short-circuit" operators (i.e., _&&_, _||_, and _?_:_) work. In order to permit replacing this functions, the implementation evaluates all operands and the resolves a final result from the operand values. This involves monad-like processing of BoolType | CELEvalError object. This is inefficient, but simple. An alternative is to provide function closures instead of values: the function can then compute values of only the necessary operands.

Long Term Focus

The results of CEL expressions (the Result type) needs to be a subclass of PyMonad Either. The Right branch is a proper Value | CELType. The Left branch is a CELEvalError. Using an Either instance would clarify processing, and reduce the need for some of the isinstance() overheads.

For example, this can simplify the CEL && and || operators. If there are two Right branches, the operator is applied to these values. If there's one Right branch, this value is the result. Otherwise, there are two Left branches, and one of these is returned.

Further, we can consider pushing the operation of mapping a Python exception to a CELEvalError object out of the Evaluator class. This could be refactored down into the methods of various celtypes type definitions. This would require wrapping many methods with the @eval_error decorator. The implementations would be __add__ = eval_error(class.__add__) to wrap the superclass method with a decorator. (A metaclass could automate this.)

Introducing this monad may also reduce the need for the function_eval() and method_eval() methods of the Evaluation class.

There several interim solutions that avoid a breaking change to the way celtypes exceptions work.

Interim Solution 1

Define a version of eval() called result(). This function evaluates a string, mapping Python exceptions to CELEvalError objects. It will behave somewhat like the @eval_error decorator and the Evaluator.function_eval() method.

The result() function will be used by the Transpiler class around exposed, native Python operations. This catches the native exception, mapping it to CELEvalError.

This is complicated by the fact that there would be nested result("some_string") functions. Consider result("result('logical_or('result(\"true\")', 'result(\"42 / 0\")')"). The nested quotes become a bit of a problem for making sure the whole thing parses properly.

This can be worked around, by emitting a result()-based sub-expressions as separate named strings.

e_1l = "celtypes.BoolType(True)"
e_1r = "celtypes.IntType(42) / celtypes.IntType(0)"
e_1 = logical_or(result(e_1l), result(e_1r))
CEL = e_1

We can define result() using the @eval_error decorator:

result = eval_error(eval)

This kind of multi-step processing is needed for _||_, _&&_, and _?_:_, where there are expressions that may raise an exception, and the exception needs to be treated like the Left branch of an Either. This may also be required for any macros that silently absorb exceptions.

This means the overall CEL expression becomes a sequence of statements. The final statement sets any easy-to-find CEL variable to the result.

Interim Solution 2

Instead of strings for expressions that need to be wrapped in a try:/except: block, create lambda objects. This can be passed to the result() function for evaluation. This has the tiny advantage of avoiding eval(), which many folks find scary.

Each lambda can accept an Activation instance with variable bindings.

e_1l = lambda a: celtypes.BoolType(True)
e_1r = lambda a: celtypes.IntType(42) / celtypes.IntType(0)
e_1 = logical_or(result(e_1l), result(e_1r))
CEL = e_1

This relies on a global the_activation to provide the activations that will be used to compute the result. The final statement sets any easy-to-find CEL variable to the result.

It can be wrapped in template code:

def cel_expr(act: Activation) -> Result:
    global the_activation
     
    the_activation = act
    ${CEL_body}
    return CEL

The result() function, used to map Python exceptions to CELEvalError objects, could be this:

def result(expr: Callable[[Activation], Result]) -> Result:
    global the_activation
    try:
        return expr(the_activation)
    except (ValueError, KeyError, TypeError, ZeroDivisionError, OverflowError) as ex:
        return CELEvalError(str(ex), ex)

Or, an alternative is this:

def cel_eval(expr: Callable[[Activation], Result]) -> Result:
    global the_activation
    return expr(the_activation)

result = eval_error(cel_eval)

Breaking Change

Revise the celtypes package to raise CELEvalError exceptions instead of native Python exceptions. This is a potentially profound API change for any application using or extending the celtypes classes. It's also much more useful, since the native Python operators -- when applied to celtypes class instances -- would properly raise CELEvalError without the need for an additional @eval_error decoration.

Consider the following docstring from celtypes.

CEL types will raise :exc:ValueError for out-of-range values and :exc:TypeError for operations they refuse. The :py:mod:evaluation module can capture these exceptions and turn them into result values. This can permit the logic operators to quietly silence them via "short-circuiting".

In the normal course of events, CEL's evaluator may attempt operations between a CEL exception result and an instance of one of CEL types. We rely on this leading to an ordinary Python :exc:TypeError to be raised to propogate the error. Or. A logic operator may discard the error object.

The :py:mod:evaluation module extends these types with it's own :exc:CELEvalError exception. We try to keep that as a separate concern from the core operator implementations here. We leverage Python features, which means raising exceptions when there is a problem.

This needs to be altered to reflect a somewhat simpler design:

  • CELEvalError is part of celtypes.

  • The native ValueError and TypeError exceptions are not raise, instead CELEvalError is used.

Clone this wiki locally