Skip to content

Instantly share code, notes, and snippets.

@zblanco
Created April 24, 2025 15:43
Show Gist options
  • Save zblanco/c20b9f894624e2d91e35971799283c26 to your computer and use it in GitHub Desktop.
Save zblanco/c20b9f894624e2d91e35971799283c26 to your computer and use it in GitHub Desktop.
Runic - Demos

Runic - Demos

Mix.install([
  {:kino, "~> 0.15.3"},
  {:runic, github: "zblanco/runic", branch: "zw/map"}
])

Graph Visualization

defmodule Kino.Cytoscape do
  use Kino.JS

  def new(g, options \\ [])

  def new(%Graph{} = graph, options) do
    graph
    |> to_edgelist_json()
    |> new(options(options))
  end

  def new(edge_list, options) when is_list(options) do
    nodes =
      edge_list
      |> Enum.flat_map(fn %{data: edge} ->
        [
          %{data: %{id: "#{edge.source}", name: "#{edge.source}"}},
          %{data: %{id: "#{edge.target}", name: "#{edge.target}"}}
        ]
      end)
      |> Enum.uniq_by(& &1.data.id)

    new(nodes ++ edge_list, options(options))
  end

  def new(graph, options) do
    Kino.JS.new(__MODULE__, Map.put(options, :elements, graph))
  end

  defp options(:dag) do
    options = options([])

    dag_layout = %{
      name: "breadthfirst",
      fit: true,
      directed: true,
      avoidOverlap: true,
      spacingFactor: 1.0,
      grid: false,
      padding: 0,
      anime: true,
      circle: false,
      userZoomingEnabled: true
    }

    Map.put(options, :layout_options, dag_layout)
  end

  defp options(options) do
    cytoscape =
      options[:cytoscape] ||
        %{
          "zoomingEnabled" => true,
          "userZoomingEnabled" => true,
          "panningEnabled" => true,
          "userPanningEnabled" => true,
          "boxSelectionEnabled" => false
        }

    node_style =
      options[:node_style] ||
        %{
          "background-color" => "#ffffff",
          "font-size" => 14,
          "width" => 120,
          "shape" => "ellipse",
          "height" => 80,
          "text-wrap" => "wrap",
          "text-max-width" => 56,
          "color" => "#475569",
          "label" => "data(name)",
          "text-halign" => "center",
          "text-valign" => "center",
          "border-width" => 2,
          "border-color" => "#94a3b8",
          "border-opacity" => 0.5
        }

    edge_style =
      options[:edge_style] ||
        %{
          "width" => 2,
          "font-size" => 12,
          "label" => "data(label)",
          "line-color" => "#94a3b8",
          "target-arrow-color" => "#94a3b8",
          "target-arrow-shape" => "triangle",
          "curve-style" => "bezier"
        }

    layout_options =
      options[:layout_options] ||
        %{
          name: "cose",
          fit: true,
          directed: true,
          avoidOverlap: true,
          spacingFactor: 1.0,
          grid: false,
          padding: 0,
          anime: true,
          circle: false,
          userZoomingEnabled: true
        }

    %{
      cytoscape: cytoscape,
      node_style: node_style,
      edge_style: edge_style,
      layout_options: layout_options
    }
  end

  defp to_edgelist_json(graph) do
    vertices =
      graph
      |> Graph.vertices()
      |> Enum.map(fn v ->
        vertex_label = vertex_label(v)
        %{data: %{id: "#{vertex_label}", name: "#{vertex_label}"}}
      end)

    edges =
      graph
      |> Graph.edges()
      |> Enum.map(fn edge ->
        edge_label = edge_label(edge.label)

        v1 = vertex_label(edge.v1)
        v2 = vertex_label(edge.v2)

        %{
          data: %{
            id: "#{edge_label}-#{v1}#{v2}" |> :erlang.phash2(),
            label: "#{edge_label}",
            source: v1,
            target: v2
          }
        }
      end)

    vertices ++ edges
  end

  defp edge_label(label), do: label
  defp vertex_label({_, _, _} = ast), do: Macro.to_string(ast)

  defp vertex_label(%Runic.Workflow.Root{}), do: :root
  defp vertex_label(%{name: name}) when not is_nil(name), do: name

  defp vertex_label(%{__struct__: struct} = vertex),
    do: "#{to_string(struct)}#{:erlang.phash2(vertex)}"

  defp vertex_label(otherwise), do: otherwise

  asset "main.js" do
    """
    import "https://cdn.jsdelivr.net/npm/[email protected]/dist/cytoscape.min.js";

    export function init(ctx, cyto_data) {

      ctx.root.innerHTML = `<div id='cyto' style='width: 896px; height: 400px;'></div>`;

      var cy = cytoscape({
          ...cyto_data.cytoscape,
          ...{
            container: document.getElementById('cyto'),
            elements: cyto_data.elements,
            style: [
              {
                selector: 'node',
                style: cyto_data.node_style
              },
              {
                selector: 'edge',
                style: cyto_data.edge_style
              }
            ]
          }
        });

        cy.layout(cyto_data.layout_options).run();
    }
    """
  end
