R/covariance_directional_ou.R

Defines functions directional_log_transfer directional_transfer_walk directional_point_variance directional_lca_edge_out_tree directional_lca_edge_nondendritic directional_edge_downstream_of directional_edge_reachable_forward directional_lca directional_ou_setup directional_ou_setup_numeric_dispatch directional_ou_skeleton directional_ou_compose_setup directional_ou_setup_numeric directional_ou_resolve_source_var directional_ou_ancestor_labels directional_ou_setup_structure count_undirected_components directional_edge_topo_order directional_ou_pair_covariance to_absolute_PtE directional_ou_variance directional_ou_covariance_from_setup_cpp directional_ou_covariance_from_setup directional_ou_resolve_PtE_pair directional_ou_covariance

Documented in directional_ou_covariance directional_ou_variance

# covariance_directional_ou.R
#
# Closed-form covariance of the "proper global OU" directional model
# (alpha = 1, directional weights set via set_edge_weights()/
# setDirectionalWeightFunction()) on a finite acyclic directed metric tree.
# Implements Theorem thm:cov-lca of jonas_local/tex/main_rssb.tex (see
# jonas_local/cov_lca_plan.md sec 1 for a self-contained restatement).
#
# Inputs: a metric_graph with directional weights + DirectionalWeightFunction
# set, rate kappa > 0, white-noise scale tau, evaluation points (PtE, default
# the observation locations), and an optional per-source anchoring variance
# sigma_source.
# Outputs: directional_ou_covariance() -- dense covariance matrix;
# directional_ou_variance() -- marginal variance vector.
#
# Math summary (see the plan for the derivation):
#   - At interior vertex v, u(v-out) = sum_in beta_v(out,in) u(v-in) a.s.
#     beta_v(e_out, e_in) = -in_weight_by_edge[e_in]/out_weight_by_edge[e_out]
#     (directional_weight_vectors() in R/util.R -- shared with the constraint
#     matrix builder, so this is consistent with the fitted precision matrix
#     by construction).
#   - Transfer factor for a forward path x -> y: A(x,y) = exp(-kappa*d(x,y)) *
#     product of beta_v crossed strictly between x and y.
#   - Marginal variance recursion along an edge of length h, tail variance v0:
#     r(head) = exp(-2*kappa*h)*v0 + (1-exp(-2*kappa*h))/(2*kappa*tau^2).
#   - Covariance: r(s,t) = r(a,a)*A(a,s)*A(a,t), a = last common ancestor of
#     s and t (unique maximal element of the upstream sets); r(s,t) = 0 if no
#     common ancestor exists.
#
# tau convention (pinned, see plan sec 3.5): stationary variance is
# 1/(2*kappa*tau^2) -- this matches R/spde_covariance.R's r_1(), NOT the
# "reciprocal_tau" convention used elsewhere in the package
# (R/graph_likelihoods.R etc., where tau = 1/reciprocal_tau).


## ---------------------------------------------------------------------------
## Public API
## ---------------------------------------------------------------------------

#' Closed-form covariance of the directional OU model
#'
#' Evaluates the covariance function of the "proper global OU" directional
#' model (Whittle-Matern alpha = 1 with directional vertex conditions) in
#' closed form, via the last-common-ancestor (LCA) representation, without
#' assembling or inverting the precision matrix.
#'
#' @details
#' The covariance between two points `s` and `t` is
#' \deqn{r(s,t) = r(a,a) \, A(a,s) \, A(a,t)}
#' where `a` is the last common ancestor (LCA) of `s` and `t` (or covariance
#' `0` if no common ancestor exists), `r(a,a)` is the marginal variance at
#' `a`, and
#' \deqn{A(x,y) = \exp(-\kappa \, d(x,y)) \prod_v \beta_v}
#' is the transfer factor for the (unique, forward) path from `x` to `y`,
#' the product running over the `beta_v` vertex weights of every vertex
#' crossed strictly between `x` and `y`.
#'
#' In plain terms: the last common ancestor of two points is the
#' furthest-downstream point that is upstream of both of them (it does not
#' exist if the two points lie on disjoint headwater branches, in which case
#' their covariance is exactly zero). The transfer factor `A(x,y)` is how
#' much of the value at `x` propagates forward to `y`: exponential decay
#' over the distance `d(x,y)`, attenuated by the directional weight
#' `beta_v` at every vertex the path passes through.
#'
#' This closed form requires `graph` to represent a directed *tree*: the
#' undirected skeleton must have no cycles, and the directed graph must
#' itself be acyclic. If `graph` has a directed cycle, is not a tree, or has
#' no directional weight function set, this errors (via the internal
#' `directional_ou_setup()` validation) -- see
#' `graph$setDirectionalWeightFunction()` to set one.
#'
#' @param graph A `metric_graph` object with directional edge weights set
#'  (`set_edge_weights(directional_weights = ...)`) and the vertex weight
#'  functions set (`setDirectionalWeightFunction()`).
#' @param kappa Rate parameter of the OU process (`kappa > 0`).
#' @param tau White-noise scale. The stationary marginal variance is
#'  `1/(2*kappa*tau^2)` -- the same convention as `MetricGraph:::r_1()`. Note
#'  this is NOT `reciprocal_tau` (used elsewhere in the package as
#'  `tau = 1/reciprocal_tau`); pass `tau` directly here.
#' @param PtE Evaluation points, a matrix with columns `(edge_number,
#'  distance_on_edge)`. Defaults to `graph$get_PtE()` (the observation
#'  locations).
#' @param PtE2 Second set of evaluation points for the cross-covariance
#'  `Cov(u(PtE), u(PtE2))`. Defaults to `PtE`, giving the symmetric
#'  `n x n` covariance matrix at the observation locations.
#' @param sigma_source Anchoring variance at the source vertices
#'  (indegree 0). `NULL` (default) anchors every source at the stationary
#'  variance `1/(2*kappa*tau^2)`, matching
#'  `Qalpha1_edges_custom(..., stationary_points = "all")`. Otherwise a
#'  numeric vector giving `Var(u)` at each source vertex, either named by
#'  vertex index (as character) or positional in the order
#'  `which(graph$get_degrees("indegree") == 0)`.
#' @param normalized If `TRUE` (default), `distance_on_edge` in `PtE`/`PtE2`
#'  is in `[0, 1]`; if `FALSE`, distances are absolute.
#' @param cpp If `TRUE` (default), use the C++ numeric setup and the shared C++
#'  covariance fill for oriented in-trees and out-trees. Irregular trees use
#'  the generic R fallback. Set to `FALSE` to use the pure-R implementation
#'  throughout.
#'
#' @return A dense `matrix` with `nrow(PtE)` rows and `nrow(PtE2)` columns.
#' @seealso [directional_ou_variance()] for the diagonal only.
#' @examples
#' edge1 <- rbind(c(0, 0), c(1, 0))
#' edge2 <- rbind(c(1, 0), c(2, 0))
#' edge3 <- rbind(c(1, 0), c(1, 1))
#' graph <- metric_graph$new(edges = list(edge1, edge2, edge3))
#' graph$set_edge_weights(weights = data.frame(w = c(1, 1, 1)),
#'                        directional_weights = "w")
#' graph$setDirectionalWeightFunction(f_in = function(x) sqrt(x / sum(x)))
#' Sigma <- directional_ou_covariance(graph, kappa = 1, tau = 1,
#'                                    PtE = rbind(c(1, 0.5), c(2, 0.5)))
#' Sigma
#' @export
directional_ou_covariance <- function(graph, kappa, tau,
                                      PtE = NULL, PtE2 = NULL,
                                      sigma_source = NULL,
                                      normalized = TRUE,
                                      cpp = TRUE) {
  setup <- directional_ou_setup(
    graph, kappa, tau, sigma_source, cpp = cpp
  )
  covariance_builder <- if (cpp) {
    directional_ou_covariance_from_setup_cpp
  } else {
    directional_ou_covariance_from_setup
  }
  covariance_builder(
    setup, PtE = PtE, PtE2 = PtE2, normalized = normalized
  )
}

