Partial Evaluation
partial() evaluates an expression with only some of its data. It returns the answer when the data you have already decides it, or a simplified expression (the residual) that needs only the missing data.
import { bonsai, t } from 'bonsai-js'
const env = bonsai({
variables: {
user: t.object({ plan: t.enum('free', 'pro') }),
order: t.object({ total: t.number() }),
limits: t.object({ minTotal: t.number() }),
},
})
const rule = env.compile('user.plan == "pro" && order.total > limits.minTotal')
rule.partial({ user: { plan: 'free' }, limits: { minTotal: 100 } }) // => { status: "value", value: false }
const result = rule.partial({ user: { plan: 'pro' }, limits: { minTotal: 100 } })
result.status // => "residual"
if (result.status === 'residual') {
result.source // => 'order.total > 100'
result.dependsOn // => ["order.total"]
result.evaluateSync({ order: { total: 150 } }) // => true
}Uses
- Decide early. A
valueresult means no value of the missing data changes the outcome (a free user never passes this rule). WithvalidateContext, data that is never read is never validated, so a decided value (or a residual on the full context) does not check it either. - Precompute. Evaluate the per-tenant or per-user part of a rule once, keep the result, and evaluate only the per-request part later. A residual does less of the expression's own work than the original program. With
validateContext, a residual whosereadsContextistruestill validates the whole context on every evaluation, as the program does, so it saves little; see Validation. - Push filters down.
toSQLandtoMongorun this partial evaluation for you: pass the original program with the known values inknown, and the part that reads the record becomes a database query.
Results
status | Meaning |
|---|---|
value | The known data decides the result: value |
error | Evaluation fails whatever the unknown data is: error |
residual | The expression still needs the unknown data: the fields below |
A residual result has these fields:
| Field | Meaning |
|---|---|
ast | The residual's syntax tree (see Offsets) |
source | The residual as source text |
bindings | Known values the residual refers to by name (see Bindings) |
dependsOn | The context paths the residual reads; for x.length, the path of x |
hostFunctions | Host functions the residual still calls |
readsContext | Whether one of them is declared call: true, so it reads the whole context you pass, not only dependsOn |
async | Whether the residual calls an async host function (evaluateSync rejects it) |
type | The program's inferred result type, which the residual's result also has |
evaluateSync(context, options?), evaluate(context, options?) | Evaluate the residual, with the same evaluation options as a program (timeout, maxSteps, signal, now, validateContext). context is typed as any part of the program's context, so a misspelled variable does not compile |
explainSync(context, options?), explain(context, options?) | Explain the residual the same way: each trace node's text is the residual's own (printed) text, and its offsets refer to the original expression |
The residual is exact: evaluating it with the full data gives the same value or error as evaluating the original expression with the full data. Simplifications that would change a result (for example turning x && false into false when x could fail) are not made. Two things hold this promise to the data you pass:
- The context must agree with
known. A residual checks that a variableknowngave is present when it calls acall: truefunction, but not that its value is the onepartial()saw. A context whose known parts differ (auser.roleof"admin"whereknownhad"viewer") gives results the original expression could never produce, mixing the folded values with the ones you pass. - With
validateContext, only what is read is validated. The program validates the whole context; a residual validates the data it reads (see Validation). Data the residual never reads, such as an ill-typed variable the known data made unnecessary, is not validated, so the residual can return a value where the program raisesINVALID_CONTEXT.
Offsets
The start and end of the nodes in ast, of a residual's trace nodes, and of its errors refer to the original expression, not to source: a part kept from the program has its span there, and a value filled in from the known data has start and end 0. A residual's errors carry the original expression as their source, so their position and code frame point into it. These offsets may become more precise in a minor release.
Bindings
Short primitives (numbers, booleans, null, strings up to 16 characters) are written into the residual. Other known values, such as longer strings, lists, maps, dates, and durations, are kept in result.bindings (the values you passed, not copies; see below) and named in the residual (order.sku in __known1); a value used in several places is bound once. result.evaluateSync(context) and result.evaluate(context) supply the bindings for you; to store a residual instead, see Storing a residual.
Options
| Option | Default | Effect |
|---|---|---|
unknown | variables and fields missing from known, and objects read whole | Variables or dotted paths (order, user.riskScore) to treat as unknown; unknown wins over a value you passed. A path starts with a variable name, and each later segment is a field name as user.riskScore or user["risk score"] reads it; an empty segment or a name the language blocks (__proto__, constructor, prototype) is a TypeError |
callHostFunctions | false | Call host functions whose inputs are known (only synchronous ones); otherwise they stay in the residual |
now | none | The time now() returns; otherwise now() stays in the residual (the environment's clock is not used, so a stored residual reads the time when it is evaluated) |
maxSteps | the environment's | Step budget for the whole partial evaluation (0 for none) |
timeout | the environment's | Wall-clock budget in milliseconds for the whole partial evaluation (0 for none) |
signal | none | Cancels the partial evaluation with an ABORTED error |
Budget limits (steps, time, cancellation) are thrown from partial() rather than guessed. Every sub-expression evaluated during one partial() call shares one step budget and one deadline. A size limit on a value a known part builds (LIST_LIMIT, STRING_LIMIT, VALUE_DEPTH_LIMIT, PATTERN_LIMIT) is treated like an evaluation error: thrown when the whole expression always reaches it, and otherwise left in the residual, so it is raised only if evaluation reaches that part. env.partial(source, known, options?) compiles through the environment's cache and does the same. Options follow the same rules as everywhere else: an unknown option or a wrong type is a TypeError. With validateContext, the variables in known are validated as evaluation validates them (a mismatch is an INVALID_CONTEXT result), so a known object must have all its declared fields. To leave part of an object unknown, list its path in unknown (user.riskScore): everything under a listed path is skipped, and the rest of that variable is still validated, so all the data the residual has folded in has been checked.
Details
- Evaluate the residual with the full context. A known part that fails (say, a division by zero, or a list longer than
maxListLength, in a branch that may not run) stays in the residual so the error can still happen, and it reads the known variables again. - Known parts of every branch are evaluated, including branches the unknown data may never choose. They count toward the step budget, and context getters they read run during
partial(). - Host functions are called only with
callHostFunctions: true, never when they are async, and functions declaredcall: trueonly when you also passunknown: [], since they can read variables the expression does not name. - Missing data is unknown by default. Without an
unknownlist,partial()decides only from data you passed, at any depth and however the expression reads it. A variable missing fromknownand a field missing from a known object (cart.itemswhenknownhascart: {}) both stay unknown, and their paths appear independsOn. A field is the same path however it is named:cart["items"]iscart.items, andtiers[level]withlevelknown to be"gold"readstiers.gold. A known object read whole (cart == saved,{...cart},cart.keys(),cart.values(),cart[key]withkeyunknown, or aletthat holds it) stays unknown too, in typed and open environments alike: the object you passed may be only part of it (an optional field, or an extra key, may still come), and a whole read would see the difference. A key that holdsundefinedcounts as missing, so it is unknown too (evaluation reads it as null); passnullfor a value that is known to be absent. Lists, dates, durations, and strings are values: a known list is taken as given, andlengthreads it. - Pass an
unknownlist to decide whole reads. It says the rest ofknownis complete, so a known object read whole is decided. - With an explicit
unknownlist, nothing else is guessed: a variable or field that is neither inknownnor listed reads asnull, as it would in normal evaluation. A listed path also says that the objects above it exist: withunknown: ["cart.items"]and nocartinknown, all ofcartis unknown (socart.couponis not read asnull). Whenknowndoes givecart, onlycart.itemsis unknown and its other fields are taken as given. expectstill applies. A program compiled withexpectchecks a decidedvalueagainst it (a mismatch is aTYPE_ERRORresult), and its residual checks results the same way.- The residual reads your context as it is. The bindings are compiled into the residual, so evaluating it copies nothing: variables and getters read your own object, and only the ones the residual reads. When
readsContextisfalse, pass at least every path independsOn; a part that always fails stays in the residual and reads the known variables again (see the first point). - Bindings are shared with
known.partial()readsknownwhen it is called, and the values it binds are your own objects, not copies (so class instances,Maps, and arrays keep what they are). Do not change the known data afterwards: a residual whose decision usedconfig.thresholdand whose binding isconfig.allowedwould mix the old threshold with the new list. When the data changes, callpartial()again, and key cached residuals by the configuration's version. To guard against changes in place, pass a deep-frozen copy (structuredClonedoes not keepDurationor other class instances: they come back as plain objects, which the language reads as maps, so a duration field stops comparing as a duration). Host functions must not change their arguments either: a residual reuses its bound values on every evaluation. call: truefunctions read the context you pass (readsContextistrue).call.contextis exactly that object, as in a program evaluation, so pass the full context, known values included. Their reads are not visible independsOn, so a context missing a variable thatknowngave (andunknowndid not list whole; in a strict environment, only declared variables count) is anINVALID_CONTEXTerror before anything runs. A variable you pass only part of is read as you pass it, as a program would read it.- Explaining a residual explains the residual. Its trace and
reasons()cover the residual's own conditions, with known values shown as the__knownnames of their bindings; the conditions the known data decided are gone. For a screen that answers "why did this user get this result", explain the full program with the full context instead. Explaining a residual costs the steps evaluating it does, so a budget that fits one fits the other. - Residual size is limited. Printing the residual counts toward the step budget, and a residual longer than the environment's
maxSourceLengthis aSOURCE_TOO_LONGlimit error thrown frompartial().
A deep-frozen copy of a configuration keeps its dates and durations as they are:
function deepFreeze<T>(value: T): T {
if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
Object.freeze(value)
for (const child of Object.values(value)) deepFreeze(child)
}
return value
}
const residual = rule.partial({ tenant: deepFreeze(loadTenantConfig()) })Validation
With validateContext, a residual validates what it reads. Everything the residual folded in was validated by partial(). When readsContext is false, the residual validates each path in dependsOn step by step, each step against its own declared type: an absent or null value passes only where that type allows null (a required object that is missing fails, and so does a missing required field of an object that is present), a key an object type does not declare and a record entry that is absent end the check, and the value at the end of the path is checked whole. Fields the path does not name are not checked, so a context holding exactly dependsOn passes. When readsContext is true, it validates the whole context, as a program does, on every evaluation; when your own code builds or has already validated that context (a tenant configuration validated once by partial(), plus request data you validate yourself), pass validateContext: false to the residual's evaluation to skip it. For a large configuration this is the difference between paying the full validation on each request and almost nothing.
Storing a residual
result.evaluateSync and result.evaluate are the reliable way to run it; keep the result in memory where you can. If you store source and bindings and compile them yourself, use a non-strict environment (the binding names are not declared variables) and pass the bindings in the context. Bindings are your known values: JSON turns a date or a duration into ISO-8601 text, so restore dates with new Date(text) (or store bindings in a format that keeps them) before evaluating. Inlined values can also make a type error in an untaken branch visible to the checker.