Skip to content

PetriNotation.kt

src/jvmMain/kotlin/afield/simulation/petri/PetriNotation.kt · 626 lines

@file:Suppress("MemberVisibilityCanBePrivate", "unused")
@file:afield.runtime.completion.CompletionCategory("Visualization")

package afield.simulation.petri

import afield.audio.synth.PatPattern
import afield.audio.synth.PatQ
import afield.audio.synth.cat
import afield.audio.synth.degrade
import afield.audio.synth.fastBy
import afield.audio.synth.pat
import afield.audio.synth.palindrome
import afield.audio.synth.rev
import afield.audio.synth.slowcat
import afield.audio.synth.stack
import afield.audio.synth.stretch

The #petri text notation: a net you can retype while it is playing.

The Kotlin DSL in PetriNets.kt is the complete surface; anything you can express, you can express there, including arbitrary lambdas. This file is the terse surface. Live coding is editing, and editing a net is only pleasant when the whole net fits in view and one arc is one line. For players, the whole notation is summarised on the Petri notation cheatsheet.

#petri
!grid 1/16

kick : 1
kick -[ hit  @1/16 : bd ]-> hats
hats -[ back @1/16 : sd ]-> kick

petri turns that text into a PetriNet; petriParse returns the net along with everything it has to say about the source.

One line, one arc

pre -[ name @dur key=value : emit => combinator ]-> post

Both sides are +-joined lists of weight*place, and either may be empty: -[ spawn ]-> p is a source and p -[ sink ]-> is a drain. A place is created the first time it is mentioned. A transition may be named on more than one line, which is how one with several inputs stays readable; its settings belong on the first of those lines, and a later line's are ignored with a warning.

verse  -[ build ]-> chorus
energy -[ build ]-> chorus

Prefixes on an input mark the two arcs that are not ordinary.

~armed  -[ fill ]-> hats        // inhibitor: only while armed is empty
&clock  -[ tick ]-> bar         // read/test: needs a token, consumes none
2*hats  -[ roll ]-> fill        // weight: needs two

Place declarations

kick   : 1              // one black token
dst    : 0 ^4           // empty, and bounded at four
verse  : "bd ~ sd ~"    // ONE token whose colour is that pattern — a section
label  : 'lead'         // one token coloured with the literal string
notes  : #60, #64, #67  // three numeric tokens

The two kinds of quote differ: a double-quoted string is parsed as a pattern and a single-quoted one stays a string, because a place holding a rhythm is what makes a net a song structure rather than a drum machine.

Directives

!timing step | event | timed
!grid 1/16
!conflict order | priority | round-robin | random
!seed 7

Everything else

// starts a comment. A leading #petri line is the editor's marker and is ignored here. Colour transforms after => come from PetriCombinators, a fixed vocabulary rather than compiled Kotlin, which keeps the notation quick to parse and the cheatsheet finite. When you want a real lambda, that is what the Kotlin DSL is for.

Errors are collected rather than thrown at the first one: petriParse hands back everything it found, so an editor can underline all of it at once. petri is the throwing convenience on top.

diagnostics

data class PetriSpan(val start: Int, val end: Int, val line: Int, val column: Int, val text: String)

Where something is in the source. Offsets are into the whole string; line and column are 1-based.

override fun toString(): String = "$line:$column '$text'"

enum class PetriSeverity { ERROR, WARNING }

data class PetriDiagnostic(val message: String, val span: PetriSpan, val severity: PetriSeverity = PetriSeverity.ERROR)

One complaint about a source, with the span to underline.

override fun toString(): String

    override fun toString(): String = "${severity.name.lowercase()} at ${span.line}:${span.column}: $message"
}

class PetriParseResult

What petriParse found: the net if it could build one, and everything it wants to say.

The span maps are what an editor hovers and jumps with, and what the visualizer uses to trace a firing back to the line that declared it.

