P1.3.1 — κ Core Evaluation Loop — Execution Packet
Step 3 of the 5-step executor chain. Builds on
docs/contracts/p1-3-1-engine-contract.md. Concrete file structure, test matrix, and implementation phasing forsrc/domains/rules/engine.ts.
§P1. File layout
§P1.1. src/domains/rules/engine.ts — sections
§1. Module-level exports (constants, Category, errors, interfaces)
§2. Internal helpers — pure, no exports
§3. evaluateExpr — recursive walker
§4. evaluate — per-rule evaluator
§5. executeRuleset — orchestrator
Each section is delimited by a banner comment matching the parser/lexer style. No internal classes. No re-exported constants from other files (except via export const redefinitions where the engine owns the value).
§P1.2. src/__tests__/domains/rules/engine.test.ts — sections
§1. Imports + test helpers (parse-rule helper, makeContext, makeRegistry)
§2. F1 — Simple admit/reject (one guard, one effect)
§3. F2 — Multi-guard first-match-wins
§4. F3 — Category ordering across 3 rules
§5. F4 — Budget enforcement (3 caps × representative cases)
§6. F6 — evaluateExpr unit tests (per-node-type return-type table)
§7. F7 — Determinism harness (assertDeterministic + inspectFunctionForbidden)
§8. F5 — Collect-then-apply purity (frozen state)
§P2. Implementation phasing
The engine is large enough that implementation is split into 4 internal phases. Each phase is locally testable; the final commit folds all four into one feat(p1-3-1-engine) commit (per the 5-step chain — implementation is one commit).
§P2.1. Phase A — types + constants + errors (~80 LOC)
- Export
MAX_INTEGER_OPS,MAX_CALL_DEPTH,MAX_ARG_COUNT. - Export
Categorytype alias +CATEGORY_ORDERconst. - Export
Mutation,BudgetTracker,Context,RuleResult,TransitionResult,CategorizedRule,RuleRegistryinterfaces. - Export
RuleBudgetExceededclass. - Internal helper
freshBudget(): BudgetTracker.
§P2.2. Phase B — evaluateExpr walker (~140 LOC)
Big switch on expr.type with one branch per AST node:
function bumpIntegerOps(b: BudgetTracker): void {
b.integer_ops += 1;
if (b.integer_ops > MAX_INTEGER_OPS) {
throw new RuleBudgetExceeded('integer_ops', MAX_INTEGER_OPS, b.integer_ops);
}
}
export function evaluateExpr(
expr: Expression,
context: Context,
): bigint | string | boolean {
bumpIntegerOps(context.budget);
switch (expr.type) {
case 'IntLiteral': return expr.value;
case 'BoolLiteral': return expr.value;
case 'StringLiteral': return expr.value;
case 'VarRef': return resolveVarRef(expr, context);
case 'UnaryOp': /* '-' only; operand must be bigint */ ;
case 'BinaryOp': /* arithmetic vs comparison split */ ;
case 'LogicalOp': /* and/or short-circuit; not single operand */ ;
case 'FuncCall': /* throws undefined_function in P1.3.1 */ ;
}
}
Critical detail: bumpIntegerOps runs before the switch. So every visit consumes a budget unit (matching extraction §5 node_budget += count_ast_nodes(...) semantics, but tracked per-visit rather than batched).
For LogicalOp short-circuit:
and: evaluate left; if false return false; else evaluate right and return.or: evaluate left; if true return true; else evaluate right and return.not: evaluate operand and return its negation.
For FuncCall:
if (expr.args.length > MAX_ARG_COUNT) throw RuleBudgetExceeded('arg_count', ...)
context.budget.call_depth += 1
if (context.budget.call_depth > MAX_CALL_DEPTH) throw RuleBudgetExceeded('call_depth', ...)
try {
// Evaluate args left-to-right (each call recurses → bumps integer_ops)
const argVals = expr.args.map(a => evaluateExpr(a, context))
// P1.3.2 will dispatch on expr.name; for P1.3.1 every FuncCall is undefined.
throw new Error(`undefined_function:${expr.name}`)
} finally {
context.budget.call_depth -= 1
}
§P2.3. Phase C — evaluate per-rule (~70 LOC)
export function evaluate(rule: RuleNode, context: Context): RuleResult {
// §1. Guard pass
let admitted = false;
for (const guard of rule.guards) {
bumpIntegerOps(context.budget); // counts the guard clause itself
const match =
guard.condition === null
? true
: evaluateExpr(guard.condition, context);
if (typeof match !== 'boolean') {
throw new Error(`type_mismatch:guard condition not boolean (got ${typeof match})`);
}
if (match) {
if (guard.action === 'reject') {
return { status: 'rejected', reason: guard.reason ?? '' };
}
admitted = true;
break;
}
}
if (!admitted) {
return { status: 'rejected', reason: 'NO_MATCH' };
}
// §2. Effect pass — collect, do not apply
const mutations: Mutation[] = [];
for (const effect of rule.effects) {
bumpIntegerOps(context.budget);
mutations.push(collectEffectMutation(effect, context));
}
return { status: 'admitted', mutations };
}
collectEffectMutation: P1.3.1 only knows three effect call shapes (the rest of κ is built around these — see effect taxonomy in concept doc). Naming convention from extraction:
set(target_field_path, value)→{ kind: 'set', target: <head>, field: <tail>, new_value: value }.emit(event_type, payload)→{ kind: 'emit', target: 'events', field: event_type, new_value: payload }.- Anything else →
{ kind: 'apply', target: <fn-name>, field: '*', new_value: <args-tuple> }.
This is a conservative P1.3.1 decision: the engine produces a structurally-valid Mutation from any effect call, deferring effect-specific validation to P1.4.1. Tests pin the three shapes.
§P2.4. Phase D — executeRuleset (~60 LOC)
export function executeRuleset(
registry: RuleRegistry,
event: Readonly<Record<string, unknown>>,
state: Readonly<Record<string, unknown>>,
rule_version: string,
epoch: bigint,
): TransitionResult {
const all_mutations: Mutation[] = [];
const per_category_results = new Map<Category, RuleResult[]>();
for (const c of CATEGORY_ORDER) per_category_results.set(c, []);
// Group + sort
const all = registry.getAll();
for (const category of CATEGORY_ORDER) {
const inCategory = all
.filter(cr => cr.category === category)
.map(cr => cr.rule)
.sort(asciiCompareByName); // pure ASCII, locale-independent
for (const rule of inCategory) {
const ctx: Context = {
event, state, rule_version, epoch,
bindings: new Map(),
budget: freshBudget(), // per-rule reset
};
let result: RuleResult;
try {
result = evaluate(rule, ctx);
} catch (err) {
result = errorToRejection(err); // see error map in contract §7
}
per_category_results.get(category)!.push(result);
if (result.status === 'admitted') {
all_mutations.push(...result.mutations);
}
}
}
return { all_mutations, per_category_results };
}
errorToRejection is a small switch:
RuleBudgetExceeded→'budget:' + e.which.OverflowError→'overflow:' + e.message.DivisionByZeroError→'div_by_zero:' + e.message.- Plain
Error→e.message(coversundefined_function:,undefined_variable:,type_mismatch:). - Anything else →
'unknown_error:' + String(err).
asciiCompareByName(a, b): return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;. JS </> on strings uses code-unit (UTF-16) ordering — deterministic across all engines. Don’t use localeCompare (locale-dependent).
§P3. Test matrix
§P3.1. F1 — Simple admit/reject (5 cases)
Source DSL fixture (parsed at test time via parse(...) from src/domains/rules/parser.js):
rule SimpleRule {
guards { $a > 5 -> admit }
effects { set($result, 42) }
}
| # | Setup | Expectation |
|---|---|---|
| F1.1 | bindings: { a: 10n } |
{ status: 'admitted', mutations: [{ kind: 'set', target: ..., new_value: 42n }] } |
| F1.2 | bindings: { a: 3n } |
{ status: 'rejected', reason: 'NO_MATCH' } |
| F1.3 | bindings: { a: 5n } (boundary) |
5 > 5 is false ⇒ NO_MATCH |
| F1.4 | bindings: { a: 6n } |
admitted |
| F1.5 | Run F1.1 ten times via assertDeterministic |
identical outputs |
§P3.2. F2 — Multi-guard first-match-wins (4 cases)
rule MultiGuard {
guards {
$x > 10 -> admit
$x < 0 -> reject "NEG"
else -> admit
}
effects { }
}
| # | $x |
Expectation |
|---|---|---|
| F2.1 | 15n |
admitted (first guard wins) |
| F2.2 | -1n |
rejected with reason 'NEG' |
| F2.3 | 5n |
admitted via else clause |
| F2.4 | 0n |
admitted via else (0 is not < 0) |
§P3.3. F3 — Category ordering (3 sub-cases)
Three rules, one per category for Admission / Consequence / Promotion (skipping StateTransition for the test, to verify the engine handles a missing-category gap correctly):
rule AdmitFoo { guards { true -> admit } effects { set($a, 1) } }
rule PromoteBar { guards { true -> admit } effects { set($a, 3) } }
rule ConsequenceBaz { guards { true -> admit } effects { set($a, 2) } }
Registry returns them in shuffled order ([Promote, Admit, Consequence]) tagged with their categories. Expected all_mutations order: [1n, 2n, 3n] (Admission then Consequence then Promotion; alpha within is moot here since each category has 1 rule).
| # | Sub-case | Expectation |
|---|---|---|
| F3.1 | Three rules above | all_mutations[i].new_value sequence = [1n, 2n, 3n] |
| F3.2 | Two rules in same category, names 'B' and 'A' |
'A' rule’s mutation comes first (alpha) |
| F3.3 | Same as F3.2 but with names 'a' and 'B' |
'B' first ('B' codepoint 66 < 'a' codepoint 97) — proves ASCII not localeCompare |
§P3.4. F4 — Budget enforcement (3 caps × 2 = 6 cases)
integer_ops:
| # | Setup | Expectation |
|---|---|---|
| F4.1 | Construct context with budget.integer_ops = MAX_INTEGER_OPS directly; call evaluate(simpleRule, ctx) (which bumps to 10001 on first walk) |
Throws RuleBudgetExceeded('integer_ops', 10000, 10001) |
| F4.2 | Construct context with budget.integer_ops = 9999; call evaluate(simpleRule, ctx) (first bump → 10000 OK; second bump → 10001 → throw) |
Throws RuleBudgetExceeded |
call_depth:
| # | Setup | Expectation |
|---|---|---|
| F4.3 | Rule with deeply nested FuncCall (constructed manually, depth 17) — even with the undefined-function error, bumpCallDepth fires first |
Throws RuleBudgetExceeded('call_depth', 16, 17) |
| F4.4 | Rule with FuncCall depth exactly 16 → undefined_function error (call_depth not exceeded) |
Throws Error with message 'undefined_function:...' |
arg_count:
| # | Setup | Expectation |
|---|---|---|
| F4.5 | Rule with FuncCall(name, arg1, ..., arg9) (9 args > 8) |
Throws RuleBudgetExceeded('arg_count', 8, 9) |
| F4.6 | Rule with FuncCall(name, arg1, ..., arg8) (exactly 8) |
Throws undefined_function (cap not exceeded) |
§P3.5. F5 — Collect-then-apply purity (3 cases)
| # | Setup | Expectation |
|---|---|---|
| F5.1 | state = Object.freeze({ counter: 0n }) and event = Object.freeze({...}); rule sets $state.counter = 1n |
After executeRuleset(...), state.counter === 0n (unchanged); all_mutations contains {kind:'set', new_value: 1n} |
| F5.2 | Pass a bindings: Map that’s been frozen via Object.freeze (after construction); rule reads $x from bindings |
Read succeeds; bindings.size unchanged |
| F5.3 | Snapshot JSON.stringify of event, state, rule_version, epoch.toString(), bindings entries, run executeRuleset(...), then re-snapshot |
Snapshots identical (proves no mutation) |
§P3.6. F6 — evaluateExpr per-node-type unit tests (~12 cases)
Each tests one AST node type’s evaluation:
| # | Node | Setup | Expected |
|---|---|---|---|
| F6.1 | IntLiteral 42n |
direct construction | bigint 42n |
| F6.2 | BoolLiteral true |
direct | true |
| F6.3 | StringLiteral “hi” |
direct | 'hi' |
| F6.4 | VarRef $a from bindings |
bindings: {a: 7n} |
7n |
| F6.5 | VarRef $user.name from event |
event: {user: {name: 'kamal'}} |
'kamal' |
| F6.6 | VarRef undefined |
empty context | throws 'undefined_variable:...' |
| F6.7 | UnaryOp - on bigint 5n |
direct | -5n |
| F6.8 | UnaryOp - on boolean |
direct | throws type_mismatch |
| F6.9 | BinaryOp + 2n + 3n |
direct | 5n |
| F6.10 | BinaryOp / 10n / 0n |
direct | throws DivisionByZeroError (propagated) |
| F6.11 | BinaryOp == strings 'a' == 'a' |
direct | true |
| F6.12 | BinaryOp < mixed types |
direct | throws type_mismatch |
| F6.13 | LogicalOp and short-circuit (left=false) |
right would throw if eval’d | false, no throw |
| F6.14 | LogicalOp or short-circuit (left=true) |
right would throw if eval’d | true, no throw |
| F6.15 | LogicalOp not on false |
direct | true |
§P3.7. F7 — Determinism harness (3 cases)
| # | Test | Expected |
|---|---|---|
| F7.1 | inspectFunctionForbidden(evaluate) |
[] (clean) |
| F7.2 | inspectFunctionForbidden(evaluateExpr) |
[] |
| F7.3 | inspectFunctionForbidden(executeRuleset) |
[] |
Plus 1 case using assertDeterministic:
| # | Test | Expected |
|---|---|---|
| F7.4 | assertDeterministic(() => executeRuleset(reg, evt, st, 'v1', 1n), [], { iterations: 10 }) for a registry with all 3 categories used |
passes (identical results 10×) |
Total test count: ~30 individual it(...) cases.
§P4. Implementation order
- Phase A — types + constants + errors. (compiles standalone with empty function bodies).
- Phase B —
evaluateExprwalker. Tests F6 light up after this. - Phase C —
evaluate. Tests F1, F2, F4 (integer_ops,call_depth,arg_count) light up. - Phase D —
executeRuleset. Tests F3, F5, F7 light up. - Ensure
npm run build && npm run lint && npm testare all green.
§P5. Risk-mitigation steps
| Risk (audit §10) | Mitigation step |
|---|---|
| Recursion stack | Tests use depth-17 FuncCall to force the call-depth cap before V8 stack runs out (16 frames is well under the ~10k V8 limit). |
| Bigint overflow | safe_mul from integer-math.ts; tests assert OverflowError propagation. |
| Map iteration order | for ... of over CATEGORY_ORDER (constant array, deterministic). per_category_results Map insertion order matches CATEGORY_ORDER. |
| Sort instability | Array.prototype.sort in V8 is Timsort (stable) since Node 12; the asciiCompareByName compare function is purely numeric on codepoints. |
| AST mutation | Every walker reads expr.foo and never assigns. Code review enforces. |
| Frozen-input write | TS readonly types catch writes at compile time; F5 catches at runtime via Object.freeze. |
§P6. Estimated diff size
src/domains/rules/engine.ts: ~350 LOC (incl. headers + section banners).src/__tests__/domains/rules/engine.test.ts: ~600 LOC (30 cases × ~20 LOC each + helpers).
§P7. Commit strategy (per the 5-step chain)
One commit at end of Phase 4 implementation (feat(p1-3-1-engine): deterministic interpreter + budgets). Verification doc + commit follow as Step 5.
§P8. PR strategy
Branch: feature/p1-3-1-engine (already created).
Title: feat(p1-3-1-engine): deterministic interpreter + budgets (R85 κ Wave 4)
Body:
- Summary
- Files added (engine.ts, engine.test.ts, 5-step docs)
- Test results
- Acceptance criteria checklist
- Notes on RuleRegistry interface (declared here; P1.2.4 implements)
Push after final commit; do not amend; do not force-push.
§P9. Quota mitigation (from dispatch packet)
Priority order if pressed: implement commit > push > verification doc > writeback. Push branch after Phase 4 implementation so PM can backstop step 5 if quota cap looms.
Ready for Step 4 (Implement). Packet is gate-approved by self-review. Implementation begins now.