From 60c2dd14cce295dcf1628fa367ee55ca4f12552a Mon Sep 17 00:00:00 2001 From: Felipe Lalanne <1822826+pipex@users.noreply.github.com> Date: Thu, 10 Aug 2023 20:18:35 -0400 Subject: [PATCH] Implement planning for parallel tasks WIP Change-type: minor --- lib/agent.spec.ts | 48 +++++++++++ lib/agent/runtime.ts | 61 +++++++++----- lib/planner.spec.ts | 133 ++++++++++++++++++++++++++++++ lib/planner/findPlan.ts | 157 +++++++++++++++++++++++++++++++++--- lib/planner/index.ts | 64 ++++++++++++--- lib/planner/node.ts | 12 ++- lib/planner/plan.ts | 10 +++ lib/planner/types.ts | 32 ++++++-- lib/task/instructions.ts | 2 +- lib/task/tasks.ts | 29 +++++-- lib/testing/builder.spec.ts | 18 +++++ package.json | 3 +- 12 files changed, 513 insertions(+), 56 deletions(-) diff --git a/lib/agent.spec.ts b/lib/agent.spec.ts index a184414..68d0fc8 100644 --- a/lib/agent.spec.ts +++ b/lib/agent.spec.ts @@ -97,6 +97,54 @@ describe('Agent', () => { // Intermediate states returned by the observable should be emitted by the agent expect(count).to.deep.equal([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); }); + + it('runs parallel plans', async () => { + type Counters = { [k: string]: number }; + + const byOne = Task.of({ + path: '/:counter', + condition: (state: Counters, ctx) => ctx.get(state) < ctx.target, + effect: (state: Counters, ctx) => ctx.set(state, ctx.get(state) + 1), + action: async (state: Counters, ctx) => { + await setTimeout(100 * Math.random()); + return ctx.set(state, ctx.get(state) + 1); + }, + description: ({ counter }) => `${counter} + 1`, + }); + + const byTwo = Task.of({ + path: '/:counter', + condition: (state: Counters, ctx) => ctx.target - ctx.get(state) > 1, + method: (_: Counters, ctx) => [byOne({ ...ctx }), byOne({ ...ctx })], + description: ({ counter }) => `increase '${counter}'`, + }); + + const multiIncrement = Task.of({ + condition: (state: Counters, ctx) => + Object.keys(state).some((k) => ctx.target[k] - state[k] > 1), + parallel: (state: Counters, ctx) => + Object.keys(state) + .filter((k) => ctx.target[k] - state[k] > 1) + .map((k) => byTwo({ counter: k, target: ctx.target[k] })), + description: `increment counters`, + }); + + const agent = Agent.of({ + initial: { a: 0, b: 0 }, + opts: { logger: console, minWaitMs: 1 * 1000 }, + tasks: [multiIncrement, byTwo, byOne], + }); + + agent.seek({ a: 3, b: 2 }); + + // We wait at most for one cycle to complete, meaning the + // state is reached immediately and the agent terminates after the + // first pause + await expect(agent.wait(1500)).to.eventually.deep.equal({ + success: true, + state: { a: 3, b: 2 }, + }); + }); }); describe('heater', () => { diff --git a/lib/agent/runtime.ts b/lib/agent/runtime.ts index cb31600..e77517a 100644 --- a/lib/agent/runtime.ts +++ b/lib/agent/runtime.ts @@ -1,11 +1,12 @@ import { setTimeout as delay } from 'timers/promises'; +import { diff, patch, Operation as PatchOperation } from 'mahler-wasm'; import { Observer, Observable } from '../observable'; -import { Planner, Node } from '../planner'; +import { Planner, Node, EmptyNode } from '../planner'; import { Sensor, Subscription } from '../sensor'; import { Target } from '../target'; import { Action } from '../task'; -import { equals } from '../json'; +import { assert } from '../assert'; import { AgentOpts, @@ -16,6 +17,7 @@ import { Timeout, UnknownError, } from './types'; +import { simplified } from '../testing'; /** * Internal error @@ -90,9 +92,12 @@ export class Runtime { return result; } - private async runAction(action: Action) { + private async runAction(action: Action): Promise { try { - const res = action(this.state); + // We keep a reference to the previous state, which is + // what we need to compare the updated state to + const before = this.state; + const res = action(before); if (Observable.is(res)) { const runtime = this; // If the action result is an observable, then @@ -101,11 +106,16 @@ export class Runtime { return new Promise((resolve, reject) => { res.subscribe({ next(s) { - runtime.state = s; - runtime.observer.next(s); + const changes = diff(before, s); + if (changes.length > 0) { + runtime.state = patch(runtime.state, changes); + runtime.observer.next(runtime.state); + } }, complete() { - resolve(runtime.state); + // There should be no more changes to perform + // here + resolve([]); }, error(e) { reject(e); @@ -113,14 +123,17 @@ export class Runtime { }); }); } else { - return await res; + const after = await res; + return diff(before, after); } } catch (e) { throw new ActionRunFailed(action, e); } } - private async runPlan(node: Node | null): Promise { + private async runPlan( + node: Node | null, + ): Promise> { const { logger } = this.opts; if (node == null) { @@ -138,12 +151,16 @@ export class Runtime { throw new ActionConditionFailed(action); } - // QUESTION: do we need to handle concurrency to deal with state changes - // coming from sensors? logger.info(`${action.description}: running ...`); - const state = await this.runAction(action); - if (!equals(this.state, state)) { - this.state = state; + const changes = await this.runAction(action); + if (changes.length > 0) { + // NOTE: there is a small chance that the state changes while the + // patch is being applied. This means there is potential to lose changes + // by a race (even though patch should be very fast). + // There are two potential solutions here, either we wrap this call in a + // mutex, so only one patch can be applied at a time, or we find a way to update + // the state object in place, so only the relevant parts of the state are updated + this.state = patch(this.state, changes); // Notify observer of the new state only if there // are changes @@ -155,11 +172,19 @@ export class Runtime { } if (Node.isFork(node)) { - // Run children in parallel - await Promise.all(node.next.map(this.runPlan)); + // Run children in parallel. Continue following the plan when reaching the + // empty node only for one of the branches + const [empty] = await Promise.all(node.next.map((n) => this.runPlan(n))); + + // There should always be at least one branch in the fork because + // of the way the planner is implemented + assert(empty !== undefined); + + return await this.runPlan(empty.next); } - // Nothing to do if the node is empty + // We return the node + return node; } start() { @@ -209,7 +234,7 @@ export class Runtime { logger.debug( 'plan found, will execute the following actions', - flatten(start), + simplified(result), ); // If we got here, we have found a suitable plan diff --git a/lib/planner.spec.ts b/lib/planner.spec.ts index 726a99c..64ab2c8 100644 --- a/lib/planner.spec.ts +++ b/lib/planner.spec.ts @@ -330,6 +330,139 @@ describe('Planner', () => { ); }); + it('solves parallel problems', () => { + type Counters = { [k: string]: number }; + + const byOne = Task.of({ + path: '/:counter', + condition: (state: Counters, ctx) => ctx.get(state) < ctx.target, + effect: (state: Counters, ctx) => ctx.set(state, ctx.get(state) + 1), + description: ({ counter }) => `${counter} + 1`, + }); + + const multiIncrement = Task.of({ + condition: (state: Counters, ctx) => + Object.keys(state).filter((k) => ctx.target[k] - state[k] > 0) + .length > 1, + parallel: (state: Counters, ctx) => + Object.keys(state) + .filter((k) => ctx.target[k] - state[k] > 0) + .map((k) => byOne({ counter: k, target: ctx.target[k] })), + description: `increment counters`, + }); + + const planner = Planner.of({ + tasks: [multiIncrement, byOne], + config: { trace: console.trace }, + }); + + const result = planner.findPlan({ a: 0, b: 0 }, { a: 3, b: 2 }); + expect(simplified(result)).to.deep.equal( + plan() + .fork() + .branch('a + 1') + .branch('b + 1') + .join() + .fork() + .branch('a + 1') + .branch('b + 1') + .join() + .action('a + 1') + .end(), + ); + }); + + it('solves parallel problems with methods', () => { + type Counters = { [k: string]: number }; + + const byOne = Task.of({ + path: '/:counter', + condition: (state: Counters, ctx) => ctx.get(state) < ctx.target, + effect: (state: Counters, ctx) => ctx.set(state, ctx.get(state) + 1), + description: ({ counter }) => `${counter} + 1`, + }); + + const byTwo = Task.of({ + path: '/:counter', + condition: (state: Counters, ctx) => ctx.target - ctx.get(state) > 1, + method: (_: Counters, ctx) => [byOne({ ...ctx }), byOne({ ...ctx })], + description: ({ counter }) => `increase '${counter}'`, + }); + + const multiIncrement = Task.of({ + condition: (state: Counters, ctx) => + Object.keys(state).some((k) => ctx.target[k] - state[k] > 1), + parallel: (state: Counters, ctx) => + Object.keys(state) + .filter((k) => ctx.target[k] - state[k] > 1) + .map((k) => byTwo({ counter: k, target: ctx.target[k] })), + description: `increment counters`, + }); + + const planner = Planner.of({ + tasks: [multiIncrement, byTwo, byOne], + config: { trace: console.trace }, + }); + + const result = planner.findPlan({ a: 0, b: 0 }, { a: 3, b: 2 }); + + expect(simplified(result)).to.deep.equal( + plan() + .fork() + .branch('a + 1', 'a + 1') + .branch('b + 1', 'b + 1') + .join() + .action('a + 1') + .end(), + ); + }); + + it('detects planning conflicts', () => { + type Counters = { [k: string]: number }; + + const byOne = Task.of({ + path: '/:counter', + condition: (state: Counters, ctx) => ctx.get(state) < ctx.target, + effect: (state: Counters, ctx) => ctx.set(state, ctx.get(state) + 1), + description: ({ counter }) => `${counter} + 1`, + }); + + const conflictingIncrement = Task.of({ + condition: (state: Counters, ctx) => + Object.keys(state).filter((k) => ctx.target[k] - state[k] > 1) + .length > 1, + parallel: (state: Counters, ctx) => + Object.keys(state) + .filter((k) => ctx.target[k] - state[k] > 1) + .flatMap((k) => [ + // We create parallel steps to increase the same element of the state + // concurrently + byOne({ counter: k, target: ctx.target[k] }), + byOne({ counter: k, target: ctx.target[k] }), + ]), + description: `increment counters`, + }); + + const planner = Planner.of({ + tasks: [conflictingIncrement, byOne], + config: { trace: console.trace }, + }); + + const result = planner.findPlan({ a: 0, b: 0 }, { a: 3, b: 2 }); + + // The resulting plan is just the linear version because the parallel version + // will result in a conflict being detected + expect(simplified(result)).to.deep.equal( + plan() + .action('a + 1') + .action('a + 1') + .action('a + 1') + .action('b + 1') + .action('b + 1') + .end(), + ); + }); + it.skip('simple travel problem', async () => { // Alice needs to go to the park and may walk or take a taxi. Depending on the distance to the park and // the available cash, some actions may be possible diff --git a/lib/planner/findPlan.ts b/lib/planner/findPlan.ts index a93ecd3..8bfc96f 100644 --- a/lib/planner/findPlan.ts +++ b/lib/planner/findPlan.ts @@ -1,3 +1,9 @@ +import { + diff as createPatch, + patch as applyPatch, + Operation as PatchOperation, +} from 'mahler-wasm'; + import { Context } from '../context'; import { Diff } from '../diff'; import { Operation } from '../operation'; @@ -13,7 +19,8 @@ import { MethodExpansionEmpty, ConditionNotMet, SearchFailed, - NotImplemented, + MergeFailed, + ConflictDetected, } from './types'; import { isTaskApplicable } from './utils'; import assert from '../assert'; @@ -25,7 +32,7 @@ interface PlanningState { operation?: Operation; trace: PlannerConfig['trace']; initialPlan: Plan; - callStack?: Array>; + callStack?: Array | Parallel>; } function findLoop(id: string, node: Node | null): boolean { @@ -63,10 +70,19 @@ function tryAction( } const state = action.effect(initialPlan.state); + // We calculate the changes only at the action level + const changes = createPatch(initialPlan.state, state); + // We create the plan reversed so we can backtrack easily const start = { id, action, next: initialPlan.start }; - return { success: true, state, start, stats: initialPlan.stats }; + return { + success: true, + start, + stats: initialPlan.stats, + state, + pendingChanges: initialPlan.pendingChanges.concat(changes), + }; } function tryMethod( @@ -91,11 +107,12 @@ function tryMethod( // We use spread here to avoid modifying the source object const plan: Plan = { ...initialPlan }; + const cStack = [...callStack, method]; for (const i of instructions) { const res = tryInstruction(i, { ...pState, initialPlan: plan, - callStack: [...callStack, method], + callStack: cStack, }); if (!res.success) { @@ -104,18 +121,129 @@ function tryMethod( // Update the plan plan.start = res.start; - plan.stats = res.stats; plan.state = res.state; + plan.pendingChanges = res.pendingChanges; } return plan; } +function findConflict( + ops: PatchOperation[][], +): [PatchOperation, PatchOperation] | undefined { + const unique = new Map(); + + for (let i = 0; i < ops.length; i++) { + const patches = ops[i]; + + for (const o of patches) { + for (const [path, [index, op]] of unique.entries()) { + if ( + i !== index && + (o.path.startsWith(path) || path.startsWith(o.path)) + ) { + // We found a conflicting operation on a different + // branch than the current one + return [o, op]; + } + } + unique.set(o.path, [i, o]); + } + } +} + function tryParallel( - _parallel: Parallel, - { initialPlan }: PlanningState, + parallel: Parallel, + { initialPlan, callStack = [], ...pState }: PlanningState, ): Plan { - return { success: false, stats: initialPlan.stats, error: NotImplemented }; + assert(initialPlan.success); + + // look task in the call stack + if (callStack.find((p) => Parallel.equals(p, parallel))) { + return { + success: false, + stats: initialPlan.stats, + error: RecursionDetected, + }; + } + + const instructions = parallel(initialPlan.state); + + // Nothing to do here as other branches may still + // result in actions + if (instructions.length === 0) { + return initialPlan; + } + + const empty = Node.empty(initialPlan.start); + + const plan: Plan = { + ...initialPlan, + start: empty, + }; + + let results: Array & { success: true }> = []; + const cStack = [...callStack, parallel]; + for (const i of instructions) { + const res = tryInstruction(i, { + ...pState, + initialPlan: plan, + callStack: cStack, + }); + + if (!res.success) { + return res; + } + + results.push(res); + } + + // There should not be any results pointing to null as we passed + // an empty node as the start node to each one + assert(results.every((r) => r.start != null)); + + // If all branches are empty (they still point to the start node we provided) + // we just return the initialPlan + results = results.filter((r) => r.start !== empty); + if (results.length === 0) { + return initialPlan; + } + + // Here is where we check for conflicts created by the parallel plan. + // If two branches change the same part of the state, that means that there is + // a conflict and the planning should fail. + // QUESTION: Intersection is an expensive operation, should we just do it during + // testing? + const conflict = findConflict(results.map((r) => r.pendingChanges)); + if (conflict) { + return { + success: false, + stats: initialPlan.stats, + error: ConflictDetected(conflict), + }; + } + + // We add the fork node + const start = Node.fork(results.map((r) => r.start!)); + + // We don't update the state here as + // applyPatch performs changes in place, which means + // we need to make a structured copy of the state + const state = initialPlan.state; + + // Since we already checked conflicts, we can just concat the changes + const pendingChanges = results.reduce( + (acc, r) => acc.concat(r.pendingChanges), + initialPlan.pendingChanges, + ); + + return { + success: true, + state, + pendingChanges, + start, + stats: initialPlan.stats, + }; } function tryInstruction( @@ -173,6 +301,7 @@ export function findPlan({ start: initialPlan.start, state: initialPlan.state, stats: { ...stats, maxDepth }, + pendingChanges: [], }; } @@ -222,12 +351,22 @@ export function findPlan({ // expansion didn't add any tasks so it makes no sense to go to a // deeper level if (taskPlan.start !== initialPlan.start) { + let state: TState; + try { + // applyPatch makes a copy of the source object so we only want to + // perform this operation if the instruction suceeded + state = applyPatch(initialPlan.state, taskPlan.pendingChanges); + } catch (e: any) { + trace(MergeFailed(e)); + continue; + } + const res = findPlan({ depth: depth + 1, diff, tasks, trace, - initialPlan: taskPlan, + initialPlan: { ...taskPlan, state, pendingChanges: [] }, callStack, }); diff --git a/lib/planner/index.ts b/lib/planner/index.ts index 36fe677..0a628ef 100644 --- a/lib/planner/index.ts +++ b/lib/planner/index.ts @@ -5,6 +5,7 @@ import { findPlan } from './findPlan'; import { PlannerConfig } from './types'; import { Plan } from './plan'; import { Node } from './node'; +import { assert } from '../assert'; export * from './types'; export * from './plan'; @@ -22,23 +23,60 @@ export interface Planner { function reversePlan( curr: Node | null, prev: Node | null = null, -): Node | null { +): Node | null | [Node, Node | null] { if (curr == null) { return prev; } + if (Node.isFork(curr)) { // When reversing a fork node, we are turning the node // into an empty node. For this reason, we create the empty node // first that we pass as the `prev` argument to the recursive call to // reversePlan for each of the children - const empty = { next: prev }; - curr.next.map((n) => reversePlan(n, empty)); - return empty; + const empty = Node.empty(prev); + + // We then recursively call reversePlan on each of the branches, + // this will run until finding an empty node, at which point it will + // return. The ends will be disconected so we will need to join them + // as part of a new fork node + const ends = curr.next.map((n) => reversePlan(n, empty)); + const forkNext: Array> = []; + let next: Node | null = null; + for (const node of ends) { + // If this fails the algorithm has a bug + assert(node != null && Array.isArray(node)); + + // We get the pointers from the fork node end + const [p, n] = node; + + // The prev pointer of the fork end will be part of the + // next list of the fork node + forkNext.push(p); + + // Every next pointer should be the same, so we just assign it here + next = n; + } + + const fork = Node.fork(forkNext); + + // We continue the recursion here + return reversePlan(next, fork); + } + + if (Node.isAction(curr)) { + const next = curr.next; + curr.next = prev; + return reversePlan(next, curr); } - const next = curr.next; - curr.next = prev; - return reversePlan(next, curr); + // If the node is empty, that means + // that a previous node must exist + assert(prev != null); + + // If empty we want the fork to handle + // the continuation, so we need to return + // the previous and next nodes of the empty node + return [prev, curr.next]; } function of({ @@ -50,10 +88,10 @@ function of({ }): Planner { // Sort the tasks putting methods and redirects first tasks = tasks.sort((a, b) => { - if (Task.isMethod(a) && Task.isAction(b)) { + if ((Task.isMethod(a) || Task.isParallel(a)) && Task.isAction(b)) { return -1; } - if (Task.isAction(a) && Task.isMethod(b)) { + if (Task.isAction(a) && (Task.isMethod(b) || Task.isParallel(b))) { return 1; } return 0; @@ -75,6 +113,7 @@ function of({ state: current, start: null, stats: { iterations: 0, maxDepth: 0, time: 0 }, + pendingChanges: [], }, diff: Diff.of(current, target), tasks, @@ -82,8 +121,11 @@ function of({ }); res.stats = { ...res.stats, time: performance.now() - time }; if (res.success) { - res.start = reversePlan(res.start); - trace({ event: 'success', start: res.start }); + const start = reversePlan(res.start); + assert(!Array.isArray(start)); + + res.start = start; + trace({ event: 'success', start }); } else { trace({ event: 'failed' }); } diff --git a/lib/planner/node.ts b/lib/planner/node.ts index 616ff72..0a621c5 100644 --- a/lib/planner/node.ts +++ b/lib/planner/node.ts @@ -61,7 +61,7 @@ function isForkNode(n: Node): n is ForkNode { } export const Node = { - of: (s: TState, a: Action): ActionNode => { + of(s: TState, a: Action): ActionNode { // We don't use the full state to calculate the // id as there may be changes in the state that have nothing // to do with the action. We just use the part of the state @@ -87,6 +87,16 @@ export const Node = { next: null, }; }, + empty(next: Node | null): EmptyNode { + return { + next, + }; + }, + fork(next: Array>): ForkNode { + return { + next, + }; + }, isAction: isActionNode, isFork: isForkNode, }; diff --git a/lib/planner/plan.ts b/lib/planner/plan.ts index 327a4fb..d7f7edd 100644 --- a/lib/planner/plan.ts +++ b/lib/planner/plan.ts @@ -1,6 +1,8 @@ import { Node } from './node'; import { PlanningStats, PlanningError } from './types'; +import { Operation } from 'mahler-wasm'; + export type Plan = | { /** @@ -21,6 +23,14 @@ export type Plan = */ state: TState; + /** + * The changes that will be applied to the initial + * state as part of the plan. We need this in order + * to be able to merge the changes from parallel + * operations + */ + pendingChanges: Operation[]; + /** * The resulting stats of the planning process */ diff --git a/lib/planner/types.ts b/lib/planner/types.ts index 42d9c2d..6399e63 100644 --- a/lib/planner/types.ts +++ b/lib/planner/types.ts @@ -1,6 +1,7 @@ import { Operation } from 'lib/operation'; import { Target } from '../target'; import { Instruction } from '../task'; +import { Operation as PatchOperation } from 'mahler-wasm'; import { Node } from './node'; @@ -122,13 +123,6 @@ export const RecursionDetected = { }; export type RecursionDetected = typeof RecursionDetected; -// Task type not implemented -export const NotImplemented = { - event: 'error' as const, - cause: 'not-implemented' as const, -}; -export type NotImplemented = typeof NotImplemented; - export function SearchFailed(depth: number) { return { event: 'error' as const, @@ -136,15 +130,37 @@ export function SearchFailed(depth: number) { depth, }; } + export type SearchFailed = ReturnType; +export function MergeFailed(failure: Error) { + return { + event: 'error' as const, + cause: 'merge-error' as const, + failure, + }; +} + +export type MergeFailed = ReturnType; + +export function ConflictDetected(conflict: [PatchOperation, PatchOperation]) { + return { + event: 'error' as const, + cause: 'conflict-detected' as const, + conflict, + }; +} + +export type ConflictDetected = ReturnType; + export type PlanningError = | ConditionNotMet | LoopDetected | RecursionDetected | MethodExpansionEmpty | SearchFailed - | NotImplemented; + | MergeFailed + | ConflictDetected; export interface PlannerConfig { /** diff --git a/lib/task/instructions.ts b/lib/task/instructions.ts index 1f452e7..8acc320 100644 --- a/lib/task/instructions.ts +++ b/lib/task/instructions.ts @@ -77,7 +77,7 @@ export interface Parallel< * The method to be called when the task is executed * if the method returns an empty list, this means the procedure is not applicable */ - (s: TState): Instruction | Array>; + (s: TState): Array>; } export type Instruction< diff --git a/lib/task/tasks.ts b/lib/task/tasks.ts index c44a1ac..541361c 100644 --- a/lib/task/tasks.ts +++ b/lib/task/tasks.ts @@ -121,7 +121,7 @@ export interface ParallelTask< parallel( s: TState, c: Context, - ): Instruction | Array>; + ): Array>; /** * The task function grounds the task @@ -194,15 +194,30 @@ function ground< }); } - const fn = isMethodTask(task) - ? (s: TState) => task.method(s, context) - : (s: TState) => task.parallel(s, context); - const tag = isMethodTask(task) ? ('method' as const) : ('parallel' as const); - return Object.assign(fn, { + if (isMethodTask(task)) { + return Object.assign((s: TState) => task.method(s, context), { + id, + path: context.path as any, + target: (ctx as any).target, + _tag: 'method' as const, + description, + condition: (s: TState) => task.condition(s, context), + toJSON() { + return { + id, + path: context.path, + description, + target: (ctx as any).target, + }; + }, + }); + } + + return Object.assign((s: TState) => task.parallel(s, context), { id, path: context.path as any, target: (ctx as any).target, - _tag: tag, + _tag: 'parallel' as const, description, condition: (s: TState) => task.condition(s, context), toJSON() { diff --git a/lib/testing/builder.spec.ts b/lib/testing/builder.spec.ts index bb7a3f1..09ea06c 100644 --- a/lib/testing/builder.spec.ts +++ b/lib/testing/builder.spec.ts @@ -58,4 +58,22 @@ describe('testing/builder', () => { .end(), ).to.deep.equal(['a', [['b', 'c']], 'f']); }); + + it('builds a plan with just forks', () => { + expect( + plan() + .fork() + .branch('a + 1') + .branch('b + 1') + .join() + .fork() + .branch('a + 1') + .branch('b + 1') + .join() + .end(), + ).to.deep.equal([ + [['a + 1'], ['b + 1']], + [['a + 1'], ['b + 1']], + ]); + }); }); diff --git a/package.json b/package.json index 1635ac9..e3f2598 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "README.md" ], "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" }, "scripts": { "clean": "rimraf build", @@ -75,6 +75,7 @@ "typescript": "^5.0.4" }, "dependencies": { + "mahler-wasm": "^0.1.0", "optics-ts": "^2.4.0" }, "versionist": {