Jul 2024  

Designing an actor-based programming language

This is one of my attemts at designing a programming language Not a standalone language, rather a DSL built on top of Javascript. Still work in progress, and implementation is in very early stage, only parser is done so far.

Inspiration

My main inspiration comes from systems like Observable and Nodes, that demonstrate interesting programming paradigms without designing a language from scratch, building on top of Javascript. It's possible to relatively easily explore and prototype different interesting programming paradigms, using web browser as a ubiquitous platform.

Instead of reactive programming like in Observable and Nodes, I decided to explore agent-based modeling systems, similar to products like hash.ai, and frameworks like Agents.jl. Another source of inspiration is actor-based languages like Erlang and Elixir, because agent-based systems are often built on top of actor model implementations.

Combining this things, we get an actor-based language, targeting interactive browser-based multi-agent simulations, built as a DSL on top of Javascript.

Language design

The main requirement for the design is simple and minimal syntax. Another requirement is easy JS interop, so that for any features that are not supported by the language, it's possible to just use inline JS code, or call JS functions.

With this design ideas in mind, after some prototyping and drafting, I based the syntax on python-like style with significant whitespace, and few building blocks:

Simple example

Starting with a simple "hello world" example, which shows usage of operators for message passing, and is commented to explain the basics of the syntax.

// operators:
// +> receive message
// -> send message
// > return to sender
agent transmitter:
  +> init:          // receive "init", which is sent on program init by default
      log(hello)
        hello -> receiver +> log  // send "hello" to receiver, log returned message
        sleep(1000)
          init >    // repeat by sending "init" to self

agent receiver:
  +> hello: world >  // on receiving "hello", return "world" to sender

Syntax

Operators

+>    receive a message
->    send a message
>     return to sender

Keywords

var, agent, Array, break

Types

receiver                  agent, referenced by name
42                        number
'x'                       string
hello                     atom (like in Erlang)
Array<receiver>(10)       arrays (like in Typescript)
{'x':0, 'y':0}            JSON literals

Variable definition

var foo: 10

Agent module definition. includes definitions for named properties, and messages handlers

agent receiver:
  x: 0               // first, define named properties
  y: 'hello'
    +> init: update >  // next, define handlers for messages
    +> update: init >

Inline Javascript code. Similar to JS blocks in Observable language.

{ i++; if (i >= tx_size) exit(); }

Javascript functions definitions. Can be included in the same source file, should be marked with special braces:

{%
function find_neighbours(x, array) {
  retuen boids.filter(el → x.sub(el.x).length() < neighbours_distance);
}
%}

Builtin functions

sleep(), log()

More complicated example

Next example shows multiple agents started with 100ms increments.

var tx_size: 10

agent transmitters:
  i: 0
  tx: Array<tx>(tx_size)
  +> init
      start -> tx[i]
      {i++; if (i >= tx_size) break;} // use inline JS code
        sleep(100)
          init >

agent tx:
  +> start:
      hello -> rx +> log
      sleep(1000) >

agent rx:
  +> hello: world >

Mult-agent simulation example

For even more complex example, I selected multi-agent flocking simulation. Explanations of the "boids" flocking algorithm can be found on pages by Craig Reynolds and Conrad Parker. It is often used as a demo for agent-based frameworks, for example:

Runtime is not implemented yet, but at least can discuss some features of the language compared to similar systems.

I think that this style promotes describing structure of the algorithm in short and concise way, but for now it's just a guess. To check it in practice have to write more code based on the finished language implementation

var separation_weight: 0.1
var alignment_weight: 0.1
var cohesion_weight: 0.1
var neighbours_distance: 0.25

agent flock:
    boids: Array<boid>(25)
    +> x: find_neighbours(x, boids) >
    +> init: update >
    +> update: draw() sleep(10) update >

agent boid:
    x: {x: 0, y: 0}
    v: {x: 0, y: 0}
    +> init: update >
    +> update:
        x -> flock +> neighbours
            separation(x, v, neighbours)
            align(x, neighbours)
            align(v, neighbours)
            {% x.add(v) %}
                x, v -> sim_space +> x, v
                    sleep(10) update >

agent sim_space:
    width: 1.0
    height: 1.0
    +> x, v:
        limit(x, v, 'x')
        limit(x, v, 'y')
            x, v >

