R/stv.R

Defines functions completeRankingTable plot.SafeVote.stv image.SafeVote.stv view.SafeVote.stv view print.summary.SafeVote.stv summary.SafeVote.stv backwards.tiebreak forwards.tiebreak ordered.tiebreak ordered.preferences solveTiebreak translate.ties loserMargin winnerMargin stv

Documented in backwards.tiebreak completeRankingTable forwards.tiebreak image.SafeVote.stv loserMargin ordered.preferences ordered.tiebreak plot.SafeVote.stv print.summary.SafeVote.stv solveTiebreak stv summary.SafeVote.stv translate.ties view view.SafeVote.stv winnerMargin

#' Count preferential ballots using an STV method
#'
#' The 'votes' parameter is as described in [condorcet()] with the following
#' additional semantics.
#'
#' By default the preferences are not allowed to contain duplicates per ballot.
#' However, if the argument 'equal.ranking' is set to 'TRUE', ballots are allowed
#' to have the same ranking for multiple candidates. The desired format is such
#' that for each preference $i$ that does not have any duplicate, there must be
#' exactly $i – 1$ preferences $j$ with $0 < j < i$. For example, valid ordered
#' preferences are '1; 1; 3; 4; …', or '1; 2; 3; 3; 3; 6; …', but NOT '1; 1; 2;
#' 3; …', or NOT '1; 2; 3; 3; 3; 5; 6; …'. If the data contain such invalid
#' votes, they are automatically corrected and a warning is issued by calling
#' the 'correct.ranking' function.
#'
#' If equal ranking is not allowed ('equal.ranking = FALSE'), the argument
#' 'invalid.partial' can be used to make ballots containing duplicates or gaps
#' partially valid. If it is 'TRUE', a ballot is considered valid up to a
#' preference that is in normal case not allowed. For example, ballots '1; 2; 3;
#' 4; 4; 6' or '1; 2; 3; 5; 6; 7' would be both converted into '1; 2; 3; 0; 0;
#' 0', because the ballots contain valid ranking only up to the third
#' preference.
#' 
#' By default, ties in the STV algorithm are resolved using the forwards
#' tie-breaking method, see Newland and Briton (Section 5.2.5). Argument 'ties'
#' can be set to ”b” in order to use the backwards tie-breaking method, see
#' O’Neill (2004). In addition, both methods are complemented by the following
#' “ordered” method: Prior to the STV election candidates are ordered by the
#' number of first preferences. Equal ranks are resolved by moving to the number
#' of second preferences, then third and so on. Remaining ties are broken by
#' random draws. Such complete ordering is used to break any tie that cannot be
#' resolved by the forwards or backwards method. If there is at least one tie
#' during the processing, the output contains a row indicating in which count a
#' tie-break happened (see the 'ties' element in the Value section for an
#' explanation of the symbols).
#' 
#' The ordered tiebreaking described above can be analysed from outside of the
#' 'stv' function by using the 'ordered.tiebreak' function for viewing the
#' a-priori ordering (the highest number is the best and lowest is the worst).
#' Such ranking is produced by comparing candidates along the columns of the
#' matrix returned by 'ordered.preferences'.
#'
#' @param votes an array with one column per candidate and one row per ballot,
#'   as described in [condorcet()]
#' @param nseats the number of seats to be filled in this election
#' @param eps fuzz-factor when comparing fractional votes.  The default of 0.001
#'   is preserved from the legacy code, injecting substantial validity hazards
#'   into the codebase.  We have not attempted to mitigate any of these hazards
#'   in 'SafeVote v1.0.0'.  We prefer instead to retain backwards-compatibility
#'   with the legacy code in 'vote_2.3-2' in the knowledge that, even if these
#'   hazards were adequately addressed, the resulting code is unlikely to be
#'   reliable at replicating the results of any other implementation of any of
#'   the many variants of "STV" counting methods.  Please see the description of
#'   the 'a53_hil' dataset in this package for some preliminary findings on the
#'   magnitude of the vote-count-variances which may be injected by differing
#'   implementations of broadly-similar "STV" counting methods.
#' @param equal.ranking if 'TRUE', equal preferences are allowed.
#' @param invalid.partial 'TRUE' if ballots which do not specify a complete
#'   ranking of candidates are informal (aka "invalid") *i.e.* ignored
#'   (with a warning).  Default is 'FALSE'.
#' @param fsep column-separator for output
#' @param ties vector of tie-breaking methods: ''f'' for forward, ''b'' for
#'   backward
#' @param quota.hare 'TRUE' if Hare quota, 'FALSE' if Droop quota (default)
#' @param constant.quota 'TRUE' if quota is held constant.  Over-rides
#'   'quota.hare'. Default is 'FALSE'
#' @param win.by.elim 'TRUE' (default) if the quota is waived when there are no
#'   more candidates than vacant seats.  Note: there is no lower limit when the
#'   quota is waived, so a candidate may be elected on zero votes.
#' @param group.nseats number of seats reserved to members of a group
#' @param group.members vector of members of the group with reserved seats
#' @param complete.ranking is 'TRUE' by default.  This parameter is retained
#'   solely for backwards compatibility with [vote::stv()]. It has no effect on
#'   elections in which 'nseats' is explicitly specified in the call to
#'   [SafeVote::stv()].
#' @param verbose 'TRUE' for diagnostic output
#' @param seed integer seed for tie-breaking.  Warning: if non-'NULL', the PRNG
#'   for R is reseeded prior to *every* random tie-break among the
#'   possibly-elected candidates.  We have preserved this functionality in this
#'   branch to allow regression against the legacy codebase of [vote::stv()]. In
#'   [SafeVote::stv()] the default value for seed is 'NULL' rather than the
#'   legacy value of 1234, to mitigate the validity hazard of PRNG reseedings
#'   during a stochastic experiment.
#' @param quiet 'TRUE' to suppress console output
#' @param digits number of significant digits in the output table
#' @param backwards.compatible 'TRUE' to regress against vote2_3.2 by
#'   disabling $margins, $fuzz, $rankingTable, $safeRank
#' @param safety number of standard deviations on vote-counts, when producing a
#'   safeRank by clustering near-ties in a complete ranking
#' @param ... undocumented intent (preserved from legacy code)
#'
#' @return object of class 'vote.stv'.  Note: the winning margins in this object
#'   are valid for the elected candidates and their (total) ranking, but must be
#'   adjusted within tiegroups to be valid for the candidates' (possibly
#'   partial) safeRank.
#' @export
#'
#' @examples data(food_election)
#' @examples stv(food_election, safety = 0.0)
#' @examples stv(food_election, nseats = 2)
#'
stv <-
  function(votes,
           nseats = NULL,
           eps = 0.001,
           equal.ranking = FALSE,
           fsep = "\t",
           ties = c("f", "b"),
           quota.hare = FALSE,
           constant.quota = FALSE,
           win.by.elim = TRUE,
           group.nseats = NULL,
           group.members = NULL,
           complete.ranking = FALSE,
           invalid.partial = FALSE,
           verbose = FALSE,
           seed = NULL,
           quiet = FALSE,
           digits = 3,
           backwards.compatible = FALSE,
           safety = 1.0,
           ...) {
    if (verbose && !quiet) {
      cat("\nSingle transferable vote count")
      if (equal.ranking)
        cat(" with equal preferences")
      cat("\n===================================")
      if (equal.ranking)
        cat("==================")
      cat("\n")
    }
    
    ## Prepare by finding names of candidates, setting up vector w of
    ## vote weights and list of elected candidates,
    ## and declaring a vector of candidate ranks (recorded in order of
    ## selection, with deletions occupying the lowest ranks)
    votes <- prepare.votes(votes, fsep = fsep)
    nc <- ncol(votes)
    cnames <- colnames(votes)
    ## a complete ranking in STV should (arguably) be "ranked from the top"
    ## i.e. there should be no eliminations
    nseats <- check.nseats(
      nseats,
      ncandidates = nc,
      default = ifelse(complete.ranking && !backwards.compatible,
                       nc, floor(nc / 2)),
      complete.ranking = complete.ranking
    )
    
    ## retain a copy of the initial value of nseats  
    nseats.initial <- nseats
    
    ## check groups (if used)
    use.marking <- FALSE
    if (!is.null(group.nseats)) {
      ## number of candidates to be elected from a group
      if (is.null(group.members)) {
        stop(
          "If group.nseats is given, argument group.members must be ",
          "used to mark members of the group."
        )
      }
      if (group.nseats > nseats) {
        warning(paste(
          "group.nseats must be <= nseats. ",
          "Adjusting group.nseats to ",
          nseats,
          "."
        ))
        group.nseats <- nseats
      }
      if (length(group.members) < group.nseats) {
        warning(
          paste(
            "There are fewer group members than group.nseats. ",
            "Adjusting group.nseats to ",
            length(group.members),
            "."
          )
        )
        group.nseats <- length(group.members)
      }
      if (!is.numeric(group.members)) {
        ## convert names to indices
        gind <- match(group.members, cnames)
        if (any(is.na(gind))) {
          warning(
            paste(
              "Group member(s) ",
              paste(group.members[is.na(gind)], collapse = ", "),
              " not found in the set of candidates, ",
              "therefore removed from the group."
            )
          )
          gind <- gind[!is.na(gind)]
        }
        group.members <- gind
      }
      ## now group memebers are given as indices
      group.members <-
        unique(group.members[group.members <= nc &
                               group.members > 0])
      use.marking <- TRUE
    } else {
      group.nseats <- 0
      group.members <- c()
    }
    
    elected <- NULL
    eliminated <- NULL
    
    ##
    ## The next step is to remove invalid votes. A vote is invalid if
    ## the preferences are not numbered in consecutively increasing order.
    ## A warning is printed out for each invalid vote, but the votes are
    ## not counted. If necessary, it is possible to correct errors in the
    ## original x matrix.
    ##
    ## If x is generated from an excel spreadsheet, then the jth vote will
    ## be in row (j-1) of the spreadsheet.
    ##
    
    if (verbose && !quiet) {
      cat("Number of votes cast is", nrow(votes), "\n")
    }
    corvotes <- votes
    corrected.votes <- NULL
    
    if (equal.ranking) {
      corvotes <- correct.ranking(votes, partial = FALSE, quiet = quiet)
    } else {
      if (invalid.partial) {
        corvotes <- correct.ranking(votes, partial = TRUE, quiet = quiet)
      }
      
      x <-
        check.votes(corvotes,
                    "stv",
                    equal.ranking = equal.ranking,
                    quiet = quiet)
      corrected <-
        which(rowSums(corvotes != votes) > 0 &
                rownames(votes) %in% rownames(x))
      
      if (length(corrected) > 0) {
        corrected.votes <-
          list(
            original = votes[corrected, ],
            new = corvotes[corrected,],
            index = as.numeric(corrected)
          )
      }
      
    }
    
    nvotes <- nrow(x)
    if (is.null(nvotes) || nvotes == 0) {
      stop("There must be more than one valid ballot to run STV.")
    }
    w <- rep(1, nvotes)
    
    ## Create elimination ranking
    tie.method <- match.arg(ties)
    tie.method.name <- c(f = "forwards", b = "backwards")
    otb <- ordered.tiebreak(x, seed)
    
    if (use.marking) {
      if (verbose && !quiet) {
        cat("Number of reserved seats is", group.nseats, "\n")
        cat("Eligible for reserved seats:",
            paste(cnames[group.members], collapse = ", "),
            "\n")
      }
      group.nseats.orig <- group.nseats
    }
    
    ## initialize results
    result.pref <-
      result.elect <-
      matrix(
        NA,
        ncol = nc,
        nrow = 0,
        dimnames = list(NULL, cnames)
      )
    result.quota <-
      result.ties <-
      c()
    result.ranks <- rep(NA, nc)
    names(result.ranks) <- cnames
    if (!backwards.compatible) {
      result.margins <- rep(NA, nc)
      names(result.margins) <- cnames
    }
    orig.x <- x
    
    ##
    ## the main loop
    ##
    if (verbose && !quiet) {
      cat("\nList of first preferences in STV counts: \n")
    }
    
    count <- 0
    inPlay <- rep(TRUE, nc)
    ## elect or eliminate one candidate per execution of the loop, with an
    ## early exit if all seats are filled
    while ((nseats > 0) && any(inPlay)) {
      ## calculate quota and total first preference votes
      count <- count + 1
      ## split 1st prefs if there is more than one first ranking on a ballot
      A <- (x == 1) / rowSums(x == 1) 
      A[is.na(A)] <- 0
      uij <- w * A
      vcast <- apply(uij, 2, sum)
      names(vcast) <- cnames
      if (!constant.quota || count == 1) {
        ## Quota calculation: either Hare (when quota.hare is TRUE) or Droop

        ## This is legacy code, with undocumented intent.  *Possibly* the only
        ## intent is to avoid a quota ever being recorded as a negative number
        ## (due to floating point roundoff errors).  However if this is the
        ## only intent, then it would be better to test for this case explicitly
        ## and shift the quota up to 0.0 if it ever becomes negative.
        
        ## Another possible intent is to ensure that a candidate must be clearly above the quota
        ## in order to be elected.  This seems unlikely, because I would expect a candidate
        ## who has 100 first-preference votes in the first round with quota 100
        ## to be elected in this round.
        
        ## Another possible intent is to ensure that, even in elections with
        ## large numbers of voters and many rounds, candidates must meet the
        ## quota to be electable -- despite the vagaries of floating-point
        ## roundoff.  This seems a likely intent but would be exceedingly
        ## difficult to achieve in R, in Python 3, or in any other language
        ## which does not offer fixed-precision arithmetic. It would also be
        ## very difficult to achieve in Java or C or Scheme, except when
        ## implementing an stv specification which explicitly defines a fixed
        ## precision (e.g. 3 decimal digits) and therefore allows the
        ## implementer to avoid the performance-pitfalls and
        ## implementation-difficulties of unbounded-precision arithmetic.
        
        ## As a "hack improvement" to this legacy code, it might be considered
        ## appropriate to add eps/2 when computing the quotas, rather than full
        ## eps.  This would reserve half of the roundoff "fuzz" budget for the
        ## calculation of transfer votes.  The idea here is that a 
        ## candidate on 2/3 votes may be computed as somewhat less than
        ## 0.6666665, due to floating point roundoff; and such a candidate
        ## would still be within eps of the quota if if were computed as
        ## 2/3 + eps/2 rather than the approx 0.6676666 resulting from the
        ## 2/3 + eps calculation in the legacy code below.
        
        ## I have left this legacy code unchanged, in part to preserve backwards
        ## compatibility, but mostly because *all* implementations of stv are
        ## sui generis -- they will almost surely fail some cross-validations
        ## with other implementations of the same natural-language specification
        ## of an "STV ballot-counting process".

        quota <- if (quota.hare)
          sum(vcast) / nseats + eps
        else
          sum(vcast) / (nseats + 1) + eps
        stopifnot(quota > 0)  ## note: when sum(vcast) == 0, quota is eps
      }
      result.quota <- c(result.quota, quota)
      result.pref <- rbind(result.pref, vcast)
      result.elect <- rbind(result.elect, rep(0, nc))
      tie <- 0
      if (verbose && !quiet) {
        cat("\nCount:" , count, "\n")
        df <-
          data.frame(QUOTA = round(quota, 3), t(round(vcast[vcast != 0], 3)))
        rownames(df) <- count
        print(df)
      }
      
      ## if leading candidate exceeds quota, declare elected and adjust weights
      ##
      ## mark candidate for elimination in subsequent counting
      ##
      ## if the number of remaining candidates is less than or equal
      ## to the number of seats, then select the one with the
      ## largest vcast, no matter if quota is exceeded
      ##
      vmax <- max(vcast)
      
      ## Restrict attention to in-play candidates (with max votes if they're
      ## not group members).  Note that in a corner case, ic will include
      ## candidates with zero votes.
      ic <-
        (1:nc)[(inPlay & (vcast == vmax)) | ((1:nc) %in% group.members)]
      
      D <-
        colSums(abs(result.elect)) == 0  # set of hopeful candidates
      if (use.marking) {
        Dm <- D
        Dm[-group.members] <-
          FALSE  # set of hopeful marked candidates
      }
      
      ## With win.by.elim, or with (backwards.compatible && constant.quota),
      ## elected candidates need not reach quota when filling the last seats.
      ##
      ## In a corner case: a candidate who receives no votes may be elected.
      ##
      ## The clause "!(! ic %in% group.members && nseats == group.nseats)" was
      ## recoded Nov 2022 to avoid throwing an error when group.members is NULL
      ## and ic is not a singleton.  Another recoding is necessary if the intent
      ## is to avoid electing a group member on zero votes, in cases where vmax
      ## is greater than 0 due to some non-group candidate being in play but 
      ## below quota.
      ##
      ## TODO: review for correctness, against a specification of how the corner
      ## cases should be handled.
      ##
      ## TODO: regress against the results of actual elections, to assess
      ## validity with respect to the actual STV ballot-counting method employed
      ## in those elections.  For example, the [Dublin West 2002
      ## results](https://en.wikipedia.org/wiki/Dublin_West_(D%C3%A1il_constituency)
      ## show markedly different vote-transfers to those of [stv()] in the second
      ## round. The list of elected candidates is identical, and the vote totals
      ## in round 1 are identical; but [stv()] is inexact in its calculations of
      ## the margins of victory, and its vote-totals are at significant variance
      ## to the official vote-totals.
      ##
      ## Note that the 'vmax' terms in each of the three disjunctive clauses of
      ## the following guard ensure that a non-group candidate must receive at
      ## least one vote (even if it's only a last-preference!) in order to be
      ## elected. This implies that seats may remain unfilled, even if there are
      ## at least as many candidates as seats.
      
      if ((vmax >= quota &&
           !(!any(ic %in% group.members) &&
             (nseats == group.nseats))) ||
          ((win.by.elim || (backwards.compatible && constant.quota))
           && sum(D) <= nseats) ||
          (use.marking &&
           any(ic %in% group.members) &&
           (sum(Dm) <= group.nseats || sum(D) - sum(Dm) == 0))) {
        
        if (use.marking && length(ic) > 1 && sum(Dm) <= group.nseats) {
          ## if a tiebreak, choose marked candidates if needed
          ic <- ic[ic %in% group.members]
        }
        
        if (length(ic) > 1) {
          ## tie
          ic <- solveTiebreak(tie.method, result.pref, ic, otb,
                              elim = FALSE)
          tie <- 1
          tie <- tie + (attr(ic, "ordered") == TRUE)
          tie <- tie + (attr(ic, "sampled") == TRUE)
        }
        surplus <- if (vmax > quota)
          (vmax - quota) / vmax
        else
          0
        index <-
          (x[, ic] == 1) ## ballots where ic has the first preference
        w[index] <- uij[index, ic] * surplus ## update weights
        if (equal.ranking) {
          w[index] <-
            w[index] +
            rowSums(uij[index, , drop = FALSE]) -
            uij[index, ic]
        }
        
        ## reduce number of seats available
        nseats <- nseats - 1
        if (use.marking && ic %in% group.members) {
          group.nseats <- group.nseats - 1
        }
        elected <- c(elected, cnames[ic])
        result.elect[count, ic] <- 1
        
        result.ranks[ic] <- length(elected)
        if (!backwards.compatible) {
          wm <- winnerMargin(vcast[inPlay])
          ## sanity test on tie-breaking method: ic must have maximal votes
          stopifnot(vcast[ic] == vcast[names(wm[1])])
          result.margins[ic] <- wm[2]  ## note: margin may be NA
        }
        inPlay[ic] <- FALSE
        if (verbose && !quiet) {
          cat("Candidate", cnames[ic], "elected")
          if (!backwards.compatible) {
            cat(" with margin", result.margins[ic])
          }
          if (tie > 0) {
            cat(" using", tie.method.name[tie.method])
            if (tie == 2)
              cat(" & ordered")
            cat(" tie-breaking method ")
            if (tie > 2)
              cat("(sampled)")
          }
          cat("\n")
        }
      } else {
        ## no candidate reaches quota, and at least one can be eliminated
        stopifnot(any(D))
        elim.select <- D
        if (use.marking &&
            (nseats == group.nseats ||
             sum(Dm) <= group.nseats)) {
          elim.select <- elim.select & !Dm
        }
        ## sanity test for a corner case on Dm (group member gets no votes)
        stopifnot(any(elim.select)) 
        vmin <- min(vcast[elim.select])
        ic <- (1:nc)[vcast == vmin & elim.select]
        stopifnot(length(ic) > 0)  ## sanity test
        
        if (length(ic) > 1) {
          ## tie
          ic <-
            solveTiebreak(tie.method, result.pref, ic, otb,
                          elim = TRUE)
          tie <- 1
          tie <- tie + (attr(ic, "ordered") == TRUE)
          tie <- tie + (attr(ic, "sampled") == TRUE)
        }
        result.elect[count, ic] <- -1
        eliminated <- c(eliminated, cnames[ic])
        nEliminated <- length(eliminated)
        result.ranks[ic] <- nc - nEliminated + 1
        
        lm <- loserMargin(vcast[inPlay])
        ## sanity test on tie-breaking method: ic must have minimal votes
        stopifnot(vcast[ic] == vcast[names(lm[1])])
        if (!backwards.compatible) {
          result.margins[ic] <- lm[2] ## note: margin may be NA
        }
        inPlay[ic] <- FALSE
        
        if (verbose && !quiet) {
          cat("Candidate",
              cnames[ic],
              "eliminated")
          if (!backwards.compatible) {
            cat(" with margin", result.margins[ic])
          }
          if (tie > 0) {
            cat(" using", tie.method.name[tie.method])
            if (tie == 2)
              cat(" & ordered")
            cat(" tie-breaking method ")
            if (tie > 2)
              cat("(sampled)")
          }
          cat("\n")
        }
      }
      
      result.ties <- c(result.ties, tie)
      
      stopifnot(length(ic) == 1)  ## sanity test
      ## shift votes for voters who voted for ic
      jp <- x[, ic]
      for (i in which(jp > 0)) {
        index <- x[i, ] > jp[i]
        x[i, index] <- x[i, index] - 1
      }
      x[, ic] <- 0
      
    }
    
    rownames(result.pref) <- 1:count
    partialResult <- structure(
      list(
        elected = elected,
        preferences = result.pref,
        quotas = result.quota,
        elect.elim = result.elect,
        equal.pref.allowed = equal.ranking,
        ties = translate.ties(result.ties, tie.method),
        data = orig.x,
        invalid.votes =
          votes[setdiff(rownames(votes), rownames(x)), , drop = FALSE],
        corrected.votes = corrected.votes,
        reserved.seats = if (use.marking)
          group.nseats.orig
        else
          NULL,
        group.members = if (use.marking)
          group.members
        else
          NULL
      )
    )
    if (!backwards.compatible) {
      partialResult$nseats = nseats.initial
      partialResult$ranking = result.ranks
      partialResult$margins = result.margins
      partialResult$fuzz = safety * sqrt(nrow(orig.x))
      crt <- completeRankingTable(partialResult,
                                  quiet = quiet,
                                  verbose = verbose)
      result <- partialResult
      result$ranking <- crt$ranking
      result$margins <- crt$Margin
      result$rankingTable = crt
      sr <- crt$SafeRank  ## extract from rankingTable for convenience
      names(sr) <- row.names(crt)
      result$safeRank = sr
      attr(result, "class") <- append("SafeVote.stv", class(result))
    } else {
      result <- partialResult
      attr(result, "class") <- "vote.stv"
    }
    
    if (!quiet) {
      print(summary(result, digits = digits))
    }
    invisible(result)
  }

