P1.3.1 — κ Core Evaluation Loop — Audit

Step 1 of the 5-step executor chain (audit → contract → packet → implement → verify). Builds on the P1.2.2 parser (src/domains/rules/parser.ts, R84 7218b34b), P1.1.1 integer math (src/domains/rules/integer-math.ts), P1.1.3 BPS constants (src/domains/rules/bps-constants.ts), and P1.1.2 determinism harness (src/domains/rules/determinism.ts).

§1. Surface inventory

§1.1. Target files (greenfield for this task)

Path Exists at base 7218b34b? Purpose
src/domains/rules/engine.ts No AST evaluator + budget tracker + executeRuleset orchestrator
src/__tests__/domains/rules/engine.test.ts No Jest tests (5 fixture families per task §IMPLEMENTATION SPEC)

§1.2. Touched but not owned

Path Delta Purpose
src/domains/rules/ already present with bps-constants.ts, determinism.ts, integer-math.ts, lexer.ts, parser.ts (5 files) Adding engine.ts as a peer — no edits to existing files.
package.json / package-lock.json none No new runtime deps. The engine is a pure consumer of the existing parser AST + integer-math layer.

§1.3. Test-file layout reconciliation

The task prompt places the test at src/domains/rules/__tests__/engine.test.ts. Wave 1–3 of κ uses the convention tests live under src/__tests__/domains/<name>/ (verified at base):

  • src/__tests__/domains/rules/{bps-constants,determinism,integer-math,lexer,parser}.test.ts (P1.1.1, P1.1.2, P1.1.3, P1.2.1, P1.2.2)
  • src/__tests__/domains/{router,skills,tasks,proof,trail}/... (Phase 0 axes)

Jest testMatch covers both paths but the in-tree convention is src/__tests__/.... The engine test will live at:

src/__tests__/domains/rules/engine.test.ts

Same convention reconciliation as the lexer / parser audits — not a spec deviation.

§2. Authoritative spec sources