class PetriParseResult(
    val net: PetriNet?,
    val diagnostics: List<PetriDiagnostic>,
    val placeSpans: Map<String, PetriSpan> = emptyMap(),
    val transitionSpans: Map<String, PetriSpan> = emptyMap(),
) {

val errors: List<PetriDiagnostic> get()

    val errors: List<PetriDiagnostic> get() = diagnostics.filter { it.severity == PetriSeverity.ERROR }

val warnings: List<PetriDiagnostic> get()

    val warnings: List<PetriDiagnostic> get() = diagnostics.filter { it.severity == PetriSeverity.WARNING }

val ok: Boolean get() = net != null && errors.isEmpty()

override fun toString(): String

    override fun toString(): String =
        "PetriParseResult(${if (ok) "ok" else "${errors.size} errors"}, ${warnings.size} warnings)"
}

the combinator vocabulary

object PetriCombinators

The named colour transforms the notation's => can reach.

Each takes the tokens a firing consumed and returns the colour of the token it produces. The set is fixed on purpose — see the file header — and every name in it is required by the docs coverage guard to have a worked example, so this map and petri_cheatsheet.md cannot drift apart.

object PetriCombinators {

    private fun patterns(binding: PetriBinding): List<PatPattern<Any?>> =
        binding.consumed.mapNotNull {
            @Suppress("UNCHECKED_CAST")
            it.colour as? PatPattern<Any?>
        }

    private fun firstPattern(binding: PetriBinding): PatPattern<Any?>? = patterns(binding).firstOrNull()

    private val table: Map<String, (PetriBinding) -> Any?> = linkedMapOf(
        // The default, spelled out so it can be written explicitly.
        "carry" to { b -> b.colour() },
        "black" to { _ -> null },
        "swap" to { b -> b.consumed.getOrNull(1)?.colour ?: b.colour() },

        // Pattern algebra over the consumed tokens.
        "stack" to { b -> patterns(b).takeIf { it.isNotEmpty() }?.let { stack(*it.toTypedArray()) } ?: b.colour() },
        "cat" to { b -> patterns(b).takeIf { it.isNotEmpty() }?.let { slowcat(*it.toTypedArray()) } ?: b.colour() },
        "fastcat" to { b -> patterns(b).takeIf { it.isNotEmpty() }?.let { cat(*it.toTypedArray()) } ?: b.colour() },
        "rev" to { b -> firstPattern(b)?.rev() ?: b.colour() },
        "palindrome" to { b -> firstPattern(b)?.palindrome() ?: b.colour() },
        "degrade" to { b -> firstPattern(b)?.degrade() ?: b.colour() },
        "fast2" to { b -> firstPattern(b)?.fastBy(PatQ.of(2)) ?: b.colour() },
        "slow2" to { b -> firstPattern(b)?.stretch(PatQ.of(2)) ?: b.colour() },

        // Numeric colours.
        "sum" to { b -> b.consumed.sumOf { (it.colour as? Number)?.toDouble() ?: 0.0 } },
        "inc" to { b -> ((b.colour() as? Number)?.toDouble() ?: 0.0) + 1.0 },
        "count" to { b -> b.consumed.size.toDouble() },
    )

val names: List<String> get() = table.keys.toList()

Every combinator name, in the order the cheatsheet lists them.

operator fun get(name: String): ((PetriBinding) -> Any?)? = table[name]

The transform called name, or null if there is no such word.

val docs: Map<String, String>

A one-line description of each word, for editor hovers and the cheatsheet.

    val docs: Map<String, String> = linkedMapOf(
        "carry" to "the first consumed colour, unchanged — the default",
        "black" to "no colour: produce a plain black token",
        "swap" to "the second consumed colour, falling back to the first",
        "stack" to "all consumed patterns layered on top of each other",
        "cat" to "all consumed patterns, one per cycle",
        "fastcat" to "all consumed patterns squeezed side by side into one cycle",
        "rev" to "the first consumed pattern, reversed",
        "palindrome" to "the first consumed pattern, then its reverse",
        "degrade" to "the first consumed pattern with half its events dropped",
        "fast2" to "the first consumed pattern at double speed",
        "slow2" to "the first consumed pattern at half speed",
        "sum" to "the numeric consumed colours added together",
        "inc" to "the first numeric consumed colour plus one",
        "count" to "how many tokens were consumed",
    )
}

parsing

fun petri(source: String): PetriNet

Builds a net from the #petri notation, throwing on the first error. See petriParse.