#' Find a winner and their margin of victory
#'
#' @param votes cleaned ballots
#'
#' @return length-2 vector: the index of a winning candidate, and their margin
#'         of victory (0 if a tie, NA if no losers)
#'
winnerMargin <- function(votes) {
  stopifnot(length(votes) > 0)
  winner <- which(votes == max(votes))
  ## random tie-break (no-op if length(winner)==1)
  winner <- winner[[sample(length(winner), 1)]]
  if (length(votes) > length(winner)) {
    margin <- max(votes) - max(votes[-winner])
  } else {
    margin <- NA
  }
  return(c(winner, margin))
}

#' Find a loser and their margin of victory
#'
#' @param votes cleaned ballots
#'
#' @return length-2 vector: the index of a losing candidate, and their margin
#'         of loss (0 if a tie, NA if no winners)
#'
loserMargin <- function(votes) {
  stopifnot(length(votes) > 0)
  loser <- which(votes == min(votes))
  ## random tie-break
  loser <- loser[[sample(length(loser), 1)]]
  if (length(votes) > length(loser)) {
    margin <- min(votes[-loser]) - min(votes)
  } else {
    margin <- NA
  }
  return(c(loser, margin))
}

#' Undocumented internal method from original code
#'
#' @param ties undocumented
#' @param method 'f' for forward, 'b' for backward
#'
#' @return undocumented
#'
translate.ties <- function(ties, method) {
  ties.char <- ifelse(ties == 0, "", method)
  ties.char <- ifelse(ties > 1, paste0(ties.char, "o"), ties.char)
  ties.char <- ifelse(ties > 2, paste0(ties.char, "s"), ties.char)
  names(ties.char) <- 1:length(ties)
  return(ties.char)
}