# Resolve PtE/PtE2 defaults, convert to absolute distance, and apply the
# DIRECTIONAL_OU_MAX_POINTS dense-matrix size guard -- the boundary logic
# shared by directional_ou_covariance_from_setup() (R pairwise fill) and
# directional_ou_covariance_from_setup_cpp() (C++ oriented-tree fill dispatch).
# Extracted so the size guard (correctness-relevant: a dense n1 x n2 matrix
# beyond this is not viable) can't silently drift between the two paths.
#'
#' @param DIRECTIONAL_OU_MAX_POINTS Maximum number of points allowed in either
#'   `PtE` or `PtE2`. Defaults to the `DIRECTIONAL_OU_MAX_POINTS` option, or
#'   `10000L` when that option is unset. Larger inputs are rejected because the
#'   covariance calculation constructs a dense matrix.
#' @noRd
directional_ou_resolve_PtE_pair <- function(setup, PtE, PtE2, normalized,
                                            DIRECTIONAL_OU_MAX_POINTS =
                                              getOption(
                                                "DIRECTIONAL_OU_MAX_POINTS",
                                                10000L
                                              )) {
  graph <- setup$graph
  if (is.null(PtE)) {
    PtE <- graph$get_PtE()
  }
  if (is.null(PtE2)) {
    PtE2 <- PtE
  }

  # normalized=TRUE distances are in [0,1]; convert to absolute distance once,
  # here, at the boundary -- everything below this point works in absolute
  # edge-length units.
  edge_lengths <- graph$edge_lengths
  PtE_abs <- to_absolute_PtE(PtE, edge_lengths, normalized)
  PtE2_abs <- to_absolute_PtE(PtE2, edge_lengths, normalized)

  n1 <- nrow(PtE_abs)
  n2 <- nrow(PtE2_abs)
  if (length(DIRECTIONAL_OU_MAX_POINTS) != 1L ||
      !is.numeric(DIRECTIONAL_OU_MAX_POINTS) ||
      !is.finite(DIRECTIONAL_OU_MAX_POINTS) ||
      DIRECTIONAL_OU_MAX_POINTS < 1) {
    stop("option DIRECTIONAL_OU_MAX_POINTS must be one positive finite number")
  }
  if (n1 > DIRECTIONAL_OU_MAX_POINTS || n2 > DIRECTIONAL_OU_MAX_POINTS) {
    stop(sprintf(
      "directional_ou_covariance() builds a dense n1 x n2 matrix; got n1=%d, n2=%d, both must be <= %d. Supply a smaller PtE/PtE2.",
      n1, n2, DIRECTIONAL_OU_MAX_POINTS
    ))
  }

  list(PtE_abs = PtE_abs, PtE2_abs = PtE2_abs)
}

# Pairwise-fill covariance from an already-built setup (from
# directional_ou_setup(), or a cached directional_ou_setup_structure() plus
# a freshly-computed directional_ou_setup_numeric() merged into the same
# shape -- see directional_ou_setup()'s composer). Split out of
# directional_ou_covariance() so a caller that already has a setup (e.g. a
# precomputed likelihood evaluator reusing the graph-structural part across
# repeated kappa/tau) can skip rebuilding it.
#' @noRd
directional_ou_covariance_from_setup <- function(setup, PtE, PtE2 = NULL,
                                                  normalized = TRUE) {
  resolved <- directional_ou_resolve_PtE_pair(setup, PtE, PtE2, normalized)
  PtE_abs <- resolved$PtE_abs
  PtE2_abs <- resolved$PtE2_abs

  n1 <- nrow(PtE_abs)
  n2 <- nrow(PtE2_abs)
  Sigma <- matrix(0, nrow = n1, ncol = n2)
  same_point_set <- identical(PtE_abs, PtE2_abs)
  for (i in seq_len(n1)) {
    j_start <- if (same_point_set) i else 1L
    for (j in seq_len(n2)) {
      if (same_point_set && j < i) next  # fill by symmetry below
      Sigma[i, j] <- directional_ou_pair_covariance(
        setup, PtE_abs[i, ], PtE2_abs[j, ]
      )
    }
  }
  if (same_point_set) {
    Sigma[lower.tri(Sigma)] <- t(Sigma)[lower.tri(Sigma)]
  }
  Sigma
}

# Dispatch wrapper for the pairwise-fill covariance: routes both supported
# oriented-tree shapes through one O(n^2) C++ kernel -- converging in-trees
# (including the K1/K2 river graphs) use Euler ancestor containment, while
# diverging out-trees (including reversed continuity) use their cached
# Euler/RMQ LCA index. Only irregularly oriented trees retain the generic R
# graph-walk fallback.
#' @noRd
directional_ou_covariance_from_setup_cpp <- function(setup, PtE, PtE2 = NULL,
                                                       normalized = TRUE) {
  if (!(setup$tree_orientation %in% c("in", "out"))) {
    return(directional_ou_covariance_from_setup(setup, PtE, PtE2, normalized))
  }

  resolved <- directional_ou_resolve_PtE_pair(setup, PtE, PtE2, normalized)
  PtE_abs <- resolved$PtE_abs
  PtE2_abs <- resolved$PtE2_abs

  directional_ou_covariance_oriented_tree_cpp(
    E = setup$graph$E,
    edge_lengths = setup$graph$edge_lengths,
    tree_orientation = setup$tree_orientation,
    kappa = setup$kappa,
    sigma_stationary = setup$sigma_stationary,
    var_tail = setup$var_tail,
    logG_tail = setup$logG_tail,
    signG_tail = setup$signG_tail,
    enter = setup$enter,
    exit = setup$exit,
    depth = setup$depth,
    out_tree_lca_index = setup$out_tree_lca_index,
    PtE_abs = PtE_abs,
    PtE2_abs = PtE2_abs,
    same_point_set = identical(PtE_abs, PtE2_abs)
  )
}