fun petri(source: String): PetriNet {
    val result = petriParse(source)
    result.net?.takeIf { result.errors.isEmpty() }?.let { return it }
    val first = result.errors.firstOrNull()
    throw IllegalArgumentException(
        "petri notation: ${first?.message ?: "could not build a net"}" +
            (first?.let { " (line ${it.span.line}, column ${it.span.column})" } ?: "") +
            if (result.errors.size > 1) " — and ${result.errors.size - 1} more" else ""
    )
}

fun petriParse(source: String): PetriParseResult = PetriParser(source).parse()

Parses the #petri notation, collecting every complaint rather than stopping at the first.

Returns a net whenever one can be built at all; a source with errors in it may still yield a partial net, which is what a live-coding editor wants — the last good thing keeps playing while you are halfway through typing the next.

source — 405 lines, from line 221
private class PetriParser(private val source: String) {

    private val diagnostics = mutableListOf<PetriDiagnostic>()
    private val placeSpans = LinkedHashMap<String, PetriSpan>()
    private val transitionSpans = LinkedHashMap<String, PetriSpan>()

    private val declared = LinkedHashMap<String, Int?>()          // place -> capacity
    private val marking = LinkedHashMap<String, MutableList<PetriToken>>()
    private val specs = LinkedHashMap<String, TransitionSpec>()

    private var timing = PetriTiming.STEP
    private var grid = PatQ.of(1, 16)
    private var conflict = PetriConflict.PRIORITY
    private var seed = 0

    private class TransitionSpec(val name: String, val span: PetriSpan) {
        val inputs = mutableListOf<PetriInput>()
        val outputs = mutableListOf<PetriOutput>()
        var duration: PatQ = PatQ.ZERO
        var delay: PatQ = PatQ.ZERO
        var priority: Int = 0
        var weight: Double = 1.0
        var emit: Any? = null
        var combinator: ((PetriBinding) -> Any?)? = null
        var modifiersSeen = false
    }

    fun parse(): PetriParseResult {
        var offset = 0
        for ((index, raw) in source.lines().withIndex()) {
            val line = raw.substringBefore("//")
            parseLine(line, index + 1, offset)
            offset += raw.length + 1
        }
        val net = buildNet()
        return PetriParseResult(net, diagnostics, placeSpans, transitionSpans)
    }

    // ── lines ──────────────────────────────────────────────────────────────────────────────

    private fun parseLine(line: String, lineNumber: Int, lineOffset: Int) {
        val trimmed = line.trim()
        if (trimmed.isEmpty()) return
        if (trimmed.equals("#petri", ignoreCase = true) || trimmed.startsWith("#petri ")) return
        when {
            trimmed.startsWith("!") -> parseDirective(trimmed, line, lineNumber, lineOffset)
            ARC_RE.containsMatchIn(line) -> parseArc(line, lineNumber, lineOffset)
            line.contains(':') -> parsePlaceDeclaration(line, lineNumber, lineOffset)
            else -> error("expected a place declaration ('name : 1'), an arc ('a -[ t ]-> b') or a '!' directive", span(line, lineNumber, lineOffset))
        }
    }

    private fun parseDirective(trimmed: String, line: String, lineNumber: Int, lineOffset: Int) {
        val parts = trimmed.removePrefix("!").trim().split(Regex("\\s+"), limit = 2)
        val key = parts.firstOrNull().orEmpty().lowercase()
        val value = parts.getOrNull(1)?.trim().orEmpty()
        val where = span(line, lineNumber, lineOffset)
        when (key) {
            "timing" -> when (value.lowercase()) {
                "step" -> timing = PetriTiming.STEP
                "event" -> timing = PetriTiming.EVENT
                "timed" -> timing = PetriTiming.TIMED
                else -> error("!timing must be step, event or timed — not '$value'", where)
            }
            "grid" -> parseRational(value, where)?.let {
                if (it > PatQ.ZERO) grid = it else error("!grid must be greater than zero", where)
            }
            "conflict" -> when (value.lowercase()) {
                "order" -> conflict = PetriConflict.ORDER
                "priority" -> conflict = PetriConflict.PRIORITY
                "round-robin", "roundrobin" -> conflict = PetriConflict.ROUND_ROBIN
                "random" -> conflict = PetriConflict.RANDOM
                else -> error("!conflict must be order, priority, round-robin or random — not '$value'", where)
            }
            "seed" -> value.toIntOrNull()?.let { seed = it }
                ?: error("!seed needs a whole number, not '$value'", where)
            else -> error("unknown directive '!$key' — try timing, grid, conflict or seed", where)
        }
    }