#' Undocumented internal method, renamed from 'solve.tiebreak' to avoid
#' confusion with generic solve()
#'
#' @param method undocumented
#' @param prefs undocumented
#' @param icans undocumented
#' @param ordered.ranking undocumented
#' @param elim undocumented
#'
#' @return undocumented
#'
solveTiebreak <- function(method,
                          prefs,
                          icans,
                          ordered.ranking = NULL,
                          elim = TRUE) {
  if (method == "f") {
    ## forwards
    ic <- forwards.tiebreak(prefs, icans, elim = elim)
  }
  else {
    ## backwards
    ic <- backwards.tiebreak(prefs, icans, elim = elim)
  }
  # solve remaining ties by ordered ranking
  sampled <- FALSE
  ordered <- FALSE
  if (length(ic) > 1) {
    ic <- ic[if (elim)
      which.min(ordered.ranking[ic])
      else
        which.max(ordered.ranking[ic])]
    sampled <- attr(ordered.ranking, "sampled")[ic]
    ordered <- TRUE
  }
  attr(ic, "sampled") <- sampled
  attr(ic, "ordered") <- ordered
  return(ic)
}

#' Undocumented internal method
#'
#' @param vmat undocumented
ordered.preferences <- function(vmat) {
  sapply(1:ncol(vmat),
         function(pref)
           apply(vmat, 2, function(f)
             sum(f == pref)))
}

