@codeforamerica/blueprint-rules-engine
v0.2.0
Published
Browser-compatible rules evaluator for Blueprint dependency graph rulesets
Keywords
Readme
@codeforamerica/blueprint-rules-engine
Browser-compatible rules evaluator for Blueprint dependency graph rulesets
Pre-release: This package is at
0.x. Until1.0.0, minor versions may include breaking changes. Pin your version if stability matters.
Installation
npm install @codeforamerica/blueprint-rules-engineWhat it does
Evaluates a compiled rule graph — declarative CEL expressions that derive facts from inputs such as household circumstances and policy parameters. It runs in Node.js and in the browser, with no network call and no contract tooling installed.
Compiling a *-rules.yaml contract into a graph is a build step, and it belongs to
blueprint-core. This
package is the runtime. Keeping the two apart is what lets the evaluator ship to a
browser: it depends on a CEL interpreter and nothing else.
Which to use
A ruleset defined in a blueprint contract set is reachable through its API:
every ruleset declares an endpoint, POST /{domain}{path}, which the mock
server serves and a real implementation provides. An application should call
that endpoint — through the typed client blueprint-generate-ts-clients emits,
or any HTTP client — so the same call works against the mock, in a page or in
Node, and against a real service.
This package is for evaluating a ruleset without the blueprint's APIs: offline
or in the browser with no server, in tooling, or in tests of a ruleset itself.
It takes a compiled graph, the rules engine's own contract, which needs nothing
else from the blueprint. To run a ruleset from the command line, use
blueprint-evaluate from @codeforamerica/blueprint-cli.
Usage
import { evaluate } from '@codeforamerica/blueprint-rules-engine';
const nodes = evaluate(graph, {
household: { members: [{ name: 'Alice', age: 22, employed: true }] },
policy: { minAge: 18, maxAge: 65 },
});
nodes.adultsFilter;
// { type: 'output', state: 'complete', value: [ { name: 'Alice', … } ] }The return is a flat map of every fact in the graph, keyed by fact name. Inputs are keyed
by the graph's top-level namespaces — { household: … } for paths beginning
$.household.
To produce the graph, compile the contract once at build time:
import { discover, load, generate } from '@codeforamerica/blueprint-core';
const docs = discover('./contracts', 'rules').map(load);
const graphs = generate(docs, 'graph'); // [{ graph, … }, …]Compiled graphs are also written to disk as *-graph.yaml by blueprint-resolve, so a
consumer can load one directly rather than depending on core at all.
Partial inputs
Inputs may be incomplete. Nothing throws — each fact reports what it could do:
| state | Meaning |
|---|---|
| complete | Resolved; every input it reads was supplied |
| placeholder | Resolved, but at least one input fell back to a default declared in the graph |
| missing | Could not compute; missing lists the input paths it could not reach |
| error | Could not compute; message says why (bad input type, failed expression, or a dependency in one of those states) |
Each node also carries type, either output (a fact the ruleset declares as an answer)
or intermediate (a step on the way to one).
Omit an input that has a declared default and the fact resolves, flagged:
evaluate(graph, { household: { members } }); // no policy supplied
// adultsFilter → { type: 'output', state: 'placeholder', value: [ … ] }
// allEmployed → { type: 'output', state: 'complete', value: false }Defaults are read from the graph's inputs, which declares them per field path, so
supplying part of a namespace still defaults the rest of it.
Omit one with no default and its dependents say exactly what they need:
evaluate(graph, { policy: { minAge: 18, maxAge: 65 } }); // no household supplied
// adultsFilter → {
// type: 'output', state: 'missing', value: null,
// missing: ['$.household.members[]']
// }That list is what drives progressive disclosure: ask for the inputs the pending outputs actually need, and nothing else.
TypeScript
Types ship with the package — no @types install.
import { evaluate, type Graph, type FactNode } from '@codeforamerica/blueprint-rules-engine';
const nodes: Record<string, FactNode<unknown>> = evaluate(graph, inputs);
if (nodes.adultsFilter.state === 'missing') {
nodes.adultsFilter.missing; // string[] — narrowed by the discriminated union
}FactNode is a union discriminated on state, so missing and message are reachable
only on the states that carry them.
For per-ruleset input and result types rather than unknown, generate a client with
blueprint-generate-ts-clients: it emits ${Ruleset}Inputs and ${Ruleset}Result and a
Rules export that embeds the graph.
Browser
A pre-built IIFE bundle ships at dist/browser.js, exposed through the ./browser
package export. Loaded from a script tag it sets window.RulesEngine:
<script src="https://unpkg.com/@codeforamerica/blueprint-rules-engine/dist/browser.js"></script>
<script>
const nodes = window.RulesEngine.evaluate(graph, {
household: { members: [{ name: 'Alice', age: 22, employed: true }] },
});
</script>The bundle is an IIFE and exports nothing importable, so the global is the only way to
reach it. With a bundler, import evaluate from the package root instead — the global is
typed via declare global for the script-tag case.
The bundle is regenerated from source by npm run build:browser. dist/ is not
committed — prepare builds it on every install and before publish, and files
ships it to npm, so the copy a consumer gets is always built from the src/ beside
it. A minified bundle is not byte-identical across platforms, so committing one and
checking it had not drifted was a check that could not hold.
Known limitation: decimal precision
CEL has no exact decimal type, so type: number is evaluated in binary
floating point — 0.1 + 0.2 is 0.30000000000000004. This only changes an
answer on exact-equality comparisons and long accumulation chains, not on
ordinary threshold tests.
For exactness today, declare money as type: integer in cents. Tracked in
#445.
Conformance
This package is a reference implementation, not a mandated engine. The
behaviour any engine must reproduce — the four states, what makes an input
missing rather than empty, how a bad input propagates — is written as data in
tests/conformance/cases.json: a graph, inputs,
and the nodes expected for each. tests/conformance.test.js is one runner over
it; an engine in another language needs its own.
CONFORMANCE.md records what happened when engines were run against that corpus — which cases each one satisfies, where they differ, and what an engine could not express at all.
Related packages
@codeforamerica/blueprint-core— compiles rulesets into graphs and resolves contracts@codeforamerica/blueprint-cli—blueprint-evaluateruns rulesets from the command line;blueprint-generate-ts-clientsemits typed per-ruleset clients@codeforamerica/blueprint-mock-server— serves every ruleset's POST endpoint, at the path it declares
