UNPKG

mahler

Version:

A automated task composer and HTN based planner for building autonomous system agents

1,077 lines (850 loc) • 68.1 kB
# Mahler A automated task composer and [HTN](https://en.wikipedia.org/wiki/Hierarchical_task_network) based planner for building autonomous system agents in typescript. **NOTE** even though this project is out of v0.x, we still consider it experimental, as we continue exploring different mechanisms to improve efficiency of various aspects of the framework. This project does adhere to the [semantic versioning guidelines](https://semver.org/), so we wont perform any breaking API changes without a major version bump. ## Features - Simple API. Define primitive tasks by declaring the `effect` it has on the system state, a `condition` for the task to be chosen, and an `action`, which is the asynchronous operation that will be performed on the system if the task is chosen for the plan. Tasks can be used by other compound tasks (or _methods_) to guide the planner towards a desired behavior. - Highly configurable `Agent` interface allows to create autonomous agents to serve a wide variety of use cases. Create a single shot agent to just reach a specific target, or create a service agent that keeps monitoring the state of the world and making changes as needed to keep the system on target. Agents support re-planning if the state of the system changes during the plan execution or errors occur while executing actions. This runtime context can be used as feedback to the planning stage to chose different paths if needed. - Observable runtime. The agent runtime state and knowledge of the world can be monitored at all times with different levels of detail. Human readable metadata for tasks can be provided via the task `description` property. Plug in a trace function to generate human readable logs. - Parallel execution of tasks. The planner automatically detects when operations can be performed in parallel and creates branches in the plan to tell the agent to run concurrent operations. - Easy to debug. Agent observable state and known goals allow easy replicability when issues occur. The planning decision tree and resulting plans can be diagrammed to visually inspect where planning is failing. ## Requirements - Node.js 18+. Other runtimes are unsupported at this moment. ## Installation ``` npm install --save mahler ``` ## Core Concepts - **Autonomous system agent** a process on a system that needs to operate with little or not feedback from an external system. An autonomous agent needs to be able to recover from failures and adapt if conditions on the system change while performing its duties. In our definition, such an agent operates based on a given target and will keep trying to achieve the target until this changes or some other exit conditions are met. - **Hierarchical Task Network (HTN)** is a type of automated planning system that allows to define actions in the planning domain in a hierarchical manner, allowing actions to be re-used as part of compound tasks. This reduces the search domain and provides developers more control on what plans are preferable (over something like [STRIPS](https://es.wikipedia.org/wiki/STRIPS)). This has made this type of system popular in [game design](https://www.youtube.com/watch?v=kXm467TFTcY). - **Task** a task is any operation defined by a domain expert to provide to the planner. A task can be a primitive task, called an _action_ on this framework, e.g. "download a file", "write X to the database", or a _method_, i.e. a compound task, that provides a sequence of steps to follow. - **Plan** a plan encodes what actions need to be executed by the agent in order to reach a certain target. Plans are represented as Directed Acyclic Graphs (DAG). - **Target** a target is a desired state of the system. e.g, "temperature of the room == 25 degrees". - **Sensor** a sensor is an observer of the system state, the agent can subscribe to one or more sensors in order to keep its local view of the state up-to-date and trigger re-planning if necessary. ## Design The library design is inspired by the work in [Exploring HTN Planners through example](https://www.gameaipro.com/GameAIPro/GameAIPro_Chapter12_Exploring_HTN_Planners_through_Example.pdf). ![Planner design](./design.png) ## Basic Usage Let's to create a system controller for a counter. For that we can use an `Agent`, ```typescript import { Agent } from 'mahler'; const counterAgent = Agent.from({ // The initial state of the system as known to the agent initial: 0, // A list of action or method tasks that encode knowledge about // operations in the system tasks: [], }); ``` The code above creates an agent with a counter starting at 0. We need to provide the agent with some tasks on how to control the system. ```typescript import { Task, View } from 'mahler'; const MySystem = Domain.of<number>(); const plusOne = Task.from({ // This means the task can only be triggered // if the system state is below the target condition: (state: number, { target }) => state < target, // The effect of the action is increasing the system // counter by 1 // `state._` allows us to get the value pointed by the View effect: (state: View<number>) => ++state._, // An optional description. Useful for testing description: '+1', }); ``` The code above creates a task that has the `effect` of increasing the system state by 1 if the state is below a given target (the `condition`). As function arguments are passed by value on JavaScript, we make use of the `View` type to allow tasks to modify the actual state passed to the function instead of a copy. The need for this will hopefully become apparent as we start to use with more complex types. Note above that only the `effect` function receives a `View<number>` while the condition property receives just a `number`. This is because only the condition should not modify the state and thus does not need to deal with views. The code above however is a bit too verbose, though. The following is a cleaner way to do the same. ```typescript import { Task } from 'mahler'; // Setting the generic type allows typescript to infer the values of // the function arguments const plusOne = Task.from<number>({ condition: (state, { target }) => state < target, effect: (state) => ++state._, description: '+1', }); ``` Now we can give the task to the agent to use during planning. ```typescript // The type of Agent is inferred as `Agent<number>` from the task types const counterAgent = Agent.from({ initial: 0, tasks: [plusOne], }); // This tells the agent to find a plan from the current // state (0) to the target state (3). `seek` starts // the agent operation without need to await counterAgent.seek(3); // Wait for the agent to return some result const res = await counterAgent.wait(); if (res.success) { console.log(res.state); // 3 } ``` The above code now initializes the agent with `plusOne` as its only task and tells it to find a plan that gets the system to a state equal to `3`. On `seek`, the agent will start perform the following operations: - It will first calculate a plan to the target - If found, it will execute the actions in the plan. If successful it will stop (by default, this can be configured) - If no plan is found, it will wait for a bit and retry (the wait period and number of tries is configurable). - If some error occurs while executing the plan, the agent will look for an alternative plan and continue. In the example above, the agent should calculate a plan with 3 actions, and execute them in sequence before stopping. The planner the agent uses can also be invoked directly, this is useful for testing our actions ```typescript import { Planner } from 'mahler/planner'; import { sequence, stringify } from 'mahler/testing'; // Create a new planner const planner = Planner.from({ tasks: [plusOne] }); // Find a plan from 0 to 3 const res = planner.findPlan(0, 3); // Compare the resulting plan with the desired sequence expect(stringify(res)).to.deep.equal(sequence('+1', '+1', '+1')); ``` The code above uses the `Planner` instance exposed under the `mahler/planner` namespace as well as a couple of testing utilities. We'll learn more about these utilities further ahead. ## Actions The previous code only acts on the internal state of the agent, and doesn't perform any side-effects. In fact, the `effect` function should **NEVER** perform side effects, as this function may be called multiple times during the planning process while searching for a path to the target. In order to interact with the underlying system, an `action` property can be provided to the task definition. Let's imagine we want the `plusOne` task from the previous example to store the value of the counter somewhere for later retrieval using an async `storeCounter` function that returns the stored value on success. Below is how that is implemented with Mahler. > [!NOTE] > If no `action` property is provided, the `Task.from` constructor will use the `effect` function as the task action. ```typescript const plusOne = Task.from<number>({ condition: (state, { target }) => state < target, effect: (state) => ++state._, action: async (state) => { // storeCounter stores the given value on disk or // throws if an error happens await storeCounter(++state._); }, description: '+1', }); ``` The `action` property above updates the state and stores the value. While the `action` and `effect` functions are similar, the `action` defines what will actually happen when the task is chosen as part of a plan, while the `effect` just provides a "simulation" of the changes on the system state to be used during planning. One more thing to note is that if `storeCounter` throws while writing to disk, the agent runtime will revert the internal state to before the `plusOne` action was executed to prevent the system state from becoming inconsistent. Let's update the task to also read the stored state before updating it to avoid writing an inconsistent state. ```typescript const plusOne = Task.from({ // the rest of the task definition goes here // since action can modify state, the argument given to action // is also a `View`, same as effect action: async (state, { target }) => { // This is one way to deal with an out-of-date system state. // we can read it as part of the action body and allow the // agent to re-plan if it detects some inconsistency state._ = await readCounter(); // We only update the stored value if it is below the target if (state._ < target) { await storeCounter(++state._); } }, }); ``` There is some duplication going on between the `action`, `effect` and `condition` functions. This is a necesity as the planner cannot determine the outcome of a task without executing its `effect` function. Similarly, the agent needs the `condition` function to detect if a specific task is still applicable or a re-plan is needed. This is a way to deal with non-determinism happening from the `action` functions. For instance, let's analyze how an agent with the above task definition would execute a plan from `0` (initial state) to `3` the target state. At the end of planning stage, the agent will need to execute the following plan ```mermaid graph LR A(+1) --> B(+1) --> C(+1) ``` When starting execution of the plan, the agent state is `0`. In the best case scenario, the agent execution will do `0 + 1 + 1 + 1 = 3` and terminate. Let's imagine now that the initial assumption about the system state being equal to `0` is wrong and when executing the first task `readCounter` returns 1. ```mermaid graph LR A("state: 0 condition: 0 < 3 action: +1 result: 2 ") --> B("state: 2 condition: 2 < 3 action: +1 result: 3 ") --> C("state: 3 condition: 3 < 3 action: +1 result: abort ") ``` In the above scenario, the agent will execute the first task, since as far as the agent knowledge goes, the condition holds given the current state. The task will modify the state and set it to `2`. Since the condition still holds, the agent goes and executed the second action of the plan, which updates the state to `3`. When executing the third step in the plan, the agent sees the condition no longer holds, so it aborts execution. The condition acts here as a guard preventing the agent from running an action that would potentially put the system in an inconsistent state. In this case, it allows the agent to get to the desired target state, even despite the plan not being completely executed. Some of the duplication in the previous example can be mitigated through modeling though. We could, for instance, model the system to read the state of the world at the beginning and write at the end, which would simplify the `plusOne` task. We'll do that next. ## Target state Let's modify our system to be less agressive with read and writes, and reduce some code duplication. We'll update our system state model to be aware of reads and writes. Let's create the model first ```typescript type System = { // Our counter state counter: number; // The timestamp for the last read of the the counter // state obtained with performance.now() lastRead: number | null; // A boolean flag to track if the state needs // to be commited to storage needsWrite: boolean; }; ``` The new model now keeps track of reads via a timestamp, we'll use that property to ensure the agent knows that the counter state is up to date before making changes. Writes are tracked via a boolean flag, we can use that flag when setting a target to make sure the state is commited to storage. Let's write a `read` and `store` tasks. ```typescript // This is the maximum time allowed between reads const MAX_READ_DELAY_MS = 1000; const read = Task.from<System>({ // We only read if the state is out of date. condition: (state) => state.lastRead == null || performance.now() - state.lastRead > MAX_READ_DELAY_MS, effect: (state) => { // The effect of the task is resetting of the timer state._.lastRead = performance.now(); }, action: async (state) => { // The action reads the counter and resets the timer state._.counter = await readCounter(); state._.lastRead = performance.now(); }, description: 'readCounter', }); const store = Task.from<System>({ // We only write after the system counter has reached the target condition: (state, { target }) => state.counter === target.counter && state.needsWrite, // The effect of the store task is toggle the needsWrite flag effect: (state) => { state._.needsWrite = false; }, action: async (state) => { // We write the counter and toggle the flag await storeCounter(state._.counter); state._.needsWrite = false; }, description: 'storeCounter', }); ``` We can now modify the `plusOne` task to only perform the update operation. ```typescript const plusOne = Task.from<System>({ condition: (state, { target }) => state.counter < target.counter && // We'll only update the counter if we know the internal counter is // synchronized with the stored state (state.lastRead == null || state.lastRead + MAX_READ_DELAY_MS >= performance.now()), // The task has the effect of updating the counter and modifying the write requirement // We no longer need to set an action as this operation no longer performs IO effect: (state) => { state._.counter++; state._.needsWrite = false; }, description: '+1', }); ``` The tasks above now will make sure that no counter changes can happen if more than 1 second has passed between reads, that condition will make sure a `read` operation is added first to the plan. A `store` will happen only once the counter target has been reached, this ensures that this action is always put last on the plan. This can be tested as follows. ```typescript const planner = Planner.from({ tasks: [plusOne, read, store] }); // Find a plan from 0 to 3 const res = planner.findPlan( { counter: 0, needsWrite: false }, { counter: 3, needsWrite: false }, ); expect(stringify(res)).to.deep.equal( sequence('readCounter', '+1', '+1', '+1', 'storeCounter'), ); ``` Let's talk for a second about the functions passed as task properties: `condition`, `effect` and `action`. Until now we have seen that they receive the system state (or a `View<System>`) as the first argument. These functions also receive a second argument, that can be seen in the `plusOne` definition above where we used [object destructuring](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) to get the `target`, property. The second argument is called the `Context` object and it contains relevant metadata about the the context where the task is being used. We'll talk more about the context argument when we talk about [Lenses](#lenses). For now let us dive into the target property and the target state property. You'll most likely have deduced by now that the `target` property has the type of the task generic argument (i.e. `System` in this case) and contains the target state passed to the planner. In our last example this means the target will be `{ counter: 3, needsWrite: false }`. It's worth pointing out that the second argument of `findPlan` above, or the argument to `Agent.seek` in this case is not a `System` type, but of `Target<System>`. This is a [special type](lib/target.ts) that allows to provide partial versions of the type argument. This helps us to provide the planner/agent only with those parts of the state we care about as target. Let's say in our previous example, that we don't care if the counter is commited to storage just yet. We could do it with ```typescript // Find a plan from 0 to 3. Since we don't give a value for `needsWrite`, the planner will not // add the storeCounter task to the plan const res = planner.findPlan({ counter: 0, needsWrite: false }, { counter: 3 }); expect(stringify(res)).to.deep.equal(sequence('readCounter', '+1', '+1', '+1')); ``` This goes into the implementation of the planner. When we provide `{ counter: 3 }` as target, we are telling the planner to stop as soon as it finds that `counter === 3`. If we provide `{ counter: 0, needsWrite: false }`, we are telling the planner to stop when `counter === 3 && needsWrite === false`. Since the `plusOne` actions in our plan flip the value for `needsWrite` to `true`, after reaching `counter === 3` the planner knows it still needs to look for another task to flip the `needsWrite` flag back to `false`. What about deletes? If `Target<System>` is a partial type on the `System` type, how can we tell the planner if we want to delete a value? Mahler provides the symbol `UNDEFINED` to achieve this. ```typescript import { UNDEFINED } from 'mahler'; // This won't find a plan since we have no action defined to // delete the lastRead property const res = planner.findPlan( { counter: 0, needsWrite: false }, { counter: 3, lastRead: UNDEFINED }, ); ``` The example above is a bit nonsensical, but it shows how the `UNDEFINED` symbol would be used. This tells the planner that we want the `lastRead` property removed from the state object, so the planner would look for a task that results in the deletion of that property. Note that this is different than setting the value to `undefined`. The difference is shown below ```typescript // If successful, it should result in the following state: {counter: 3, needsWrite: true} agent.seek({ counter: 3, lastRead: UNDEFINED }); // If successful, it should result in the following state: {counter: 3, needsWrite: true, lastRead: undefined} agent.seek({ counter: 3, lastRead: undefined }); ``` You can also use the `seekStrict` function of `Agent` to tell the agent to look for the exact state given as the target ```ts // This tells the agent the exact system state that // we want to see at the end of the run. agent.seekStrict({ counter: 3, needsWrite: true }); // The above is equivalent to agent.seek({ counter: 3, lastRead: UNDEFINED }); ``` We'll learn later how we can add an `op` property to tasks to tell Mahler when a task is applicable to a `delete` [operation](#operations). One last thing before moving on from this topic. What if you assign a required value the value of `UNDEFINED`? ```typescript // Compilation fais with: `type symbol is not assignable to number|undefined` agent.seek({ counter: UNDEFINED }); ``` The compiler will prevent you from doing that to avoid ending up with inconsistent state (as long as you don't use `as any`). ## Methods Now, as programmers, we want to be able to build code by composing simpler behaviors into more complex ones. We might want to guide the planner towards a specific solution, using the primitives we already have. For instance, let's say we want to help the planner get to a solution faster as adding tasks one by one takes too much time. We want to define a `plusTwo` task, that increases the counter by `2`. We could create another primitive task to update the counter by two, but as programmers, we would like to reuse the code we have already defined. We can do that using methods. ```typescript // We'll go back to the simpler definition for this example const plusOne = Task.from<number>({ condition: (state, { target }) => state.counter < target, effect: (state) => ++state._, description: '+1', }); // Inspecting the plusTwo variable shows that this variable is of type `MethodTask` const plusTwo = Task.from<number>({ // We want this method to be chosen only if the difference between the current // state and the target is bigger than one condition: (state, { target }) => target - state > 1, // Defining the method property makes this task into a method // A method should never modify the state, just return a sequence of applicable actions method: (_, { target }) => [plusOne({ target }), plusOne({ target })], description: '+2', }); ``` Now there is a lot happening here. We have replaced `effect` and `action` in the task constructor with `method`. We are also directly using the `plusOne` task as a function and passing the target as one of the properties of the argument object. Let's parse this example piece by piece. The code above is the way to create compound tasks in Mahler, called methods. Using `method` tells the task constructor to return a `MethodTask` instead of an `ActionTask` (like `plusOne`). A method should not directly modify the state, but return instead a sequence of actions that are applicable under the given conditions. As we see above, in this case, the method returns a sequence of two `plusOne` tasks applied to the target. Objects generated by task constructors are callable, and receive part of the `Context` as the argument. Passing the context to a task binds it to the context, and this happens normally as part of the planning process. However, we reuse this mechanism to be able to reuse tasks as part of methods. ```typescript // Another test utility import { runTask } from 'mahler/testing'; // We can execute the task directly by binding it to a context // and then providing a state console.log(await plusOne({ target: 3 })(0)); // 1 // Mahler provides the `runTask` helper function to // call the task with the given state and context // the call will throw if the task condition fails console.log(await runTask(plusOne, 0, { target: 3 })); // 1 // `runTask` also works with methods, expanding the method // into its actions and executing the actions sequentially console.log(await runTask(doPlusTwo, 0, { target: 3 })); // 2 ``` Methods are useful for tweaking the plans under certain conditions. They also help reduce the search space. When looking for a plan, the Planner will try methods first, and only if methods fail, proceed to look for action tasks. During planning, the method is expanded recursively into its component actions, so they won't appear on the final plan. ```typescript const planner = Planner.from({ tasks: [plusOne, plusTwo], }); const res = planner.findPlan(0, 3); // The method has already been expanded expect(stringify(res)).to.deep.equal(sequence('+1', '+1', '+1')); ``` We can see method expansion by diagramming the planning process. Mahler provides a test util to generate [Mermaid](https://mermaid.js.org/) diagrams. We can construct the tracer using the mermaid function and pass it as a configuration to the planner. ```typescript import { mermaid } from 'mahler/testing'; // Create a tracer using the mermaid tool. const trace = mermaid(); const planner = Planner.from({ tasks: [plusOne, plusTwo], // Pass the trace as a configuration option to the planner config: { trace }, }); // Find a plan from 0 to 3 const res = planner.findPlan(0, 3); // render() returns a mermaid valid graph console.log(trace.render()); ``` Passing the output to the [Mermaid Live Editor](https://mermaid.live) will produce the following diagram. ```mermaid graph TD start(( )) start -.- d0{ } d0 -.- f751444[["+2"]] f751444 -.- b795d43("+1") b795d43 -.- 4eb0bdd("+1") 4eb0bdd -.- d1{ } d1 -.- 580ece0[["+2"]] 580ece0 -.- 580ece0-err[ ] 580ece0-err:::error d1 -.- d634a71("+1") d634a71 -.- stop(( )) stop:::finish classDef finish stroke:#000,fill:#000 start:::selected start --> b795d43 b795d43:::selected b795d43 --> 4eb0bdd 4eb0bdd:::selected 4eb0bdd --> d634a71 d634a71:::selected d634a71 --> stop classDef error stroke:#f00 classDef selected stroke:#0f0 ``` In the diagram, the nodes connected via the dotted lines show the planning process, while nodes connected via the solid arrows show the resulting plan. The diagram shows how the planner tries first the `+2` method, which results into the expansion `('+1', '+1')`, a second try of the method fails, because the condition no longer holds, so the planner adds a `+1` action next, which allows the planner to reach the target. Let's compare this diagram with one where the planner doesn't know the `plusTwo` method. This new diagram looks like this: ```mermaid graph TD start(( )) start -.- d0{ } d0 -.- 2586e9e("+1") 2586e9e -.- d1{ } d1 -.- 82f6258("+1") 82f6258 -.- d2{ } d2 -.- a1f7280("+1") a1f7280 -.- stop(( )) stop:::finish classDef finish stroke:#000,fill:#000 start:::selected start --> 2586e9e 2586e9e:::selected 2586e9e --> 82f6258 82f6258:::selected 82f6258 --> a1f7280 a1f7280:::selected a1f7280 --> stop classDef error stroke:#f00 classDef selected stroke:#0f0 ``` Here we see here that the planner needs to perform 3 iterations (the diamond nodes) to find a plan to the target. Methods can also call other methods. We can now write `plusThree` using `plusOne` and `plusTwo` ```typescript const plusThree = Task.from<number>({ condition: (state, { target }) => target - state > 2, // Methods can be referenced from other methods method: (_, { target }) => [plusTwo({ target }), plusOne({ target })], description: '+3', }); ``` ## Lenses Let's say now that we want our agent to manage multiple counters. Let's redefine our system state once more to do this ```typescript type System = { counters: { [key: string]: number } }; ``` We can now easily (?) redefine our `plusOne` task to handle this case. ```typescript const plusOne = Task.from<System>({ // This task will be chosen only if one of the keys is smaller than the target condition: (state, { target }) => Object.keys(state.counters).some( (k) => state.counters[k] < target.counters[k], ), effect: (state, { target }) => { // We find the first counter below the target, we know it exists because of // the condition so we use the non-null assertion (!) at the end const key = Object.keys(state._.counters).find( (k) => state._.counters[k] < target.counters[k], )!; // Update the changed counter state._.counters[key]++; }, description: '+1', }); ``` The above code achieves the same as the single counter `plusOne` (increase one of the counters), but is a bit convoluted, and we cannot tell from the description which counter will be increased. We can improve this by setting the `lens` property when defining the action. ```typescript const plusOne = Task.of<System>().from({ // The lens defines the part of the state where this task is applicable // this property defaults to '/', meaning the root object lens: '/counters/:counterId', condition: (counter, { target }) => counter < target, effect: (counter) => ++counter._, description: ({ counterId }) => `${counterId} + 1`, }); ``` We are again introducing a few new things here, but hopefully the code above is relatively intuitive. This declares `plusOne` as a task on the `System` type, but that acts on a specific counter, specified by the lens property. Since we don't know a priori which counter this will apply to, we replace the name of the counter with the placeholder variable, `:counterId`. This variable is passed as part of the context to the task constructor properties. We can see this in the definition of `description`, where we use `counterId`. By the way, this also shows that the description can also be a function acting on the context. Note also that instead of the usual `Task.from` we preface the `from` call with a `Task.of` with a generic type argument. This is because of a limitation in typescript that does not allow it to [perform partial inference of generic types](https://github.com/microsoft/TypeScript/issues/10571). We'll see later how we can shorten this call by using a [domain](#domains). Now we can go a bit deeper on the context object, we mentioned before that it contains the context from the task selection process during planning. More specifically, the context object is composed by - A `target` property, with the expected target value for the part of the state that the task acts on. This will have the same type as the first argument of the function, which will match the type pointed by the `lens` property. We'll see when we talk about [operations](#operations) that this property is not present for delete or wildcard (`*`) operation. - A `system` property, providing a read-only copy of the global state at the moment the task is used. This can be used, for instance, to define tasks with conditions on parts of the system that are unrelated to the sub-object pointed to by the lens. - A `path` property, indicating the full path of the lens where the task is being applied. - Zero or more properties with the names of the placeholders in the lens definition. For instance is the lens is `/a/:aId/b/:bId/c/:cId`, `aId`, `bId`, and `cId` will be provided in the context, with the right type for the property within the path. > [!NOTE] > Lenses allow us to have tasks operate on a part of the state to allow cleaner code. While the framework provides the `system` property to be able to reference unrelated parts of the state object, it should be used sparingly as the code will quickly get harder to follow. If you find that you are using the `system` property too often, consider refactoring of your model. The name _lens_ comes from the [lens concept in functional programming](https://en.wikibooks.org/wiki/Haskell/Lenses_and_functional_references), that are these structures that provide a view into a specific part of an object, allowing to read and modify values without needing to manipulate the full object. They can be thought as analogous to database views. However, unlike traditional database views or functional lenses, in order to make lenses useful in the context of tasks we need them to be bi-directional, that is, a modification to the contents of the view should affect the original data and vice-versa. For now the only supported lens allows us to _focus_ into a specific sub-property of the system model, however, we would like to add some more powerful lenses in the future based on existing work in this area (see the work by [Foster et al](https://www.cis.upenn.edu/~bcpierce/papers/lenses-toplas-final.pdf), [Hoffman et al](http://dmwit.com/papers/201107EL.pdf) and [Project Cambria](https://www.inkandswitch.com/cambria/)). > [!NOTE] > Hopefully this makes it clearer the reason the `View` name was chosen to pass [references to effect and action functions](#basic-usage), changes to the view are indeed propagated to the global state object. We can reuse these tasks in methods the same way as before, but we need to pass the values for the placeholder variables along with the target. ```typescript const plusTwo = Task.of<System>().from({ lens: '/counters/:counterId', condition: (counter, { target }) => target - counter > 1, method: (_, { target, counterId }) => [ plusOne({ target, counterId }), plusOne({ target, counterId }), ], description: ({ counterId }) => `${counterId} + 2`, }); ``` ## Parallelism What happens with the above case if we look for a plan for multiple counters? What will be the plan generated below? ```typescript const planner = Planner.from({ tasks: [plusOne], }); // Find a plan from 0 to 3 const res = planner.findPlan( { counters: { a: 0, b: 0 } }, { counters: { a: 2, b: 2 } }, ); ``` Again we can draw the plan using the mermaid test helper ```mermaid graph TD start(( )) start -.- d0{ } d0 -.- 4418a5c("a + 1") 4418a5c -.- d1{ } d1 -.- a53644f("a + 1") a53644f -.- d2{ } d2 -.- 47bcbb6("b + 1") 47bcbb6 -.- d3{ } d3 -.- a48a56b("b + 1") a48a56b -.- stop(( )) stop:::finish classDef finish stroke:#000,fill:#000 start:::selected start --> 4418a5c 4418a5c:::selected 4418a5c --> a53644f a53644f:::selected a53644f --> 47bcbb6 47bcbb6:::selected 47bcbb6 --> a48a56b a48a56b:::selected a48a56b --> stop classDef error stroke:#f00 classDef selected stroke:#0f0 ``` Even though the `a + 1` and `b + 1` actions modify totally different parts of the state, the planner still comes up with a sequential plan. This is a limitation of the planner right now, that we intend to improve in the future. Not all is bad news though, the planner does support parallel planning for methods. Let's define a generic `nPlusOne` task that increases any pending counters. ```typescript const nPlusOne = Task.of<System>().from({ lens: '/counters', condition: (counters, { target }) => Object.keys(counters).some((k) => counters[k] < target[k]), method: (counters, { target }) => Object.keys(counters) .filter((k) => counters[k] < target[k]) .map((k) => plusOne({ counterId: k, target: target[k] })), description: 'counters++', }); const planner = Planner.of({ tasks: [plusOne, nPlusOne], config: { trace }, }); planner.findPlan({ counters: { a: 0, b: 0 } }, { counters: { a: 2, b: 2 } }); ``` Here is how the plan looks now. ```mermaid graph TD start(( )) start -.- d0{ } d0 -.- 10de649[["counters++"]] 10de649 -.- c68b28e("a + 1") 10de649 -.- 49589fa("b + 1") c68b28e -.- j8cb35b9 49589fa -.- j8cb35b9 j8cb35b9(( )) j8cb35b9 -.- d1{ } d1 -.- 5787f35[["counters++"]] 5787f35 -.- 65925ed("a + 1") 5787f35 -.- a8de3b6("b + 1") 65925ed -.- jce8b4d8 a8de3b6 -.- jce8b4d8 jce8b4d8(( )) jce8b4d8 -.- stop(( )) stop:::finish classDef finish stroke:#000,fill:#000 start:::selected start --> fj8cb35b9(( )) fj8cb35b9:::selected fj8cb35b9 --> c68b28e c68b28e:::selected fj8cb35b9 --> 49589fa 49589fa:::selected j8cb35b9(( )) c68b28e --> j8cb35b9 49589fa --> j8cb35b9 j8cb35b9:::selected j8cb35b9 --> fjce8b4d8(( )) fjce8b4d8:::selected fjce8b4d8 --> 65925ed 65925ed:::selected fjce8b4d8 --> a8de3b6 a8de3b6:::selected jce8b4d8(( )) 65925ed --> jce8b4d8 a8de3b6 --> jce8b4d8 jce8b4d8:::selected jce8b4d8 --> stop classDef error stroke:#f00 classDef selected stroke:#0f0 ``` As we can see, for the `counters++` method, the planner detected that there were no conflicts between the steps returned by the method and automatically generated parallel branches for the plan. If conflicting changes were found between the branches, the planner would have reverted to a sequential execution (as it happens with `plusTwo` in our previous examples). Plans generated this way also tell the agent that is safe to execute tasks in the given branches using `Promise.all`, allowing faster execution overall. This process is not perfect though, the planner can only detect if writes to the same part of the state happen, but what happens if one of the returned tasks of the method reads a value that is modified by the parallel branch? What would happen for instance with a method like the following? ```typescript const plusOneAndStore = Task.from({ method: (_, { target }) => [plusOne({ target }), store({ target })], }); ``` The `store` action depends on the state being updated by `plusOne` before it has anything to store, so parallel execution of this method will fail. This is again due a limitation of the planner that it cannot yet "see" into the action implementation, and as such, it doesn't know if an action is performing a read that conflicts with a write of the parallel branch. To solve this we provide the `expansion` property for methods, that allows to force sequential expansion of the method as follows. ```typescript const plusOneAndStore = Task.from<number>({ // Force sequential expansion of the method. // Supported values are 'detect' (default) or 'sequential' expansion: 'sequential', method: (_, { target }) => [plusOne({ target }), store({ target })], }); ``` Going back to the `nPlusOne` example. How can we test that the generated plan meets our expectations? Mahler also provides test helpers for parallel plans ```typescript import { stringify, plan, branch } from 'mahler/testing'; const res = planner.findPlan( { counters: { a: 0, b: 0 } }, { counters: { a: 2, b: 2 } }, ); // Compare the resulting plan to our expectation expect(stringify(res)).to.deep.equal( // Start a plan plan() // A fork with two branches, with one action each .fork(branch('a + 1'), branch('b + 1')) .fork(branch('a + 1'), branch('b + 1')) // Convert the plan to a string for comparison .end(), ); ``` Internally, the code above converts plans to a string representation for easier comparison using diff tools. Here is how the plan above would be represented ``` + ~ - a + 1 ~ - b + 1 + ~ - a + 1 ~ - b + 1 ``` This represents a plan with two forks, each fork with two parallel branches of a single action each. Here is what the symbology above can be read - `+` denotes a fork in the plan, the point where the plan splits into branches - `~` denotes a branch of a fork - `-` denotes an element of the branch - Indentation denotes the level that the symbol is on with respect to the previous code A couple of examples. The following represents a sequence of `a + 1` actions. ``` - a + 1 - a + 1 - a + 1 ``` The following represents a plan with a fork with two branches, each one with two actions. The fork is followed by a single `a + 1` action. ``` + ~ - a + 1 - a + 1 ~ - b + 1 - b + 1 - a + 1 ``` Symbols can be also chained for a more compact representation, can you figure out what the following means? ``` + ~ + ~ - a++ - a++ ~ - b++ - b++ ~ + ~ - c++ - c++ ~ - d++ - d++ - a++ ``` ## Operations ### Initializing data What does the planner return in the following scenario? ```typescript type System = { counters: { [key: string]: number } }; const plusOne = Task.of<System>().from({ lens: '/counters/:counterId', condition: (counter, { target }) => counter < target, effect: (counter) => ++counter._, description: ({ counterId }) => `${counterId} + 1`, }); const planner = Planner.from({ tasks: [plusOne], config: { trace }, }); const res = planner.findPlan( { counters: { a: 0 } }, { counters: { a: 2, b: 1 } }, ); console.log(res.success); // false ``` In the above case the planner fails to find a plan, let's quickly analyze why. When given a target, the planner will calculate the pending changes between the current state and the target. When first starting the planner sees there that two operations are needed to get to the target, an update operation of counter `a` and a create operation of counter `b`, this is internally represented as below. ```typescript [ { op: 'update', path: '/a', value: 2 }, { op: 'create', path: '/b', value: 1 }, ]; ``` Tasks by default apply to `update` operations, meaning the planner will know to chose the `plusOne` task to serve the `update` operation, but it will not find any task to serve the `create` operation and hence planning fails. In the above scenario we would like perhaps that the planner would infer that counters start at `0`, but while that may work for this simple example, it is not true in the general case, particularly when dealing with more complex types. Moreover, creating a new counter could require some side effects that the planner could not anticipate. In order to allow the planner to find a plan here, we need to provide a task for a `create` operation. ```typescript const initCounter = Task.of<System>().from({ // This tells the planner that the task is applicable to a 'create' operation // valid values are 'update' (default), 'create', 'delete', or '*' op: 'create', lens: '/counters/:counterId', // No condition is needed in this case, the task will be chosen only if the counter // is undefined effect: (counter) => { // Set the initial state for the counter counter._ = 0; }, description: ({ counterId }) => `${counterId} = 0`, }); ``` If we now add our task to the planner to look for the plan ```typescript const planner = Planner.from({ tasks: [plusOne, initCounter], config: { trace }, }); const res = planner.findPlan( { counters: { a: 0 } }, { counters: { a: 2, b: 1 } }, ); ``` Will now yield the following plan ```mermaid graph TD start(( )) start -.- d0{ } d0 -.- 6d8d899("a + 1") 6d8d899 -.- d1{ } d1 -.- 99adff5("a + 1") 99adff5 -.- d2{ } d2 -.- 9312e2b("b = 0") 9312e2b -.- d3{ } d3 -.- fd40601("b + 1") fd40601 -.- stop(( )) stop:::finish classDef finish stroke:#000,fill:#000 start:::selected start --> 6d8d899 6d8d899:::selected 6d8d899 --> 99adff5 99adff5:::selected 99adff5 --> 9312e2b 9312e2b:::selected 9312e2b --> fd40601 fd40601:::selected fd40601 --> stop classDef error stroke:#f00 classDef selected stroke:#0f0 ``` ### Deleting data What about deletion? What if you want to search for the following plan? ```typescript import { UNDEFINED } from 'mahler'; const res = planner.findPlan( { counters: { a: 0, b: 1 } }, // `UNDEFINED` is a symbol that tells mahler to // look for a target that doesn't have the property `b` { counters: { a: 2, b: UNDEFINED } }, ); ``` Same thing applies, the planner doesn't know if it can safely delete the value, so it will fail unless it finds a `delete` task that is applicable to the path. ```typescript const delCounter = Task.of<System>().from({ op: 'delete', lens: '/counters/:counterId', // No condition is needed in this case, the task will be chosen only if the value // exists effect: () => { // perform necessary cleanup here, no need to delete the counter as that happens automatically }, description: ({ counterId }) => `delete ${counterId}`, }); ``` Once the effect/action functions have succeeded, the library will remove the counter. Is important to note that delete tasks do not have a `target` property in their context argument as that value is undefined for this type of operation. ### Wildcard tasks In some cases, we may want to create tasks that are applicable to any operation, because we want to deal with complexity inside the task functions or some other reason. In that case, Mahler allows to use `*` (wildcard) as the value of the `operation` value. These tasks will be tried by the planner if the path for the pending operation matches, no matter what the operation is. As with `delete` operations, wildcard tasks don't have a context `target` property. Unlike `delete` operations though, the library will not automatically delete the value once the effect/action functions are executed. Here is how we would define our `delCounter` task as a wildcard task. ```typescript const delCounter = Task.of<System>().from({ op: '*', lens: '/counters/:counterId', // Do not use the task if the counter was already deleted condition: (counter) => counter != null, effect: (counter) => { // we need to manually delete the counter here as the library cannot infer this step for wildcard tasks counter.delete(); }, description: ({ counterId }) => `delete ${counterId}`, }); ``` The `View` type provides the convenience `delete()` method for such scenario. > [!NOTE] > As wildcard tasks are applicable to any operation, restricting the condition is very important to prevent the planner from repeatedly chosing the task. ### Operation precedence What if for a given operation we have multiple tasks that are applicable? For instance, for an `update` operation on path `/counters/a`, we could have a task that applies to the path `/counters` (as with the parallel update example) and a task that applies to a given counter (like `plusOne`). In that case, the planner will try to apply the task that applies to highest level path first (`/counters` in this case), only if that fails, it will try the task that is applicable to `/counters/a`. In fact, when comparing the current state and target state, the planner will look for task that fits on every level. With our previous example, when comparing `{ counters: { a: 0 } }` and `{ counters: { a: 0, b: 1 } }` the planner will start from the root path looking for applicable operations, as shown below. ```typescript [ { op: 'update', path: '/', value: { counters: { a: 0, b: 1 } } }, { op: 'update', path: '/counters', value: { a: 0, b: 1 } }, { op: 'create', path: '/counters/b', value: 1 }, ]; ``` Let's look at a couple more examples Comparing `{ a: {} }` to `{ a: { b: { c: 0 } } }`, will cause the planner to look for tasks applicable to the following operations ```typescript [ { op: 'update', path: '/', value: { a: { b: { c: 0 } } } }, { op: 'update', path: '/a', value: { b: { c: 0 } } }, { op: 'create', path: '/a/b', value: { c: 0 } }, ]; ``` We can see above that the planner will stop the search when a `create` operation is found. Comparing `{ a: { b: { c: 0 } } }` to `{ a: { b: { c: 1 } } }`, will cause the planner to look for tasks applicable to the following operations ```typescript [ { op: 'update', path: '/', value: { a: { b: { c: 1 } } } }, { op: 'update', path: '/a', value: { b: { c: 1 } } }, { op: 'update', path: '/a/b', value: { c: 1 } }, { op: 'update', path: '/a/b/c', value: 1 }, ]; ``` For `delete` operations, the planner will expand the search to sub-elements of the deleted property. For example, comparing `{ a: { b: { c: { d: 'e' } } } }` to `{ a: { b: UNDEFINED } }`, will cause the planner to look for tasks applicable to the following operations ```typescript [ { op: 'update', path: '/', value: { a: {} } }, { op: 'update', path: '/a', value: {} }, { op: 'delete', path: '/a/b' }, { op: 'delete', path: '/a/b/c' }, { op: 'delete', path: '/a/b/c/d' }, ]; ``` This is because of the implied hierarchy in the state, deleting a value of type `/a/b` may need to make sure that no sub-elements of type `c` exist before the delete can take place. This is similar to a database, where deletes of related entities require cascading deletes. The planner doesn't enforce any hierarchy though, so it is up to the developer to add conditions to tasks to let the planner know if certain delete sequence is necessary. ## Task precedence As mentioned before, the planner uses root to leaf precedence related to changing paths. When multiple tasks are applicable to the same path, the planner will use Method tasks first and then chose tasks in order of insertion in the task array given to the planner. ## Domains Tasks can be grouped in a domain for less verbose code ```typescript import { Domain } from 'mahler'; const MySystem = Domain.of<System>(); const myTask = MySystem.task({ // task definition here }); ``` This is also true for sensors, which we describe in the [next section](#sensors) ```typescript const mySensor = MySystem.sensor({ // sensor definition here }); ``` ## Sensors We are almost done introducing concepts, before continuing however, let's move to a better example. Let's write an agent for a simple space heater controller. The heater design is very simple, it is composed by a resistor that can be turned ON or OFF to heat the room, and a termometer that detects the room temperature. The heater interface allows to set a target room temperature. The controller will turn the resistor ON if the temperature is below target or OFF if the temperature is above target. Let's start first by modelling the state. As the per the hardware design, the state needs to keep track of the resistor state and the room temperature. ```typescript import { Domain, Agent } from 'mahler'; type Heater = { roomTemp: number; resistorOn: boolean }; // We'll reuse the type name for the domain const Heater = Domain.of<Heater>(); ``` Now let's define a task for turning the heater ON. ```typescript const turnOn = Heater.task({ // Only run this task if the room temperature is below target condition: (state, { target }) => state.roomTemp < target.roomTemp && !state.resistorOn, // What should the planner expect after running this task