#' Undocumented internal method
#'
#' @param vmat undocumented
#' @param seed undocumented
ordered.tiebreak <- function(vmat, seed = NULL) {
  ## Create elimination ranking using ordered tie-breaking element
  ## ij in matrix nij is the number of j-th preferences for
  ## candidate i
  nij <- ordered.preferences(vmat)
  ## ranking for each preference
  nij.ranking <- apply(nij, 2, rank, ties.method = "min")
  rnk <- nij.ranking[, 1]
  dpl <- duplicated(rnk) | duplicated(rnk, fromLast = TRUE)
  sampled <- rep(FALSE, length(rnk))
  ## resolve ranking duplicates by moving to the next column
  if (any(dpl)) {
    if (!is.null(seed))
      ## TODO: consider using a difficult-to-predict dithering method which
      ## favours different candidates in each round, rather than always using
      ## the same sequence of "random" numbers to break every tie.  See Sched 1A
      ## of New Zealand's Local Electoral Regulations 2001.  See also Hill,
      ## "Implementing STV by Meek’s method", Voting matters, Issue 22.
      
      ## TODO: stv() should use a private PRNG, to maintain
      ## backwards-compatibility and modifiability, and to avoid side-effects on
      ## subsequent or concurrent (interleaved) uses of R's interpreter.
      set.seed(seed)
    for (pref in 1:ncol(vmat)) {
      if (!pref %in% rnk[dpl])
        next
      j <- 2
      rnk[rnk == pref] <- NA
      while (any(is.na(rnk))) {
        ## which candidates to resolve
        in.game <- is.na(rnk)
        ## if we moved across all columns and there are still
        ## duplicates, determine the rank randomly
        if (j > ncol(nij)) {
          rnk[in.game] <- sample(sum(in.game)) + pref - 1
          sampled <- sampled | in.game
          break
        }
        rnk[in.game] <-
          rank(nij.ranking[in.game, j], ties.method = "min") +
          pref - 1
        dplj <-
          rnk == pref &
          (duplicated(rnk) |
             duplicated(rnk, fromLast = TRUE))
        rnk[dplj] <- NA
        j <- j + 1
      }
    }
  }
  attr(rnk, "sampled") <- sampled
  return(rnk)
}

