From fe5a14fbb61be87ecece0593aea5eee57ef31899 Mon Sep 17 00:00:00 2001 From: albertgwo Date: Wed, 18 Mar 2026 08:11:50 -0400 Subject: [PATCH 1/3] refactor(engine): extract shared rules logic into core.ts (DRY fix) Move pure evaluation functions (resolveDotPath, ruleMatches, evaluateCondition, normalizeCondition, evaluateOperator, applyHitPolicy) from evaluator-browser.ts into a new core.ts module. Both Node.js and browser evaluators now import from core.ts, eliminating duplication. --- .../__tests__/rules/evaluator-browser.test.ts | 20 +- packages/engine/src/rules/core.ts | 165 +++++++++++++++++ .../engine/src/rules/evaluator-browser.ts | 174 ++---------------- packages/engine/src/rules/evaluator.ts | 2 +- packages/engine/tsup.config.ts | 2 +- 5 files changed, 196 insertions(+), 167 deletions(-) create mode 100644 packages/engine/src/rules/core.ts diff --git a/packages/engine/src/__tests__/rules/evaluator-browser.test.ts b/packages/engine/src/__tests__/rules/evaluator-browser.test.ts index 3de80a4..2004623 100644 --- a/packages/engine/src/__tests__/rules/evaluator-browser.test.ts +++ b/packages/engine/src/__tests__/rules/evaluator-browser.test.ts @@ -17,15 +17,19 @@ function makeDoc(overrides: Partial = {}): RulesDocument { describe('browser-safe evaluateRules', () => { describe('no Node.js imports', () => { it('does not import node:fs, node:path, node:vm, acorn, or yaml', () => { - const filePath = resolve(import.meta.dirname, '../../rules/evaluator-browser.ts') - const source = readFileSync(filePath, 'utf-8') - const forbidden = ['node:fs', 'node:path', 'node:vm', 'acorn', 'yaml'] - for (const mod of forbidden) { - expect(source).not.toContain(`from '${mod}'`) - expect(source).not.toContain(`from "${mod}"`) - expect(source).not.toContain(`require('${mod}')`) - expect(source).not.toContain(`require("${mod}")`) + const files = [ + resolve(import.meta.dirname, '../../rules/evaluator-browser.ts'), + resolve(import.meta.dirname, '../../rules/core.ts'), + ] + for (const filePath of files) { + const source = readFileSync(filePath, 'utf-8') + for (const mod of forbidden) { + expect(source).not.toContain(`from '${mod}'`) + expect(source).not.toContain(`from "${mod}"`) + expect(source).not.toContain(`require('${mod}')`) + expect(source).not.toContain(`require("${mod}")`) + } } }) }) diff --git a/packages/engine/src/rules/core.ts b/packages/engine/src/rules/core.ts new file mode 100644 index 0000000..1816eff --- /dev/null +++ b/packages/engine/src/rules/core.ts @@ -0,0 +1,165 @@ +/** + * Core rules evaluation logic shared between Node.js and browser evaluators. + * + * Contains all pure evaluation functions (operators, conditions, hit policies, + * dot-path resolution) without any platform-specific dependencies. + */ + +import type { + Rule, + Condition, + OperatorCondition, + RulesEvaluationResult, +} from './types.js' + +/** + * Resolve a dot-path (e.g., "order.total_amount") against a context object. + */ +export function resolveDotPath(path: string, context: Record): unknown { + const parts = path.split('.') + let current: unknown = context + + for (const part of parts) { + if (typeof current !== 'object' || current === null) { + return undefined + } + current = (current as Record)[part] + } + + return current +} + +/** + * Check if a single rule matches against the resolved inputs. + * A rule with no `when` clause always matches (wildcard/default). + */ +export function ruleMatches( + rule: Rule, + resolvedInputs: Map, + rulesContext: Record, +): boolean { + if (!rule.when) { + return true // Wildcard rule + } + + for (const [field, condition] of Object.entries(rule.when)) { + const value = resolvedInputs.has(field) + ? resolvedInputs.get(field) + : resolveDotPath(field, rulesContext) + if (!evaluateCondition(value, condition)) { + return false + } + } + + return true +} + +/** + * Evaluate a condition against a value. + * Handles both shorthand scalars and operator objects. + */ +export function evaluateCondition(value: unknown, condition: Condition): boolean { + const normalized = normalizeCondition(condition) + + for (const [op, operand] of Object.entries(normalized)) { + if (!evaluateOperator(op, value, operand)) { + return false + } + } + + return true +} + +/** + * Normalize a condition to an OperatorCondition. + * Shorthand scalars (string, number, boolean, null) become { eq: value }. + */ +export function normalizeCondition(condition: Condition): OperatorCondition { + if ( + condition === null || + typeof condition === 'string' || + typeof condition === 'number' || + typeof condition === 'boolean' + ) { + return { eq: condition } + } + return condition +} + +/** + * Evaluate a single operator against a value. + * No type coercion — types must match exactly. + */ +export function evaluateOperator(op: string, value: unknown, operand: unknown): boolean { + switch (op) { + case 'eq': + return value === operand + case 'not_eq': + return value !== operand + case 'gt': + return typeof value === 'number' && typeof operand === 'number' && value > operand + case 'gte': + return typeof value === 'number' && typeof operand === 'number' && value >= operand + case 'lt': + return typeof value === 'number' && typeof operand === 'number' && value < operand + case 'lte': + return typeof value === 'number' && typeof operand === 'number' && value <= operand + case 'in': + return Array.isArray(operand) && operand.includes(value) + case 'not_in': + return Array.isArray(operand) && !operand.includes(value) + case 'between': { + if (!Array.isArray(operand) || operand.length !== 2) return false + const [low, high] = operand as [number, number] + return typeof value === 'number' && value >= low && value <= high + } + default: + return false + } +} + +/** + * Apply the hit policy to the list of matched rules. + */ +export function applyHitPolicy( + hitPolicy: string, + matchedRules: Rule[], +): RulesEvaluationResult { + switch (hitPolicy) { + case 'first': + return { + hit_policy: 'first', + matched_count: matchedRules.length, + output: matchedRules[0]?.then ?? {}, + } + + case 'collect': + return { + hit_policy: 'collect', + matched_count: matchedRules.length, + output: matchedRules.map((r) => r.then), + } + + case 'all': + if (matchedRules.length === 0) { + throw new Error('Hit policy "all" requires at least one matching rule, but none matched') + } + return { + hit_policy: 'all', + matched_count: matchedRules.length, + output: matchedRules.map((r) => r.then), + } + + case 'priority': { + const sorted = [...matchedRules].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) + return { + hit_policy: 'priority', + matched_count: matchedRules.length, + output: sorted[0]?.then ?? {}, + } + } + + default: + throw new Error(`Unknown hit policy: ${hitPolicy}`) + } +} diff --git a/packages/engine/src/rules/evaluator-browser.ts b/packages/engine/src/rules/evaluator-browser.ts index 518e00c..ab3dbba 100644 --- a/packages/engine/src/rules/evaluator-browser.ts +++ b/packages/engine/src/rules/evaluator-browser.ts @@ -1,18 +1,30 @@ /** * Browser-safe rules evaluator. * - * Contains all pure evaluation logic (operators, conditions, hit policies, - * dot-path resolution) without Node.js dependencies. The Node.js evaluator - * imports these helpers and adds file-loading + labeled expression support. + * Contains the browser-safe entry point that delegates to shared core logic. + * The Node.js evaluator adds file-loading + labeled expression support. */ import type { RulesDocument, Rule, - Condition, - OperatorCondition, RulesEvaluationResult, } from './types.js' +import { + resolveDotPath, + ruleMatches, + applyHitPolicy, +} from './core.js' + +// Re-export core functions for consumers that import from this module +export { + resolveDotPath, + ruleMatches, + evaluateCondition, + normalizeCondition, + evaluateOperator, + applyHitPolicy, +} from './core.js' /** * Evaluate a rules document against a plain input object. @@ -64,155 +76,3 @@ function resolveSimpleInputs( return resolved } - -/** - * Resolve a dot-path (e.g., "order.total_amount") against a context object. - */ -export function resolveDotPath(path: string, context: Record): unknown { - const parts = path.split('.') - let current: unknown = context - - for (const part of parts) { - if (typeof current !== 'object' || current === null) { - return undefined - } - current = (current as Record)[part] - } - - return current -} - -/** - * Check if a single rule matches against the resolved inputs. - * A rule with no `when` clause always matches (wildcard/default). - */ -export function ruleMatches( - rule: Rule, - resolvedInputs: Map, - rulesContext: Record, -): boolean { - if (!rule.when) { - return true // Wildcard rule - } - - for (const [field, condition] of Object.entries(rule.when)) { - const value = resolvedInputs.has(field) - ? resolvedInputs.get(field) - : resolveDotPath(field, rulesContext) - if (!evaluateCondition(value, condition)) { - return false - } - } - - return true -} - -/** - * Evaluate a condition against a value. - * Handles both shorthand scalars and operator objects. - */ -export function evaluateCondition(value: unknown, condition: Condition): boolean { - const normalized = normalizeCondition(condition) - - for (const [op, operand] of Object.entries(normalized)) { - if (!evaluateOperator(op, value, operand)) { - return false - } - } - - return true -} - -/** - * Normalize a condition to an OperatorCondition. - * Shorthand scalars (string, number, boolean, null) become { eq: value }. - */ -export function normalizeCondition(condition: Condition): OperatorCondition { - if ( - condition === null || - typeof condition === 'string' || - typeof condition === 'number' || - typeof condition === 'boolean' - ) { - return { eq: condition } - } - return condition -} - -/** - * Evaluate a single operator against a value. - * No type coercion — types must match exactly. - */ -export function evaluateOperator(op: string, value: unknown, operand: unknown): boolean { - switch (op) { - case 'eq': - return value === operand - case 'not_eq': - return value !== operand - case 'gt': - return typeof value === 'number' && typeof operand === 'number' && value > operand - case 'gte': - return typeof value === 'number' && typeof operand === 'number' && value >= operand - case 'lt': - return typeof value === 'number' && typeof operand === 'number' && value < operand - case 'lte': - return typeof value === 'number' && typeof operand === 'number' && value <= operand - case 'in': - return Array.isArray(operand) && operand.includes(value) - case 'not_in': - return Array.isArray(operand) && !operand.includes(value) - case 'between': { - if (!Array.isArray(operand) || operand.length !== 2) return false - const [low, high] = operand as [number, number] - return typeof value === 'number' && value >= low && value <= high - } - default: - return false - } -} - -/** - * Apply the hit policy to the list of matched rules. - */ -export function applyHitPolicy( - hitPolicy: string, - matchedRules: Rule[], -): RulesEvaluationResult { - switch (hitPolicy) { - case 'first': - return { - hit_policy: 'first', - matched_count: matchedRules.length, - output: matchedRules[0]?.then ?? {}, - } - - case 'collect': - return { - hit_policy: 'collect', - matched_count: matchedRules.length, - output: matchedRules.map((r) => r.then), - } - - case 'all': - if (matchedRules.length === 0) { - throw new Error('Hit policy "all" requires at least one matching rule, but none matched') - } - return { - hit_policy: 'all', - matched_count: matchedRules.length, - output: matchedRules.map((r) => r.then), - } - - case 'priority': { - const sorted = [...matchedRules].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) - return { - hit_policy: 'priority', - matched_count: matchedRules.length, - output: sorted[0]?.then ?? {}, - } - } - - default: - throw new Error(`Unknown hit policy: ${hitPolicy}`) - } -} diff --git a/packages/engine/src/rules/evaluator.ts b/packages/engine/src/rules/evaluator.ts index cc54892..10ca9a4 100644 --- a/packages/engine/src/rules/evaluator.ts +++ b/packages/engine/src/rules/evaluator.ts @@ -9,7 +9,7 @@ import { resolveDotPath, ruleMatches, applyHitPolicy, -} from './evaluator-browser.js' +} from './core.js' import type { RulesDocument, Rule, diff --git a/packages/engine/tsup.config.ts b/packages/engine/tsup.config.ts index 0ece211..e319b0a 100644 --- a/packages/engine/tsup.config.ts +++ b/packages/engine/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts', 'src/rules/evaluator-browser.ts'], + entry: ['src/index.ts', 'src/rules/evaluator-browser.ts', 'src/rules/core.ts'], format: ['esm', 'cjs'], dts: { compilerOptions: { From 969a8947cdae4d6928100c31a58d91def2cd9965 Mon Sep 17 00:00:00 2001 From: albertgwo Date: Wed, 18 Mar 2026 08:15:24 -0400 Subject: [PATCH 2/3] feat(engine-gorules): add GoRules ZEN wrapper for expressions and rules New @ruminaider/flowprint-engine-gorules package wrapping @gorules/zen-engine: - evaluateExpression/evaluateExpressions: sync expression evaluation via ZEN - translateToJDM: converts flowprint .rules.yaml to GoRules JDM format - evaluateRulesViaZen: async decision table evaluation with caching - conditionToUnary: operator-to-ZEN unary expression mapping Supports all flowprint operators (eq, gt, gte, lt, lte, not_eq, in, not_in, between) and all hit policies (first, collect, all, priority). --- packages/engine-gorules/package.json | 34 ++ packages/engine-gorules/src/evaluator.ts | 38 ++ packages/engine-gorules/src/index.ts | 5 + .../engine-gorules/src/rules-evaluator.ts | 66 ++++ .../engine-gorules/src/rules-translator.ts | 332 ++++++++++++++++++ packages/engine-gorules/tsconfig.json | 10 + packages/engine-gorules/tsup.config.ts | 13 + packages/engine-gorules/vitest.config.ts | 9 + 8 files changed, 507 insertions(+) create mode 100644 packages/engine-gorules/package.json create mode 100644 packages/engine-gorules/src/evaluator.ts create mode 100644 packages/engine-gorules/src/index.ts create mode 100644 packages/engine-gorules/src/rules-evaluator.ts create mode 100644 packages/engine-gorules/src/rules-translator.ts create mode 100644 packages/engine-gorules/tsconfig.json create mode 100644 packages/engine-gorules/tsup.config.ts create mode 100644 packages/engine-gorules/vitest.config.ts diff --git a/packages/engine-gorules/package.json b/packages/engine-gorules/package.json new file mode 100644 index 0000000..e3dcff6 --- /dev/null +++ b/packages/engine-gorules/package.json @@ -0,0 +1,34 @@ +{ + "name": "@ruminaider/flowprint-engine-gorules", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "build": "tsup", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@gorules/zen-engine": "^0.54.0", + "@ruminaider/flowprint-engine": "workspace:*" + }, + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + } +} diff --git a/packages/engine-gorules/src/evaluator.ts b/packages/engine-gorules/src/evaluator.ts new file mode 100644 index 0000000..5373af7 --- /dev/null +++ b/packages/engine-gorules/src/evaluator.ts @@ -0,0 +1,38 @@ +import { evaluateExpressionSync } from '@gorules/zen-engine' + +/** + * Evaluate a single expression using GoRules ZEN. + * Wraps evaluateExpressionSync with our context mapping. + * + * GoRules uses `and`/`or`/`not` syntax (not `&&`/`||`/`!`). + * Expressions must use ZEN syntax directly. + */ +export function evaluateExpression( + expression: string, + context: Record, +): unknown { + return evaluateExpressionSync(expression, context) +} + +/** + * Evaluate multiple expressions (expression node). + * Returns output object with computed values. + * + * Later expressions can reference the results of earlier ones, + * enabling chained computations within a single node. + */ +export function evaluateExpressions( + expressions: Record, + context: Record, +): Record { + const output: Record = {} + const evalContext = { ...context } + + for (const [key, expr] of Object.entries(expressions)) { + const result = evaluateExpressionSync(expr, evalContext) + output[key] = result + evalContext[key] = result + } + + return output +} diff --git a/packages/engine-gorules/src/index.ts b/packages/engine-gorules/src/index.ts new file mode 100644 index 0000000..d2aa314 --- /dev/null +++ b/packages/engine-gorules/src/index.ts @@ -0,0 +1,5 @@ +export { evaluateExpression, evaluateExpressions } from './evaluator.js' +export { evaluateRulesViaZen, disposeZenEngine } from './rules-evaluator.js' +export type { ZenRulesResult } from './rules-evaluator.js' +export { translateToJDM, conditionToUnary } from './rules-translator.js' +export type { JDMDocument } from './rules-translator.js' diff --git a/packages/engine-gorules/src/rules-evaluator.ts b/packages/engine-gorules/src/rules-evaluator.ts new file mode 100644 index 0000000..69392eb --- /dev/null +++ b/packages/engine-gorules/src/rules-evaluator.ts @@ -0,0 +1,66 @@ +import { ZenEngine } from '@gorules/zen-engine' +import type { RulesDocument } from '@ruminaider/flowprint-engine' +import { translateToJDM } from './rules-translator.js' + +/** Result of evaluating a rules document via GoRules ZEN */ +export interface ZenRulesResult { + hit_policy: string + matched_count: number + output: unknown +} + +// Cache compiled decisions by document content hash +const decisionCache = new Map>() +let zenEngine: ZenEngine | null = null + +function getEngine(): ZenEngine { + if (!zenEngine) zenEngine = new ZenEngine() + return zenEngine +} + +/** + * Evaluate a flowprint RulesDocument using GoRules ZEN engine. + * + * Translates the document to JDM format, compiles it into a ZEN decision, + * and evaluates the input. Results are cached by document content for + * repeated evaluations with different inputs. + */ +export async function evaluateRulesViaZen( + doc: RulesDocument, + input: Record, +): Promise { + const cacheKey = JSON.stringify(doc) + let decision = decisionCache.get(cacheKey) + + if (!decision) { + const jdm = translateToJDM(doc) + decision = getEngine().createDecision(jdm) + decisionCache.set(cacheKey, decision) + } + + const response = await decision.evaluate(input) + + const isCollect = doc.hit_policy === 'collect' || doc.hit_policy === 'all' + + return { + hit_policy: doc.hit_policy, + matched_count: isCollect + ? (Array.isArray(response.result) ? response.result.length : 0) + : (isNonEmptyObject(response.result) ? 1 : 0), + output: response.result, + } +} + +function isNonEmptyObject(value: unknown): boolean { + return typeof value === 'object' && value !== null && Object.keys(value).length > 0 +} + +/** + * Dispose the shared ZEN engine and clear the decision cache. + * Call this during cleanup to free native WASM memory. + */ +export function disposeZenEngine(): void { + zenEngine?.dispose() + zenEngine = null + decisionCache.clear() +} diff --git a/packages/engine-gorules/src/rules-translator.ts b/packages/engine-gorules/src/rules-translator.ts new file mode 100644 index 0000000..c1cdd7c --- /dev/null +++ b/packages/engine-gorules/src/rules-translator.ts @@ -0,0 +1,332 @@ +/** + * Translates flowprint `.rules.yaml` documents to GoRules JDM format. + * + * Our format uses operator-based conditions (eq, gt, gte, etc.) while + * JDM uses unary expressions in decision table cells. This module + * bridges the two representations. + */ + +import type { + RulesDocument, + Rule, + Condition, + OperatorCondition, +} from '@ruminaider/flowprint-engine' + +/** JDM decision table input column */ +interface JDMInput { + id: string + name: string + type: 'expression' + field: string +} + +/** JDM decision table output column */ +interface JDMOutput { + id: string + name: string + type: 'expression' + field: string +} + +/** JDM decision table rule row */ +interface JDMRule { + _id: string + [columnId: string]: string +} + +/** JDM decision table content */ +interface JDMDecisionTableContent { + hitPolicy: 'first' | 'collect' + inputs: JDMInput[] + outputs: JDMOutput[] + rules: JDMRule[] +} + +/** JDM graph node */ +interface JDMNode { + id: string + type: string + name: string + position: { x: number; y: number } + content?: JDMDecisionTableContent +} + +/** JDM graph edge */ +interface JDMEdge { + id: string + type: 'edge' + sourceId: string + targetId: string +} + +/** Complete JDM document */ +export interface JDMDocument { + nodes: JDMNode[] + edges: JDMEdge[] +} + +/** + * Translate a flowprint RulesDocument to GoRules JDM format. + * + * Maps our hit policies to JDM: + * - `first` -> `first` (returns first match as object) + * - `collect` / `all` -> `collect` (returns all matches as array) + * - `priority` -> `first` (rules pre-sorted by priority) + */ +export function translateToJDM(doc: RulesDocument): JDMDocument { + const { inputColumns, inputFieldMap } = buildInputColumns(doc) + const outputColumns = buildOutputColumns(doc.rules) + const hitPolicy = mapHitPolicy(doc.hit_policy) + + const rules = doc.hit_policy === 'priority' + ? [...doc.rules].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) + : doc.rules + + const jdmRules = rules.map((rule, i) => + translateRule(rule, i, inputFieldMap, outputColumns), + ) + + return { + nodes: [ + { + id: 'input', + type: 'inputNode', + name: 'Input', + position: { x: 0, y: 0 }, + }, + { + id: 'table', + type: 'decisionTableNode', + name: 'Rules', + position: { x: 200, y: 0 }, + content: { + hitPolicy, + inputs: inputColumns, + outputs: outputColumns, + rules: jdmRules, + }, + }, + { + id: 'output', + type: 'outputNode', + name: 'Output', + position: { x: 400, y: 0 }, + }, + ], + edges: [ + { id: 'e1', type: 'edge', sourceId: 'input', targetId: 'table' }, + { id: 'e2', type: 'edge', sourceId: 'table', targetId: 'output' }, + ], + } +} + +function mapHitPolicy(hitPolicy: string): 'first' | 'collect' { + switch (hitPolicy) { + case 'first': + case 'priority': + return 'first' + case 'collect': + case 'all': + return 'collect' + default: + return 'first' + } +} + +/** + * Build input columns from the rules document. + * Discovers all unique fields referenced in rule `when` conditions. + */ +function buildInputColumns(doc: RulesDocument): { + inputColumns: JDMInput[] + inputFieldMap: Map +} { + const fields = new Set() + + // Collect fields from declared inputs + if (doc.inputs) { + for (const input of doc.inputs) { + if (typeof input === 'string') { + fields.add(input) + } + // Skip labeled expressions — not mappable to JDM columns + } + } + + // Collect fields from rule conditions + for (const rule of doc.rules) { + if (rule.when) { + for (const field of Object.keys(rule.when)) { + fields.add(field) + } + } + } + + const inputColumns: JDMInput[] = [] + const inputFieldMap = new Map() + + let i = 0 + for (const field of fields) { + const id = `col-in-${String(i)}` + inputColumns.push({ + id, + name: field, + type: 'expression', + field, + }) + inputFieldMap.set(field, id) + i++ + } + + return { inputColumns, inputFieldMap } +} + +/** + * Build output columns from all unique output fields across rules. + */ +function buildOutputColumns(rules: Rule[]): JDMOutput[] { + const fields = new Set() + for (const rule of rules) { + for (const key of Object.keys(rule.then)) { + fields.add(key) + } + } + + return Array.from(fields).map((field, i) => ({ + id: `col-out-${String(i)}`, + name: field, + type: 'expression' as const, + field, + })) +} + +/** + * Translate a single rule to a JDM rule row. + */ +function translateRule( + rule: Rule, + index: number, + inputFieldMap: Map, + outputColumns: JDMOutput[], +): JDMRule { + const jdmRule: JDMRule = { _id: `r${String(index)}` } + + // Map when conditions to input columns + if (rule.when) { + for (const [field, condition] of Object.entries(rule.when)) { + const colId = inputFieldMap.get(field) + if (colId) { + jdmRule[colId] = conditionToUnary(condition) + } + } + } + + // Map then outputs to output columns + for (const col of outputColumns) { + const value = rule.then[col.field] + if (value !== undefined) { + jdmRule[col.id] = valueToExpression(value) + } + } + + return jdmRule +} + +/** + * Convert a flowprint condition to a JDM unary expression. + * + * Operator mapping: + * - `eq: "enterprise"` -> `"enterprise"` (string literal) + * - `eq: 42` -> `42` (number literal) + * - `gt: 10000` -> `> 10000` + * - `gte: 100` -> `>= 100` + * - `lt: 50` -> `< 50` + * - `lte: 50` -> `<= 50` + * - `not_eq: "basic"` -> `not("basic")` + * - `in: ["US", "CA"]` -> `"US", "CA"` + * - `not_in: ["X"]` -> `not("X")` + * - `between: [10, 20]` -> `[10..20]` + * - Shorthand scalar -> normalized to eq first + */ +export function conditionToUnary(condition: Condition): string { + const normalized = normalizeCondition(condition) + const parts: string[] = [] + + for (const [op, operand] of Object.entries(normalized)) { + if (operand === undefined) continue + parts.push(operatorToUnary(op, operand)) + } + + // Multiple operators are ANDed; join with `and` for ZEN + return parts.length === 1 ? parts[0]! : parts.join(' and ') +} + +function normalizeCondition(condition: Condition): OperatorCondition { + if ( + condition === null || + typeof condition === 'string' || + typeof condition === 'number' || + typeof condition === 'boolean' + ) { + return { eq: condition } + } + return condition +} + +function operatorToUnary(op: string, operand: unknown): string { + switch (op) { + case 'eq': + return formatLiteral(operand) + case 'not_eq': + return `not(${formatLiteral(operand)})` + case 'gt': + return `> ${String(operand)}` + case 'gte': + return `>= ${String(operand)}` + case 'lt': + return `< ${String(operand)}` + case 'lte': + return `<= ${String(operand)}` + case 'in': + if (Array.isArray(operand)) { + return operand.map((v: unknown) => formatLiteral(v)).join(', ') + } + return String(operand) + case 'not_in': + if (Array.isArray(operand)) { + return operand.map((v: unknown) => `not(${formatLiteral(v)})`).join(' and ') + } + return `not(${String(operand)})` + case 'between': + if (Array.isArray(operand) && operand.length === 2) { + return `[${String(operand[0])}..${String(operand[1])}]` + } + return String(operand) + default: + return String(operand) + } +} + +function formatLiteral(value: unknown): string { + if (typeof value === 'string') { + return `"${value}"` + } + if (value === null) { + return 'null' + } + return String(value) +} + +/** + * Convert an output value to a JDM expression string. + */ +function valueToExpression(value: unknown): string { + if (typeof value === 'string') { + return `"${value}"` + } + if (value === null) { + return 'null' + } + return String(value) +} diff --git a/packages/engine-gorules/tsconfig.json b/packages/engine-gorules/tsconfig.json new file mode 100644 index 0000000..3c0a611 --- /dev/null +++ b/packages/engine-gorules/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "references": [{ "path": "../engine" }] +} diff --git a/packages/engine-gorules/tsup.config.ts b/packages/engine-gorules/tsup.config.ts new file mode 100644 index 0000000..96ad490 --- /dev/null +++ b/packages/engine-gorules/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: { + compilerOptions: { + composite: false, + }, + }, + clean: true, + sourcemap: true, +}) diff --git a/packages/engine-gorules/vitest.config.ts b/packages/engine-gorules/vitest.config.ts new file mode 100644 index 0000000..32f6b32 --- /dev/null +++ b/packages/engine-gorules/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + passWithNoTests: true, + }, +}) From e48fef45a5deb790a54051b527928578c9a8a90f Mon Sep 17 00:00:00 2001 From: albertgwo Date: Wed, 18 Mar 2026 08:15:34 -0400 Subject: [PATCH 3/3] test(engine-gorules): add evaluator and translator tests 38 tests covering: - Expression evaluation: arithmetic, boolean logic, ZEN syntax, errors - Multiple expressions with chained references - Rules translator: all operator mappings, JDM structure, hit policies - Rules evaluator: first/collect hit policies, wildcard rules, no-match --- .../src/__tests__/evaluator.test.ts | 72 ++++++ .../src/__tests__/rules-evaluator.test.ts | 113 ++++++++++ .../src/__tests__/rules-translator.test.ts | 213 ++++++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 packages/engine-gorules/src/__tests__/evaluator.test.ts create mode 100644 packages/engine-gorules/src/__tests__/rules-evaluator.test.ts create mode 100644 packages/engine-gorules/src/__tests__/rules-translator.test.ts diff --git a/packages/engine-gorules/src/__tests__/evaluator.test.ts b/packages/engine-gorules/src/__tests__/evaluator.test.ts new file mode 100644 index 0000000..c492601 --- /dev/null +++ b/packages/engine-gorules/src/__tests__/evaluator.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest' +import { evaluateExpression, evaluateExpressions } from '../evaluator.js' + +describe('evaluateExpression', () => { + it('evaluates arithmetic: qty * price', () => { + const result = evaluateExpression('qty * price', { qty: 5, price: 10 }) + expect(result).toBe(50) + }) + + it('evaluates boolean with ZEN syntax: and/or', () => { + const result = evaluateExpression( + 'tier == "enterprise" and amount > 10000', + { tier: 'enterprise', amount: 15000 }, + ) + expect(result).toBe(true) + }) + + it('evaluates boolean false result', () => { + const result = evaluateExpression( + 'tier == "enterprise" and amount > 10000', + { tier: 'basic', amount: 15000 }, + ) + expect(result).toBe(false) + }) + + it('evaluates string comparison', () => { + const result = evaluateExpression('status == "active"', { status: 'active' }) + expect(result).toBe(true) + }) + + it('evaluates numeric comparison', () => { + const result = evaluateExpression('score >= 80', { score: 85 }) + expect(result).toBe(true) + }) + + it('throws on invalid expression syntax', () => { + expect(() => evaluateExpression('((( invalid', {})).toThrow() + }) +}) + +describe('evaluateExpressions', () => { + it('evaluates multiple expressions', () => { + const result = evaluateExpressions( + { + subtotal: 'qty * price', + tax: 'qty * price * 0.1', + }, + { qty: 5, price: 10 }, + ) + expect(result).toEqual({ + subtotal: 50, + tax: 5, + }) + }) + + it('later expressions reference earlier results', () => { + const result = evaluateExpressions( + { + subtotal: 'qty * price', + total: 'subtotal * 1.1', + }, + { qty: 5, price: 10 }, + ) + expect(result.subtotal).toBe(50) + expect(result.total).toBeCloseTo(55) + }) + + it('returns empty object for empty expressions', () => { + const result = evaluateExpressions({}, { qty: 5 }) + expect(result).toEqual({}) + }) +}) diff --git a/packages/engine-gorules/src/__tests__/rules-evaluator.test.ts b/packages/engine-gorules/src/__tests__/rules-evaluator.test.ts new file mode 100644 index 0000000..036f6cc --- /dev/null +++ b/packages/engine-gorules/src/__tests__/rules-evaluator.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, afterAll } from 'vitest' +import { evaluateRulesViaZen, disposeZenEngine } from '../rules-evaluator.js' +import type { RulesDocument } from '@ruminaider/flowprint-engine' + +afterAll(() => { + disposeZenEngine() +}) + +function makeDoc(overrides: Partial = {}): RulesDocument { + return { + schema: 'flowprint-rules/1.0', + name: 'test-rules', + hit_policy: 'first', + rules: [], + ...overrides, + } +} + +describe('evaluateRulesViaZen', () => { + describe('first hit policy', () => { + it('returns first matching rule output', async () => { + const doc = makeDoc({ + hit_policy: 'first', + inputs: ['tier'], + rules: [ + { when: { tier: { eq: 'enterprise' } }, then: { discount: 20 } }, + { when: { tier: { eq: 'pro' } }, then: { discount: 10 } }, + { then: { discount: 0 } }, + ], + }) + + const result = await evaluateRulesViaZen(doc, { tier: 'enterprise' }) + expect(result.hit_policy).toBe('first') + expect(result.output).toEqual({ discount: 20 }) + expect(result.matched_count).toBe(1) + }) + + it('returns empty object when no match', async () => { + const doc = makeDoc({ + hit_policy: 'first', + inputs: ['tier'], + rules: [ + { when: { tier: { eq: 'enterprise' } }, then: { discount: 20 } }, + ], + }) + + const result = await evaluateRulesViaZen(doc, { tier: 'basic' }) + expect(result.matched_count).toBe(0) + expect(result.output).toEqual({}) + }) + + it('evaluates numeric comparison operators', async () => { + const doc = makeDoc({ + hit_policy: 'first', + inputs: ['amount'], + rules: [ + { when: { amount: { gte: 1000 } }, then: { tier: 'high' } }, + { when: { amount: { gte: 100 } }, then: { tier: 'mid' } }, + { then: { tier: 'low' } }, + ], + }) + + const result = await evaluateRulesViaZen(doc, { amount: 500 }) + expect(result.output).toEqual({ tier: 'mid' }) + }) + }) + + describe('collect hit policy', () => { + it('returns all matching rules as array', async () => { + const doc = makeDoc({ + hit_policy: 'collect', + inputs: ['x'], + rules: [ + { when: { x: { gt: 0 } }, then: { rule: 1 } }, + { when: { x: { gt: 5 } }, then: { rule: 2 } }, + { when: { x: { gt: 100 } }, then: { rule: 3 } }, + ], + }) + + const result = await evaluateRulesViaZen(doc, { x: 10 }) + expect(result.hit_policy).toBe('collect') + expect(result.output).toEqual([{ rule: 1 }, { rule: 2 }]) + expect(result.matched_count).toBe(2) + }) + + it('returns empty array when no match', async () => { + const doc = makeDoc({ + hit_policy: 'collect', + inputs: ['x'], + rules: [ + { when: { x: { gt: 100 } }, then: { rule: 1 } }, + ], + }) + + const result = await evaluateRulesViaZen(doc, { x: 5 }) + expect(result.output).toEqual([]) + expect(result.matched_count).toBe(0) + }) + }) + + describe('wildcard rules', () => { + it('matches wildcard rule (no when clause)', async () => { + const doc = makeDoc({ + hit_policy: 'first', + rules: [{ then: { default: true } }], + }) + + const result = await evaluateRulesViaZen(doc, {}) + expect(result.output).toEqual({ default: true }) + expect(result.matched_count).toBe(1) + }) + }) +}) diff --git a/packages/engine-gorules/src/__tests__/rules-translator.test.ts b/packages/engine-gorules/src/__tests__/rules-translator.test.ts new file mode 100644 index 0000000..783dff7 --- /dev/null +++ b/packages/engine-gorules/src/__tests__/rules-translator.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect } from 'vitest' +import { translateToJDM, conditionToUnary } from '../rules-translator.js' +import type { RulesDocument } from '@ruminaider/flowprint-engine' + +function makeDoc(overrides: Partial = {}): RulesDocument { + return { + schema: 'flowprint-rules/1.0', + name: 'test-rules', + hit_policy: 'first', + rules: [], + ...overrides, + } +} + +describe('conditionToUnary', () => { + it('maps eq string to quoted literal', () => { + expect(conditionToUnary({ eq: 'enterprise' })).toBe('"enterprise"') + }) + + it('maps eq number to literal', () => { + expect(conditionToUnary({ eq: 42 })).toBe('42') + }) + + it('maps eq null to null', () => { + expect(conditionToUnary({ eq: null })).toBe('null') + }) + + it('maps eq boolean to literal', () => { + expect(conditionToUnary({ eq: true })).toBe('true') + }) + + it('maps shorthand string scalar to eq', () => { + expect(conditionToUnary('enterprise')).toBe('"enterprise"') + }) + + it('maps shorthand number scalar to eq', () => { + expect(conditionToUnary(42)).toBe('42') + }) + + it('maps gt to > operator', () => { + expect(conditionToUnary({ gt: 10000 })).toBe('> 10000') + }) + + it('maps gte to >= operator', () => { + expect(conditionToUnary({ gte: 100 })).toBe('>= 100') + }) + + it('maps lt to < operator', () => { + expect(conditionToUnary({ lt: 50 })).toBe('< 50') + }) + + it('maps lte to <= operator', () => { + expect(conditionToUnary({ lte: 50 })).toBe('<= 50') + }) + + it('maps not_eq to not() expression', () => { + expect(conditionToUnary({ not_eq: 'basic' })).toBe('not("basic")') + }) + + it('maps in to comma-separated values', () => { + expect(conditionToUnary({ in: ['US', 'CA'] })).toBe('"US", "CA"') + }) + + it('maps not_in to ANDed not() expressions', () => { + expect(conditionToUnary({ not_in: ['X', 'Y'] })).toBe('not("X") and not("Y")') + }) + + it('maps between to range syntax', () => { + expect(conditionToUnary({ between: [10, 20] })).toBe('[10..20]') + }) + + it('maps multi-operator condition with and', () => { + expect(conditionToUnary({ gte: 50, lte: 100 })).toBe('>= 50 and <= 100') + }) +}) + +describe('translateToJDM', () => { + it('produces valid JDM structure with inputNode -> decisionTableNode -> outputNode', () => { + const doc = makeDoc({ + rules: [{ when: { tier: 'enterprise' }, then: { discount: 20 } }], + }) + const jdm = translateToJDM(doc) + + expect(jdm.nodes).toHaveLength(3) + expect(jdm.nodes[0]!.type).toBe('inputNode') + expect(jdm.nodes[1]!.type).toBe('decisionTableNode') + expect(jdm.nodes[2]!.type).toBe('outputNode') + + expect(jdm.edges).toHaveLength(2) + expect(jdm.edges[0]).toEqual({ + id: 'e1', + type: 'edge', + sourceId: 'input', + targetId: 'table', + }) + expect(jdm.edges[1]).toEqual({ + id: 'e2', + type: 'edge', + sourceId: 'table', + targetId: 'output', + }) + }) + + it('translates first hit policy correctly', () => { + const doc = makeDoc({ + hit_policy: 'first', + rules: [ + { when: { tier: 'enterprise' }, then: { discount: 20 } }, + { then: { discount: 0 } }, + ], + }) + const jdm = translateToJDM(doc) + const table = jdm.nodes[1]! + + expect(table.content!.hitPolicy).toBe('first') + expect(table.content!.rules).toHaveLength(2) + }) + + it('translates collect hit policy correctly', () => { + const doc = makeDoc({ + hit_policy: 'collect', + rules: [ + { when: { x: { gt: 0 } }, then: { rule: 1 } }, + { when: { x: { gt: 5 } }, then: { rule: 2 } }, + ], + }) + const jdm = translateToJDM(doc) + const table = jdm.nodes[1]! + + expect(table.content!.hitPolicy).toBe('collect') + }) + + it('translates all hit policy to collect', () => { + const doc = makeDoc({ + hit_policy: 'all', + rules: [{ when: { x: { gt: 0 } }, then: { out: true } }], + }) + const jdm = translateToJDM(doc) + const table = jdm.nodes[1]! + + expect(table.content!.hitPolicy).toBe('collect') + }) + + it('translates priority hit policy to first with sorted rules', () => { + const doc = makeDoc({ + hit_policy: 'priority', + rules: [ + { when: { x: { gt: 0 } }, then: { rule: 'C' }, priority: 3 }, + { when: { x: { gt: 0 } }, then: { rule: 'A' }, priority: 1 }, + { when: { x: { gt: 0 } }, then: { rule: 'B' }, priority: 2 }, + ], + }) + const jdm = translateToJDM(doc) + const table = jdm.nodes[1]! + + expect(table.content!.hitPolicy).toBe('first') + // Rules should be sorted by priority (1, 2, 3) + const outputCol = table.content!.outputs[0]!.id + expect(table.content!.rules[0]![outputCol]).toBe('"A"') + expect(table.content!.rules[1]![outputCol]).toBe('"B"') + expect(table.content!.rules[2]![outputCol]).toBe('"C"') + }) + + it('discovers input fields from rule conditions', () => { + const doc = makeDoc({ + rules: [ + { when: { tier: 'enterprise', amount: { gt: 1000 } }, then: { discount: 10 } }, + ], + }) + const jdm = translateToJDM(doc) + const table = jdm.nodes[1]! + const inputFields = table.content!.inputs.map((i) => i.field) + + expect(inputFields).toContain('tier') + expect(inputFields).toContain('amount') + }) + + it('discovers output fields from all rules', () => { + const doc = makeDoc({ + rules: [ + { when: { tier: 'gold' }, then: { discount: 10, note: 'gold discount' } }, + { then: { discount: 0 } }, + ], + }) + const jdm = translateToJDM(doc) + const table = jdm.nodes[1]! + const outputFields = table.content!.outputs.map((o) => o.field) + + expect(outputFields).toContain('discount') + expect(outputFields).toContain('note') + }) + + it('maps operator conditions to JDM unary expressions', () => { + const doc = makeDoc({ + rules: [ + { + when: { amount: { gte: 100 }, tier: 'enterprise' }, + then: { approved: true }, + }, + ], + }) + const jdm = translateToJDM(doc) + const table = jdm.nodes[1]! + const rule = table.content!.rules[0]! + + // Find the column IDs for the input fields + const amountCol = table.content!.inputs.find((i) => i.field === 'amount')!.id + const tierCol = table.content!.inputs.find((i) => i.field === 'tier')!.id + + expect(rule[amountCol]).toBe('>= 100') + expect(rule[tierCol]).toBe('"enterprise"') + }) +})