Skip to content

DatalogAst.kt

src/jvmMain/kotlin/afield/data/query/datalog/DatalogAst.kt · 265 lines

package afield.data.query.datalog

The abstract syntax of Field's Datalog — see Datalog for the facade, DatalogParser for the concrete syntax and DatalogEngine for what evaluates this.

Everything here is a value type with structural equality, because the engine leans on that in two places: rules are de-duplicated when the same text is executed twice (a live box re-runs its whole program on every keystroke-triggered execute), and derived rows are stored in hash sets.

Names are Dl-prefixed. Boxes get import afield.data.query.datalog.* for free (see afield.document.graph.intro), on top of the standard document, geometry and math imports, so an unprefixed Var, Atom or Rule in this package would be a name collision waiting to happen in user code.

sealed interface DlTerm

A Datalog term: either a variable or a constant.

data class DlVar(val name: String) : DlTerm

A logic variable. _ in source becomes a fresh DlVar per occurrence (named _1, _2, …), which is what makes emp(_, D, _) mean "some row" rather than "a row whose first and third columns are equal".

val isAnonymous: Boolean get() = name.startsWith("_")

override fun toString() = name

data class DlConst(val value: Any?) : DlTerm

A constant. Values are normalised by dlNormalize: a bare lowercase identifier in source is a String, so edge(a, b) and edge("a", "b") are the same fact, and any integral number is a Long however it was written or computed.

override fun toString() = dlFormat(value)

sealed interface DlArith

An arithmetic/string expression, usable on either side of a comparison or bind.

data class DlAValue(val term: DlTerm) : DlArith

data class DlABinary(val op: String, val left: DlArith, val right: DlArith) : DlArith

data class DlAUnary(val op: String, val operand: DlArith) : DlArith

data class DlACall(val fn: String, val args: List<DlArith>) : DlArith

sealed interface DlLiteral

A body item. Heads are always a positive DlAtom.

data class DlAtom(val pred: String, val terms: List<DlTerm>, val negated: Boolean = false) : DlLiteral

pred(t1, …, tn), or !pred(…) / not pred(…) when negated.

val arity: Int get() = terms.size

override fun toString()

    override fun toString() =
        (if (negated) "!" else "") + pred + "(" + terms.joinToString(", ") { it.toString() } + ")"
}

data class DlCompare(val op: String, val left: DlArith, val right: DlArith) : DlLiteral

a <op> b for < <= > >= != =.

X = <expr> is parsed as a comparison and becomes a binding at planning time when X is not yet bound at that point in the body — the planner decides, not the parser, because the answer depends on the order the planner picks for the other literals.

data class DlAggregate

Target = <op> <over> : { body } — an aggregate over the solutions of body, grouped by whichever of body's variables are already bound by the enclosing rule.

over is null for count. Ops: count sum min max mean collect.

data class DlAggregate(
    val target: DlVar,
    val op: String,
    val over: DlArith?,
    val body: List<DlLiteral>,
) : DlLiteral

data class DlRule(val head: DlAtom, val body: List<DlLiteral>, val source: String? = null)

head :- body., or head. when body is empty (a fact). source is for error messages.

val isFact: Boolean get() = body.isEmpty()

override fun toString()

    override fun toString() =
        if (body.isEmpty()) "$head." else "$head :- " + body.joinToString(", ") { dlLiteralText(it) } + "."
}

data class DlQuery(val body: List<DlLiteral>, val source: String? = null)

A parsed ?- goal. directive.

data class DlDirective(val name: String, val argument: String, val line: Int)

A #directive line — the box language's REPL commands (#reset, #relations, #boxes, #why …).

class DlStatement

One statement of a program and the lines it spans (firstLine..lastLine, 1-based): exactly one of rule (a fact is a rule with no body), query or directive. See DatalogParser.statements.

class DlStatement(
    val firstLine: Int,
    val lastLine: Int,
    val rule: DlRule? = null,
    val query: DlQuery? = null,
    val directive: DlDirective? = null,
)