#' Undocumented internal method
#'
#' @param prefs undocumented
#' @param icans undocumented
#' @param elim undocumented
forwards.tiebreak <- function(prefs, icans, elim = TRUE) {
  if (!elim)
    prefs <- -prefs
  if (is.null(dim(prefs)))
    dim(prefs) <- c(1, length(prefs))
  rnk <- t(apply(prefs, 1, rank, ties.method = "min"))
  if (is.null(dim(rnk)))
    dim(rnk) <- c(1, length(rnk))
  i <- 0
  icv <- rep(FALSE, ncol(prefs))
  icv[icans] <- TRUE
  while (i < nrow(rnk) && length(icans) > 1) {
    i <- i + 1
    ic.rnk <- rnk[i, icans]
    icans <- which(icv & (rnk[i,] == min(ic.rnk)))
  }
  return(icans)
}

#' Undocumented internal method
#'
#' @param prefs undocumented
#' @param icans undocumented
#' @param elim undocumented
backwards.tiebreak <- function(prefs, icans, elim = TRUE) {
  if (!elim)
    prefs <- -prefs
  if (is.null(dim(prefs)))
    dim(prefs) <- c(1, length(prefs))
  rnk <- t(apply(prefs, 1, rank, ties.method = "min"))
  if (is.null(dim(rnk)))
    dim(rnk) <- c(1, length(rnk))
  i <- nrow(rnk)
  icv <- rep(FALSE, ncol(prefs))
  icv[icans] <- TRUE
  while (i > 1 && length(icans) > 1) {
    i <- i - 1
    ic.rnk <- rnk[i, icans]
    icans <- which(icv & (rnk[i,] == min(ic.rnk)))
  }
  return(icans)
}