    /** `name : <items> [^capacity]` */
    private fun parsePlaceDeclaration(line: String, lineNumber: Int, lineOffset: Int) {
        val colon = line.indexOf(':')
        val nameText = line.substring(0, colon).trim()
        val where = span(line, lineNumber, lineOffset)
        if (!nameText.matches(NAME_RE)) {
            error("'$nameText' is not a usable place name (letters, digits, . _ - and no spaces)", where)
            return
        }
        val tokens = mutableListOf<PetriToken>()
        var capacity: Int? = null

        for (item in splitTop(line.substring(colon + 1), ',')) {
            val text = item.trim()
            if (text.isEmpty()) continue
            if (text.startsWith("^")) {
                capacity = text.drop(1).trim().toIntOrNull()
                    ?.also { if (it <= 0) error("a capacity must be greater than zero", where) }
                    ?: run { error("'$text' is not a capacity — write ^4", where); null }
                continue
            }
            // `0 ^4` puts the count and the capacity in the same comma-separated item. Only an
            // unquoted item may be split on whitespace: a pattern is full of spaces, and cutting
            // `"bd ~ sd ~"` at the first one leaves an unterminated string.
            var head = text
            if (!text.startsWith("\"") && !text.startsWith("'")) {
                val parts = text.split(Regex("\\s+"), limit = 2)
                head = parts[0]
                val tail = parts.getOrNull(1)?.trim()
                if (tail != null) {
                    if (tail.startsWith("^")) {
                        capacity = tail.drop(1).trim().toIntOrNull()
                            ?: run { error("'$tail' is not a capacity — write ^4", where); null }
                    } else {
                        error("did not understand '$tail' — separate several tokens with commas", where)
                    }
                }
            }
            tokens += placeItem(head, where) ?: continue
        }

        declarePlace(nameText, capacity, where)
        marking.getOrPut(nameText) { mutableListOf() } += tokens
    }

    private fun placeItem(text: String, where: PetriSpan): List<PetriToken>? = when {
        text.isEmpty() -> emptyList()
        text.startsWith("\"") ->
            unquote(text, '"', where)?.let { listOf(PetriToken(pat(it))) }
        text.startsWith("'") ->
            unquote(text, '\'', where)?.let { listOf(PetriToken(it)) }
        text.startsWith("#") ->
            text.drop(1).toDoubleOrNull()?.let { listOf(PetriToken(it)) }
                ?: run { error("'$text' is not a number — write #60", where); null }
        else -> text.toIntOrNull()?.let { count ->
            if (count < 0) {
                error("a place cannot start with $count tokens", where); null
            } else List(count) { blackToken() }
        } ?: run {
            error("'$text' is not a token count, a \"pattern\", a 'string' or a #number", where); null
        }
    }

    /** `pre -[ name modifiers ]-> post` */
    private fun parseArc(line: String, lineNumber: Int, lineOffset: Int) {
        val match = ARC_RE.find(line)!!
        val where = span(line, lineNumber, lineOffset)
        val pre = line.substring(0, match.range.first)
        val post = line.substring(match.range.last + 1)
        val inner = match.groupValues[1].trim()

        if (inner.isEmpty()) {
            error("a transition needs a name — write -[ hit ]->", where)
            return
        }

        val head = inner.takeWhile { !it.isWhitespace() }
        if (!head.matches(NAME_RE)) {
            error("'$head' is not a usable transition name (letters, digits, . _ - and no spaces)", where)
            return
        }
        val bracketColumn = match.range.first + 1
        val nameSpan = PetriSpan(
            lineOffset + bracketColumn, lineOffset + bracketColumn + head.length, lineNumber, bracketColumn + 1, head
        )
        val spec = specs.getOrPut(head) { TransitionSpec(head, nameSpan) }
        transitionSpans.putIfAbsent(head, nameSpan)

        parseModifiers(inner.drop(head.length), spec, where)

        for (term in splitTop(pre, '+')) {
            val text = term.trim()
            if (text.isEmpty()) continue
            parseInput(text, where)?.let { spec.inputs += it }
        }
        for (term in splitTop(post, '+')) {
            val text = term.trim()
            if (text.isEmpty()) continue
            parseOutput(text, where)?.let { spec.outputs += it }
        }
    }