#' Closed-form marginal variance of the directional OU model
#'
#' `r(x,x)` at each evaluation point -- the diagonal of
#' [directional_ou_covariance()], computed directly via the marginal
#' variance recursion along each edge, without any last-common-ancestor or
#' transfer-factor work.
#'
#' @inheritParams directional_ou_covariance
#' @return A numeric vector of length `nrow(PtE)`.
#' @seealso [directional_ou_covariance()] for the full closed-form
#'  covariance (including the `\deqn{r(s,t) = r(a,a) A(a,s) A(a,t)}` formula
#'  and the tree/acyclicity requirements).
#' @examples
#' edge1 <- rbind(c(0, 0), c(1, 0))
#' edge2 <- rbind(c(1, 0), c(2, 0))
#' edge3 <- rbind(c(1, 0), c(1, 1))
#' graph <- metric_graph$new(edges = list(edge1, edge2, edge3))
#' graph$set_edge_weights(weights = data.frame(w = c(1, 1, 1)),
#'                        directional_weights = "w")
#' graph$setDirectionalWeightFunction(f_in = function(x) sqrt(x / sum(x)))
#' directional_ou_variance(graph, kappa = 1, tau = 1,
#'                         PtE = rbind(c(1, 0.5), c(2, 0.5)))
#' @export
directional_ou_variance <- function(graph, kappa, tau, PtE = NULL,
                                    sigma_source = NULL, normalized = TRUE,
                                    cpp = TRUE) {
  if (is.null(PtE)) {
    PtE <- graph$get_PtE()
  }
  edge_lengths <- graph$edge_lengths
  PtE_abs <- to_absolute_PtE(PtE, edge_lengths, normalized)

  setup <- directional_ou_setup(
    graph, kappa, tau, sigma_source, cpp = cpp
  )
  directional_point_variance(setup, PtE_abs)
}


## ---------------------------------------------------------------------------
## Boundary helpers (public-function plumbing only, no graph math)
## ---------------------------------------------------------------------------

# Convert a PtE matrix to absolute distance-on-edge, once, at the boundary of
# the public functions.
#' @noRd
to_absolute_PtE <- function(PtE, edge_lengths, normalized) {
  PtE <- as.matrix(PtE)
  if (normalized) {
    PtE[, 2] <- PtE[, 2] * edge_lengths[PtE[, 1]]
  }
  PtE
}

# r(s,t) for a single pair of (already absolute-distance) points.
#' @noRd
directional_ou_pair_covariance <- function(setup, point_s, point_t) {
  lca <- directional_lca(setup, point_s, point_t)
  if (is.null(lca)) {
    return(0)
  }
  r_aa <- directional_point_variance(setup, matrix(lca$point, nrow = 1))
  log_A_s <- directional_log_transfer(setup, lca$point, point_s)
  log_A_t <- directional_log_transfer(setup, lca$point, point_t)
  r_aa * log_A_s$sign * log_A_t$sign * exp(log_A_s$log_abs + log_A_t$log_abs)
}


## ---------------------------------------------------------------------------
## Internal layer -- per-graph setup
## ---------------------------------------------------------------------------
##
## directional_ou_setup() precomputes everything the pairwise/pointwise
## evaluators above need, once per (graph, kappa, tau, sigma_source):
##   - a topological order of the edges (source -> outlet), which also
##     doubles as the acyclicity check the theorem requires;
##   - beta_v(e_out, e_in) inputs (out_by_edge/in_by_edge), shared with the
##     constraint-matrix builder via directional_weight_vectors();
##   - the vertex variance recursion var_tail/var_head (marginal variance of
##     u just before/after crossing each edge);
##   - root-normalised log-transfer accumulators logG_*/signG_* -- the
##     log|A(edge-head or edge-tail, outlet)| and its sign, needed by the
##     (not-yet-written) fast dendritic covariance path so it can form
##     A(x,y) = A(x,outlet)/A(y,outlet) without repeated products of beta's
##     along a path.

# Topological order of the edges: edge e depends on every edge f whose head
# is e's tail (E[f,2] == E[e,1]) -- f must be "crossed" before e can be. This
# is Kahn's algorithm on that edge-precedence graph. An edge's initial
# in-edge-graph-degree is exactly the indegree of its tail vertex
# (V_indegree[E[e,1]]), since every in-edge at that vertex is a predecessor.
#' @noRd
directional_edge_topo_order <- function(E, nE, nV, V_indegree) {
  out_edges_by_vertex <- split(seq_len(nE), factor(E[, 1], levels = seq_len(nV)))
  remaining_indeg <- V_indegree  # decremented as each in-edge at a vertex is processed

  queue <- which(V_indegree[E[, 1]] == 0)  # edges whose tail vertex is already a source
  topo_order <- integer(nE)
  n_done <- 0L
  head_ptr <- 1L
  while (head_ptr <= length(queue)) {
    e <- queue[head_ptr]
    head_ptr <- head_ptr + 1L
    n_done <- n_done + 1L
    topo_order[n_done] <- e

    v <- E[e, 2]
    remaining_indeg[v] <- remaining_indeg[v] - 1L
    if (remaining_indeg[v] == 0L) {
      queue <- c(queue, out_edges_by_vertex[[v]])
    }
  }

  if (n_done != nE) {
    stop("the directed graph has a cycle: not all edges could be topologically ordered. directional_ou_setup() requires an acyclic directed graph (Theorem thm:cov-lca).")
  }
  topo_order
}

# Number of connected components of the undirected skeleton (E[,1]-E[,2]
# edges, direction ignored), via union-find. Used for the tree-structure
# guard: a directed graph gives every point a unique last-common-ancestor
# only if it is a tree once direction is forgotten.
#' @noRd
count_undirected_components <- function(E, nE, nV) {
  parent <- seq_len(nV)
  find_root <- function(x) {
    while (parent[x] != x) {
      parent[x] <<- parent[parent[x]]  # path halving
      x <- parent[x]
    }
    x
  }
  for (e in seq_len(nE)) {
    r1 <- find_root(E[e, 1])
    r2 <- find_root(E[e, 2])
    if (r1 != r2) parent[r1] <- r2
  }
  roots <- vapply(seq_len(nV), find_root, numeric(1))
  length(unique(roots))
}

