Coverage Summary for Class: ClauseStore (it.unibo.tuprolog.argumentation.core.runtime.context)

Class Method, % Branch, % Line, % Instruction, %
ClauseStore 100% (16/16) 68.5% (37/54) 98.3% (59/60) 91.8% (436/475)
ClauseStore$Companion 100% (5/5) 92% (23/25) 95.8% (23/24) 96.7% (178/184)
ClauseStore$Data 100% (8/8) 84.1% (37/44) 100% (49/49) 98.6% (408/414)
ClauseStore$Node 100% (6/6) 83.3% (10/12) 100% (22/22) 95.6% (174/182)
ClauseStore$Position 100% (6/6) 95.5% (21/22) 100% (32/32) 99.2% (238/240)
ClauseStore$Predicate 100% (2/2) 100% (2/2) 100% (12/12) 100% (110/110)
ClauseStore$Stored 100% (1/1) 100% (3/3) 100% (12/12)
Total 100% (44/44) 81.8% (130/159) 99% (200/202) 96.2% (1556/1617)


 package it.unibo.tuprolog.argumentation.core.runtime.context
 
 import it.unibo.tuprolog.argumentation.core.primitives.ids
 import it.unibo.tuprolog.argumentation.core.runtime.Arg2pUnificator
 import it.unibo.tuprolog.core.Atom
 import it.unibo.tuprolog.core.Clause
 import it.unibo.tuprolog.core.Fact
 import it.unibo.tuprolog.core.Numeric
 import it.unibo.tuprolog.core.Rule
 import it.unibo.tuprolog.core.Struct
 import it.unibo.tuprolog.core.Substitution
 import it.unibo.tuprolog.core.Term
 import it.unibo.tuprolog.core.Truth
 import it.unibo.tuprolog.core.Var
 
 /**
  * Clause database backing the argumentation context.
  *
  * It replaces a full Prolog solver per context branch: clauses are only ever asserted (asserta order),
  * retracted, and looked up as facts or through clause/2, so a dedicated store suffices.
  * Every argument position of a stored head is indexed by the preorder path of its symbols, up to the
  * first variable: lookups only unify the clauses whose paths are compatible with the query ones.
  * Branches share their data until one of them is modified (copy-on-write), and then only copy the predicates they modify.
  */
 internal class ClauseStore private constructor(
     private var data: Data,
     private var shared: Boolean,
 ) {
     constructor() : this(Data(), false)
 
     fun fork(): ClauseStore {
         shared = true
         return ClauseStore(data, true)
     }
 
     private fun writable(): Data {
         if (shared) {
             data = data.copy()
             shared = false
         }
         return data
     }
 
     /** asserta: the new clause is returned before the existing ones */
     fun assertA(term: Term) {
         writable().add(toClause(term))
     }
 
     /** retractall: removes every clause whose head (and body, for a `:-` pattern) unifies with the given term */
     fun retractAll(term: Term) {
         val pattern = term.asStruct() ?: return
         val (head, body) = if (pattern.isRule()) pattern[0] to pattern[1] else pattern to null
         val headStruct = head.asStruct() ?: return
         val matching =
             data.candidates(headStruct).filter { stored ->
                 val fresh = stored.clause.freshCopy()
                 val unifier = Arg2pUnificator.mgu(headStruct, fresh.head!!)
                 unifier.isSuccess && (body == null || Arg2pUnificator.mgu(body.apply(unifier), fresh.body.apply(unifier)).isSuccess)
             }
         if (matching.isNotEmpty()) {
             val target = writable()
             matching.forEach { target.remove(it) }
         }
     }
 
     /**
      * A value derived from the clauses of the predicate [functor]/[arity] (newest first), named [name]: computed once,
      * until the predicate changes (the branches sharing the predicate share it)
      */
     fun <T : Any> derived(
         functor: String,
         arity: Int,
         name: String,
         compute: (List<Clause>) -> T,
     ): T = data.derived("$functor/$arity", name, compute)
 
     /** The stored clauses whose head may unify with the given one, newest first, as stored */
     fun clausesFor(head: Term): List<Clause> = head.asStruct()?.let { struct -> data.candidates(struct).map { it.clause } } ?: emptyList()
 
     /** Solutions of a goal (a fact, a single-goal rule, or `clause(Head, Body)`) on a snapshot of the store */
     fun solve(goal: Term): Sequence<Substitution> {
         val struct = goal.asStruct() ?: return emptySequence()
         val variables = struct.variables.toSet()
         val solutions =
             if (struct.functor == "clause" && struct.arity == 2) {
                 lookupClause(struct[0], struct[1])
             } else {
                 resolve(struct, Substitution.empty(), 0)
             }
         return solutions.map { unifier -> unifier.filter { v, _ -> v in variables } }
     }
 
     /**
      * Bodies of the clauses whose head unifies with the given one, as stored: the head bindings are not applied,
      * so variables in the stored clauses are left untouched
      */
     fun unifiableBodies(
         head: Term,
         body: Term,
     ): Sequence<Substitution> {
         val headStruct = head.asStruct() ?: return emptySequence()
         val variables = body.variables.toSet()
         return data.candidates(headStruct).asSequence().mapNotNull { stored ->
             val fresh = stored.clause.freshCopy()
             if (!Arg2pUnificator.mgu(headStruct, fresh.head!!).isSuccess) return@mapNotNull null
             Arg2pUnificator
                 .mgu(body, fresh.body)
                 .takeIf { it.isSuccess }
                 ?.filter { v, _ -> v in variables }
         }
     }
 
     private fun lookupClause(
         head: Term,
         body: Term,
     ): Sequence<Substitution> {
         val headStruct = head.asStruct() ?: return emptySequence()
         val query = Struct.of("clause", headStruct, body)
         return data.candidates(headStruct).asSequence().mapNotNull { stored ->
             val fresh = stored.clause.freshCopy()
             Arg2pUnificator
                 .mgu(query, Struct.of("clause", fresh.head!!, fresh.body))
                 .takeIf { it.isSuccess }
         }
     }
 
     private fun resolve(
         goal: Term,
         unifier: Substitution,
         depth: Int,
     ): Sequence<Substitution> {
         val current = goal.apply(unifier)
         if (current is Truth && current.isTrue) return sequenceOf(unifier)
         val struct = current.asStruct() ?: return emptySequence()
         if (struct.functor == "," && struct.arity == 2) {
             return resolve(struct[0], unifier, depth).flatMap { resolve(struct[1], it, depth) }
         }
         check(depth < MAX_DEPTH) { "Context rules nested too deeply while solving $goal" }
         return data.candidates(struct).asSequence().flatMap { stored ->
             val fresh = stored.clause.freshCopy()
             val step = Arg2pUnificator.mgu(struct, fresh.head!!)
             if (!step.isSuccess) {
                 emptySequence()
             } else {
                 val composed = (unifier + step).takeIf { it.isSuccess } ?: return@flatMap emptySequence<Substitution>()
                 resolve(fresh.body, composed, depth + 1)
             }
         }
     }
 
     private class Stored(
         val id: Long,
         val clause: Clause,
     )
 
     private class Node {
         val children = HashMap<String, Node>()
         val here = LinkedHashSet<Long>()
 
         fun copy(): Node =
             Node().also { node ->
                 node.here.addAll(here)
                 children.forEach { (key, child) -> node.children[key] = child.copy() }
             }
 
         fun collectAll(target: MutableSet<Long>) {
             target.addAll(here)
             children.values.forEach { it.collectAll(target) }
         }
 
         fun add(
             arg: Term,
             id: Long,
         ) {
             var node = this
             path(arg).first.forEach { symbol -> node = node.children.getOrPut(symbol) { Node() } }
             node.here.add(id)
         }
 
         fun remove(
             arg: Term,
             id: Long,
         ) {
             var node = this
             for (symbol in path(arg).first) node = node.children[symbol] ?: return
             node.here.remove(id)
         }
 
         /** Ids of the stored arguments whose path is compatible with the given one */
         fun compatible(
             symbols: List<String>,
             complete: Boolean,
             target: MutableSet<Long>,
         ) {
             var node: Node? = this
             for (symbol in symbols) {
                 // stored arguments with a variable (or truncated) at this point are compatible
                 target.addAll(node!!.here)
                 node = node.children[symbol]
                 if (node == null) break
             }
             // stored arguments extending the (truncated or variable-ending) query path are compatible
             if (node != null) {
                 if (complete) target.addAll(node.here) else node.collectAll(target)
             }
         }
     }
 
     /**
      * Index of an argument position: ground arguments are looked up exactly, by the term itself, the others by the
      * path of their symbols (which is truncated, so that it would not tell apart large terms sharing a prefix).
      * The paths of the ground arguments are only needed by partially instantiated lookups: they are indexed on the
      * first of them, so that positions only looked up by ground terms or variables (as labels) do not pay for them.
      */
     private class Position {
         val ground = HashMap<Term, LinkedHashSet<Long>>()
         val nonGround = Node()
         private var groundPaths: Node? = null
 
         fun copy(): Position =
             Position().also { position ->
                 ground.forEach { (term, ids) -> position.ground[term] = LinkedHashSet(ids) }
                 position.nonGround.children.putAll(nonGround.copy().children)
                 position.nonGround.here.addAll(nonGround.here)
                 position.groundPaths = groundPaths?.copy()
             }
 
         fun add(
             arg: Term,
             id: Long,
         ) {
             if (arg.isGround) {
                 ground.getOrPut(arg) { LinkedHashSet() }.add(id)
                 groundPaths?.add(arg, id)
             } else {
                 nonGround.add(arg, id)
             }
         }
 
         fun remove(
             arg: Term,
             id: Long,
         ) {
             if (arg.isGround) {
                 ground[arg]?.let {
                     it.remove(id)
                     if (it.isEmpty()) ground.remove(arg)
                 }
                 groundPaths?.remove(arg, id)
             } else {
                 nonGround.remove(arg, id)
             }
         }
 
         // built from the ground arguments stored so far, then kept up to date: a cache, also on shared data
         private fun groundPaths(): Node =
             groundPaths ?: Node().also { node ->
                 ground.forEach { (term, ids) -> ids.forEach { node.add(term, it) } }
                 groundPaths = node
             }
 
         /** Ids of the stored arguments which may unify with the given one, or null when it does not restrict them */
         fun candidates(arg: Term): Set<Long>? {
             val (symbols, complete) = path(arg)
             if (symbols.isEmpty()) return null
             val found = LinkedHashSet<Long>()
             if (arg.isGround) {
                 ground[arg]?.let { found.addAll(it) }
             } else {
                 groundPaths().compatible(symbols, complete, found)
             }
             nonGround.compatible(symbols, complete, found)
             return found
         }
     }
 
     private class Predicate(
         arity: Int,
     ) {
         val clauses = LinkedHashMap<Long, Stored>()
         val indexes = Array(arity) { Position() }
 
         // Whole ground heads, for the exact lookup of ground goals
         val groundHeads = HashMap<Term, LinkedHashSet<Long>>()
         val nonGroundHeads = LinkedHashSet<Long>()
 
         // The values derived from the clauses (see ClauseStore.derived), until they change
         val derived = HashMap<String, Any>()
 
         fun copy(): Predicate =
             Predicate(indexes.size).also { predicate ->
                 predicate.clauses.putAll(clauses)
                 indexes.forEachIndexed { i, position -> predicate.indexes[i] = position.copy() }
                 groundHeads.forEach { (head, ids) -> predicate.groundHeads[head] = LinkedHashSet(ids) }
                 predicate.nonGroundHeads.addAll(nonGroundHeads)
             }
     }
 
     private class Data(
         private val predicates: HashMap<String, Predicate> = HashMap(),
         private var nextId: Long = 0,
     ) {
         // Predicates this data can modify in place, the others are shared with the data it was copied from
         // (shared data is never modified: a branch copies it before its first change)
         private val owned = HashSet<String>()
 
         fun copy(): Data = Data(HashMap(predicates), nextId)
 
         /** The predicate stored under key, copied the first time this data modifies it */
         private fun writable(
             key: String,
             arity: Int,
         ): Predicate {
             val predicate = predicates[key]
             if (!owned.add(key) && predicate != null) return predicate
             return (predicate?.copy() ?: Predicate(arity)).also { predicates[key] = it }
         }
 
         fun add(clause: Clause) {
             val head = clause.head!!
             val stored = Stored(nextId++, clause)
             val predicate = writable(key(head), head.arity)
             predicate.derived.clear()
             predicate.clauses[stored.id] = stored
             head.args.forEachIndexed { i, arg -> predicate.indexes[i].add(arg, stored.id) }
             if (head.isGround) {
                 predicate.groundHeads.getOrPut(head) { LinkedHashSet() }.add(stored.id)
             } else {
                 predicate.nonGroundHeads.add(stored.id)
             }
         }
 
         fun remove(stored: Stored) {
             val head = stored.clause.head!!
             if (predicates[key(head)]?.clauses?.containsKey(stored.id) != true) return
             val predicate = writable(key(head), head.arity)
             predicate.derived.clear()
             predicate.clauses.remove(stored.id)
             head.args.forEachIndexed { i, arg -> predicate.indexes[i].remove(arg, stored.id) }
             if (head.isGround) {
                 predicate.groundHeads[head]?.let {
                     it.remove(stored.id)
                     if (it.isEmpty()) predicate.groundHeads.remove(head)
                 }
             } else {
                 predicate.nonGroundHeads.remove(stored.id)
             }
         }
 
         fun <T : Any> derived(
             key: String,
             name: String,
             compute: (List<Clause>) -> T,
         ): T {
             val predicate = predicates[key] ?: return compute(emptyList())
             @Suppress("UNCHECKED_CAST")
             return predicate.derived.getOrPut(name) {
                 val clauses = predicate.clauses.values.map { it.clause }
                 compute(clauses.asReversed())
             } as T
         }
 
         /** Stored clauses whose head may unify with the given one, newest first */
         fun candidates(head: Struct): List<Stored> {
             val predicate = predicates[key(head)] ?: return emptyList()
             if (head.isGround && predicate.nonGroundHeads.size <= MAX_NON_GROUND_SCAN) {
                 val ids = LinkedHashSet<Long>()
                 predicate.groundHeads[head]?.let { ids.addAll(it) }
                 ids.addAll(predicate.nonGroundHeads)
                 return ids.sortedDescending().mapNotNull { predicate.clauses[it] }
             }
             var best: Set<Long>? = null
             head.args.forEachIndexed { i, arg ->
                 val found = predicate.indexes[i].candidates(arg) ?: return@forEachIndexed
                 if (best.let { it == null || found.size < it.size }) best = found
             }
             // all the clauses: in the order of their ids (they are added in that order), the newest first
             val ids =
                 best ?: return predicate.clauses.values
                     .toList()
                     .asReversed()
             return ids.sortedDescending().mapNotNull { predicate.clauses[it] }
         }
 
         private fun key(head: Struct) = "${head.functor}/${head.arity}"
     }
 
     companion object {
         private const val MAX_PATH = 16
 
         // Ground goals are looked up by their whole head, plus every stored clause with a non-ground head: when
         // these are more, the lookup goes through the argument indexes instead
         private const val MAX_NON_GROUND_SCAN = 8
         private const val MAX_DEPTH = 64
 
         private fun Term.isRule() = this is Struct && functor == ":-" && arity == 2
 
         // The anonymous variables of a term named: a fresh copy of a clause (as lookups make) gives each occurrence of
         // one a variable of its own, so p(_1, _1) would match p(a, b)
         private fun named(term: Term): Term {
             val anonymous =
                 term.variables
                     .filter { it.isAnonymous }
                     .distinct()
                     .toList()
             return if (anonymous.isEmpty()) term else term.apply(Substitution.of(anonymous.associateWith { Var.of("A") }))
         }
 
         private fun toClause(term: Term): Clause {
             val struct = named(term).castToStruct()
             return if (struct.isRule()) Rule.of(struct[0].castToStruct(), struct[1]) else Fact.of(struct)
         }
 
         private fun symbol(term: Term): String =
             when (term) {
                 is Numeric -> "#${term.decimalValue}"
                 is Atom -> "a:${term.value}"
                 is Struct -> "s:${term.functor}/${term.arity}"
                 else -> error("unexpected term $term")
             }
 
         /** Preorder symbols of a term up to its first variable; complete when the term was fully visited */
         private fun path(term: Term): Pair<List<String>, Boolean> {
             val symbols = ArrayList<String>()
             val stack = ArrayDeque<Term>().apply { addLast(term) }
             while (stack.isNotEmpty()) {
                 if (symbols.size == MAX_PATH) return symbols to false
                 val current = stack.removeLast()
                 if (current is Var) return symbols to false
                 symbols.add(symbol(current))
                 if (current is Struct && current !is Atom) {
                     for (i in current.arity - 1 downTo 0) stack.addLast(current[i])
                 }
             }
             return symbols to true
         }
     }
 }