    /** `@dur`, `key=value`, `: emit`, `=> combinator` — in any order, after the name. */
    private fun parseModifiers(rest: String, spec: TransitionSpec, where: PetriSpan) {
        var text = rest.trim()
        if (text.isEmpty()) return
        if (spec.modifiersSeen) {
            warn("transition '${spec.name}' already had its settings on an earlier line; these are ignored", where)
            return
        }

        // `: emit` and `=> combinator` run to the end of the bracket, so peel them off first.
        val arrowAt = text.indexOf("=>")
        if (arrowAt >= 0) {
            val word = text.substring(arrowAt + 2).trim().substringBefore(':').trim()
            val combinator = PetriCombinators[word]
            if (combinator == null) {
                error(
                    "'$word' is not a combinator — try ${PetriCombinators.names.joinToString(", ")}",
                    where,
                )
            } else {
                spec.combinator = combinator
            }
            val emitPart = text.substring(arrowAt + 2).substringAfter(':', "").trim()
            text = text.substring(0, arrowAt).trim()
            if (emitPart.isNotEmpty()) spec.emit = parseEmit(emitPart, where)
        }
        val colonAt = text.indexOf(':')
        if (colonAt >= 0) {
            spec.emit = parseEmit(text.substring(colonAt + 1).trim(), where)
            text = text.substring(0, colonAt).trim()
        }

        for (token in text.split(Regex("\\s+")).filter { it.isNotBlank() }) {
            when {
                token.startsWith("@") -> parseRational(token.drop(1), where)?.let { spec.duration = it }
                token.contains('=') -> {
                    val key = token.substringBefore('=').lowercase()
                    val value = token.substringAfter('=')
                    when (key) {
                        "delay" -> parseRational(value, where)?.let { spec.delay = it }
                        "pri", "priority" -> value.toIntOrNull()?.let { spec.priority = it }
                            ?: error("pri= needs a whole number, not '$value'", where)
                        "w", "weight" -> value.toDoubleOrNull()?.takeIf { it > 0.0 }?.let { spec.weight = it }
                            ?: error("w= needs a number greater than zero, not '$value'", where)
                        else -> error("unknown setting '$key=' — try delay=, pri= or w=", where)
                    }
                }
                else -> error("did not understand '$token' inside -[ … ]-", where)
            }
        }
        spec.modifiersSeen = true
    }

    private fun parseEmit(text: String, where: PetriSpan): Any? = when {
        text.isEmpty() || text == "~" -> null
        text.startsWith("\"") || text.startsWith("'") -> unquote(text, text[0], where)
        text.startsWith("#") -> text.drop(1).toDoubleOrNull()
            ?: run { error("'$text' is not a number — write #60", where); null }
        else -> text
    }

    /** `[~&][N*]place` */
    private fun parseInput(text: String, where: PetriSpan): PetriInput? {
        val kind = when (text.first()) {
            '~' -> PetriArcKind.INHIBIT
            '&' -> PetriArcKind.READ
            else -> PetriArcKind.NORMAL
        }
        val body = if (kind == PetriArcKind.NORMAL) text else text.drop(1).trim()
        val (weight, name) = splitWeight(body, where) ?: return null
        declarePlace(name, null, where)
        return PetriInput(name, weight, kind)
    }

    /** `[N*]place` */
    private fun parseOutput(text: String, where: PetriSpan): PetriOutput? {
        if (text.first() == '~' || text.first() == '&') {
            error("'${text.first()}' only makes sense on the left of a transition", where)
            return null
        }
        val (weight, name) = splitWeight(text, where) ?: return null
        declarePlace(name, null, where)
        return PetriOutput(name, weight)
    }

    private fun splitWeight(text: String, where: PetriSpan): Pair<Int, String>? {
        val star = text.indexOf('*')
        val weight = if (star < 0) 1 else text.substring(0, star).trim().toIntOrNull()
            ?: run { error("'${text.substringBefore('*')}' is not an arc weight — write 2*hats", where); return null }
        if (weight < 1) {
            error("an arc weight must be at least 1", where)
            return null
        }
        val name = text.substring(star + 1).trim()
        if (!name.matches(NAME_RE)) {
            error("'$name' is not a usable place name (letters, digits, . _ - and no spaces)", where)
            return null
        }
        return weight to name
    }

