@divmain/jdm-asm
v0.2.3
Published
JDM to AssemblyScript WASM Compiler - Transforms JSON Decision Models into efficient WebAssembly
Readme
jdm-asm
A high-performance JDM (JSON Decision Model) compiler that transforms decision models into WebAssembly modules using AssemblyScript. Compatible with GoRules zen-engine JDM format with sub-10 microsecond decision latency and 5-9x faster execution than zen-engine.
Table of Contents
- Overview
- Installation
- Quick Start
- Usage Guide
- JDM Format Reference
- Architecture
- Performance
- Compatibility with zen-engine
- Development
- License
Overview
jdm-asm compiles JSON Decision Models (JDM) into optimized WebAssembly modules. This approach moves expensive parsing and interpretation to compile-time, resulting in significantly faster runtime execution compared to traditional JavaScript rule engines.
Key Features:
- Ultra-Low Latency: 3.5-7 microsecond execution for typical decisions in single-threaded mode
- High Performance: 5-9x faster than zen-engine across all scenarios
- Type Safety: TypeBox schemas for input/output validation
- zen-engine Compatible: Drop-in replacement for most JDM files
- Extended Hit Policies: Supports DMN-standard policies (unique, ruleOrder, outputOrder, priority)
- Multi-threaded: Worker pool support for high-throughput batch processing
Installation
npm install jdm-asmRequirements:
- Node.js 18+
- TypeScript 5.0+ (for TypeBox schemas)
Quick Start
import { Type } from '@sinclair/typebox';
import { compile, createCompiledDecision } from 'jdm-asm';
// 1. Define input/output schemas
const InputSchema = Type.Object({
customerType: Type.String(),
orderAmount: Type.Number(),
});
const OutputSchema = Type.Object({
discount: Type.Number(),
});
// 2. Define your decision model (or load from file)
const jdm = {
nodes: [
{ id: 'input', type: 'inputNode', name: 'Request', position: { x: 0, y: 0 } },
{
id: 'table',
type: 'decisionTableNode',
name: 'Discount Rules',
position: { x: 200, y: 0 },
content: {
hitPolicy: 'first',
inputs: [
{ id: 'in1', name: 'Customer', field: 'customerType' },
{ id: 'in2', name: 'Amount', field: 'orderAmount' },
],
outputs: [{ id: 'out1', name: 'Discount', field: 'discount' }],
rules: [
{ _id: 'r1', in1: '"premium"', in2: '> 100', out1: '0.15' },
{ _id: 'r2', in1: '"standard"', in2: '> 100', out1: '0.10' },
{ _id: 'r3', in1: '', in2: '', out1: '0' },
],
},
},
{ id: 'output', type: 'outputNode', name: 'Response', position: { x: 400, y: 0 } },
],
edges: [
{ id: 'e1', sourceId: 'input', targetId: 'table', type: 'edge' },
{ id: 'e2', sourceId: 'table', targetId: 'output', type: 'edge' },
],
};
// 3. Compile to WebAssembly
const compiled = await compile({
jdm,
inputSchema: InputSchema,
outputSchema: OutputSchema,
});
// 4. Create decision instance
const decision = await createCompiledDecision(compiled);
// 5. Evaluate
const result = await decision.evaluate({
customerType: 'premium',
orderAmount: 150,
});
console.log(result); // { discount: 0.15 }
// 6. Clean up when done
decision.dispose();Usage Guide
Defining Schemas
jdm-asm uses TypeBox schemas to define the structure of input and output data. Schemas enable:
- Compile-time type checking
- Runtime input validation
- Optimized memory layout for WASM
import { Type } from '@sinclair/typebox';
// Simple flat schema
const InputSchema = Type.Object({
age: Type.Number(),
name: Type.String(),
active: Type.Boolean(),
});
// Nested schema
const InputSchema = Type.Object({
customer: Type.Object({
id: Type.String(),
tier: Type.String(),
loyaltyYears: Type.Number(),
}),
order: Type.Object({
total: Type.Number(),
items: Type.Array(Type.Object({
sku: Type.String(),
price: Type.Number(),
quantity: Type.Number(),
})),
}),
});
// Output schema
const OutputSchema = Type.Object({
eligible: Type.Boolean(),
discount: Type.Number(),
message: Type.String(),
});Creating JDM Files
JDM files are JSON documents containing nodes and edges that form a decision graph:
{
"nodes": [
{
"id": "unique-node-id",
"type": "inputNode|outputNode|expressionNode|decisionTableNode|switchNode|decisionNode",
"name": "Human-readable name",
"position": { "x": 0, "y": 0 },
"content": { }
}
],
"edges": [
{
"id": "unique-edge-id",
"type": "edge",
"sourceId": "source-node-id",
"targetId": "target-node-id"
}
]
}Every JDM must have:
- Exactly one
inputNode(entry point) - At least one
outputNode(exit point) - A connected path from input to output
Compiling Decisions
The compile() function transforms JDM into WebAssembly:
import { compile } from 'jdm-asm';
const result = await compile({
// Required
jdm: jdmObject, // JDM as object or JSON string
inputSchema: InputSchema, // TypeBox schema
outputSchema: OutputSchema,
// Optional
optimize: true, // Enable WASM optimizations (default: true)
debug: false, // Include AS source and WAT in output
noMatchBehavior: { // When no rules match:
type: 'returnNull' // 'returnNull' | 'throwError' | 'returnDefault'
},
loadDecision: (key) => {}, // Custom loader for sub-decisions
});Compilation Result:
interface CompilationResult {
wasm: Uint8Array; // Compiled WASM binary
schemaHash: bigint; // Hash for runtime validation
marshalCode: string; // JS code for data marshaling
validationCode: string; // JS code for input validation
wat?: string; // WAT text format (debug mode)
assemblyScript?: string; // Generated AS source (debug mode)
}Executing Decisions
Use createCompiledDecision() to instantiate and execute compiled decisions:
import { createCompiledDecision } from 'jdm-asm';
// Create instance (instantiates WASM module)
const decision = await createCompiledDecision(compiledResult);
// Evaluate with input data
const output = await decision.evaluate({
customerType: 'premium',
orderAmount: 150,
});
// Reuse for multiple evaluations (recommended for performance)
for (const input of inputs) {
const result = await decision.evaluate(input);
processResult(result);
}
// Release resources when done
decision.dispose();CompiledDecision API:
class CompiledDecision {
// Evaluate with input
evaluate<I, O>(input: I): Promise<O>;
// Get WASM memory (debugging)
getMemory(): WebAssembly.Memory | null;
// Memory statistics
getMemoryStats(): { current: number; maximum: number; used: number };
// Release resources
dispose(): void;
}Configuration Options
No-Match Behavior
Configure what happens when no rules match:
// Return null (default)
{ type: 'returnNull' }
// Set error code and return null
{ type: 'throwError' }
// Return a specific default value
{ type: 'returnDefault', value: { discount: 0, message: 'No match' } }Can be set globally via compile options or per-node in the JDM content.
Compilation Cache
jdm-asm caches WASM compilations to disk for faster subsequent builds:
import {
clearCache,
getCacheStats,
pruneCache,
getCacheDirectory,
} from 'jdm-asm';
// Clear all cached compilations
clearCache();
// Get cache statistics
const stats = getCacheStats();
// Remove old entries
pruneCache();JDM Format Reference
Node Types
Input Node
Entry point for the decision graph.
{
"id": "input-1",
"type": "inputNode",
"name": "Request"
}Output Node
Exit point that returns accumulated context data.
{
"id": "output-1",
"type": "outputNode",
"name": "Response"
}Expression Node
Evaluates expressions and sets output fields.
{
"id": "expr-1",
"type": "expressionNode",
"name": "Calculate",
"content": {
"expressions": [
{ "id": "e1", "key": "total", "value": "price * quantity" },
{ "id": "e2", "key": "tax", "value": "total * 0.08" },
{ "id": "e3", "key": "grandTotal", "value": "$.total + $.tax" }
],
"passThrough": true, // Include input fields in output
"inputField": "order", // Scope to nested field
"outputPath": "result" // Store results under this key
}
}Self-Referential Expressions: Use $ to reference earlier computed values within the same expression node (e.g., $.total refers to the total computed above).
Decision Table Node
Rule-based decision table with conditions and outputs.
{
"id": "table-1",
"type": "decisionTableNode",
"name": "Discount Rules",
"content": {
"hitPolicy": "first",
"inputs": [
{ "id": "in1", "name": "Customer Type", "field": "customerType" },
{ "id": "in2", "name": "Order Amount", "field": "orderAmount" }
],
"outputs": [
{ "id": "out1", "name": "Discount", "field": "discount" }
],
"rules": [
{ "_id": "r1", "in1": "\"premium\"", "in2": "> 100", "out1": "0.15" },
{ "_id": "r2", "in1": "\"standard\"", "in2": "> 100", "out1": "0.10" },
{ "_id": "r3", "in1": "", "in2": "", "out1": "0" }
],
"passThrough": false
}
}Unary Mode: In decision table cells, expressions are implicitly compared against the column's input value ($):
| Cell Value | Interpreted As |
|------------|----------------|
| "admin" | $ == "admin" |
| > 100 | $ > 100 |
| >= 18, < 65 | $ >= 18 and $ < 65 |
| [1..10] | $ >= 1 and $ <= 10 |
| "a", "b", "c" | $ == "a" or $ == "b" or $ == "c" |
| ["gold", "platinum"] | $ in ["gold", "platinum"] |
| `` (empty) or - | true (always matches) |
Switch Node
Conditional branching based on expressions.
{
"id": "switch-1",
"type": "switchNode",
"name": "Route",
"content": {
"statements": [
{ "id": "s1", "condition": "status == 'active'" },
{ "id": "s2", "condition": "status == 'pending'" },
{ "id": "s3", "condition": "" }
],
"hitPolicy": "first"
}
}Edges from switch nodes use sourceHandle to specify which branch they belong to (matching the statement id).
Decision Node
References external sub-decisions for modular composition.
{
"id": "sub-1",
"type": "decisionNode",
"name": "Calculate Shipping",
"content": {
"key": "shipping-rules.json"
}
}The loadDecision option in compile controls how sub-decisions are loaded.
Expression Language
jdm-asm supports the ZEN expression language:
Literals
42, 3.14, -5 // Numbers
"hello", 'world' // Strings
true, false // Booleans
null // Null
[1, 2, 3] // Arrays
{key: value} // ObjectsOperators
| Category | Operators |
|----------|-----------|
| Arithmetic | +, -, *, /, %, ^ (power) |
| Comparison | ==, !=, <, >, <=, >= |
| Logical | and, or, not |
| Null coalescing | ?? |
| Membership | in, not in |
| Ternary | condition ? then : else |
Access Patterns
object.property // Member access
object["key"] // Bracket access
array[0] // Index access
nested.deeply.nested.value // Chained accessTemplate Literals
`Hello, ${name}!`
`Order ${id}: ${items.length} items totaling ${total}`Interval Notation (DMN-style)
[1..10] // 1 <= x <= 10 (inclusive)
(1..10) // 1 < x < 10 (exclusive)
[1..10) // 1 <= x < 10
(1..10] // 1 < x <= 10Built-in Functions
Array Functions
| Function | Description | Example |
|----------|-------------|---------|
| sum(array) | Sum numeric values | sum([1, 2, 3]) → 6 |
| avg(array) | Average of values | avg([1, 2, 3]) → 2 |
| min(array) | Minimum value | min([3, 1, 2]) → 1 |
| max(array) | Maximum value | max([3, 1, 2]) → 3 |
| count(array) | Array length | count([1, 2, 3]) → 3 |
| sort(array) | Sort ascending | sort([3, 1, 2]) → [1, 2, 3] |
| flat(array, depth?) | Flatten nested arrays | flat([[1], [2, 3]]) → [1, 2, 3] |
| contains(array, val) | Check membership | contains([1, 2], 2) → true |
Higher-Order Functions
| Function | Description | Example |
|----------|-------------|---------|
| filter(array, predicate) | Filter elements | filter(items, # > 5) |
| map(array, transform) | Transform elements | map(items, # * 2) |
| reduce(array, expr, init) | Reduce to value | reduce(nums, acc + #, 0) |
| all(array, predicate) | All match | all(scores, # >= 60) |
| some(array, predicate) | Any match | some(items, # == "gold") |
Note: Use # to reference the current array element in predicates.
String Functions
| Function | Description | Example |
|----------|-------------|---------|
| upper(str) | Uppercase | upper("hello") → "HELLO" |
| lower(str) | Lowercase | lower("HELLO") → "hello" |
| trim(str) | Remove whitespace | trim(" hi ") → "hi" |
| substring(str, start, end?) | Extract substring | substring("hello", 0, 2) → "he" |
| indexOf(str, search) | Find index | indexOf("hello", "l") → 2 |
| startsWith(str, prefix) | Check prefix | startsWith("hello", "he") → true |
| endsWith(str, suffix) | Check suffix | endsWith("hello", "lo") → true |
| split(str, delim) | Split to array | split("a,b,c", ",") → ["a", "b", "c"] |
| join(array, delim) | Join to string | join(["a", "b"], "-") → "a-b" |
| replace(str, find, repl) | Replace first | replace("hello", "l", "L") → "heLlo" |
| replaceAll(str, find, repl) | Replace all | replaceAll("hello", "l", "L") → "heLLo" |
| contains(str, substr) | Check substring | contains("hello", "ell") → true |
Math Functions
| Function | Description | Example |
|----------|-------------|---------|
| abs(num) | Absolute value | abs(-5) → 5 |
| floor(num) | Round down | floor(3.7) → 3 |
| ceil(num) | Round up | ceil(3.2) → 4 |
| round(num) | Round nearest | round(3.5) → 4 |
Date/Time Functions
| Function | Description | Example |
|----------|-------------|---------|
| date(str) | Parse ISO date to timestamp (seconds) | date("2025-03-20T10:30:00Z") |
| date("now") | Current timestamp | date("now") |
| time(str) | Parse time to seconds since midnight | time("10:30:00") → 37800 |
| duration(str) | Parse duration string | duration("24h") → 86400 |
Type Functions
| Function | Description | Example |
|----------|-------------|---------|
| number(str) | Parse to number | number("42") → 42 |
| string(val) | Convert to string | string(42) → "42" |
| keys(obj) | Get object keys | keys({a: 1}) → ["a"] |
| values(obj) | Get object values | values({a: 1}) → [1] |
Decision Table Hit Policies
| Policy | Behavior | zen-engine |
|--------|----------|------------|
| first | Return first matching rule | Yes |
| collect | Return all matches as array | Yes |
| unique | Return single match (error if multiple) | No |
| ruleOrder | Return all matches in rule definition order | No |
| outputOrder | Return all matches sorted by output value | No |
| priority | Return highest priority match | No |
Architecture
Compile Phase vs Execution Phase
jdm-asm operates in two distinct phases:
Compile Phase (~250ms-10s depending on complexity/hardware):
- Parse JDM JSON and validate structure
- Build dependency graph from nodes/edges
- Topologically sort for evaluation order
- Generate AssemblyScript code for each node
- Compile AssemblyScript to WebAssembly
- Generate JavaScript marshaling code
Execution Phase (~3-1000 microseconds):
- Validate input against schema
- Marshal JavaScript object to WASM linear memory
- Call WASM
evaluate()function - Unmarshal result from WASM memory to JavaScript
This separation enables high performance: expensive compilation happens once, while fast execution occurs many times.
Data Marshaling
Data flows between JavaScript and WASM via typed memory:
JavaScript WASM Linear Memory
────────── ──────────────────
{ name: "John", ──► [ValueMap]
age: 30, ├─ length: 2
tags: ["a","b"] } ├─ key0 → "name" (UTF-16)
├─ val0 → [STRING, ptr → "John"]
├─ key1 → "age"
├─ val1 → [FLOAT, 30.0]
├─ key2 → "tags"
└─ val2 → [ARRAY, ptr → [...]]Value Types (tagged union):
| Tag | Type | Storage | |-----|------|---------| | 0 | Null | Tag only | | 1 | Boolean | Tag + u8 | | 2 | Integer | Tag + i64 | | 3 | Float | Tag + f64 | | 4 | String | Tag + pointer to UTF-16 data | | 5 | Array | Tag + pointer to element array | | 6 | Object | Tag + pointer to ValueMap |
Performance
Benchmark Results
Benchmarks run on Apple M3 Max, Node.js 24.7, comparing jdm-asm to @gorules/zen-engine:
Single-Threaded Latency (Best-Case)
In single-threaded mode, jdm-asm achieves microsecond-level latency:
| Scenario | jdm-asm | zen-engine | Speedup | |----------|---------|------------|---------| | Simple (5 rules) | 3.5 µs | 32.4 µs | 9.2x faster | | Moderate (12 rules) | 7.2 µs | 36.2 µs | 5.0x faster | | Complex (~8000 rules) | 492 µs | 1.47 ms | 3x faster |
For typical decision tables (5-50 rules), expect sub-10 microsecond execution times.
Single-Threaded Latency Percentiles
| Scenario | p50 | p95 | p99 | |----------|-----|-----|-----| | Simple | 3.5 µs | 4.2 µs | 4.7 µs | | Moderate | 7.2 µs | 8.0 µs | 9.7 µs | | Complex | 492 µs | 1.1 ms | 1.6 ms |
Throughput (decisions/second)
| Mode | Scenario | zen-engine | jdm-asm | |------|----------|------------|---------| | Single-threaded | Simple | 34.6K | 277.6K | | Single-threaded | Moderate | 27.6K | 139.1K | | Single-threaded | Complex | 682 | 892 | | Multi-thread (16) | Simple | 119K | 180K | | Multi-thread (16) | Moderate | 98K | 178K | | Multi-thread (16) | Complex | 5.6K | 20.6K |
Multi-Threaded Batch Latency
When using worker threads (includes IPC overhead):
| Scenario | jdm-asm (16 threads) | zen-engine | |----------|----------------------|------------| | Simple | 2.7ms / 4.9ms / 6.7ms | 4.4ms / 6.3ms / 7.5ms | | Moderate | 2.7ms / 5.0ms / 5.9ms | 5.3ms / 8.2ms / 8.9ms | | Complex | 20.9ms / 38.1ms / 43.9ms | 121.9ms / 159.9ms / 173.8ms |
Compilation Time
| Scenario | WASM Size | Compile Time | |----------|-----------|--------------| | Simple | 34 KB | 1.4s | | Moderate | 55 KB | 0.8s | | Complex | 6.1 MB | 9.5s |
When to Use jdm-asm
Best for:
- Ultra-low latency requirements (sub-10 µs for typical decisions)
- High-throughput APIs evaluating hundreds of thousands of decisions per second
- Latency-sensitive applications where p99 matters
- Simple to moderate complexity rules (5-50 rules)
- Cases where rules change infrequently (amortize compilation cost)
Consider zen-engine when:
- Rules change frequently (compilation cost is high)
- Using function nodes with complex JavaScript logic
- Quick prototyping without schema definitions
Compatibility with zen-engine
jdm-asm is designed as a drop-in replacement for zen-engine with some differences:
Fully Supported:
- All node types (input, output, expression, decision table, switch, decision)
- Expression language (operators, functions, access patterns)
- Decision table hit policies:
first,collect - Sub-decision composition
Extended Features (not in zen-engine):
- DMN-standard hit policies:
unique,ruleOrder,outputOrder,priority - Compile-time optimization
- Multi-threaded execution
Limitations:
- Function nodes: Parsed but not executed (returns placeholder)
- Division by zero: Returns
nullinstead ofInfinity - Full ISO 8601 durations: Simple formats (
24h,30d) work; complex format (P1Y2M3D) not supported
Development
# Install dependencies
npm install
# Build TypeScript
npm run build
# Build AssemblyScript runtime
npm run asbuild
# Run tests
npm run test
# Run benchmarks
npm run bench
# Lint code
npm run lint
# Type check
npx tsc --noEmitProject Structure
jdm-asm/
├── src/
│ ├── compiler/ # TypeScript compiler code
│ └── runtime/ # AssemblyScript runtime
├── tests/
│ ├── unit/ # Unit tests
│ ├── integration/ # Integration tests
│ └── helpers/ # Test utilities
├── test-data/ # Ported zen-engine test fixtures
├── benchmarks/ # Performance benchmarks
├── build/ # Compiled WASM output
└── dist/ # Built npm packageLicense
MIT License - see LICENSE for details.