Source Path Weight
Heritage extraction §5 docs/reference/extractions/kappa-rule-engine-extraction.md §5 (Rule Execution Flow pseudocode) Authoritative for execute_rule shape (collect-then-apply, first-match-wins, AST node budget).
Heritage extraction §6 docs/reference/extractions/kappa-rule-engine-extraction.md §6 (Rule Execution Order) Authoritative for the Admission → StateTransition → Consequence → Promotion ordering, alpha within.
Concept doc docs/3-world/physics/laws/rule-engine.md §Rule application algorithm + §Evaluation budget + §Default budget constants Authoritative for the 3 budget constants (MAX_INTEGER_OPS=10000, MAX_CALL_DEPTH=16, MAX_ARG_COUNT=8) and the RuleBudgetExceeded failure mode.
Spec docs/spec/s11-rule-engine.md Load-bearing semantic spec.
Parser source src/domains/rules/parser.ts The 11-node AST shape this evaluator walks.
Integer-math source src/domains/rules/integer-math.ts Bigint helpers for arithmetic — used by binary +/-/*///% evaluation.
BPS constants source src/domains/rules/bps-constants.ts (Imported only if the engine’s evaluator uses any of the named bps levels — see §6 below; current decision is no, the engine just dispatches on operator and bigint operands.)

§3. Drift findings

§3.1. P1.2.4 RuleRegistry not yet shipped (parallel-wave dependency)

The original task prompt (docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md §P1.3.1) lists src/domains/rules/registry.ts (P1.2.4) as a pre-flight read. It does not exist at base 7218b34b — P1.2.4 is gated on P1.2.3 (AST validator), which is itself one of the parallel slices of R85 Wave 4. P1.3.1 cannot import from a not-yet-shipped module.

Resolution (per the dispatch packet): define a minimal RuleRegistry interface inline in engine.ts. The interface exposes only the methods that executeRuleset actually calls — getAll(): readonly RuleNode[]. P1.2.4 will implement this interface (or a superset of it). The contract will document the choice and the import direction (engine declares; registry implements).

This is identical to the convention used by the parser when the lexer was the dependency: parser imports the lexer’s already-shipped surface; here, the engine imports nothing from a registry, only declares the shape the registry must satisfy. The registry is dependency-injected at executeRuleset call time.

§3.2. ADR-006-dsl-grammar still missing (carried forward)

Same drift surfaced by R83.C lexer audit and R84 parser audit: docs/architecture/decisions/ADR-006-dsl-grammar.md is referenced from concept docs but not in the repo at 7218b34b. Out of scope for this task (engine is semantic, not grammatical) — note only.

§3.3. Rule classification mechanism not yet decided

Extraction §6 prescribes the four-category execution order (Admission → StateTransition → Consequence → Promotion) but the parser produces RuleNode without a category attribute. The lexer reserves Admission, Transition, Consequence, Promotion as keywords (see lexer §2 export bundle), but the extraction §1 EBNF does not yet bind them. Classification is therefore a P1.3.1 / P1.2.4 design call.

Decision for this task (will be elaborated in §6 of the contract): the engine accepts an explicit category per rule via the RuleRegistry interface. The registry computes the category from a rule annotation or rule-name convention; the engine is agnostic. This keeps the engine pure and lets P1.2.4 own the classification policy.

engine.ts exports a Category enum ('Admission' | 'StateTransition' | 'Consequence' | 'Promotion') and the RuleRegistry interface getAll(): readonly { rule: RuleNode; category: Category }[]. P1.2.4’s implementation will fill this in.

§4. AST shape consumed by the evaluator (per parser §2)

11 node types. The evaluator dispatches on the type discriminant. Each node carries {type, location, ...fields}. From src/domains/rules/parser.ts:

# Node type Fields the evaluator reads Walker action
1 RuleNode name, guards, effects Top-level — walked in evaluate(rule, context).
2 GuardClause condition, action, reason Walked in evaluate guard loop; first-match-wins.
3 EffectCall function, args Walked in evaluate effect loop; collected as Mutation (no application).
4 BinaryOp op, left, right evaluateExpr recurses. Arithmetic uses integer-math.ts; comparison returns boolean.
5 UnaryOp op (- only), operand evaluateExpr recurses; numeric negation.
6 LogicalOp op (and/or/not), operands evaluateExpr recurses; short-circuit is OK for determinism but each visited node still consumes 1 op-budget.
7 IntLiteral value: bigint Leaf; returns value.
8 BoolLiteral value: boolean Leaf; returns value.
9 StringLiteral value: string Leaf; returns value (engine accepts strings only as arg-position values; out-of-position strings are caller bugs).
10 VarRef path: string[] Leaf; resolves against context (event / state / bindings).
11 FuncCall name, args Recurses into args; budget bumps call_depth and checks args.length against MAX_ARG_COUNT. P1.3.2 will register actual built-ins; for P1.3.1, a FuncCall with no registered builtin raises a typed error so tests can assert behavior.

§4.1. Evaluation result types

evaluate(rule, context) returns:

  • { status: 'admitted', mutations: Mutation[] }, or
  • { status: 'rejected', reason: string }.

Mutation is { kind: 'set' | 'emit' | 'apply'; target: string; field: string; old_value?: unknown; new_value: unknown }. The kind discriminant lets downstream apply-pass dispatch on type. P1.3.1 only collects; never applies.

executeRuleset(registry, event, state, rule_version, epoch) returns TransitionResult = { all_mutations: Mutation[]; per_category_results: Map<Category, RuleResult[]> }.

§5. Budget model

Three caps per the concept doc §Default budget constants:

Cap Default Scope
MAX_INTEGER_OPS 10_000 Total integer ops across guard + effects (combined) per rule.
MAX_CALL_DEPTH 16 Nested function-call frames during expression evaluation.
MAX_ARG_COUNT 8 Arity of any single function call.

The BudgetTracker is per-executeRuleset-call, not global. Sharing across calls would leak counts. The current decision is to construct a fresh BudgetTracker at the top of executeRuleset and pass it through to evaluate(rule, ctx) for each rule (so all rules in one ruleset evaluation share one budget across rules — this matches the concept doc’s “Total integer ops across guard + effects (combined)” wording, applied per rule, but with budget tracker reset between rules). Question for the contract: should the budget reset between rules or carry across? Per-rule reset matches extraction §5’s pseudocode (node_budget = 0 at the start of execute_rule) — that’s the contract decision.

RuleBudgetExceeded is a typed error with { which: 'integer_ops' | 'call_depth' | 'arg_count'; limit: number; observed: number }. Throwing it stops the current rule’s evaluation; per concept doc, the rule then “deterministically returns ADMISSION_DENIED”. For P1.3.1 (rule-level eval), the engine catches the error in executeRuleset and surfaces the rule result as { status: 'rejected', reason: 'budget:<which>' }.

§6. Imports + import direction

Direction Module Why
Imports from ./parser.js AST type + node shapes.
Imports from ./integer-math.ts safe_mul, safe_div, OverflowError, DivisionByZeroError for bigint arithmetic with int64-safe semantics.
Imports from ./bps-constants.ts NOT imported. The engine evaluates raw bigint operations from the AST; bps semantics are a higher-level concern (built-ins in P1.3.2).
Imports from (no other repo modules) Pure, dep-free at the engine level.
Exports to ./registry.ts (P1.2.4) Consumes RuleRegistry interface.
Exports to ./application.ts (P1.4.1, future) Consumes Mutation[] from TransitionResult.
Exports to tests/ Engine surface for fixture tests.

The import direction respects the dependency graph: lexer → parser → engine, with integer-math as a sibling utility that engine imports.

§7. Determinism guardrails

engine.ts is subject to the same forbidden-ops pattern manifest as the rest of src/domains/rules/ (per determinism.ts §FORBIDDEN_PATTERNS). Concretely:

  • No Math.* (use integer-math.ts helpers).
  • No Date.* / new Date() (no clock reads).
  • No setTimeout / setInterval / setImmediate.
  • No fetch / XMLHttpRequest.
  • No crypto.* / process.hrtime / process.nextTick.
  • No await / async function.
  • No float literals (e.g. 3.14 — but 1n, 100, 1_000_000 are fine).

The engine module itself is synchronous, takes a context object, and returns a synchronous result. Determinism is the load-bearing invariant: two arbiters with the same registry + same event produce bit-identical mutation lists (per gotcha §1 in the dispatch packet).

§8. Acceptance criteria coverage map

Acceptance criterion (from dispatch packet) Where addressed
Recursive AST walker with immutable context §4 (walker structure); contract will spell out the immutability rule.
Execution order: Admission → StateTransition → Consequence → Promotion §3.3 + §6 (registry returns category-tagged rules; engine groups + sorts).
Alphabetical within group engine sorts by rule.name within each category (Array.prototype.sort with locale-independent compare).
First-match-wins guard evaluation §4 + extraction §5. Once a guard’s condition matches, the loop breaks.
Mutations collected, not applied during evaluate §4.1 — evaluate returns Mutation[]; no state writes inside the evaluator.
3 budget caps enforced with typed RuleBudgetExceeded §5 — three caps, three branches of the typed error.
evaluate is pure (no writes to inputs); proven in test Tests F5 will use Object.freeze on input state and assert no thrown TypeError ⇒ no writes attempted.
npm run build && npm run lint && npm test ALL THREE green Verification doc cites raw output.

§9. Test plan (high level — fully expanded in the packet)

5 fixture families per the dispatch packet:

  • F1. Simple admission/rejection rule (one guard, one effect).
  • F2. Multi-guard first-match-wins (3 clauses incl. else).
  • F3. Category ordering across 3 rules (one per category for 3 of the 4 categories).
  • F4. Budget enforcement (integer_ops near-cap + 1 → RuleBudgetExceeded).
  • F5. Collect-then-apply purity (frozen state input → no mutation observed).

The packet will list ~15–25 individual it(...) cases across these families, plus determinism-harness self-checks (pattern: assert inspectFunctionForbidden(evaluate) returns empty).

§10. Risk register

# Risk Mitigation
1 Map iteration order in JS (insertion order, but Object.keys may surprise) Use explicit alphabetical sort + spec the comparator in contract.
2 Bigint arithmetic overflow on * Use safe_mul from integer-math.ts; uncovered overflow → OverflowError → engine surfaces as { status: 'rejected', reason: 'overflow:...' } (or rethrows; contract decides).
3 Recursion depth could exceed JS engine stack on adversarial AST The parser’s MAX_AST_NODES_PER_RULE = 10_000 is the upstream guard; combined with MAX_CALL_DEPTH = 16 for FuncCall frames and MAX_INTEGER_OPS = 10_000 for total walked nodes, the recursion is bounded well below V8’s default ~10k frame stack.
4 Future-task contract drift if P1.2.4 ships a different RuleRegistry shape Engine declares the minimum interface; P1.2.4 must satisfy it. The contract pins this.
5 Determinism violation through Map/Set non-stability Avoid Set/Map for ordered output; use Array + explicit sort.
6 JSON.stringify on bigint in error messages would throw Render bigints via .toString() + 'n'; the determinism harness already does this — borrow the pattern.
7 evaluateExpr returning bigint | string | boolean — TS type juggling at AST nodes Each AST node type has a known result type; the contract will pin a per-node type table so downstream consumers can rely on it without runtime type-checks beyond what the AST already encodes.

Ready for Step 2 (Contract). Signed-off audit of P1.3.1 surface — files, dependencies, and risk register.


Back to top

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

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