#' Precompute the graph-structural (kappa/tau/sigma_source-independent) part
#' of the directional OU setup
#'
#' Validates that `graph` is a valid input for Theorem thm:cov-lca
#' (directional weight function set, acyclic, a tree), then computes a
#' topological edge order and the beta_v inputs (`out_by_edge`/`in_by_edge`).
#' This is the part of `directional_ou_setup()` that depends only on `graph`,
#' not on `kappa`/`tau`/`sigma_source` -- split out so it can be computed once
#' and reused across multiple `(kappa, tau, sigma_source)` calls (e.g. during
#' likelihood optimization).
#'
#' @param graph A `metric_graph` object with directional edge weights and
#'  vertex weight functions set, as in [directional_ou_covariance()].
#' @return A list with components `graph`, `E`, `nE`, `nV`, `V_indegree`,
#'  `V_outdegree`, `topo_order`, `out_by_edge`, `in_by_edge`, `is_dendritic`,
#'  `tree_orientation` (`"in"`/`"out"`/`"irregular"`),
#'  `in_edges_by_vertex`, `out_edges_by_vertex`, `enter`, `exit`, and `depth`
#'  (the last three `NULL` for irregular trees), plus `parent_edge` and
#'  `out_tree_lca_index` (`NULL` unless `tree_orientation == "out"`).
#' @noRd
directional_ou_setup_structure <- function(graph) {
  if (is.null(graph$DirectionalWeightFunction_in)) {
    stop("graph has no directional weight function set; call graph$setDirectionalWeightFunction() before using the directional OU covariance.")
  }

  E <- graph$E
  nE <- graph$nE
  nV <- graph$nV
  V_indegree <- graph$get_degrees("indegree")
  V_outdegree <- graph$get_degrees("outdegree")

  topo_order <- directional_edge_topo_order(E, nE, nV, V_indegree)

  n_components <- count_undirected_components(E, nE, nV)
  if (nE != nV - n_components) {
    stop(paste0(
      "graph is not a tree (nE = ", nE, ", nV = ", nV, ", undirected components = ",
      n_components, "); directional OU covariance requires a directed tree. ",
      "Vertices with indegree > 1 (candidates for a broken unique-path structure): ",
      paste(which(V_indegree > 1), collapse = ", ")
    ))
  }

  # Same accessor pattern as buildDirectionalConstraints() (R/metric_graph.R).
  dw <- graph$.__enclos_env__$private$directional_weights
  weight <- as.vector(graph$get_edge_weights()[[dw]])
  weight_vectors <- directional_weight_vectors(
    E = E, nE = nE, weight = weight,
    DirectionalWeightFunction_out = graph$DirectionalWeightFunction_out,
    DirectionalWeightFunction_in = graph$DirectionalWeightFunction_in
  )
  out_by_edge <- weight_vectors$out_by_edge
  in_by_edge <- weight_vectors$in_by_edge

  is_dendritic <- all(V_outdegree <= 1)
  is_out_tree <- !is_dendritic && all(V_indegree <= 1)
  tree_orientation <- if (is_dendritic) {
    "in"
  } else if (is_out_tree) {
    "out"
  } else {
    "irregular"
  }

  # Vertex -> incident-edge lookups, replacing which(E[,2]==v)/which(E[,1]==v)
  # scans (each O(nE)) with an O(1) list index. split() keys its result by
  # as.character() of the grouping values, so index by as.character(v), not
  # v directly; a vertex with no such edges simply has no key (index with
  # `[[...]]` returns NULL, not the empty-which()'s integer(0) -- callers
  # must guard, e.g. `%||%` a fallback of integer(0)).
  in_edges_by_vertex <- split(seq_len(nE), E[, 2])
  out_edges_by_vertex <- split(seq_len(nE), E[, 1])

  ancestor_labels <- directional_ou_ancestor_labels(
    E, V_indegree, V_outdegree, in_edges_by_vertex,
    out_edges_by_vertex, tree_orientation
  )
  out_tree_lca_index <- if (identical(tree_orientation, "out")) {
    directional_ou_out_tree_lca_index_cpp(
      ancestor_labels$parent_edge, ancestor_labels$depth
    )
  } else {
    NULL
  }

  list(
    graph = graph,
    E = E,
    nE = nE,
    nV = nV,
    V_indegree = V_indegree,
    V_outdegree = V_outdegree,
    topo_order = topo_order,
    out_by_edge = out_by_edge,
    in_by_edge = in_by_edge,
    is_dendritic = is_dendritic,
    tree_orientation = tree_orientation,
    in_edges_by_vertex = in_edges_by_vertex,
    out_edges_by_vertex = out_edges_by_vertex,
    enter = ancestor_labels$enter,
    exit = ancestor_labels$exit,
    depth = ancestor_labels$depth,
    parent_edge = ancestor_labels$parent_edge,
    out_tree_lca_index = out_tree_lca_index
  )
}

#' Euler-tour interval labels for O(1) ancestor tests on oriented trees
#'
#' In-trees are rooted at outlets and walked upstream; out-trees are rooted
#' at sources and walked downstream. The resulting interval-containment test
#' is used by `directional_edge_downstream_of()`. Irregularly oriented trees
#' return `NULL` labels and retain the generic graph-walk fallback.
#'
#' @param E The graph's edge matrix (columns `(tail, head)`).
#' @param V_indegree Indegree of each vertex.
#' @param V_outdegree Outdegree of each vertex.
#' @param in_edges_by_vertex List (as produced by `split()`, keyed by
#'  `as.character(vertex)`) of in-edges at each vertex.
#' @param out_edges_by_vertex Corresponding list of out-edges.
#' @param tree_orientation One of `"in"`, `"out"`, or `"irregular"`.
#' @return `list(enter, exit, depth, parent_edge)`. `parent_edge` is only
#'  populated for out-trees.
#' @noRd
directional_ou_ancestor_labels <- function(E, V_indegree, V_outdegree,
                                           in_edges_by_vertex,
                                           out_edges_by_vertex,
                                           tree_orientation) {
  if (!(tree_orientation %in% c("in", "out"))) {
    return(list(
      enter = NULL, exit = NULL, depth = NULL, parent_edge = NULL
    ))
  }

  nE <- nrow(E)
  enter <- integer(nE)
  exit <- integer(nE)
  depth <- integer(nE)
  counter <- 0L

  if (tree_orientation == "in") {
    roots <- which(V_outdegree[E[, 2]] == 0)
    children_of <- function(edge) {
      in_edges_by_vertex[[as.character(E[edge, 1])]]
    }
  } else {
    roots <- which(V_indegree[E[, 1]] == 0)
    children_of <- function(edge) {
      out_edges_by_vertex[[as.character(E[edge, 2])]]
    }
  }

  # Iterative, explicit-stack DFS. A recursive visit() (one R call per edge
  # along the deepest upstream chain) can exhaust the native C stack --an
  # uncatchable crash, not an R-level error -- well within plausible depth
  # for a real river network's finer headwater chains. Preallocated to
  # 2*nE frames (every edge is pushed exactly once as an "enter" frame and
  # once as an "exit" frame across the whole traversal) to avoid repeated
  # vector-growth copies. Pushing the exit frame before a node's (reversed)
  # children reproduces the recursive visit()'s pre/post-order numbering
  # exactly: children are fully unwound (enter *and* exit) before their
  # next sibling is popped, just as nested recursive calls would.
  stack_edge <- integer(2L * nE)
  stack_is_enter <- logical(2L * nE)
  stack_depth <- integer(2L * nE)
  sp <- 0L
  push <- function(edge, is_enter, edge_depth) {
    sp <<- sp + 1L
    stack_edge[sp] <<- edge
    stack_is_enter[sp] <<- is_enter
    stack_depth[sp] <<- edge_depth
  }

  for (root in roots) {
    push(root, TRUE, 0L)
    while (sp > 0L) {
      edge <- stack_edge[sp]
      is_enter <- stack_is_enter[sp]
      edge_depth <- stack_depth[sp]
      sp <- sp - 1L
      if (is_enter) {
        counter <- counter + 1L
        enter[edge] <- counter
        depth[edge] <- edge_depth
        push(edge, FALSE, edge_depth)
        for (child in rev(children_of(edge))) {
          push(child, TRUE, edge_depth + 1L)
        }
      } else {
        counter <- counter + 1L
        exit[edge] <- counter
      }
    }
  }

  parent_edge <- NULL
  if (tree_orientation == "out") {
    # In an out-tree every vertex has at most one incoming edge. Map each
    # vertex to that edge once, then index by edge tail to obtain all parent
    # edges without per-edge character conversion and named-list lookup.
    incoming_edge_by_vertex <- rep(NA_integer_, length(V_indegree))
    incoming_edge_by_vertex[E[, 2]] <- seq_len(nE)
    parent_edge <- incoming_edge_by_vertex[E[, 1]]
  }

  list(
    enter = enter, exit = exit, depth = depth, parent_edge = parent_edge
  )
}