    private fun declarePlace(name: String, capacity: Int?, where: PetriSpan) {
        val existing = declared[name]
        declared[name] = capacity ?: existing
        placeSpans.putIfAbsent(name, where)
    }

    // ── assembly ───────────────────────────────────────────────────────────────────────────

    private fun buildNet(): PetriNet? {
        if (declared.isEmpty() && specs.isEmpty()) return null

        val transitions = specs.values.map { spec ->
            val combinator = spec.combinator
            val outputs =
                if (combinator == null) spec.outputs
                else spec.outputs.map { arc ->
                    PetriOutput(arc.place, arc.weight) { binding ->
                        List(arc.weight) { PetriToken(combinator(binding), binding.at) }
                    }
                }
            if (spec.inputs.isEmpty() && spec.outputs.isEmpty()) {
                warn("transition '${spec.name}' has no arcs, so it can never do anything", spec.span)
            }
            PetriTransition(
                name = spec.name,
                inputs = spec.inputs.toList(),
                outputs = outputs,
                duration = spec.duration,
                delay = spec.delay,
                priority = spec.priority,
                weight = spec.weight,
                emit = spec.emit,
            )
        }

        return try {
            PetriNet(
                places = declared.map { (name, capacity) -> PetriPlace(name, capacity) },
                transitions = transitions,
                initial = PetriMarking.of(marking),
                timing = timing,
                grid = grid,
                conflict = conflict,
                seed = seed,
            )
        } catch (ex: IllegalArgumentException) {
            error(ex.message ?: "could not build the net", PetriSpan(0, 0, 1, 1, ""))
            null
        }
    }

    // ── small helpers ──────────────────────────────────────────────────────────────────────

    private fun span(line: String, lineNumber: Int, lineOffset: Int): PetriSpan {
        val lead = line.length - line.trimStart().length
        val text = line.trim()
        return PetriSpan(lineOffset + lead, lineOffset + lead + text.length, lineNumber, lead + 1, text)
    }

    private fun error(message: String, span: PetriSpan) {
        diagnostics += PetriDiagnostic(message, span, PetriSeverity.ERROR)
    }

    private fun warn(message: String, span: PetriSpan) {
        diagnostics += PetriDiagnostic(message, span, PetriSeverity.WARNING)
    }

    private fun unquote(text: String, quote: Char, where: PetriSpan): String? {
        if (text.length < 2 || text.last() != quote) {
            error("unterminated $quote$quote string", where)
            return null
        }
        return text.substring(1, text.length - 1)
    }

    private fun parseRational(text: String, where: PetriSpan): PatQ? {
        val trimmed = text.trim()
        if (trimmed.isEmpty()) {
            error("expected a number of cycles, like 1/16 or 4", where)
            return null
        }
        val slash = trimmed.indexOf('/')
        val numerator = (if (slash < 0) trimmed else trimmed.substring(0, slash)).trim().toLongOrNull()
        val denominator = if (slash < 0) 1L else trimmed.substring(slash + 1).trim().toLongOrNull()
        if (numerator == null || denominator == null || denominator == 0L) {
            error("'$trimmed' is not a number of cycles — write 4 or 1/16", where)
            return null
        }
        return PatQ.of(numerator, denominator)
    }

    /** Splits on [separator] but never inside quotes — so `'a,b'` stays one item. */
    private fun splitTop(text: String, separator: Char): List<String> {
        val out = mutableListOf<String>()
        val current = StringBuilder()
        var quote: Char? = null
        for (character in text) {
            when {
                quote != null -> {
                    current.append(character)
                    if (character == quote) quote = null
                }
                character == '"' || character == '\'' -> {
                    quote = character
                    current.append(character)
                }
                character == separator -> {
                    out += current.toString()
                    current.clear()
                }
                else -> current.append(character)
            }
        }
        out += current.toString()
        return out
    }

    private companion object {
        val ARC_RE = Regex("""-\[(.*?)]->""")
        val NAME_RE = Regex("""[A-Za-z_][A-Za-z0-9_.\-]*""")
    }
}