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, R847218b34b), 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— but1n,100,1_000_000are 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_opsnear-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.