data class DlUnit

Everything one chunk of source contributed.

data class DlUnit(
    val rules: List<DlRule> = emptyList(),
    val queries: List<DlQuery> = emptyList(),
    val directives: List<DlDirective> = emptyList(),
)

class DlException(message: String, val line: Int = -1, val column: Int = -1) : RuntimeException(message)

A parse, safety or stratification failure. line is 1-based; -1 when not known.

override fun toString() = if (line >= 0) "line $line: $message" else message ?: "datalog error"

value normalisation and printing

fun dlNormalize(value: Any?): Any?

Coerce a host value into the small set of types the engine joins on: Long, Double, String, Boolean, null, or an opaque object compared by equals.

What matters is that every integral number becomes a Long, including a Double that happens to be integral. Without it a computed 4.0 / 2.0 would not join with a fact containing 2, and a Datalog whose arithmetic results silently fail to match its facts is worse than useless. The cost, accepted, is that 2.0 prints as 2.

fun dlNormalize(value: Any?): Any? = when (value) {
    null -> null
    is Long, is String, is Boolean -> value
    is Int -> value.toLong()
    is Short -> value.toLong()
    is Byte -> value.toLong()
    is Char -> value.toString()
    is Float -> dlNormalize(value.toDouble())
    is Double -> if (value.isFinite() && value == Math.rint(value) && Math.abs(value) < 9.007199254740992E15) {
        value.toLong()
    } else {
        value
    }
    is java.math.BigInteger -> value.toLong()
    is java.math.BigDecimal -> dlNormalize(value.toDouble())
    else -> value
}

fun dlFormat(value: Any?): String

How a value appears in output tables and in toString of an atom. Strings are unquoted here.

fun dlFormat(value: Any?): String = when (value) {
    null -> "null"
    is String -> value
    is Double -> if (value.isNaN()) "nan" else value.toString()
    is List<*> -> value.joinToString(", ", "[", "]") { dlFormat(it) }
    else -> value.toString()
}

fun dlSourceFormat(value: Any?): String

How a value appears when a fact is printed back as source — strings are quoted and escaped.

fun dlSourceFormat(value: Any?): String = when (value) {
    is String ->
        if (value.isNotEmpty() && value[0].isLowerCase() && value.all { it.isLetterOrDigit() || it == '_' }) value
        else "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\t", "\\t") + "\""
    else -> dlFormat(value)
}

internal fun dlLiteralText(literal: DlLiteral): String

internal fun dlLiteralText(literal: DlLiteral): String = when (literal) {
    is DlAtom -> literal.toString()
    is DlCompare -> "${dlArithText(literal.left)} ${literal.op} ${dlArithText(literal.right)}"
    is DlAggregate -> buildString {
        append(literal.target.name).append(" = ").append(literal.op)
        literal.over?.let { append(' ').append(dlArithText(it)) }
        append(" : { ").append(literal.body.joinToString(", ") { dlLiteralText(it) }).append(" }")
    }
}

internal fun dlArithText(arith: DlArith): String

internal fun dlArithText(arith: DlArith): String = when (arith) {
    is DlAValue -> arith.term.toString()
    is DlABinary -> "(${dlArithText(arith.left)} ${arith.op} ${dlArithText(arith.right)})"
    is DlAUnary -> "${arith.op}${dlArithText(arith.operand)}"
    is DlACall -> "${arith.fn}(${arith.args.joinToString(", ") { dlArithText(it) }})"
}

internal fun dlSubstitute(literal: DlLiteral, bindings: Map<String, Any?>): DlLiteral

Replace the variables bindings knows about with the values they took, leaving the rest as variables.

This exists so a proof can print the instantiated form of a literal — child(a, C) rather than child(P, C) — beside the group it produced. A reader shown N = count : { child(P, C) } above a list of tuples has to work out which P this was; shown N = count : { child(a, C) } they do not. Rendering only; never fed back to the engine, which plans over the original.

