bam-for-js
v1.0.38
Published
Library for building reversible interpreters by converting them to Bidirectional Abstract Machines.
Downloads
13
Maintainers
Readme
bam.js
bam.js is a JavaScript implementation of an Edit Action language. It's a way to summarize how to transform data in another format, which makes it suitable for reversing changes. It makes it easy to create and maintain reversible interpreters written in JavaScript. It features:
- An edit action language
- Easy-to-use constructs for deterministic and non-deterministic (i.e. with variants) rewriting of records and primitives.
- Operations to compose, merge and reverse these edit actions
- Documentation
- How to transform an interpeter into a "Abstract Machine"
- How to transform an "Asbstract Machine" to a "Bidirectional Abstract Machine"
- Examples
Sandbox online
The sandbox is a nice place to experiment with BAM. Click on the buttons to apply edit actions to objects, and to back-propagate edit actions from edit actions.
To illustrate the example explained in details below:
- Put
{a: 1}
in the top left box - Put
New({b: Reuse("a"), c: Reuse("a")})
in the top middle box - Press the button
bam.apply(evaluation step, program)
. - Now in the bottom right box, replace the program with
{b: 2, c: {d: 1}}
The middle right box is filled automatically - Press the button
user step' = backPropagate(evaluation step, user step)
The middle left and middle bottom boxes are filled automatically.
Quick node.js install
Remove the -g parameter to install it for a single node project:
npm install -g bam.js
Learn BAM from Examples
BAM handles trees whatever they might represent. BAM does not care about the fields names, just that every object is a tree whose children are labelled by field names.
Here are the most basic two examples. The New()
operator creates something from scratch, and the Reuse()
operator reuses the previous tree:
apply(New("x"), {a: {}, b: "cd"}) == "x"
apply(Reuse(), {a: {}, b: "cd"}) == {a: {}, b: "cd"}
We can nest these operations to get more advanced transformations: Reuse
can take an argument which is an object whose fields indicate which fields should be changed and how:
apply(New({x: New("y")}), {a: {}, b: "cd"}) == { x: "y" }
apply(New({x: Reuse()}), {a: {}, b: "cd"}) == { x: {a: {}, b: "cd"} }
apply(Reuse({a: New(x)}), {a: {}, b: "cd"}) == {a: "x", b: "cd"}
We can also reuse elements down the tree by providing string arguments to Reuse
:
apply({Reuse("a"), {a: {b: 2}}) == {b: 2}
apply({Reuse("a", "b"), {a: {b: 2}}) == 2
apply({Reuse("stack", "hd"), {stack: {hd: {ctor: "hello"}}}) == {ctor: "hello"}
We can also reuse elements up the tree by providing the keyword up
to Reuse
:
apply({Reuse({a: Reuse(up, "b")}), {a: {}, b: "cd"}) = {a: "cd", b: "cd"}
apply({Reuse({stack: Reuse(up, "state", "hd")}), {stack: 1, state: { hd: 2} }) = {stack: 2, state: { hd: 2 } }
Language API
Deterministic Edit Actions
The standard way to express how to transform a record into another is to create a "Deterministic Edit actions". A deterministic edit action is either:
- Create a new object, whose fields are also edit actions (
New
) - Reuse an object existing at a relative path from the location,
modifying a subset of its fields by other edit actions (
Reuse
) - Create a new object with a way to explicitely reuse some field ranges (
ReuseArray
) which is especially useful for arrays or strings whose field names are numbers.
Deterministic Edit Actions are well suited to represent what an evaluator step did on the input.
In this section, all functions are accessible under bam.*
, for example New
means bam.New
.
New
For primitives, New()
takes one arguments:
apply(New(1), 42) == 1
apply(New(true), 42) == true
apply(New("abc"), 42) == "abc"
For records, New()
takes a record whose values are edit actions:
apply(New({a: New(1)}), 42) == {a: 1}
apply(New({b: New(1), c: New(3)}), 42) == {b: 1, c: 3}
New()
can also take an extra argument which is the skeleton it should build the value from.
apply(New({a: New(1)}, {b: 3}), 42) == {a: 1, b: 3}
apply(New({}, {a: 1, b: 3}), 42) == {a: 1, b: 3}
apply(New({}, {b: 1, c: 3}), 42) == {b: 1, c: 3}
This is especially useful in the case of arrays or JavaScript Maps. By passing an array or a Map as the second argument, the record of edit actions will be interpreted as actions to perform on the provided array or Map:
apply(New({0: New(1)}, []), 42) == [1]
apply(New({1: New(3)}, [1]), 42) == [1, 3]
apply(New({1: New(3)}, new Map()), 42) == new Map([[1, 3]])
apply(New({2: New(5)}, new Map([[2, 4], [1, 3]])), 42) == new Map([[2, 5], [1, 3]])
The reason to provide a non-empty Map as a skeleton is that it keeps the key ordering. Now that we know how to create new values from scratch using edit actions, we'll see in the next section how we can reuse values.
Reuse
To reuse a value, we build Reuse()
with one argument, which is a record of edit actions that should apply on the modified fields.
apply( Reuse({c: New(4)}), {a: 1, b: 2, c: 42} ) == {a: 1, b: 2, c: 4}
apply( Reuse({1: New(54)}), [40,30,90] ) == [40,54,90]
apply( Reuse({key: New(true)}), new Map([["key", false]])) == new Map([["key", true]])
Reuse()
should not be used to create new keys, only modify existing ones. To add numeric keys while keeping previous keys the same, it can be useful to look at the ReuseArray below.
Reuse()
's first arguments can also be a path, which consists of a comma-separated list of either the special string ".."
also named "up" (up the tree), or other strings or numbers (down the tree), or arrays of these.
In this case Reuse()
will fetch the tree pointed to by the path before applying its edit actions:
apply( Reuse("b"), {a: 1, b: 2, c: 42} ) == 2
apply( Reuse(["b", "c"], "d"), {a: 1, b: {c: {d: 42}}} ) == 42
apply( Reuse({c: Reuse("..", "b")}), {a: 1, b: 2, c: 42} ) == {a: 1, b: 2, c: 2}
apply( Reuse({c: Reuse(up, "b")}), {a: 1, b: 5, c: 42} ) == {a: 1, b: 5, c: 5}
One can call Reuse()
with both path elements and the edit actionr record argument to obtain clone-and-modify behaviors:
apply( Reuse({c: Reuse(up, "b", { x: New(2) })}), {b: {x: 1, y: 3}, c: 42} ) == {b: {x: 1, y: 3}, c: {x: 2, y: 3}}
path
for Reuse
Instead of providing the list of path elements, it's also possible to provide a single path
value.
A path
is a special record of type:
{up: number, down: List String}
For example, the path "..", "b"
can be represented as
{up: 1, down: List.fromArray(["b"])}
The path
function enables to build such paths by combining paths and path elements.
Arrays are exploded to single paths or path elements.
The path above could be computed in all the following ways:
path("..", "b")
path("..", ["b"])
path(["..", "b"])
path(path(".."), path("b"))
...
ReuseArray
In the case of records with consecutive numeric keys (arrays, string or even user-defined records like an "heap" in an interpreter), the ReuseArray
edit action splits the original array into two, apply its two edit actions respectively to apply each part, and recombine the result.
ReuseArray(count, editAction1, editAction2)
For example, if for two edit actions E1 and E2 we have:
apply(E1, [1, 2, 3]) = [6]
apply(E2, [4, 5]) = [5, 4]
then
apply(ReuseArray(3, E1, E2), [1, 2, 3, 4, 5]) = [6, 5, 4]
Cloning path
It is possible to clone another array instead of modifying the current one.
For that, just include a path right after ReuseArray(
, or some path elements like up
, numbers, paths, strings, or array of these, just as for Reuse.
The last number is considered to be the split count and is not part of the path.
apply(Reuse({a: ReuseArray(up, "b", 3, New([]), Reuse())}), {a: 1, b: [1, 2, 3, 4, 5]})
= {a: [4, 5], b: [1, 2, 3, 4, 5]}
Syntax shortcut
As a shorthand, we enable
ReuseArray([path, ] count1, e1, count2, e2, count3, e3, e4)
to represent
ReuseArray([path, ] count1, e1, ReuseArray(count2, e2, ReuseArray(count3, e3, e4)))
Cloning up
Only the first nested ReuseArray
increases the context stack length by adding the top-level array.
All consecutively nested ReuseArray
only modify further the portion of the array being considered.
Therefore, whatever number of ReuseArray, only one "up" is necessary in a clone path to access the entire array from any sub-array.
In the following example, only two up
s are required to access the entire array from the element "d"
, despite the intermediate windows ["c", "d", "e", "f"] obtained after splitting the first array at index 2 and taking the second half, and ["c", "d", "e"] after splitting the second array at index 3 and taking the first half.
apply(ReuseArray(2, Reuse(), ReuseArray(3, Reuse({1: Reuse(up, up, 5)}), Reuse())),
["a", "b", "c", "d", "e", "f"])
= ["a", "b", "c", "f", "e", "f"]
Full example
Here is an illustrative example, featuring deleting, keeping, cloning, inserting, and moving:
var prog = ["To move elsewhere", "Keep", "Keep with next", "Keep and to clone"};
var step = ReuseArray(
1, New([]), // Deletes 0
1, Reuse(), // Keeps 1
0, New({0: Reuse(up, 2)},[]), // Clones the field 3 before 2
2, Reuse(), // Keeps 2 and 3
0, New({0: Reuse(up, 0)}, []), // Move the field "0" there
0, New({0: New("Inserted at end")}, []) // Inserts a new value
);
var res = apply(step, prog);
console.log(res);
// Displays
// {0: "Keep", 1: "Keep and to clone", 2: "Keep with next", 3: "Keep and to clone", 4: "To move elsewhere", 5: "Inserted at end"}
Note that ReuseArray(n, edit action, m, edit action, X)
is a shortcut for ReuseArray(n, edit action, ReuseArray(m, edit action 2, X))
.
The meaning of ReuseArray(n, edit action, X)
is to consider the first n elements of the provided array extract, apply the given edit action to the sub-array and concatenate the result with the result of applying X to the remaining view of the array.
Similar to Reuse
, ReuseArray
's argument list can be prefixed with as many path elements as needed, but these path elements should not be numbers as the first number indicates how many items to extract.
Caching output length
If you happen to use ReuseArray
or Reuse
with a path inside a ReuseArray
edit action named e1
,
you will have to wrap the edit action like bam.setOutLength(e1, count)
, where count is the length of the edit action.
The reason is, merging algorithms require this length to determine whether or not edit actions are "aligned".
Special case: Strings
Strings can be treated either as primitives (for a full replacement regardless of how the string was edited), or using the ReuseArray
described before.
For example, to transform:
"Hello world! ?"
to
"Hello big world, nice world! ??"
one can use the following edit action
ReuseArray(
5, Reuse(), // "Hello"
6, // " world"
ReuseArray(
0, New(" big world, nice"), // insertion
6, Reuse() // " world"
),
2, Reuse(), // The string "! "
0, New("?"), // The inserted string "?"
1, Reuse()
)
Custom lenses
bam.js
supports a custom kind of user-defined lens. Here is an example on how to define a reversible addition that backs-propagates the diff either to the left or to the right:
> var plusEditAction =
Custom(bam.Reuse("args"),
({left, right}) => left + right,
function(outputDiff, {left, right}, outputOld) {
if(outputDiff.ctor === bam.Type.New) {
let diff = outputDiff.model - outputOld;
return nd.Reuse({left: nd.New(left + diff)}).concat(nd.Reuse({right: nd.New(right + diff)}));
} else {
console.log(stringOf(outputDiff));
throw "Unsupported output edit action for the + operator"
}
});
> bam.apply(plusEditAction, {type: "Add", args: {left: 1, right: 3}});
4
> nd.backPropagate(plusEditAction, nd.New(5));
[Reuse({args: [Reuse({left: [New(2)]})]}),
Reuse({args: [Reuse({right: [New(4)]})]})]
Language API: Non-deterministic variants
nd.New
, nd.Reuse
and nd.ReuseArray
are the non-deterministic variants of the constructs mentioned above.
Non-determinism means that wherever edit actions were needed in argument, arrays of edit actions should be used,
and these nd.*
constructs all return an array of edit action.
The original edit actions are useful to describe unambiguous interpreter steps,
whereas the non-deterministic variant is useful to describe the ambiguous user-defined transformations.
Example:
`nd.New(42)`
`nd.Reuse(1)`
Non-deterministic edit actions can be combined using the nd.or
operator -- or the JavaScript's native concat operator:
`nd.or(nd.New(42), nd.Reuse(1))`
`nd.New(42).concat(nd.Reuse(1))`
For example, a more complete non-deterministic edit action associated to the transformation of the string:
"Hello world! 1"
to
"Hello big world, nice world! ??"
one can use the following edit action:
nd.ReuseArray(
5, Reuse(),
6, nd.or( // " world"
nd.ReuseArray(
0, New(" big world, nice"), // insertion
6, Reuse() // " world"
),
nd.ReuseArray(
1, Reuse(), // " "
0, New("big world, nice "), // insertion
5, Reuse() // "world"
),
nd.ReuseArray(
1, Reuse(),
0, New("big "),
)
)
2, nd.Reuse(), // The string "! "
1, nd.or(
nd.ReuseArray(
0, New("?"),
1, Reuse()
),
nd.ReuseArray(
0, Reuse(),
1, New("?")
))
)
There are several ways to apply a non-deterministic edit action to a program, depending on the expected result.
To obtain the first result, just use
nd.apply
`nd.apply(nd.or(nd.New(42), nd.Reuse(1)), [41, 43]) == 42`
To obtain the last result, use
nd.apply
and providetrue
as an extra argument:`nd.apply(nd.or(nd.New(42), nd.Reuse(1)), [41, 43], true) == 43`
To list all results, use
nd.applyAll
:`nd.applyAll(nd.or(nd.New(42), nd.Reuse("b")), {a: 41, b: 43}) == [42, 43]
To list all results as a record where all fields are arrays of possibilities, use
nd.applyVSA
(VSA stands for Version Space Algebra):``` nd.applyVSA(nd.or(nd.New({b: nd.or(nd.Reuse("a"), nd.New(43))}), nd.New({c: nd.Reuse("a")})), {a: 42}) == [{b: [42, 43]}, {c: [42]}]```
Note that the last will fail in the case of a non-deterministic nd.ReuseArray
applied to a string. Use one of the other three versions instead.
Compute edit actions automatically
bam.js has some support to compute deterministic and non-deterministic edit actions from two values. Here is how it works:
> bam.diff(1, 2)
New(2)
> bam.diff(1, 1)
Reuse()
> bam.diff(1, [1, 2])
New({ 0: Reuse(), 1: 2}, [])
> bam.diff([1, 2], 1)
Reuse(0)
It is also possible to ask for all edit actions it can find:
> bam.nd.diff(1, 2)
[New(2)]
> bam.nd.diff(1, 1)
[Reuse()]
> bam.nd.diff([2, 1], [1, 2])
[Reuse({0: [Reuse(up, 1),
New(1)],
1: [Reuse(up, 0),
New(2)]}),
ReuseArray(0, [New({ 0: [Reuse(up, 1),
New(1)]}, [])],
[ReuseArray(1, [Reuse()],
[New([])])]),
ReuseArray(1, [New([])],
[ReuseArray(1, [Reuse()],
[New({ 0: [Reuse(up, 0),
New(2)]}, [])])]),
New({ 0: [Reuse(1)],
1: [Reuse(0)]}, [])]
It's possible to add options as the third parameter.
The option onlyReuse
(default: false) prunes out all New solutions if there are solutions that use Reuse:
> bam.nd.diff([2, 1], [1, 2], {onlyReuse: true})
[Reuse({0: [Reuse(up, 1)],
1: [Reuse(up, 0)]}),
ReuseArray(0, [New({ 0: [Reuse(up, 1)]}, [])],
[ReuseArray(1, [Reuse()],
[New([])])]),
ReuseArray(1, [New([])],
[ReuseArray(1, [Reuse()],
[New({ 0: [Reuse(up, 0)]}, [])])])]
The option maxCloneUp
(default: 2) specifies the number of maximum depth traversal when going up, when looking for clones.
> diff([1, [[2]]], [1, [[1]]], {maxCloneUp: 2})
Reuse({1: Reuse({0: Reuse({0: New(1)})})})
> diff([1, [[2]]], [1, [[1]]], {maxCloneUp: 3})
Reuse({1: Reuse({0: Reuse({0: Reuse(up, up, up, 0)})})})
The option maxCloneDown
(default: 2) specifies the number of maximum depth traversal when going down (even after going up), when looking for clones.
> diff([1], 1, {maxCloneDown: 1})
Reuse("0")
> diff([1], 1, {maxCloneDown: 0})
New(1)
Aligning elements when diffing.
When diffing arrays of elements, the options
{isCompatibleForReuseObject: (oldValue, newValue) => Boolean,
isCompatibleForReuseArray: (oldValue, newValue) => Boolean,
}
respectively indicates to diff
if it can try an alignment using Reuse
(if it shares the same keys) and ReuseArray
for arrays. By default, this option is enabled for all arrays. A useful use case is to make a function isCompatibleForReuseArray
return false if one of the value is not really an array but a tuple, e.g. ["tag", [...attributes], [...children]]]
. That way, it prevents a lot of comparisons.
Another useful option is:
{findNextSimilar: (array: Array[A], element: A, fromIndex: Int) => (newIndex: Int | -1)}
It takes an array, an index from which it searches the element A, and return the first index at which it finds it. By default, the element must be exactly the same for an alignment to occur; but it might be useful to specify a function to find a partial match, especially if the match changed as well.
Debugging edit actions
Tip: Use bam.stringOf(...)
to convert an edit action to its string representation, or use bam.debug(...)
to directly pretty-print an edit action to the standard output.
Operations on edit actions
Combination
andThen(b, a)
is defined so that
apply(b, apply(a, x)) == apply(andThen(b, a), x)
Note that andThen
simplifies at the maximum the resulting edit action. If you do not want to simplify, use the equivalent:
Sequence(a, b)
Test commutativity
commute(a, b)
returns true if we can guarantee that, for all x,
apply(b, apply(a, x)) == apply(a, apply(b, x))
Advanced: Language example
In this example, let us have a dummy program {a: 1}
that an interpreted transforms into {b: 1, c: 1}
in one step.
We are interested in how to propagates changes from the output back to the program.
- First, we rewrite the interpreter so that it does not output the result anymore, it outputs a term in our Edit Action language (
evalStep
) that transforms the program to the result. This step is explained later in this README. - For example,
evalStep = New({b: Reuse("a"), c: Reuse("a")});
, meaning the1
was cloned from fielda
. - A user modifies the output so that it becomes
{b: 2, c: {d: 1}}
. - Suppose that
userStep = nd.Reuse({b: nd.New(2), c: New({d: nd.or(nd.Reuse(), nd.Reuse("..", "b"))})});
is the edit action associated to this transformation. It encodes two possibilities: Either the 1 came from wrapping the field "c" (more likely) or was simply cloned from the field "b". "nd" stands for "non-deterministic", i.e. we can offer multiple variants. Using "nd" only makes sense for user steps.
We are interested to replicate the user step but on the program level. This library enables it in the following way:
var program1 = {a: 1};
var {Reuse, New, apply, backpropagate, nd, up} = bam;
var evalStep = New({b: Reuse("a"), c: Reuse("a")});
var output1 = apply(evalStep, program1);
console.log(output1); // Displays: {b: 1, c: 1}
var userStep = nd.Reuse({b: nd.New(2), c: nd.New({d: nd.or(nd.Reuse(), nd.Reuse("..", "b"))})});
var output2 = nd.apply(userStep, output1); // Applies the first variant.
console.log(output2); // Displays: {b: 2, c: {d: 1}}
var userStepOnProgram = backpropagate(evalStep, userStep);
console.log(userStepOnProgram);
// Displays: nd.Reuse({a: nd.New({d: nd.New(2)})})
var program2 = nd.apply(userStepOnProgram, program1);
console.log(program2); // Displays: Reuse({a: {d: 2}})
var output3 = apply(evalStep, program2); // You'd run the evaluator instead, in case some conditions changes...
console.log(output3); // Displays: {b: {d: 2}, c: {d: 2}}
This concludes a basic illustration of this API.