end

Intro

Runic is a tool for modeling your workflows as data that can be composed together at runtime.

Runic components can be integrated into a Runic.Workflow and evaluated lazily in concurrent contexts.

Runic Workflows are a decorated dataflow graph (a DAG - "directed acyclic graph") capable of modeling rules, pipelines, and state machines and more.

Basic data flow dependencies such as in a pipeline are modeled as %Step{} structs (nodes/vertices) in the graph with directed edges (arrows) between steps.

A step can be thought of as a simple input -> output lambda function. e.g.

Examples

require Runic

step = Runic.step(fn x -> x + 1 end, name: :add_one)
workflow = Runic.workflow(
  name: "example pipeline workflow",
  steps: [
    Runic.step(fn x -> x + 1 end, name: :add_one),
    Runic.step(fn x -> x * 2 end, name: :times_two),
    Runic.step(fn x -> x - 1 end, name: :minus_one)
  ]
)
workflow.graph
|> Kino.Cytoscape.new(:dag)
alias Runic.Workflow

wrk = 
  workflow
  |> Workflow.react_until_satisfied(2)
wrk |> Workflow.raw_productions()
wrk |> Workflow.productions()
serial_pipeline = 
  Runic.workflow(
  name: "serial_pipeline",
  steps: [
    {Runic.step(fn x -> x + 1 end, name: :add_one), [
      {Runic.step(fn x -> x * 2 end, name: :times_two), [
        Runic.step(fn x -> x - 1 end, name: :minus_one)
      ]}
    ]},
    Runic.step(fn x -> x + 5 end, name: :add_five)
  ]
)
serial_pipeline.graph
|> Kino.Cytoscape.new(:dag)

Text Processing

defmodule TextProcessing do
  def tokenize(text) do
    text
    |> String.downcase()
    |> String.split(~r/[^[:alnum:]\-]/u, trim: true)
  end

  def count_words(list_of_words) do
    list_of_words
    |> Enum.reduce(Map.new(), fn word, map ->
      Map.update(map, word, 1, &(&1 + 1))
    end)
  end

  def count_uniques(word_count) do
    Enum.count(word_count)
  end

  def first_word(list_of_words) do
    List.first(list_of_words)
  end

  def last_word(list_of_words) do
    List.last(list_of_words)
  end
end
import TextProcessing

word_count = 
  "anybody want a peanut?"
  |> tokenize()
  |> count_words()
  |> dbg()

first_word = 
  "anybody want a peanut?"
  |> tokenize()
  |> first_word()
  |> dbg()

last_word = 
  "anybody want a peanut?"
  |> tokenize()
  |> last_word()
  |> dbg()