internal fun dlSubstitute(literal: DlLiteral, bindings: Map<String, Any?>): DlLiteral = when (literal) {
    is DlAtom -> literal.copy(terms = literal.terms.map { dlSubstituteTerm(it, bindings) })
    is DlCompare -> literal.copy(
        left = dlSubstituteArith(literal.left, bindings),
        right = dlSubstituteArith(literal.right, bindings),
    )
    is DlAggregate -> literal.copy(
        over = literal.over?.let { dlSubstituteArith(it, bindings) },
        body = literal.body.map { dlSubstitute(it, bindings) },
    )
}

private fun dlSubstituteTerm(term: DlTerm, bindings: Map<String, Any?>): DlTerm = when (term) {
    is DlConst -> term
    is DlVar -> if (bindings.containsKey(term.name)) DlConst(bindings[term.name]) else term
}

internal fun dlSubstituteArith(arith: DlArith, bindings: Map<String, Any?>): DlArith

internal fun dlSubstituteArith(arith: DlArith, bindings: Map<String, Any?>): DlArith = when (arith) {
    is DlAValue -> DlAValue(dlSubstituteTerm(arith.term, bindings))
    is DlABinary -> arith.copy(
        left = dlSubstituteArith(arith.left, bindings),
        right = dlSubstituteArith(arith.right, bindings),
    )
    is DlAUnary -> arith.copy(operand = dlSubstituteArith(arith.operand, bindings))
    is DlACall -> arith.copy(args = arith.args.map { dlSubstituteArith(it, bindings) })
}

internal fun dlVarsOf(literals: List<DlLiteral>): List<DlVar>

Every variable mentioned, in first-appearance order.

internal fun dlVarsOf(literals: List<DlLiteral>): List<DlVar> {
    val out = LinkedHashSet<DlVar>()
    fun arith(a: DlArith) {
        when (a) {
            is DlAValue -> (a.term as? DlVar)?.let { out.add(it) }
            is DlABinary -> { arith(a.left); arith(a.right) }
            is DlAUnary -> arith(a.operand)
            is DlACall -> a.args.forEach { arith(it) }
        }
    }
    fun walk(list: List<DlLiteral>) {
        list.forEach { literal ->
            when (literal) {
                is DlAtom -> literal.terms.forEach { t -> (t as? DlVar)?.let { out.add(it) } }
                is DlCompare -> { arith(literal.left); arith(literal.right) }
                is DlAggregate -> { out.add(literal.target); literal.over?.let { arith(it) }; walk(literal.body) }
            }
        }
    }
    walk(literals)
    return out.toList()
}

internal fun dlOutputVarsOf(literals: List<DlLiteral>): List<DlVar>

The variables a body answers with: everything at the top level, plus aggregate targets, but not the variables local to an aggregate's subgoal.

dlVarsOf cannot be used for this. S = sum P : { emp(_, D, P) } mentions P, but P is bound only inside the aggregate, so putting it in a query's column list makes the synthesised query rule unsafe — which is exactly the error the first version of Datalog.query produced.

internal fun dlOutputVarsOf(literals: List<DlLiteral>): List<DlVar> {
    val out = LinkedHashSet<DlVar>()
    fun arith(a: DlArith) {
        when (a) {
            is DlAValue -> (a.term as? DlVar)?.let { out.add(it) }
            is DlABinary -> { arith(a.left); arith(a.right) }
            is DlAUnary -> arith(a.operand)
            is DlACall -> a.args.forEach { arith(it) }
        }
    }
    literals.forEach { literal ->
        when (literal) {
            is DlAtom -> literal.terms.forEach { t -> (t as? DlVar)?.let { out.add(it) } }
            is DlCompare -> { arith(literal.left); arith(literal.right) }
            is DlAggregate -> out.add(literal.target)
        }
    }
    return out.toList()
}