
# unified greedy search: hill climbing (with random restarts) and tabu search
# (with a tabu list) share the same backend, differing only in how they escape
# from a local maximum.
tabu.with.restarts = function(x, start, whitelist, blacklist, score, extra.args,
    restart, perturb, tabu, max.tabu, max.iter, maxp, optimized, debug = FALSE) {

  # cache nodes' labels.
  nodes = names(x)
  # cache the number of nodes.
  n.nodes = length(nodes)
  # set the iteration counter.
  iter = 1
  # check whether the score is score-equivalent.
  score.equivalence = is.score.equivalent(score, x, extra.args)
  # check whether the score is decomposable.
  score.decomposability = is.score.decomposable(score, extra.args)
  # nodes to be updated (all of them in the first iteration).
  updated = seq_len(n.nodes) - 1L

  # a tabu list of length zero means we are doing plain hill climbing: the
  # escape from local maxima is by random restart, not by tabu search.
  use.tabu = (tabu >= 1)
  # the tabu ring length; hill climbing gets a one-slot ring that is never
  # populated (so it never matches) to share the tabu search step function.
  n.tabu = as.integer(if (use.tabu) tabu else 1)
  # allocate the C-side search state, which holds the tabu ring (as a set of arc
  # coordinates per network) across the search iterations.
  state = .Call(call_search_state_create, nodes = nodes, tabu = n.tabu)
  # maximum number of iterations the algorithm can go on without improving the
  # best network score.
  max.loss.iter = max.tabu
  # set the counter for successful iterations.
  loss.iter = 0
  # keep track of the best score value.
  best.score = -Inf
  # set the number of random restarts.
  restart.counter = restart

  # set the reference score.
  reference.score = per.node.score(network = start, score = score,
                      targets = nodes, extra.args = extra.args, data = x)

  # encode the blacklist and the whitelist as sorted sets of flat CMC() arc
  # coordinates, so no adjacency matrix is needed for either of them.
  if (!is.null(blacklist)) {

    from.idx = match(blacklist[, "from"], nodes)
    to.idx = match(blacklist[, "to"], nodes)
    blacklist.coords = sort((from.idx - 1L) + (to.idx - 1L) * n.nodes)

  }#THEN
  else
    blacklist.coords = integer(0)

  if (!is.null(whitelist)) {

    from.idx = match(whitelist[, "from"], nodes)
    to.idx = match(whitelist[, "to"], nodes)
    whitelist.coords = sort((from.idx - 1L) + (to.idx - 1L) * n.nodes)

  }#THEN
  else
    whitelist.coords = integer(0)

  if (debug) {

    cat("----------------------------------------------------------------\n")
    cat("* starting from the following network:\n")
    print(start)
    cat("* current score: ", sum(reference.score), "\n", sep = "")
    cat("* whitelisted arcs are:\n")
    if (!is.null(whitelist)) print(whitelist)
    cat("* blacklisted arcs are:\n")
    if (!is.null(blacklist)) print(blacklist)

    # set the metadata of the network; otherwise the debugging output is
    # confusing and not nearly as informative.
    start$learning$algo = ifelse(use.tabu, "tabu", "hc")
    start$learning$ntests = 0
    start$learning$test = score
    start$learning$args = extra.args
    start$learning$optimized = optimized

  }#THEN

  repeat {

    current = as.integer((iter - 1) %% n.tabu)

    # keep the best network seen so far and its score value for the evaluation
    # of the stopping rule; but always create a "best network" in the first
    # iteration using the starting network. (Tabu search only.)
    if (use.tabu) {

      if ((sum(reference.score) == -Inf) && (best.score == -Inf) && (iter > 1)) {

        old.score = per.node.score(network = best.network, score = score,
                        targets = nodes, extra.args = extra.args, data = x)

        singular.old.nodes = (old.score == -Inf)
        singular.new.nodes = (reference.score == -Inf)

        if (all(singular.old.nodes == singular.new.nodes)) {

          # if the same nodes are singular/not singular in both networks,
          # disregard the singular nodes when comparing them.
          delta = robust.score.difference(
                    sum(reference.score[!singular.new.nodes]),
                    sum(old.score[!singular.old.nodes]))

        }#THEN
        else if (sum(singular.new.nodes) > sum(singular.old.nodes)) {

          # if there are more singular nodes in the new network than in the old
          # network, the old one is better.
          delta = -Inf

        }#THEN
        else if (sum(singular.new.nodes) < sum(singular.old.nodes)) {

          # if the new network has fewer singular nodes than the old network, the
          # new one is better.
          delta = +Inf

        }#THEN
        else {

          delta = robust.score.difference(sum(reference.score), best.score)

        }#ELSE

        if (delta > 0) {

          best.network = start
          best.score = sum(reference.score)

        }#THEN

      }#THEN
      else if ((robust.score.difference(sum(reference.score), best.score) > 0) ||
          (iter == 1)) {

        best.network = start
        best.score = sum(reference.score)

      }#THEN

      if (debug)
        cat("* iteration ", iter, " using element ", current, " of the tabu list.\n", sep = "")

    }#THEN

    # count the parents (in-degree) of each node from the arc set.
    nparents = tabulate(match(start$arcs[, "to"], nodes), nbins = n.nodes)
    nparents = structure(as.double(nparents), names = nodes)

    # a single search step, all in C: sync the current network into the search
    # state (as a coordinate set), hash it into the C tabu ring (tabu search
    # only), fill the score cache and take the best arc addition/removal/reversal.
    # BEWARE: the cache and the reference score are modified in place inside the
    # state.
    bestop = .Call(call_tabu_search_step,
                   state = state,
                   arcs = start$arcs,
                   nodes = nodes,
                   current = current,
                   hash = use.tabu,
                   data = x,
                   score = score,
                   extra = extra.args,
                   reference = reference.score,
                   equivalence = score.equivalence && optimized,
                   decomposability = score.decomposability,
                   updated = (if (optimized) updated else seq(length(nodes)) - 1L),
                   whitelist = whitelist.coords,
                   blacklist = blacklist.coords,
                   nparents = nparents,
                   maxp = maxp,
                   baseline = 0,
                   debug = debug)

    # the value FALSE is the canary value in bestop$op meaning "no operation
    # improved the network score".
    if (bestop$op == FALSE) {

      if (use.tabu) {

        # a random restart is triggered when the tabu search stalls and there
        # are still restarts left to do.
        restart.now = FALSE

        # reconsider previously discarded solutions and find the one that causes
        # the minimum decrease in the network score.
        if (loss.iter >= max.loss.iter) {

          # too many iterations without improvement: restart from the best
          # network if any random restarts are left, otherwise stop.
          if ((restart.counter > 0) && (iter < max.iter)) {

            restart.now = TRUE

          }#THEN
          else {

            # reset the return value to the best network ever found.
            start = best.network

            if (debug) {

              cat("----------------------------------------------------------------\n")
              cat("* maximum number of iterations without improvements reached, stopping.\n")
              cat("* best network ever seen is:\n")
              print(best.network)

            }#THEN

            break

          }#ELSE

        }#THEN
        else {

          # increase the counter of the iterations without improvements.
          loss.iter = loss.iter + 1

          if (debug) {

            cat("----------------------------------------------------------------\n")
            cat("* network score did not increase (for ", loss.iter,
                  " times), looking for a minimal decrease:\n", sep = "")

          }#THEN

          bestop = .Call(call_tabu_step,
                               nodes = nodes,
                               reference = reference.score,
                         whitelist = whitelist.coords,
                         blacklist = blacklist.coords,
                         state = state,
                         current = current,
                         baseline = -Inf,
                         nparents = nparents,
                         maxp = maxp,
                         debug = debug)

          # it might be that there are no more legal operations.
          if (bestop$op == FALSE) {

            # dead end: restart from the best network if any random restarts are
            # left, otherwise stop.
            if ((restart.counter > 0) && (iter < max.iter)) {

              restart.now = TRUE

            }#THEN
            else {

              if (debug) {

                cat("----------------------------------------------------------------\n")
                cat("* no more possible operations.\n")
                cat("@ stopping at iteration ", iter, ".\n", sep = "")

              }#THEN

              # reset the return value to the best network ever found.
              if (loss.iter > 0)
                start = best.network

              break

            }#ELSE

          }#THEN

        }#ELSE

        # do a random restart from a perturbation of the best network seen so
        # far, then resume the tabu search from there.
        if (restart.now) {

          # decrement the number of random restarts we still have to do and
          # reset the counter of the iterations without improvements.
          restart.counter = restart.counter - 1
          loss.iter = 0

          if (debug) {

            cat("----------------------------------------------------------------\n")
            cat("* doing a random restart, ", restart.counter, " of ", restart, " left.\n", sep = "")

          }#THEN

          # perturb the best network and recompute all the node scores.
          start = perturb.backend(network = best.network, iter = perturb,
                    nodes = nodes, whitelist = whitelist, blacklist = blacklist,
                    maxp = maxp, debug = debug)
          start$nodes = cache.structure(nodes, arcs = start$arcs)
          reference.score = per.node.score(network = start, score = score,
                              targets = nodes, extra.args = extra.args, data = x)
          updated = seq_len(n.nodes) - 1L
          start$updates = NULL

          iter = iter + 1

          next

        }#THEN

      }#THEN
      else if ((restart.counter >= 0) && (restart > 0)) {

        # escape from the local maximum with a random restart.
        if (restart.counter == restart) {

          # store away the learned network at the first restart.
          best.network = start
          best.network.score = sum(reference.score)

        }#THEN
        else {

          # if the network found by the algorithm is better, use that one as
          # starting network for the next random restart; use the old one
          # otherwise (and keep amat in sync).
          if (robust.score.difference(best.network.score, sum(reference.score)) > 0) {

            start = best.network

            if (debug) {

              cat("----------------------------------------------------------------\n")
              cat("* the old network was better, discarding the current one.\n")
              cat("* now the current network is:\n")
              print(start)
              cat("* current score: ", best.network.score, "\n", sep = "")

            }#THEN

          }#THEN
          else {

            # store away the learned network at the first restart.
            best.network = start
            best.network.score = sum(reference.score)

          }#ELSE

        }#ELSE

        # this is the end; if the network found by the algorithm is the best one
        # it's now the 'end' one once more.
        if (restart.counter == 0) break

        # don't try to do anything if there are no more iterations left.
        if (iter >= max.iter) {

          if (debug)
            cat("@ stopping at iteration ", max.iter, " ignoring random restart.\n", sep = "")

          break

        }#THEN
        # increment the iteration counter.
        iter = iter + 1

        # decrement the number of random restart we still have to do.
        restart.counter = restart.counter - 1

        if (debug) {

          cat("----------------------------------------------------------------\n")
          cat("* doing a random restart, ", restart.counter, " of ", restart, " left.\n", sep = "")

        }#THEN

        # perturb the network.
        start = perturb.backend(network = start, iter = perturb, nodes = nodes,
                  whitelist = whitelist, blacklist = blacklist, maxp = maxp,
                  debug = debug)

        # update the cached values of the end network.
        start$nodes = cache.structure(nodes, arcs = start$arcs)

        # update the scores of the nodes as needed.
        if (robust.score.difference(best.network.score, sum(reference.score)) > 0) {

          # all scores must be updated; this happens when both the network
          # resulted from the random restart is discarded and the old network
          # is perturbed.
          reference.score = per.node.score(network = start, score = score,
                          targets = nodes, extra.args = extra.args, data = x)

          updated = which(nodes %in% nodes) - 1L

        }#THEN
        else {

          # the scores whose nodes' parents changed must be updated.
          reference.score[names(start$updates)] =
            per.node.score(network = start, score = score, targets = names(start$updates),
               extra.args = extra.args, data = x)

          updated = which(nodes %in% names(start$updates)) - 1L

        }#THEN

        # nuke start$updates from orbit.
        start$updates = NULL

        next

      }#THEN
      else
        break

    }#THEN
    else if (use.tabu) {

      # reset the counter of the iterations without improvements.
      if (robust.score.difference(sum(reference.score), best.score) > 0)
        loss.iter = 0

    }#THEN

    # update the network structure.
    start = arc.operations(start, from = bestop$from, to = bestop$to,
              op = bestop$op, check.cycles = FALSE, check.illegal = FALSE,
              update = TRUE, debug = FALSE)

    if (!use.tabu) {

      if ("prior" %in% names(extra.args) && extra.args$prior %in% c("cs", "marginal"))
        reference.score[c(bestop$from, bestop$to)] =
          per.node.score(network = start, score = score,
            targets = c(bestop$from, bestop$to), extra.args = extra.args, data = x)

    }#THEN

    # set the nodes whose cached score deltas are to be updated.
    if (bestop$op == "reverse")
      updated = which(nodes %in% c(bestop$from, bestop$to)) - 1L
    else
      updated = which(nodes %in% bestop$to) - 1L

    # reset the reference score values of singular nodes: the score delta in
    # the cache is not representative of the difference from the baseline.
    # (Hill climbing only; tabu search accepts singular nodes via its own
    # best-network bookkeeping.)
    if (!use.tabu) {

      if (bestop$op == "reverse") {

        if ((reference.score[bestop$to] == -Inf) && is.finite(bestop$to.delta))
          reference.score[bestop$to] =
            per.node.score(network = start, score = score, targets = bestop$to,
              extra.args = extra.args, data = x)

        if ((reference.score[bestop$from] == -Inf) && is.finite(bestop$from.delta))
          reference.score[bestop$from] =
            per.node.score(network = start, score = score, targets = bestop$from,
              extra.args = extra.args, data = x)

      }#THEN
      else {

        if ((reference.score[bestop$to] == -Inf) && is.finite(bestop$to.delta))
          reference.score[bestop$to] =
            per.node.score(network = start, score = score, targets = bestop$to,
              extra.args = extra.args, data = x)

      }#ELSE

    }#THEN

    if (debug) {

      # update the test counter of the network; very useful to check how many
      # score comparison has been done up to now.
      start$learning$ntests = test.counter()

      cat("----------------------------------------------------------------\n")
      cat("* best operation was: ")
      if (bestop$op == "set")
        cat("adding ", bestop$from, " -> ", bestop$to, ".\n", sep = "")
      else if (bestop$op == "drop")
        cat("removing ", bestop$from, " -> ", bestop$to, ".\n", sep = "")
      else
        cat("reversing ", bestop$from, " -> ", bestop$to, ".\n", sep = "")
      cat("* current network is:\n")
      print(start)
      cat("* current score: ", sum(reference.score), "\n", sep = "")
      if (use.tabu)
        cat(sprintf("* best score up to now: %s (delta: %s)\n",
          format(best.score),
          format(robust.score.difference(sum(reference.score), best.score))))

    }#THEN

    # check the current iteration index against the max.iter parameter.
    if (iter >= max.iter) {

      if (debug)
        cat("@ stopping at iteration ", max.iter, ".\n", sep = "")

      # reset the return value to the best network ever found.
      if (use.tabu && (loss.iter > 0))
        start = best.network

      break

    }#THEN
    else iter = iter + 1

  }#REPEAT

  return(start)

}#TABU.WITH.RESTARTS