text_processing_workflow = 
  Runic.workflow(
    name: "basic text processing example",
    steps: [
      {Runic.step(&tokenize/1),
        [
          {Runic.step(&count_words/1),
          [
            Runic.step(&count_uniques/1)
          ]},
          Runic.step(&first_word/1),
          Runic.step(&last_word/1)
        ]}
    ]
  )
text_processing_workflow.graph
|> Kino.Cytoscape.new(:dag)
text_processing_workflow 
|> Workflow.react_until_satisfied("anybody want a peanut?") 
|> Workflow.raw_productions()

Joins

join_with_many_dependencies =
  Runic.workflow(
    name: "workflow with joins",
    steps: [
      {[Runic.step(fn num -> num * 2 end), Runic.step(fn num -> num * 3 end)],
       [
         Runic.step(fn num_1, num_2 -> num_1 * num_2 end),
         Runic.step(fn num_1, num_2 -> num_1 + num_2 end),
         Runic.step(fn num_1, num_2 -> num_2 - num_1 end)
       ]}
    ]
  )
join_with_many_dependencies.graph
|> Kino.Cytoscape.new(:dag)

Rules

rules_workflow =
  Runic.workflow(
    name: "a test workflow",
    rules: [
      Runic.rule(fn :foo -> :bar end, name: "foobar"),
      Runic.rule(
        if: fn lhs -> lhs == :potato end, 
        do: fn rhs -> rhs == :tomato end, 
        name: "tomato when potato"
      ),
      Runic.rule(
        fn item when is_integer(item) and item > 41 and item < 43 ->
          "the answer to life the universe and everything"
        end,
        name: "what about the question?"
      )
    ]
  )
rules_workflow.graph
|> Kino.Cytoscape.new(:dag)

State Machines

state_machine =
  Runic.state_machine(
    name: "transitional_factor",
    init: 0,
    reducer: fn
      num, state when is_integer(num) and state >= 0 and state < 10 -> state + num * 1
      num, state when is_integer(num) and state >= 10 and state < 20 -> state + num * 2
      num, state when is_integer(num) and state >= 20 and state < 30 -> state + num * 3
      _num, state -> state
    end
  )
  |> Runic.transmute()
state_machine.graph
|> Kino.Cytoscape.new(:dag)

Map

map_expression =
  Runic.workflow(
    name: "map test",
    steps: [
      {Runic.step(fn num -> Enum.map(0..3, &(&1 + num)) end),
       [
         Runic.map(
           {Runic.step(fn num -> num * 2 end),
            [
              Runic.step(fn num -> num + 1 end),
              Runic.step(fn num -> num + 4 end)
            ]}
         )
       ]}
    ]
  )
map_expression.graph
|> Kino.Cytoscape.new(:dag)

Reduce

reduce_example =
  Runic.workflow(
    name: "reduce test",
    steps: [
      {Runic.step(fn num -> Enum.map(0..3, &(&1 + num)) end),
       [
         {Runic.map(fn num -> num * 2 end),
          [
            Runic.reduce([], fn num, acc -> [num | acc] end)
          ]}
       ]}
    ]
  )
reduce_example.graph
|> Kino.Cytoscape.new(:dag)
reduce_wrk =
  Runic.workflow(
    name: "reduce test",
    steps: [
      {Runic.step(fn _ -> 0..3 end),
       [
         Runic.map(
           {step(fn num -> num + 1 end),
            [
              Runic.step(fn num -> num + 4 end),
              Runic.step(fn num -> num + 2 end, name: :plus2)
            ]},
           name: "map"
         )
       ]}
    ]
  )

reduce_wrk =
  Workflow.add(
    reduce_wrk,
    Runic.reduce(0, fn num, acc -> num + acc end, name: "reduce", map: "map"),
    to: :plus2
  )
reduce_wrk
|> Workflow.react_until_satisfied(:anything)
|> Workflow.productions()
reduce_wrk.graph
|> Kino.Cytoscape.new(:dag)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment