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 for src/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 Category type alias + CATEGORY_ORDER const.
  • Export Mutation, BudgetTracker, Context, RuleResult, TransitionResult, CategorizedRule, RuleRegistry interfaces.
  • Export RuleBudgetExceeded class.
  • 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 Errore.message (covers undefined_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

  1. Phase A — types + constants + errors. (compiles standalone with empty function bodies).
  2. Phase BevaluateExpr walker. Tests F6 light up after this.
  3. Phase Cevaluate. Tests F1, F2, F4 (integer_ops, call_depth, arg_count) light up.
  4. Phase DexecuteRuleset. Tests F3, F5, F7 light up.
  5. Ensure npm run build && npm run lint && npm test are 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.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.