#' Resolve `sigma_source` into a flat, per-vertex anchoring-variance vector
#'
#' Implements the `sigma_source` name-matching/positional-matching semantics
#' documented on [directional_ou_covariance()]: `NULL` anchors every source
#' vertex (indegree 0) at `sigma_stationary`; otherwise `sigma_source` is
#' matched either by name (character vertex index) or positionally, in the
#' order `which(V_indegree == 0)`. Extracted out of
#' `directional_ou_setup_numeric()` so the C++ port
#' (`directional_ou_setup_numeric_cpp()`) has a single R-side place to
#' resolve this from -- R closures/named-vector semantics don't cross the
#' C++ boundary, so this is evaluated once, up front, in R, exactly like
#' `directional_weight_vectors()` is for the constraint-matrix builder.
#'
#' @param structure Output of `directional_ou_setup_structure()`.
#' @param sigma_source `NULL` or a numeric vector, as documented on
#'  [directional_ou_covariance()].
#' @param sigma_stationary The stationary variance `1/(2*kappa*tau^2)`.
#' @return A length-`nV` numeric vector, indexed by vertex: entry `v` is the
#'  anchoring variance for vertex `v`. Only meaningful where
#'  `V_indegree[v] == 0`; non-source entries default to `sigma_stationary`
#'  but are otherwise unused -- callers must still guard on `V_indegree`.
#' @noRd
directional_ou_resolve_source_var <- function(structure, sigma_source, sigma_stationary) {
  V_indegree <- structure$V_indegree
  nV <- structure$nV

  source_vertices <- which(V_indegree == 0)
  named_source_var <- stats::setNames(rep(sigma_stationary, length(source_vertices)),
                                      as.character(source_vertices))
  if (!is.null(sigma_source)) {
    if (!is.null(names(sigma_source))) {
      bad_names <- setdiff(names(sigma_source), as.character(source_vertices))
      if (length(bad_names) > 0) {
        stop(sprintf(
          "directional_ou_resolve_source_var(): sigma_source name(s) %s do not match any source vertex (indegree 0); source vertices are %s",
          paste(bad_names, collapse = ", "), paste(source_vertices, collapse = ", ")
        ))
      }
      named_source_var[names(sigma_source)] <- sigma_source
    } else {
      if (length(sigma_source) != length(source_vertices)) {
        stop(sprintf(
          "directional_ou_resolve_source_var(): unnamed sigma_source has length %d, but there are %d source vertices (indegree 0); supply a length-%d vector (in the order of `which(V_indegree == 0)`) or name sigma_source by vertex index",
          length(sigma_source), length(source_vertices), length(source_vertices)
        ))
      }
      named_source_var[as.character(source_vertices)] <- sigma_source
    }
  }

  flat <- rep(sigma_stationary, nV)
  flat[source_vertices] <- named_source_var[as.character(source_vertices)]
  flat
}

#' Precompute the kappa/tau/sigma_source-dependent part of the directional OU
#' setup
#'
#' Given the graph-structural part from `directional_ou_setup_structure()`,
#' computes the vertex variance recursion (`var_tail`/`var_head`) and the
#' root-normalised log-transfer accumulators (`logG_*`/`signG_*`) -- the
#' quantities that change whenever `kappa`, `tau`, or `sigma_source` change,
#' even if `graph` doesn't.
#'
#' @param structure Output of `directional_ou_setup_structure()`.
#' @inheritParams directional_ou_covariance
#' @return A list with components `kappa`, `tau`, `sigma_stationary`,
#'  `var_tail`, `var_head`, `logG_head`, `logG_tail`, `signG_head`,
#'  `signG_tail`.
#' @noRd
directional_ou_setup_numeric <- function(structure, kappa, tau, sigma_source) {
  E <- structure$E
  nE <- structure$nE
  topo_order <- structure$topo_order
  V_indegree <- structure$V_indegree
  V_outdegree <- structure$V_outdegree
  out_by_edge <- structure$out_by_edge
  in_by_edge <- structure$in_by_edge
  edge_lengths <- structure$graph$edge_lengths
  in_edges_by_vertex <- structure$in_edges_by_vertex
  out_edges_by_vertex <- structure$out_edges_by_vertex

  sigma_stationary <- 1 / (2 * kappa * tau^2)

  # Per-source-vertex anchoring variance, broadcast across all out-edges of
  # that vertex (a source can have outdegree > 1). See
  # directional_ou_resolve_source_var() for the sigma_source name-matching
  # logic (extracted out so the C++ port has a single R-side place to get a
  # flat, resolved anchoring-variance vector from).
  source_var <- directional_ou_resolve_source_var(structure, sigma_source, sigma_stationary)

  var_tail <- numeric(nE)
  var_head <- numeric(nE)
  for (e in topo_order) {
    v <- E[e, 1]
    if (V_indegree[v] == 0) {
      var_tail[e] <- source_var[v]
    } else {
      E_in <- in_edges_by_vertex[[as.character(v)]]  # replaces which(E[,2]==v); V_indegree[v]>0 guarantees a non-NULL key here
      beta_sq <- (in_by_edge[E_in] / out_by_edge[e])^2  # beta_v(e,f) = -in_by_edge[f]/out_by_edge[e]
      var_tail[e] <- sum(beta_sq * var_head[E_in])
    }
    c2 <- exp(-2 * kappa * edge_lengths[e])
    var_head[e] <- c2 * var_tail[e] + (1 - c2) * sigma_stationary
  }

  # Root-normalised log-transfer accumulators. In-trees are normalised toward
  # their outlet and processed in reverse topological order. Out-trees are
  # normalised from their source and processed in forward topological order.
  # The latter is the orientation produced by reversing a dendritic river
  # graph for the continuity stand-in.
  logG_head <- numeric(nE)
  logG_tail <- numeric(nE)
  signG_head <- numeric(nE)
  signG_tail <- numeric(nE)
  if (identical(structure$tree_orientation, "out")) {
    for (e in topo_order) {
      v <- E[e, 1]
      if (V_indegree[v] == 0) {
        logG_tail[e] <- 0
        signG_tail[e] <- 1
      } else {
        f <- in_edges_by_vertex[[as.character(v)]][1]
        beta_ef <- -in_by_edge[f] / out_by_edge[e]
        logG_tail[e] <- logG_head[f] + log(abs(beta_ef))
        signG_tail[e] <- signG_head[f] * sign(beta_ef)
      }
      logG_head[e] <- logG_tail[e] - kappa * edge_lengths[e]
      signG_head[e] <- signG_tail[e]
    }
  } else {
    for (e in rev(topo_order)) {
      v <- E[e, 2]
      if (V_outdegree[v] == 0) {
        logG_head[e] <- 0
        signG_head[e] <- 1
      } else {
        # Off the in-tree path the first out-edge is arbitrary, so irregular
        # trees never consume these accumulators.
        g <- out_edges_by_vertex[[as.character(v)]][1]
        beta_ge <- -in_by_edge[e] / out_by_edge[g]
        logG_head[e] <- logG_tail[g] + log(abs(beta_ge))
        signG_head[e] <- signG_tail[g] * sign(beta_ge)
      }
      logG_tail[e] <- logG_head[e] - kappa * edge_lengths[e]
      signG_tail[e] <- signG_head[e]
    }
  }

  list(
    kappa = kappa,
    tau = tau,
    sigma_stationary = sigma_stationary,
    var_tail = var_tail,
    var_head = var_head,
    logG_head = logG_head,
    logG_tail = logG_tail,
    signG_head = signG_head,
    signG_tail = signG_tail
  )
}

#' Precompute per-edge quantities for the directional OU covariance
#'
#' Internal setup shared by [directional_ou_covariance()] and
#' [directional_ou_variance()]: validates that `graph` is a valid input for
#' Theorem thm:cov-lca (directional weight function set, acyclic, a tree),
#' then computes a topological edge order, the beta_v inputs, the vertex
#' variance recursion, and root-normalised log-transfer accumulators.
#'
#' A thin composer of `directional_ou_setup_structure()` (the
#' `graph`-only part) and `directional_ou_setup_numeric()` (the
#' `kappa`/`tau`/`sigma_source`-dependent part) -- kept split so the
#' structural part can be reused across repeated calls with different
#' `kappa`/`tau`/`sigma_source` (e.g. likelihood optimization).
#'
#' @inheritParams directional_ou_covariance
#' @return A list with components `graph`, `kappa`, `tau`, `sigma_stationary`,
#'  `V_indegree`, `V_outdegree`, `topo_order`, `out_by_edge`, `in_by_edge`,
#'  `var_tail`, `var_head`, `logG_head`, `logG_tail`, `signG_head`,
#'  `signG_tail`, `is_dendritic`, `tree_orientation`, `enter`, `exit`,
#'  `depth`, and `parent_edge`.
#' @noRd
# Compose the structural and numeric setup pieces in one place. Public
# covariance calls and cached likelihood calls use the same object shape.
#' @noRd
directional_ou_compose_setup <- function(structure, numeric_part) {
  list(
    graph = structure$graph,
    kappa = numeric_part$kappa,
    tau = numeric_part$tau,
    sigma_stationary = numeric_part$sigma_stationary,
    V_indegree = structure$V_indegree,
    V_outdegree = structure$V_outdegree,
    topo_order = structure$topo_order,
    out_by_edge = structure$out_by_edge,
    in_by_edge = structure$in_by_edge,
    var_tail = numeric_part$var_tail,
    var_head = numeric_part$var_head,
    logG_head = numeric_part$logG_head,
    logG_tail = numeric_part$logG_tail,
    signG_head = numeric_part$signG_head,
    signG_tail = numeric_part$signG_tail,
    is_dendritic = structure$is_dendritic,
    tree_orientation = structure$tree_orientation,
    enter = structure$enter,
    exit = structure$exit,
    depth = structure$depth,
    parent_edge = structure$parent_edge,
    out_tree_lca_index = structure$out_tree_lca_index,
    in_edges_by_vertex = structure$in_edges_by_vertex,
    out_edges_by_vertex = structure$out_edges_by_vertex
  )
}

# Graph-skeleton view of directional_ou_setup_structure()'s output, shaped
# for an eventual R->C++ boundary (matching the skeleton <- list(nE=,
# edge_lengths=, E=, get_degrees=) convention in
# R/graph_likelihoods_v2.R's profile_Q_alpha1_directional(), extended with
# the directional-specific fields a C++ core will also need). Consumed by
# directional_ou_covariance_loglik_precompute() (R/likelihood_directional_ou_covariance.R).
#' @noRd
directional_ou_skeleton <- function(structure) {
  list(
    nE = structure$nE,
    edge_lengths = structure$graph$edge_lengths,
    E = structure$E,
    get_degrees = structure$V_indegree == 0,
    V_indegree = structure$V_indegree,
    V_outdegree = structure$V_outdegree,
    out_by_edge = structure$out_by_edge,
    in_by_edge = structure$in_by_edge,
    topo_order = structure$topo_order,
    tree_orientation = structure$tree_orientation
  )
}

# Select the numeric setup implementation without duplicating source-variance
# resolution or setup composition in each caller.
#' @noRd
directional_ou_setup_numeric_dispatch <- function(structure, kappa, tau,
                                                  sigma_source, cpp = TRUE) {
  if (!cpp) {
    return(directional_ou_setup_numeric(
      structure, kappa, tau, sigma_source
    ))
  }

  sigma_stationary <- 1 / (2 * kappa * tau^2)
  source_var <- directional_ou_resolve_source_var(
    structure, sigma_source, sigma_stationary
  )
  directional_ou_setup_numeric_cpp(
    directional_ou_skeleton(structure), kappa, tau, source_var
  )
}

#' @noRd
directional_ou_setup <- function(graph, kappa, tau, sigma_source,
                                 cpp = TRUE) {
  structure <- directional_ou_setup_structure(graph)
  numeric_part <- directional_ou_setup_numeric_dispatch(
    structure, kappa, tau, sigma_source, cpp = cpp
  )
  directional_ou_compose_setup(structure, numeric_part)
}


## ---------------------------------------------------------------------------
## Internal layer -- point evaluators
## ---------------------------------------------------------------------------
##
## The pointwise/pairwise evaluators used by directional_ou_pair_covariance()
## and directional_ou_variance(): the last-common-ancestor (LCA) search, the
## marginal-variance recursion evaluated at an arbitrary point on an edge
## (not just its head), and the transfer factor A(from, to) between two
## points with from upstream of (or equal to) to -- via the O(1) dendritic
## fast path when available, else an explicit forward path walk.

#' Last common ancestor (LCA) of two points
#'
#' Finds the unique maximal element of `Lambda-up(point_s) \cap
#' Lambda-up(point_t)` (the ancestor sets, i.e. points reachable by walking
#' upstream against edge direction), needed by
#' `directional_ou_pair_covariance()` to anchor `r(s,t) = r(a,a) * A(a,s) *
#' A(a,t)`. Four cases, tried in order:
#'   1. Same edge: the LCA is the upstream one of the two (smaller distance).
#'   2. One point's edge is (weakly) downstream of the other's: the LCA is
#'      the upstream point itself.
#'   3. Neither contains the other: only possible when the graph is
#'      non-dendritic (some vertex has outdegree > 1 -- a true diffluence
#'      upstream of both points). In a dendritic tree, two points whose
#'      branches only merge downstream of both share no common ancestor at
#'      all (confluences, indegree > 1, don't create upstream ambiguity) --
#'      that's case 4, not case 3. Best-effort / untested here: no
#'      non-dendritic fixture exists yet.
#'   4. No common ancestor: returns `NULL` (the caller,
#'      `directional_ou_pair_covariance()`, treats this as covariance 0).
#'
#' @param setup A setup list from `directional_ou_setup()`.
#' @param point_s,point_t Length-2 `c(edge_number, absolute_distance)`
#'  vectors.
#' @return `list(point = c(edge_number, absolute_distance))` giving the LCA,
#'  or `NULL` if `point_s` and `point_t` have no common ancestor.
#' @noRd
directional_lca <- function(setup, point_s, point_t) {
  E <- setup$graph$E

  # Case 1: same edge -- the LCA is the upstream (smaller-distance) point.
  if (point_s[1] == point_t[1]) {
    return(list(point = c(point_s[1], min(point_s[2], point_t[2]))))
  }

  # Case 2: one point's edge is (weakly) downstream of the other's.
  if (directional_edge_downstream_of(setup, point_s[1], point_t[1])) {
    return(list(point = point_s))
  }
  if (directional_edge_downstream_of(setup, point_t[1], point_s[1])) {
    return(list(point = point_t))
  }

  # Case 3: neither contains the other -- non-dendritic diffluence case.
  if (!setup$is_dendritic) {
    lca <- if (identical(setup$tree_orientation, "out")) {
      directional_lca_edge_out_tree(setup, point_s[1], point_t[1])
    } else {
      directional_lca_edge_nondendritic(setup, point_s[1], point_t[1])
    }
    if (!is.null(lca)) {
      if (!is.null(lca$root)) {
        # The two branches share a common source vertex (indegree 0) but no
        # common upstream *edge* -- which, by construction, only happens
        # when that source has outdegree > 1 (a single out-edge would
        # itself be a shared ancestor edge, so directional_lca_edge_nondendritic()
        # would have returned an `edge`, not a `root`, above). There is no
        # upstream edge to anchor a single lca$point to, and
        # c(lca_edge, 0) only reaches the lca_edge branch itself via the
        # forward-BFS transfer walk, not its sibling branch(es). Narrow,
        # known gap -- not silently mishandled.
        stop(sprintf(
          "directional_lca(): LCA at a branching source vertex (indegree 0, outdegree > 1) is not yet supported -- vertex %d",
          lca$root
        ))
      }
      lca_edge <- lca$edge
      return(list(point = c(lca_edge, setup$graph$edge_lengths[lca_edge])))
    }
  }

  # Case 4: no common ancestor.
  NULL
}

# Is to_edge reachable by walking forward (via out-edges) from from_edge's
# head vertex? Same BFS-over-out-edges shape as directional_transfer_walk(),
# but only a boolean reachability check -- no transfer-factor bookkeeping.
# Kept as the non-dendritic fallback for directional_edge_downstream_of();
# not itself touched by the O(1) dendritic fast path below. Uses the
# out_edges_by_vertex bucket (built once in directional_ou_setup_structure())
# instead of which(E[,1]==v) -- the latter is an O(nE) scan repeated at
# every BFS step, which is invisible on small fixtures but makes this
# function O(nE) *per step* on a real, heavily-branched network (measured:
# single-digit seconds per call on the 18,668-edge Mid-Columbia graph before
# this fix).
#' @noRd
directional_edge_reachable_forward <- function(E, out_edges_by_vertex, from_edge, to_edge) {
  visited <- out_edges_by_vertex[[as.character(E[from_edge, 2])]]
  frontier <- visited
  while (!(to_edge %in% visited) && length(frontier) > 0) {
    next_frontier <- integer(0)
    for (g in frontier) {
      children <- setdiff(out_edges_by_vertex[[as.character(E[g, 2])]], visited)
      next_frontier <- c(next_frontier, children)
    }
    visited <- c(visited, next_frontier)
    frontier <- next_frontier
  }
  to_edge %in% visited
}

# Is to_edge reachable forward from from_edge (i.e. from_edge is upstream of
# or equal to to_edge)? O(1) via orientation-aware Euler interval containment
# for in/out trees; otherwise use the explicit BFS fallback.
#' @noRd
directional_edge_downstream_of <- function(setup, from_edge, to_edge) {
  if (!is.null(setup$enter)) {
    if (identical(setup$tree_orientation, "out")) {
      setup$enter[from_edge] <= setup$enter[to_edge] &&
        setup$exit[to_edge] <= setup$exit[from_edge]
    } else {
      setup$enter[to_edge] <= setup$enter[from_edge] &&
        setup$exit[from_edge] <= setup$exit[to_edge]
    }
  } else {
    directional_edge_reachable_forward(setup$graph$E, setup$out_edges_by_vertex, from_edge, to_edge)
  }
}

# Deepest common ancestor of edge_s and edge_t, for the non-dendritic case 3
# above (case 2 already ruled out one containing the other). Walks upstream
# (via in-edges) from each edge's tail vertex to build its ancestor edge-set
# -- upstream can itself branch at confluences (indegree > 1), so the BFS
# may fan out over multiple in-edges per step. Each walk also records the
# source vertex(es) (indegree 0) it dead-ends at, since a source has no key
# in in_edges_by_vertex to expand from.
#
# Returns list(edge = <edge or NULL>, root = <vertex or NULL>):
#   - If the two edge-sets share an edge, the deepest (latest in
#     setup$topo_order) is a genuine, edge-anchorable LCA: `edge` is set.
#   - Else, if the two walks share a dead-end source vertex, that source is
#     the true (vertex-level) LCA but has no upstream edge to anchor to:
#     `root` is set. (That source is guaranteed to have outdegree > 1: a
#     single out-edge would itself be a shared ancestor edge, which the
#     first branch above would already have caught.)
#   - Else NULL: the ancestor sets are genuinely disjoint (case 4, no
#     common ancestor at all).
#
# Uses the in_edges_by_vertex bucket instead of which(E[,2]==v) -- see
# directional_edge_reachable_forward()'s comment for why the which()-in-
# a-BFS-loop pattern is a real, not cosmetic, bottleneck on this graph.
#' @noRd
directional_lca_edge_nondendritic <- function(setup, edge_s, edge_t) {
  E <- setup$graph$E
  in_edges_by_vertex <- setup$in_edges_by_vertex
  V_indegree <- setup$V_indegree
  ancestors_of <- function(edge) {
    start_tail <- E[edge, 1]
    roots <- if (V_indegree[start_tail] == 0) as.character(start_tail) else character(0)
    visited <- in_edges_by_vertex[[as.character(start_tail)]]
    frontier <- visited
    while (length(frontier) > 0) {
      next_frontier <- integer(0)
      for (f in frontier) {
        tail_f <- E[f, 1]
        if (V_indegree[tail_f] == 0) {
          roots <- c(roots, as.character(tail_f))
        } else {
          next_frontier <- c(next_frontier, setdiff(in_edges_by_vertex[[as.character(tail_f)]], visited))
        }
      }
      visited <- c(visited, next_frontier)
      frontier <- next_frontier
    }
    list(edges = visited, roots = unique(roots))
  }

  anc_s <- ancestors_of(edge_s)
  anc_t <- ancestors_of(edge_t)

  common_edges <- intersect(anc_s$edges, anc_t$edges)
  if (length(common_edges) > 0) {
    lca_edge <- common_edges[which.max(match(common_edges, setup$topo_order))]
    return(list(edge = lca_edge, root = NULL))
  }

  common_roots <- intersect(anc_s$roots, anc_t$roots)
  if (length(common_roots) > 0) {
    return(list(edge = NULL, root = as.integer(common_roots[1])))
  }

  NULL
}