#' summary() method for a SafeVote result
#'
#' @param object undocumented, legacy code
#' @param ... undocumented
#' @param digits undocumented
#' @return data.frame summarising 'object', for use by 'print' method
#' @export
#'
summary.SafeVote.stv <- function(object, ..., digits = 3) {
  
  ## The following function is of undocumented origin.  The intent of the code
  ## is apparently to introduce a "fuzz" on float-to-integer automagic
  ## conversions which is right-sized: large enough that programmer-intended
  ## integral values appear to have been computed by integer arithmetic, but not
  ## so large that any values which "really are" non-integral are ever
  ## inappropriately rounded to the nearest integer in a printout. All "fuzzing"
  ## regimes (including the ones found in various versions of Basic and Python)
  ## have the potential to distort results -- especially if the programmer has
  ## attempted to emulate integer arithmetic using "fuzzed" floating-point
  ## values. One fundamental problem is that the limited-precision mantissa of a
  ## double-precision float is mostly occupied by the significant digits of a
  ## large integer value, leaving very little headroom for "fuzzing".  Also,
  ## subtracting two floating-point numbers which are nearly equal to each other
  ## may result in a result with surprisingly-low precision, in a phenomenon
  ## sometimes called "catastrophic cancellation" [Goldberg
  ## (1991)](https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html).
  decimalplaces <- function(x) {
    ifelse(abs(x - round(x)) > .Machine$double.eps ^ 0.5,
           nchar(sub(
             '^\\d+\\.', '', sub('0+$', '', as.character(x))
           )),
           0)
  }
  ## TODO: consider using formattable::formattable() instead of this function.
  ## Note that formattable::formattable() is imported by view.stv().  See
  ## https://stackoverflow.com/questions/3443687/formatting-decimal-places-in-r

  backwards.compatible <- is.null(object$nseats)
  
  ncounts <- nrow(object$preferences)
  df <- data.frame(matrix(
    NA,
    nrow = ncol(object$preferences) + 4,
    ncol = 2 * ncounts - 1
  ),
  stringsAsFactors = FALSE)
  rownames(df) <- c("Quota",
                    colnames(object$preferences),
                    "Tie-breaks",
                    "Elected",
                    "Eliminated")
  colnames(df)[1] <- 1
  idxcols <- 1
  if (ncounts > 1) {
    colnames(df)[2:ncol(df)] <-
      paste0(rep(2:ncounts, each = 2), c("-trans", ""))
    idxcols <- c(idxcols, seq(3, ncol(df), by = 2))
  }
  df["Quota", idxcols] <- object$quotas
  df[2:(nrow(df) - 3), idxcols] <- t(object$preferences)
  ## calculate transfers
  pref <- object$preferences
  ## remove quotas for winners and compute difference
  where.winner <- which(rowSums(object$elect.elim == 1) == 1)
  pref[where.winner, ] <-
    pref[where.winner, ] -
    object$elect.elim[where.winner, ] * object$quotas[where.winner]
  if (ncounts > 1) {
    tmp <- t(object$preferences[2:nrow(object$preferences), ] -
               pref[1:(nrow(pref) - 1), ])
    if (nrow(tmp) == 1) {
      tmp <- as.numeric(tmp)  ## because of R weirdness with
      ## vectors and matrices (when there are just two counts)
    }
    df[2:(nrow(df) - 3), seq(2, ncol(df), by = 2)] <- tmp
  }
  ## format the right number of digits
  df[1:(nrow(df) - 3), ] <-
    apply(df[1:(nrow(df) - 3), , drop = FALSE], 2,
          function(d) {
            ifelse(!is.na(d),
                   format(round(d, digits),
                          nsmall =
                            min(digits,
                                max(
                                  decimalplaces(round(d[!is.na(d)], digits))
                                  
                                ))),
                   "")
          })
  where.elim <- which(rowSums(object$elect.elim == -1) == 1)
  cnames <- colnames(object$elect.elim)
  for (i in 1:ncounts) {
    if (i %in% where.winner) {
      ## Recoded Nov 22 from ... cnames[which(object$elect.elim[i,]==1)]
      elected <- cnames[object$elect.elim[i, ] == 1]
      df["Elected", idxcols[i]] <-
        paste(elected, collapse = ", ")
      for (can in elected) {
        if (idxcols[i] + 2 <= ncol(df)) {
          df[can, (idxcols[i] + 2):ncol(df)] <- NA
          
        }
      }
      
    }
    if (i %in%  where.elim) {
      ## Recoded Nov 22 from ... cnames[which(object$elect.elim[i,]==-1)]
      eliminated <- cnames[object$elect.elim[i, ] == -1]
      df["Eliminated", idxcols[i]] <-
        paste(eliminated, collapse = ", ")
      for (can in eliminated) {
        if (idxcols[i] + 2 <=  ncol(df)) {
          df[can, (idxcols[i] + 2):ncol(df)] <- NA
        }
      }
    }
  }
  
  if (any(object$ties != "")) {
    df["Tie-breaks", seq(1, ncol(df), by = 2)] <- object$ties
  }
  else {
    ## Recoded Nov 22 from ... df[-which(rownames(df) == "Tie-breaks")...
    df <- df[-(rownames(df) == "Tie-breaks"), , drop = FALSE]
  }
  if (!is.null(object$reserved.seats)) {
    rownames(df)[object$group.members + 1] <-
      paste0(rownames(df)[object$group.members + 1], "*")
  }
  df[is.na(df)] <- ""
  class(df) <- c('summary.SafeVote.stv', class(df))
  attr(df, "number.of.votes") <- nrow(object$data)
  attr(df, "number.of.invalid.votes") <-
    nrow(object$invalid.votes)
  attr(df, "number.of.candidates") <- ncol(object$preferences)
  attr(df, "number.of.seats") <- object$nseats
  if (!backwards.compatible) {
    attr(df, "number.of.seats.unfilled") <-
      object$nseats - length(object$elected)
  }
  if (!is.null(object$reserved.seats)) {
    attr(df, "reserved.seats") <- object$reserved.seats
    attr(df, "reservation.eligible") <- object$group.members
  }
  attr(df, "equal.pref.allowed") <- object$equal.pref.allowed
  attr(df, "rankingTable") <- object$rankingTable
  attr(df, "fuzz") <- object$fuzz
  return(df)
}

#' print() method for a summary() of a SafeVote result
#'
#' @param x election results
#' @param ... args to be passed to kable()
#' @return no return value, called for side-effect of printing to console
#'
#' @export
print.summary.SafeVote.stv <- function(x, ...) {
  cat("\nResults of Single transferable vote")
  if (attr(x, "equal.pref.allowed"))
    cat(" with equal preferences")
  cat("\n===================================")
  if (attr(x, "equal.pref.allowed"))
    cat("=======================")
  
  election.info(x)

  if (!is.null(attr(x, "number.of.seats.unfilled"))) {
    cat(paste0("Number of unfilled seats: ",
        attr(x, "number.of.seats.unfilled"),
        "\n"))
  }
  if (!is.null(attr(x, "reserved.seats"))) {
    cat("Number of reserved seats:\t",
        attr(x, "reserved.seats"),
        "\n")
    cat("Eligible for reserved seats:\t",
        length(attr(x, "reservation.eligible")), "\n")
  }
  if (!is.null(attr(x, "rankingTable"))) {
    cat("\nFuzz on safeRank:\t", attr(x, "fuzz"))
    cat("\nComplete ranking:")
    print(knitr::kable(attr(x, "rankingTable"),
                       align = c("r", "l", "c", "r", "l"), ...))
  }
  
  cat("\nVote transfers:")
  print(knitr::kable(x, align = 'r', ...))
  
  cat("\nElected:",
      paste(x['Elected', x['Elected', ] != ""], collapse = ", "),
      "\n\n")
}