{%

function find_neighbours(x, array) {
    retuen boids.filter(el → x.sub(el.x).length() < neighbours_distance);
}

function separation(x, v, neighbours) {
    neighbours.map(n => v.add(x.clone().sub(n.x))
}

function align(property, array) {
    let values = array.map(a => a[property]);
    let mean = values.reduce((a, b) => a.add(b)).divideScalar(array.length);
    boid[property].add(mean.sub(boid[property]));
}

funciton limit(x, v, axis) {
    x[axis] < 0 && (v[axis] += 0.05);
    x[axis] > 1 && (v[axis] -= 0.05);
}

function draw() {
    var canvas = document.querySelector("canvas");
    var canvas_ctx = canvas.getContext("2d");
    for (let boid of boids) {
        canvas_ctx.beginPath();
        canvas_ctx.arc(boid.x.x*500, boid.x.y*500, 1, 0, 2 * Math.PI);
        canvas_ctx.fill();
    }
}
%}

Grammar and parser (code, diagrams)

For now the only thing done for the implementation is a parser, that is generated with nearley.js, using moo.js as a lexer. Grammar definition:

@{%

let lexer = moo.compile({
    js_expression_inline: /\{\%.*?\%\}/,
    js_expression: /\{\%[^]*?\%\}/,
    js_object: /\{.*?\}/,
    keyword: ['var', 'agent', 'Array'],
    comma: ',',
    lparen: '(',
    rparen: ')',
    receive: '+>',
    send: '->',
    le: '<',
    ge: '>',
    ws: /[ \t]+/,
    label: /.*?:/,
    comment: /\/\/.*?$/,
    number: /-?(?:[0-9]|[1-9][0-9]+)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?\b/,
    identifier: /[A-Za-z_][A-Za-z0-9_]*/,
    string: /'.*?'/,
    nl: { match: /\n/, lineBreaks: true },
})

%}

@lexer lexer

program -> empty:* expression:*
    {% function(d) { return d[1]; } %}
empty -> %nl | %ws

expression -> var | agent

var -> "var" %ws %label %ws %number %nl:+
    {% function(d) { return [d[0].value, d[2].value, d[4].value] ; } %}

agent -> "agent" %ws %label %nl property:* receiver:* %nl:+
    {% function(d) { return [d[0].value, d[2].value, d[4], d[5]] ; } %}

property -> %ws %label %ws property_value %nl
    {% function(d) { return [d[1].value, d[3]] ; } %}

property_value -> %number {% function(d) { return d[0].value; } %}
                | array_of_agents
                | %js_object {% function(d) { return d[0].value; } %}

array_of_agents -> "Array" %le %identifier %ge %lparen %number %rparen
    {% function(d) { return [d[0].value,  d[2].value, d[5].value] } %}

receiver -> %ws %receive %ws %label %nl:? statement:*
    {% function(d) { return ["receive", d[3].value, d[5]]; } %}

statement -> %ws %identifier args:? %comma:? %nl:?
            {% function(d) { return d[3]?["id", d[1].value, d[2],
                                    d[3].value]:["id", d[1].value, d[2]]; } %}
           | %ws %send {% function(d) { return "send"; } %}
           | %ws %receive {% function(d) { return "receive"; } %}
           | %ws %ge %nl:+ {% function(d) { return "return_to_sender"; } %}
           | %ws %js_expression_inline %nl:* {% function(d) { return d[1].value; } %}
           | %ws args %nl:* {% function(d) { return d[1]; } %}

args -> %lparen arg:* %rparen
    {% function(d) { return ["arg", d[1]]; } %}

arg -> arg_value %comma:* %ws:* {% function(d) { return d[0]; } %}
arg_value -> %identifier {% function(d) { return d[0].value; } %}
           | %number {% function(d) { return d[0].value; } %}
           | %string {% function(d) { return d[0].value; } %}

Example of parsing results:

agent transmitter:
  +> init:
      log(hello)
        hello -> receiver +> log
        sleep(1000)
          init >

agent receiver:
  +> hello: world >
[[["agent",
      "transmitter:",
      [],
      [["receive",
          "init:",
          [["id", "log", ["arg", ["hello"]]],
            ["id", "hello", null],
            "send",
            ["id", "receiver", null],
            "receive",
            ["id", "log", null],
            ["id", "sleep", ["arg", ["1000"]]],
            ["id", "init", null],
            "return_to_sender"]]]]],
  [["agent",
      "receiver:",
      [],
      [["receive", "hello:", [["id", "world", null], "return_to_sender"]]]]]]