#' Last common ancestor edge on an out-tree
#'
#' Uses precomputed parent pointers and depths to align two edges, then climbs
#' them in lockstep until they meet. The caller has already ruled out either
#' edge being an ancestor of the other, so a match is the third edge above a
#' genuine diffluence.
#'
#' @param setup A setup list from `directional_ou_setup()` with
#'  `tree_orientation == "out"`.
#' @param edge_s,edge_t Edge numbers.
#' @return `list(edge, root)` for an edge-anchorable LCA, or `NULL` for
#'  different components. A branching-source-only LCA remains unsupported.
#' @noRd
directional_lca_edge_out_tree <- function(setup, edge_s, edge_t) {
  depth <- setup$depth
  parent_edge <- setup$parent_edge
  E <- setup$graph$E

  a <- edge_s
  b <- edge_t
  while (depth[a] > depth[b]) a <- parent_edge[a]
  while (depth[b] > depth[a]) b <- parent_edge[b]

  while (a != b) {
    if (depth[a] == 0L) {
      if (E[a, 1] == E[b, 1]) {
        stop(sprintf(
          paste0(
            "directional_lca(): LCA at a branching source vertex ",
            "(indegree 0, outdegree > 1) is not yet supported -- vertex %d"
          ),
          E[a, 1]
        ))
      }
      return(NULL)
    }
    a <- parent_edge[a]
    b <- parent_edge[b]
  }

  list(edge = a, root = NULL)
}

#' Marginal variance at arbitrary points (vectorized)
#'
#' `r(x,x)` at each row of `PtE_abs`, via the same intra-edge variance
#' recursion used to build `var_head` in `directional_ou_setup()`
#' (`r(head) = exp(-2*kappa*h)*v0 + (1-exp(-2*kappa*h))*sigma_stationary`),
#' evaluated at the point's own distance along the edge rather than only at
#' the edge head.
#'
#' @param setup A setup list from `directional_ou_setup()`.
#' @param PtE_abs A matrix with columns `(edge_number, absolute_distance)`,
#'  one row per point (may be a single row).
#' @return A numeric vector of length `nrow(PtE_abs)`.
#' @noRd
directional_point_variance <- function(setup, PtE_abs) {
  PtE_abs <- as.matrix(PtE_abs)
  e <- PtE_abs[, 1]
  t <- PtE_abs[, 2]
  decay <- exp(-2 * setup$kappa * t)
  as.numeric(decay * setup$var_tail[e] + (1 - decay) * setup$sigma_stationary)
}

#' Transfer factor A(from, to) by explicit forward path walk
#'
#' Fallback for the non-dendritic case (and a cross-check for the fast
#' dendritic path in `directional_log_transfer()`): finds the unique forward
#' edge path from `from`'s edge to `to`'s edge by BFS through the directed
#' tree, then accumulates the `-kappa * length` legs and the `beta_v`
#' crossings edge by edge. `from` must be upstream of (or equal to) `to` on
#' the unique directed tree path (caller's responsibility, not re-verified
#' here). O(depth) per call -- this is an explicit fallback, not the fast
#' path, and is not meant to be optimized further.
#'
#' @param setup A setup list from `directional_ou_setup()`.
#' @param from,to Length-2 `c(edge_number, absolute_distance)` vectors.
#' @return `list(sign, log_abs)` with `sign * exp(log_abs) == A(from, to)`.
#' @noRd
directional_transfer_walk <- function(setup, from, to) {
  kappa <- setup$kappa
  E <- setup$graph$E
  edge_lengths <- setup$graph$edge_lengths
  out_edges_by_vertex <- setup$out_edges_by_vertex

  if (from[1] == to[1]) {
    return(list(sign = 1, log_abs = -kappa * (to[2] - from[2])))
  }

  # BFS forward from from's edge (starting at its head vertex) until to's
  # edge is discovered, recording a parent-edge map for path reconstruction.
  # Uses the out_edges_by_vertex bucket instead of which(E[,1]==v) -- see
  # directional_edge_reachable_forward()'s comment for why the which()-in-
  # a-BFS-loop pattern is a real, not cosmetic, bottleneck on this graph.
  parent_edge <- list()
  frontier <- out_edges_by_vertex[[as.character(E[from[1], 2])]]
  for (g in frontier) parent_edge[[as.character(g)]] <- from[1]
  visited_edges <- frontier
  while (!(to[1] %in% visited_edges) && length(frontier) > 0) {
    next_frontier <- integer(0)
    for (g in frontier) {
      children <- setdiff(out_edges_by_vertex[[as.character(E[g, 2])]], visited_edges)
      for (child in children) parent_edge[[as.character(child)]] <- g
      next_frontier <- c(next_frontier, children)
    }
    visited_edges <- c(visited_edges, next_frontier)
    frontier <- next_frontier
  }
  if (!(to[1] %in% visited_edges)) {
    stop("directional_transfer_walk(): to's edge is not reachable forward from from's edge; from must be upstream of to.")
  }

  # Reconstruct the edge sequence from from's edge to to's edge by following
  # parent pointers backward from to's edge, then reversing.
  edge_seq <- to[1]
  while (edge_seq[1] != from[1]) {
    edge_seq <- c(parent_edge[[as.character(edge_seq[1])]], edge_seq)
  }
  edge_seq <- edge_seq[-1]  # drop from[1] itself; keep only descendants

  log_abs <- -kappa * (edge_lengths[from[1]] - from[2])  # remainder of from's edge
  sign_acc <- 1
  prev <- from[1]
  for (g in edge_seq) {
    beta <- -setup$in_by_edge[prev] / setup$out_by_edge[g]
    log_abs <- log_abs + log(abs(beta))
    sign_acc <- sign_acc * sign(beta)
    traversal <- if (g != to[1]) edge_lengths[g] else to[2]
    log_abs <- log_abs - kappa * traversal
    prev <- g
  }
  list(sign = sign_acc, log_abs = log_abs)
}

#' Log-transfer factor A(from, to) -- fast oriented-tree path or explicit walk
#'
#' Dispatches to the O(1) root-normalised accumulators (`logG_tail`/
#' `signG_tail` from `directional_ou_setup()`) for in/out trees. In-trees
#' use outlet-normalised ratios; out-trees use source-normalised ratios in
#' the opposite subtraction order. Irregular trees retain the explicit walk.
#'
#' @param setup A setup list from `directional_ou_setup()`.
#' @param from,to Length-2 `c(edge_number, absolute_distance)` vectors, with
#'  `from` upstream of (or equal to) `to` on the unique directed tree path
#'  (caller's responsibility, not re-verified here).
#' @return `list(sign, log_abs)` with `sign * exp(log_abs) == A(from, to)`.
#' @noRd
directional_log_transfer <- function(setup, from, to) {
  if (identical(setup$tree_orientation, "out")) {
    logG_at <- function(point) {
      setup$logG_tail[point[1]] - setup$kappa * point[2]
    }
    log_abs <- logG_at(to) - logG_at(from)
    sign_val <- setup$signG_tail[from[1]] * setup$signG_tail[to[1]]
    return(list(sign = sign_val, log_abs = log_abs))
  }

  if (!setup$is_dendritic) {
    return(directional_transfer_walk(setup, from, to))
  }

  # logG_tail[e] + kappa*t equals logG_tail[e] at t=0 and logG_head[e] at
  # t=edge_lengths[e], since logG_tail[e] = logG_head[e] - kappa*length(e).
  logG_at <- function(point) setup$logG_tail[point[1]] + setup$kappa * point[2]

  log_abs <- logG_at(from) - logG_at(to)
  sign_val <- setup$signG_tail[from[1]] * setup$signG_tail[to[1]]
  list(sign = sign_val, log_abs = log_abs)
}

Try the MetricGraph package in your browser

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

MetricGraph documentation built on Nov. 11, 2026, 1:09 a.m.