#' generic view() for classes defined in this package
#'
#' @param object election object to be viewed
#' @param ... additional parameters, passed to formattable::formattable()
#'
#' @return html-formatted object, with side-effect in RStudio's Viewer pane
#' @export
view <- function(object, ...) {
  UseMethod("view")
}

#' view method for the result of an stv() ballot-count
#' @param object object to be viewed
#' @param ... additional parameters, passed to formattable::formattable()
#'
#' @return html-formatted object
#' @import formattable 
#' @export
#'
view.SafeVote.stv <- function(object, ...) {
  s <- summary(object)
  formatter <-
    list(
      formattable::area(row = 2:(nrow(s) - 2),
                        col = seq(1, ncol(s), by = 2)) ~
        formattable::color_text("red", "red"),
      formattable::area(row = 1, col = seq(1, ncol(s), by = 2)) ~
        formattable::color_text("blue", "blue")
      ## Quota = color_text("blue", "blue")  # comment from legacy code
    )
  formattable::formattable(s, formatter, ...)
}

#' visualisation of joint and marginal distributions in STV preferences
#'
#' @param x STV results to be visualised
#' @param xpref,ypref candidates shown in a joint distribution plot
#' @param all.pref plot the joint distribution of two preferences (if
#'   'all.pref=FALSE') or the marginal distribution of all preferences (if
#'   'all.pref=TRUE').
#' @param proportion The joint distribution can be shown either as proportions
#'   (if 'proportion=TRUE') or raw vote counts (if 'proportion=FALSE').
#' @param ... args passed to fields::image.plot()
#'
#' @return image object, with side-effect in RStudio Plots pane
#' @importFrom grDevices hcl.colors
#' @importFrom graphics axis mtext text par
#' @importFrom fields image.plot
#' @export
#' 
image.SafeVote.stv <- function(x,
                               xpref = 2,
                               ypref = 1,
                               all.pref = FALSE,
                               proportion = TRUE,
                               ...) {
  stopifnot(requireNamespace("ggplot2", quietly = TRUE))
  
  # Declaring temps for data.table calls.  Warning: overloads rank()
  # https://www.r-bloggers.com/2019/08/no-visible-binding-for-global-variable/
  voter <- rank <- NULL 
  
  xd <- x$data
  nc <- ncol(xd)
  if (all.pref) {
    nij <- ordered.preferences(xd)[nc:1, ]
    image.plot(
      x = 1:nc,
      y = 1:nc,
      t(nij),
      axes = FALSE,
      xlab = "",
      ylab = "",
      col = grDevices::hcl.colors(12, "YlOrRd", rev = TRUE),
      ...
    )
    graphics::axis(3, at = 1:nc, labels = 1:nc)
    graphics::axis(
      2,
      at = 1:nc,
      labels = rev(colnames(xd)),
      tick = FALSE,
      las = 2
    )
    graphics::mtext("Ranking", side = 1, line = 0.5)
  } else {
    xdt <- data.table(xd)
    xdt[, voter := 1:nrow(xd)]
    xdtl <- data.table::melt(
      xdt,
      id.vars = "voter",
      variable.name = "candidate",
      value.name = "rank"
    )
    xdtl <- xdtl[rank %in% c(xpref, ypref)]
    if (sum(duplicated(xdtl[, c("voter", "rank"), with = FALSE])) > 0) {
      stop("Sorry, the image function is not available for ballots",
           "with equal preferences.")
    }
    xdtw <- dcast(xdtl, voter ~ rank, value.var = "candidate")
    setnames(xdtw, as.character(xpref), "xpref")
    setnames(xdtw, as.character(ypref), "ypref")
    ctbl <- table(xdtw[, ypref], xdtw[, xpref])
    if (proportion) {
      ctbl <- ctbl / rowSums(ctbl)
      ctbl[is.na(ctbl)] <- 0
    }
    fields::image.plot(
      x = 1:nc,
      y = 1:nc,
      t(ctbl[nc:1, ]),
      axes = FALSE,
      xlab = "",
      ylab = "",
      col = grDevices::hcl.colors(12, "YlOrRd", rev = TRUE),
      ...
    )
    graphics::axis(
      2,
      at = nc:1,
      labels = rownames(ctbl),
      tick = FALSE,
      las = 1
    )
    graphics::text(
      1:nc,
      y = graphics::par("usr")[4],
      labels = colnames(ctbl),
      xpd = NA,
      srt = 45,
      adj = 0
    )
    graphics::mtext(paste("Preference", ypref),
          side = 4,
          line = 0.1)
    graphics::mtext(paste("Preference", xpref),
          side = 1,
          line = 0.5)
  }
}

#' plot() method for the result of an stv() ballot-count
#'
#' The 'plot' function shows the evolution of the total score for each candidate
#' as well as the quota. 
#'
#' @param x stv results
#' @param xlab,ylab axis labels
#' @param point.size diameter of elected/eliminated points
#' @param ... params for generic plot()
#' @return graphics object, with side-effect in RStudio's Plots pane
#' @export
plot.SafeVote.stv <-
  function(x,
           xlab = "Count",
           ylab = "Preferences",
           point.size = 2,
           ...) {
    stopifnot(requireNamespace("ggplot2", quietly = TRUE))
    
    ## Declare temp objects for use by data.table
    Count <- value <- selection <-
      i.value <- count.select <- Candidate <-
      i.Count <- NULL
    
    ## Plot evolution of the preferences

    ## prepare data in the long format
    df <- data.table(x$preferences)
    df[, Count := 1:nrow(df)]
    dfl <-
      data.table::melt(df, id.vars = "Count", variable.name = "Candidate")
    dfl <- rbind(dfl, dfl[Count == 1][, Count := 0]) ## add Count 0
    ## with initial values dataset for plotting the quota
    dfq <- data.table(
      Count = 1:length(x$quotas),
      value = x$quotas,
      Candidate = "Quota"
    )
    
    ## dataset for plotting points of elected and eliminated candidates
    dfe <-
      data.table::melt(
        data.table(Count = 1:nrow(x$elect.elim), x$elect.elim),
        id.vars = "Count",
        variable.name = "Candidate"
      )
    dfe <- dfe[value != 0]
    dfe[, selection := ifelse(value > 0, "elected", "eliminated")]
    dfe <- dfe[dfl, value := i.value, on = c("Count", "Candidate")]
    
    ## remove data after candidates are selected
    dfl[dfe, count.select := i.Count, on = "Candidate"]
    dfl <- dfl[is.na(count.select) | Count <= count.select]
    
    ## create plots
    g <- ggplot2::ggplot(dfl,
                         ggplot2::aes(
                           x = as.factor(Count),
                           y = value,
                           color = Candidate,
                           group = Candidate
                         )) +
      ggplot2::geom_line()
    g <- g + ggplot2::geom_line(data = dfq,
                                ggplot2::aes(x = as.factor(Count)),
                                color = "black") +
      ggplot2::xlab(xlab) + ggplot2::ylab(ylab)
    g <- g + ggplot2::geom_point(data = dfe,
                                 ggplot2::aes(shape = selection),
                                 size = point.size) +
      ggplot2::ylim(range(0, max(dfl$value, dfq$value)))
    g <- g + ggplot2::annotate(
      geom = "text",
      x = as.factor(1),
      y = dfq[Count == 1, value],
      label = "Quota",
      hjust = "right"
    )
    g
  }

#' internal method to analyse the partial results of an stv() ballot count, to
#' discover a complete ranking of all candidates.  The ranking may depend on the
#' value of nseats, because this affects how votes are transferred.
#'
#' @param object partial results
#' @param quiet TRUE to suppress console output
#' @param verbose TRUE to produce diagnostic output
#'
#' @return data.frame with columns TotalRank, Margin, Candidate, Elected,
#'   SafeRank
#' 
completeRankingTable <- function(object, quiet, verbose) {
  cand.in.play <- colSums(abs(object$elect.elim)) == 0
  ## list the eliminated candidates in reverse order of elimination
  eliminated <- rev(names(unlist(
    apply(object$elect.elim, 1, function(r)
      which(r < 0))
  )))
  ranking <- object$ranking
  loseMargins <- object$margins[eliminated]
  winMargins = object$margins
  
  ## Candidates who were neither elected nor eliminated are ranked by their
  ## position in the last round
  if (any(cand.in.play)) {
    lastRound <-
      object$preferences[nrow(object$preferences), cand.in.play]
    ranking[cand.in.play] <-
      length(object$elected) + rank(-lastRound, ties.method = "random")
    if (verbose && !quiet) {
      cat("\nRanking of unelected uneliminated candidates in a final round:\n")
      print(ranking[cand.in.play])
    }
    lastRound <- lastRound[order(ranking[cand.in.play])]
    winMarg <- lastRound - c(lastRound[-1], NA)
    ## The winning margin of the last in-play candidate is the losing margin of
    ## the first-eliminated candidate (if any).
    if (length(loseMargins) > 0) {
      winMarg[length(winMarg)] <- loseMargins[1]
    } else {
      ## if no candidate has been eliminated, the winning margin of the last
      ## in-play candidate is their number of votes in the last round
      winMarg[length(winMarg)] <- lastRound[length(lastRound)]
    }
    ## Copy winMarg into the matching positions of winMargins
    winMargins[names(cand.in.play)[cand.in.play]
               [order(ranking[cand.in.play])]] <- winMarg
  } else {  ## all candidates have been either elected or eliminated
    if (any(is.na(winMargins))) {
      ## If a candidate had been elected unopposed, their winning margin is the
      ## losing margin of the first-eliminated candidate (if any).
      unopposed <- names(which(is.na(winMargins)))
      ## Sanity check: impossible to have multiple candidates elected unopposed!
      stopifnot(length(unopposed) == 1)
      if (length(loseMargins) > 0) {
        winMargins[unopposed] <- loseMargins[1]
      } else {
        ## If all candidates have been elected, the winning margin of the last
        ## candidate to be elected is their number of votes in the last round
        winMargins[unopposed] <- 
          object$preferences[nrow(object$preferences), unopposed]
        if (winMargins[unopposed] == 0) {
          ## I have found no discussion of this case in the literature on stv,
          ## but a candidate might be elected on zero votes -- if quotas are
          ## ever waived e.g. when there are "reserved seats" in the sense of
          ## vote2.3-2 or (as in STV 1.0.3) there are no more candidates in play
          ## than open seats to be filled.
          if (!quiet) {
            cat("\n", unopposed, "was elected despite having zero votes.")
          }
        }
      }
    }
  }
  
  ## Push the loseMargin of each eliminated candidate (other than the
  ## highest-ranked one) into the winMargin of the next-higher ranked candidate
  ## (if any) among the eliminated candidates.
  if (length(loseMargins) > 1) {
    winMargins[eliminated] <- c(loseMargins[-1], NA) 
  }
  
  ## The winning margin of the first candidate (if any) to be eliminated is
  ## their number of votes in the round when they were eliminated
  if (length(eliminated) > 0) {
    firstElim <- eliminated[1]
    winMargins[firstElim] <-
      object$preferences[[which(object$elect.elim[, firstElim] == -1),
                          firstElim]]
  }
  
  ## Corner case: a candidate may have been elected on NA votes in the final
  ## round, after one or more eliminations of candidates on 0 votes.
  if (any(is.na(winMargins))) {
    stopifnot(sum(is.na(winMargins)) == 1)
    winMargins[which(is.na(winMargins))] <- 0
  }
  
  ## Sanity checks: winMargins are non-negative and ranking is total
  stopifnot(!any(is.na(winMargins)) || !any(winMargins < 0))
  stopifnot(!any(sort(ranking) != c(1:length(ranking))))
  
  ## Iterative 1-d clustering to produce a "safe" ranking
  safeRank <- ranking[order(ranking)]
  rmargs <- winMargins[order(ranking)]
  for (i in 2:length(ranking)) {
    if (is.na(rmargs[i - 1])) {
      stop("NA margin at rank ", i)
    } else {
      if (rmargs[i - 1] < object$fuzz) {
        safeRank[i] <- safeRank[i - 1]
      }
    }
  }
  ## Rearrange safeRank into canonical order (as on ballots)
  safeRank <- safeRank[names(ranking)]
  
  result <-
    data.frame(
      TotalRank = ranking,
      SafeRank = safeRank,
      Margin = winMargins
    )

  return(result)
}

Try the SafeVote package in your browser

Any scripts or data that you put into this service are public.

SafeVote documentation built on Oct. 6, 2024, 1:07 a.m.