R/duremstats.R

Defines functions activeSharedPartners activeDegreeDyad activeDegreeMax activeDegreeMin activeSharedPartners_isp activeSharedPartners_osp activeSharedPartners_itp activeSharedPartners_otp activeTotaldegreeDyad activeTotaldegreeReceiver activeTotaldegreeSender activeIndegreeReceiver activeOutdegreeSender activeReciprocalTie activeTie .active_effect_cfg duremstats .compute_active_stats .parse_active_effects .validate_consider_type_durem .maybe_prepend_baseline .compute_sentinel .apply_sentinel .sentinel_base_stypes .build_riskset_matrix .prepare_dual_edgelist

Documented in activeDegreeDyad activeDegreeMax activeDegreeMin activeIndegreeReceiver activeOutdegreeSender activeReciprocalTie activeSharedPartners activeSharedPartners_isp activeSharedPartners_itp activeSharedPartners_osp activeSharedPartners_otp activeTie activeTotaldegreeDyad activeTotaldegreeReceiver activeTotaldegreeSender .build_riskset_matrix .compute_active_stats duremstats .maybe_prepend_baseline .parse_active_effects .prepare_dual_edgelist .validate_consider_type_durem

# ─────────────────────────────────────────────────────────────────────────────
# duremstats.R
# Active-state statistics for Duration Relational Event Models
#
# These statistics capture properties of the currently active event network
# at each time point — events that have started but not yet ended.
# Unlike the history-weighted statistics computed via tomstats (which can be
# made duration-aware through psi-weighting), active-state statistics require
# explicit tracking of which events are currently open and cannot be derived
# from weighted event history alone.
#
# Entry point: called internally by .remstats_durem_dispatch() when the
#   formula contains active-state effects.
# Returns:     a list with $start_stats and $end_stats (same shape as
#              remstats_durem, so they can be combined at estimation time)
# ─────────────────────────────────────────────────────────────────────────────


# ── Effect name → stat_type integer ──────────────────────────────────────────

# Sentinel values >= 96L flag R-level post-processing (no direct C++ stat_type):
#   98L = activeReciprocalTie  : look up reversed dyad from activeTie (stat_type 1)
#   97L = activeTotaldegreeDyad: sum of activeTotaldegreeSender + activeTotaldegreeReceiver
#   (undirected)
#   98L = activeDegreeMin      : min(stat_type 2, stat_type 3)
#   97L = activeDegreeMax      : max(stat_type 2, stat_type 3)
#   96L = activeDegreeDyad     : stat_type 2 + stat_type 3
.durem_stat_type_directed <- c(
	activeTie                   = 1L,
	activeOutdegreeSender       = 2L,
	activeIndegreeReceiver      = 3L,
	activeTotaldegreeSender     = 4L,
	activeTotaldegreeReceiver   = 5L,
	activeSharedPartners_otp    = 6L,
	activeSharedPartners_itp    = 7L,
	activeSharedPartners_osp    = 8L,
	activeSharedPartners_isp    = 9L,
	activeReciprocalTie         = 98L,   # R-derived: activeTie on reversed dyad
	activeTotaldegreeDyad       = 97L    # R-derived: totaldegreeSender + Receiver
)

.durem_stat_type_undirected <- c(
	activeTie                   = 1L,
	activeDegreeMin             = 98L,   # R-derived: min(stat_type 2, stat_type 3)
	activeDegreeMax             = 97L,   # R-derived: max(stat_type 2, stat_type 3)
	activeDegreeDyad            = 96L,   # R-derived: stat_type 2 + stat_type 3
	activeSharedPartners        = 4L
)


# ── Internal helper: encode dual edgelist for C++ ────────────────────────────

#' Prepare the dual edgelist for \code{calculate_active_stats}
#'
#' Converts actor names to 0-based integer IDs and status to 0/1.
#' Remify stores actor IDs as 1-based integers; C++ expects 0-based.
#'
#' @param edgelist_dual  The \code{$edgelist_dual} data.frame from a
#'   \code{remify_durem} object.
#' @param actor_ids      Named integer vector mapping actor name → 0-based ID.
#' @return A numeric matrix with columns [time, actor1_id, actor2_id, status].
#' @keywords internal
.prepare_dual_edgelist <- function(edgelist_dual, actor_ids) {
	matrix(
		c(edgelist_dual$time,
			actor_ids[edgelist_dual$actor1],
			actor_ids[edgelist_dual$actor2],
			ifelse(edgelist_dual$status == "start", 0L, 1L)),
		ncol = 4L,
		dimnames = list(NULL, c("time", "actor1", "actor2", "status"))
	)
}


#' Build the riskset matrix for active-state stats
#'
#' Uses the exported C++ helpers \code{get_riskset} and
#' \code{convert_to_risksetMatrix} with 0-based actor IDs, matching the
#' convention used internally by \code{tomstats}.
#'
#' @param N        Number of actors.
#' @param directed Logical.
#' @param C        Number of event types (default 1).
#' @return Numeric matrix of dyad IDs (0-based, -999 for absent dyads).
#'   Dimensions N × N when C = 1; N*C × N*C when C > 1.
#' @keywords internal
.build_riskset_matrix <- function(N, directed, C = 1L) {
	actor_ids_0 <- as.integer(seq_len(N) - 1L)
	type_ids    <- as.integer(seq_len(C) - 1L)
	riskset     <- get_riskset(actor_ids_0, type_ids, directed)
	convert_to_risksetMatrix(riskset, N = N, C = C)
}


# ── Sentinel helpers ──────────────────────────────────────────────────────────

# Return the pair of real C++ stat_types needed to compute a sentinel effect.
# Returns a list of two integers, or a single integer if only one call needed.
#
# Directed sentinels:
#   98L = activeReciprocalTie  : needs stat_type 1 (activeTie)
#   97L = activeTotaldegreeDyad: needs stat_type 4 + stat_type 5
# Undirected sentinels:
#   98L = activeDegreeMin      : needs stat_type 2 + stat_type 3
#   97L = activeDegreeMax      : needs stat_type 2 + stat_type 3
#   96L = activeDegreeDyad     : needs stat_type 2 + stat_type 3
.sentinel_base_stypes <- function(stype, directed) {
	if (directed) {
		if (stype == 98L) return(1L)          # activeReciprocalTie: one call
		if (stype == 97L) return(list(4L, 5L)) # activeTotaldegreeDyad: two calls
	} else {
		if (stype %in% c(98L, 97L, 96L)) return(list(2L, 3L))
	}
	stop("Unknown sentinel stat_type: ", stype, call. = FALSE)
}

# Given a cached result (matrix or list with $a/$b) and a sentinel stype,
# return the final [M × D] matrix.
.apply_sentinel <- function(cached, stype, directed, riskset_mat, rev_lookup) {
	if (directed && stype == 98L) {
		# activeReciprocalTie: permute columns of activeTie matrix
		mat <- if (is.matrix(cached)) cached else cached
		out <- matrix(0, nrow = nrow(mat), ncol = ncol(mat))
		for (d in seq_len(ncol(mat))) {
			rd <- rev_lookup[d]
			if (!is.null(rd) && length(rd) == 1L && rd > 0L)
				out[, d] <- mat[, rd]
		}
		return(out)
	}
	# Two-component sentinels
	a <- cached$a; b <- cached$b
	if (directed && stype == 97L) return(a + b)  # activeTotaldegreeDyad
	if (!directed) {
		if (stype == 98L) return(pmin(a, b))       # activeDegreeMin
		if (stype == 97L) return(pmax(a, b))       # activeDegreeMax
		if (stype == 96L) return(a + b)            # activeDegreeDyad
	}
	stop("Unknown sentinel: ", stype, call. = FALSE)
}

# Compute the [M × D] matrix for a sentinel effect from one C++ batch.
# full_timeline: if TRUE the C++ call covers the full type-filtered edgelist
#   and the caller will forward-fill; if FALSE start/stop are used directly
#   and the extra trailing row is dropped.
.compute_sentinel <- function(stype, directed, edgelist_mat, riskset_mat,
                               start_0, stop_0, M, rev_lookup,
                               full_timeline = FALSE) {
	base <- .sentinel_base_stypes(stype, directed)

	run_one <- function(st) {
		mat <- calculate_active_stats(
			edgelist         = edgelist_mat,
			risksetMatrix    = riskset_mat,
			stat_type        = st,
			directed         = directed,
			start            = as.integer(start_0),
			stop             = as.integer(stop_0),
			display_progress = FALSE
		)
		if (full_timeline) mat else mat[seq_len(M), , drop = FALSE]
	}

	if (is.list(base)) {
		cached <- list(a = run_one(base[[1L]]), b = run_one(base[[2L]]),
		               sentinel = stype)
	} else {
		cached <- run_one(base)
	}
	.apply_sentinel(cached, stype, directed, riskset_mat, rev_lookup)
}




#' Prepend a baseline column of 1s to a 3-D stats array
#'
#' Checks whether the formula includes an intercept (no \code{-1}).
#' If so, prepends a \code{baseline{suffix}} slice of 1s along dim 3.
#'
#' @param arr    3-D array \[M × D × P\] or \code{NULL}.
#' @param formula Formula that produced the array.
#' @param suffix  \code{".start"} or \code{".end"}.
#' @return Updated array with P+1 slices, or the original if no intercept.
#' @keywords internal
.maybe_prepend_baseline <- function(arr, formula, suffix) {
	if (is.null(arr) || is.null(formula)) return(arr)
	has_intercept <- attr(terms(formula), "intercept") == 1L
	if (!has_intercept) return(arr)
	d   <- dim(arr)
	bl  <- array(1, dim = c(d[1L], d[2L], 1L),
							 dimnames = list(NULL, NULL, paste0("baseline", suffix)))
	out <- array(0, dim = c(d[1L], d[2L], d[3L] + 1L),
							 dimnames = list(NULL, NULL,
							 								c(dimnames(bl)[[3L]], dimnames(arr)[[3L]])))
	out[, , 1L]              <- bl
	out[, , 2L:(d[3L] + 1L)] <- arr
	out
}


# ── Argument helpers ─────────────────────────────────────────────────────────

#' Normalise and validate consider_type for active-state effects
#'
#' Accepts TRUE/FALSE aliases: TRUE -> "separate", FALSE -> "ignore".
#' @return The normalised character value.
#' @keywords internal
.validate_consider_type_durem <- function(consider_type) {
	if (isTRUE(consider_type))  consider_type <- "separate"
	if (isFALSE(consider_type)) consider_type <- "ignore"
	valid <- c("ignore", "separate", "interact")
	if (!consider_type %in% valid)
		stop(
			"`consider_type = \"", consider_type, "\"` is not supported for ",
			"active-state statistics.\n",
			"Valid values: \"ignore\", \"separate\", \"interact\" ",
			"(or TRUE / FALSE as aliases for \"separate\" / \"ignore\").",
			call. = FALSE
		)
	consider_type
}


# ── Parse formula into effect configs ────────────────────────────────────────

#' Evaluate terms in a \code{duremstats} formula into effect config lists
#'
#' @param formula  Formula such as
#'   \code{~ activeTie() + activeOutdegreeSender(scaling = "std")}
#' @return A list of named lists, each with \code{$effect}, \code{$scaling},
#'   and \code{$consider_type}.
#' @keywords internal
.parse_active_effects <- function(formula) {
	if (is.null(formula)) return(list())
	tt     <- terms(formula)
	labels <- attr(tt, "term.labels")
	lapply(labels, function(lbl) {
		fname <- sub("[(].*$", "", lbl)
		# Distinguish a genuinely unknown effect (the stub function does not
		# exist) from a stub that exists but errors while evaluating (e.g. a
		# missing internal helper). The old handler blamed the effect name for
		# ANY "could not find function" error, masking the real cause.
		if (!exists(fname, mode = "function")) {
			stop("Unknown active-state effect '", fname, "'. ",
					 "See ?active_effects for available effects.", call. = FALSE)
		}
		tryCatch(
			eval(parse(text = lbl)),
			error = function(e)
				stop("Could not evaluate active-state effect term '", lbl,
						 "': ", conditionMessage(e), call. = FALSE)
		)
	})
}


# ── Compute active-state stats for one sub-model ─────────────────────────────

#' Compute active-state statistics for a single sub-model
#'
#' @param effect_configs A list of effect config lists, each with \code{$effect},
#'   \code{$scaling}, and \code{$consider_type} (output of
#'   \code{.parse_active_effects}).
#' @param reh        A \code{remify_durem} object.
#' @param directed   Logical. Whether the sub-model is directed.
#' @param start      First time-point index (0-based).
#' @param stop       Last  time-point index (0-based).
#' @param suffix     ".start" or ".end".
#' @param display_progress Logical.
#' @return 3-D array \[M × D × P\] or \code{NULL} if no effects.
#' @keywords internal
.compute_active_stats <- function(effect_configs, reh, directed,
																	start, stop, suffix,
																	display_progress) {
	if (length(effect_configs) == 0L) return(NULL)
	
	stat_map <- if (directed) .durem_stat_type_directed
	else           .durem_stat_type_undirected
	
	# Extract names and validate
	eff_names <- vapply(effect_configs, `[[`, character(1L), "effect")
	unknown   <- setdiff(eff_names, names(stat_map))
	if (length(unknown) > 0L)
		stop("Unknown active-state effect(s): ",
				 paste(unknown, collapse = ", "), ".\n",
				 "Available: ", paste(names(stat_map), collapse = ", "),
				 call. = FALSE)
	
	# Validate consider_type
	for (cfg in effect_configs)
		.validate_consider_type_durem(cfg$consider_type)
	
	# ── Warn if activeTie appears in end_effects ─────────────────────────────
	if (suffix == ".end" && any(eff_names == "activeTie"))
		warning(
			"'activeTie' in end_effects is always 1 for all risk-set dyads ",
			"(all end-risk dyads are active by definition). ",
			"It carries no information as a predictor and will produce a ",
			"perfectly collinear column. Consider removing it.",
			call. = FALSE
		)

	# Build actor ID lookup (0-based).
	# reh$meta$dictionary$actors$actorID is 1-based; subtract 1 for C++.
	actor_dict  <- reh$meta$dictionary$actors
	actor_ids   <- setNames(actor_dict$actorID - 1L, actor_dict$actorName)
	N           <- reh$N
	
	# Number of event types.
	# Use C > 1 (from reh) only when extend_riskset_by_type = TRUE, so that
	# D in the output array is consistent with reh$D for "ignore" effects.
	# For "separate" effects we always use C = 1 per per-type call (see below).
	has_types    <- isTRUE(reh$meta$with_type)
	type_riskset <- isTRUE(reh$meta$with_type_riskset)
	C_reh        <- if (!is.null(reh$C)) reh$C else 1L
	
	# For "ignore" effects respect the typed riskset when requested.
	C_ignore     <- if (type_riskset) C_reh else 1L
	
	# Riskset matrix and D for "ignore" effects
	riskset_mat_ignore <- .build_riskset_matrix(N, directed, C_ignore)
	D_ignore <- as.integer(max(riskset_mat_ignore[riskset_mat_ignore >= 0]) + 1L)
	
	# Riskset matrix for "separate" per-type calls (always C = 1)
	riskset_mat_sep <- if (C_ignore == 1L) riskset_mat_ignore
	else .build_riskset_matrix(N, directed, 1L)
	D_sep <- as.integer(max(riskset_mat_sep[riskset_mat_sep >= 0]) + 1L)
	
	# Dual edgelist encoded for C++ (full, for "ignore" effects)
	ed_mat_full <- .prepare_dual_edgelist(reh$edgelist_dual, actor_ids)
	
	# Per-type encoded edgelists (for "separate" and "interact" effects)
	type_levels <- if (has_types) sort(unique(reh$edgelist$type)) else character(0L)
	
	needs_per_type <- has_types && any(vapply(effect_configs,
																						function(cfg) cfg$consider_type %in% c("separate", "interact"),
																						logical(1L)))
	
	ed_mat_per_type <- if (needs_per_type) {
		lapply(type_levels, function(tc) {
			ed_tc <- reh$edgelist_dual[reh$edgelist_dual$type == tc, ]
			.prepare_dual_edgelist(ed_tc, actor_ids)
		})
	} else NULL
	
	# D for "interact": one D_sep-wide block per type, concatenated
	D_interact <- as.integer(D_sep * C_reh)
	
	# Number of output time points (relative to the full dual edgelist)
	M <- as.integer(stop - start + 1L)
	
	# Full timeline — used for forward-filling per-type results
	full_utimes <- sort(unique(reh$edgelist_dual$time))
	
	# Helper: expand a per-type C++ result (n_tc rows) into M rows.
	#
	# C++ computes the active state BEFORE each unique time in the type-tc
	# edgelist:
	#   mat_tc[k, ] = active state before tc_utimes[k]
	#
	# For each time t in the full output window:
	#   - If t IS a tc time point (t == tc_utimes[k]): use row k — the state
	#     before that tc event is the correct "before t" statistic.
	#   - If t is BETWEEN tc events (tc_utimes[k] < t < tc_utimes[k+1]): use
	#     row k+1 — the state "before tc_utimes[k+1]" already incorporates
	#     all tc events up through tc_utimes[k], which is exactly the active
	#     state at time t.
	#   - If t is before any tc event: the active state is empty (all zeros).
	.expand_to_M <- function(mat_tc, tc_edgelist_mat) {
		tc_utimes  <- sort(unique(tc_edgelist_mat[, "time"]))
		n_tc       <- length(tc_utimes)
		segment    <- full_utimes[(start + 1L):(stop + 1L)]  # 1-based R indexing
		k          <- findInterval(segment, tc_utimes)        # last tc_time <= t
		is_tc_time <- segment %in% tc_utimes
		# tc times: row k; between tc times: row k+1 (capped at n_tc)
		row_map 	 <- ifelse(is_tc_time, k, pmin(k + 1L, n_tc + 1L))
		mat_full   <- matrix(0, nrow = M, ncol = ncol(mat_tc))
		nz         <- row_map > 0L
		if (any(nz))
			mat_full[nz, ] <- mat_tc[row_map[nz], , drop = FALSE]
		mat_full
	}
	
	# ── Expand effect configs ──────────────────────────────────────────────────
	# Each entry carries:
	#   $type_label  — name suffix for dimnames
	#   $event_type  — filter active ties by this event type (interact only)
	#   $dyad_type   — column block index in the D*C output (interact only)
	expanded <- list()
	for (cfg in effect_configs) {
		if (cfg$consider_type == "separate" && has_types && length(type_levels) > 0L) {
			# "separate": one output stat per type
			for (tc in type_levels) {
				e            <- cfg
				e$type_label <- tc
				expanded     <- c(expanded, list(e))
			}
		} else if (cfg$consider_type == "interact" && (!has_types || length(type_levels) == 0L)) {
			# "interact" on untyped data: degrade silently to "ignore"
			cfg$consider_type <- "ignore"
			cfg$type_label    <- NA_character_
			expanded          <- c(expanded, list(cfg))
		} else if (cfg$consider_type == "interact" && has_types && !type_riskset) {
			# "interact" requires extend_riskset_by_type = TRUE; coerce to
			# "separate" (same behavior as tomstats for non-duration models)
			warning("\"interact\" requires extend_riskset_by_type = TRUE; ",
							"coercing to \"separate\".", call. = FALSE)
			cfg$consider_type <- "separate"
			for (tc in type_levels) {
				e            <- cfg
				e$type_label <- tc
				expanded     <- c(expanded, list(e))
			}
		} else if (cfg$consider_type == "interact" && has_types && type_riskset) {
			# "interact" with extended riskset: C×C entries
			# Convention follows tomstats: stat.eventType.dyadType
			for (et in type_levels) {
				for (dt in type_levels) {
					e            <- cfg
					e$type_label <- paste0(et, ".", dt)
					e$event_type <- et    # filter active ties by this type
					e$dyad_type  <- dt    # place result in this column block
					expanded     <- c(expanded, list(e))
				}
			}
		} else {
			# "ignore": single output stat
			cfg$type_label <- NA_character_
			expanded       <- c(expanded, list(cfg))
		}
	}
	
	# Output dimension names
	out_names <- vapply(expanded, function(e) {
		base <- e$effect
		if (!is.na(e$type_label)) paste0(base, ".", e$type_label) else base
	}, character(1L))
	
	# Choose D for each expanded effect
	# When type_riskset=TRUE, "separate" also uses D_interact (the 6-col result
	# is replicated across all C type blocks, matching tomstats behavior).
	out_D <- vapply(expanded, function(e) {
		if (e$consider_type == "interact")               D_interact
		else if (!is.na(e$type_label) && type_riskset)   D_interact
		else if (!is.na(e$type_label))                   D_sep
		else                                             D_ignore
	}, integer(1L))
	
	# All effects should agree on D; mixing consider_type values with different
	# D dimensions (e.g. "ignore" + "interact") in one formula is unsupported.
	D_out <- out_D[1L]
	if (length(unique(out_D)) > 1L)
		warning("Effects in this formula produce different D dimensions ",
						"(mixing \"ignore\", \"separate\", and/or \"interact\" with ",
						"typed risksets). Output array uses D = ", D_out,
						" from the first effect; other effects may be misaligned.",
						call. = FALSE)
	
	# ── Allocate output array [M × D × P_expanded] ───────────────────────────
	P_out <- length(expanded)
	out   <- array(0, dim      = c(M, D_out, P_out),
								 dimnames = list(NULL, NULL, paste0(out_names, suffix)))
	
	# ── Pre-compute per-type active stats for interact effects ──────────────
	# Cache keyed by (effect, type) to avoid redundant C++ calls across C×C
	# entries that share the same event_type.
	interact_cache <- list()
	
	# ── Helper: run one C++ call and return [M × D] ───────────────────────────
	# For "ignore": uses the full edgelist and ignore riskset, drops extra row.
	# For "separate"/"interact": caller passes type-filtered edgelist and untyped
	# riskset; result is forward-filled.
	.call_cpp <- function(stype, edgelist_mat, riskset_mat, start_0, stop_0,
	                      full_timeline = FALSE) {
		n_times <- length(unique(edgelist_mat[, "time"]))
		mat <- calculate_active_stats(
			edgelist         = edgelist_mat,
			risksetMatrix    = riskset_mat,
			stat_type        = stype,
			directed         = directed,
			start            = as.integer(start_0),
			stop             = as.integer(stop_0),
			display_progress = display_progress
		)
		if (full_timeline) mat else mat[seq_len(M), , drop = FALSE]
	}

	# ── Pre-build reverse-dyad lookup for activeReciprocalTie (directed) ──────
	# For each column d (dyad i→j in riskset_mat_ignore), find the column for
	# j→i. -1L means no reverse dyad in the riskset (asymmetric risksets).
	if (directed && any(eff_names == "activeReciprocalTie")) {
		nr <- nrow(riskset_mat_ignore)
		rev_lookup <- integer(D_ignore)
		for (i in seq_len(nr)) {
			for (j in seq_len(nr)) {
				if (i == j) next
				d_fwd <- riskset_mat_ignore[i, j]
				d_rev <- riskset_mat_ignore[j, i]
				if (d_fwd >= 0L && d_rev >= 0L)
					rev_lookup[d_fwd + 1L] <- d_rev + 1L   # 1-based
				else if (d_fwd >= 0L)
					rev_lookup[d_fwd + 1L] <- -1L
			}
		}
	}

	# ── Per-effect computation ────────────────────────────────────────────────
	for (p_idx in seq_along(expanded)) {
		e     <- expanded[[p_idx]]
		eff   <- e$effect
		stype <- stat_map[[eff]]
		
		if (display_progress)
			message("Calculating active-state statistic: ", eff,
							if (!is.na(e$type_label)) paste0(" [type = ", e$type_label, "]") else "")
		
		# ── Dispatch by consider_type / sentinel ────────────────────────────────
		if (e$consider_type == "interact" && !is.null(e$event_type)) {
			# C×C interact: compute per event_type (cached), place in dyad_type block
			cache_key <- paste0(eff, "::", e$event_type)
			if (is.null(interact_cache[[cache_key]])) {
				et_idx  <- match(e$event_type, type_levels)
				ed_tc   <- ed_mat_per_type[[et_idx]]
				n_tc    <- length(unique(ed_tc[, "time"]))
				raw_stype <- if (stype >= 96L) .sentinel_base_stypes(stype, directed) else stype
				if (is.list(raw_stype)) {
					# Sentinel needing two calls — cache both components
					mat_a <- .expand_to_M(
						.call_cpp(raw_stype[[1L]], ed_tc, riskset_mat_sep, 0L, n_tc - 1L, TRUE),
						ed_tc)
					mat_b <- .expand_to_M(
						.call_cpp(raw_stype[[2L]], ed_tc, riskset_mat_sep, 0L, n_tc - 1L, TRUE),
						ed_tc)
					interact_cache[[cache_key]] <- list(a = mat_a, b = mat_b,
					                                   sentinel = stype)
				} else {
					mat_tc <- .call_cpp(raw_stype, ed_tc, riskset_mat_sep, 0L, n_tc - 1L, TRUE)
					interact_cache[[cache_key]] <- .expand_to_M(mat_tc, ed_tc)
				}
			}
			cached <- interact_cache[[cache_key]]
			et_mat <- if (stype >= 96L)
				.apply_sentinel(cached, stype, directed, riskset_mat_sep, rev_lookup = NULL)
			else
				cached
			dt_idx <- match(e$dyad_type, type_levels)
			mat    <- matrix(0, nrow = M, ncol = D_interact)
			col_start <- (dt_idx - 1L) * D_sep + 1L
			col_end   <- dt_idx * D_sep
			mat[, col_start:col_end] <- et_mat

		} else if (is.na(e$type_label)) {
			# "ignore": full edgelist, possibly typed riskset
			if (stype >= 96L) {
				mat <- .compute_sentinel(stype, directed,
				                         ed_mat_full, riskset_mat_ignore,
				                         as.integer(start), as.integer(stop),
				                         M, rev_lookup,
				                         full_timeline = FALSE)
			} else {
				mat <- .call_cpp(stype, ed_mat_full, riskset_mat_ignore,
				                 as.integer(start), as.integer(stop))
			}

		} else {
			# "separate": type-filtered edgelist, untyped riskset
			tc_idx <- match(e$type_label, type_levels)
			ed_tc  <- ed_mat_per_type[[tc_idx]]
			n_tc   <- length(unique(ed_tc[, "time"]))
			if (stype >= 96L) {
				mat_sep <- .compute_sentinel(stype, directed,
				                             ed_tc, riskset_mat_sep,
				                             0L, as.integer(n_tc - 1L),
				                             n_tc, rev_lookup,
				                             full_timeline = TRUE)
				mat_sep <- .expand_to_M(mat_sep, ed_tc)
			} else {
				mat_tc  <- .call_cpp(stype, ed_tc, riskset_mat_sep, 0L, n_tc - 1L, TRUE)
				mat_sep <- .expand_to_M(mat_tc, ed_tc)
			}
			if (type_riskset) {
				mat <- do.call(cbind, rep(list(mat_sep), C_reh))
			} else {
				mat <- mat_sep
			}
		}   # mat is [M × D_out]
		
		# ── Scaling ───────────────────────────────────────────────────────────
		if (identical(e$scaling, "std")) {
			for (m in seq_len(M)) {
				vals <- mat[m, ]
				mu   <- mean(vals)
				s    <- sd(vals)
				mat[m, ] <- if (s > 0) (vals - mu) / s else vals - mu
			}
		}
		
		out[, , p_idx] <- mat
	}
	
	out
}


# ── Public entry point ────────────────────────────────────────────────────────

#' Compute active-state statistics for a \code{remify_durem} object
#'
#' Computes statistics that capture the current state of the active event
#' network at each time point.  These complement the history-weighted statistics
#' returned by \code{\link{remstats}} and cannot be derived from weighted
#' event history alone.
#'
#' See \code{\link{active_effects}} for the full list of available effects
#' and their descriptions.
#'
#' @param reh A \code{remify_durem} object.
#' @param start_effects Formula of active-state effects for the start model,
#'   e.g. \code{~ activeTie() + activeOutdegreeSender()}.
#' @param end_effects Formula of active-state effects for the end model.
#' @param start Integer. Index of first time point to compute (default 2).
#' @param stop  Integer. Index of last  time point to compute (default Inf).
#' @param display_progress Logical. Show progress messages.
#' @return A list with \code{$start_stats} and \code{$end_stats}: 3-D arrays
#'   \[M x D x P\] with effect names suffixed \code{.start} / \code{.end},
#'   and \code{attr(., "reh")} set to \code{reh}.  The same shape as a
#'   \code{remstats_durem} object so the two can be combined at estimation time.
#' @keywords internal
duremstats <- function(reh,
											 start_effects    = NULL,
											 end_effects      = NULL,
											 start            = 2L,
											 stop             = Inf,
											 display_progress = FALSE) {
	
	if (!inherits(reh, "remify_durem"))
		stop("`reh` must be a `remify_durem` object.")
	
	# Unique time points in the dual edgelist
	ed    <- reh$edgelist_dual
	utimes <- sort(unique(ed$time))
	M_total <- length(utimes)
	
	start <- max(1L, as.integer(start)) - 1L           # convert to 0-based
	stop  <- if (is.infinite(stop)) M_total - 1L       # Inf → last time point
	else min(M_total - 1L, as.integer(stop) - 1L)
	
	directed_start <- isTRUE(reh$meta$directed)
	directed_end   <- isTRUE(reh$durem$dur_directed_end)
	
	eff_start <- .parse_active_effects(start_effects)
	eff_end   <- .parse_active_effects(end_effects)
	
	ss <- .compute_active_stats(eff_start, reh, directed_start,
															start, stop, ".start", display_progress)
	es <- .compute_active_stats(eff_end,   reh, directed_end,
															start, stop, ".end",   display_progress)
	
	# ── Prepend baseline (intercept) when formula has no -1 ───────────────────
	ss <- .maybe_prepend_baseline(ss, start_effects, ".start")
	es <- .maybe_prepend_baseline(es, end_effects,   ".end")
	
	out <- list(start_stats = ss, end_stats = es)
	attr(out, "reh") <- reh
	attr(out, "model") <- reh$meta$model
	attr(out, "subset") <- c(start+1,stop+1)
	
	class(out) <- c("remstats_durem", "remstats")   # same class → compatible with estimation
	out
}


# ── Effect constructor functions ──────────────────────────────────────────────
# Each function mirrors the remstats pattern: calling it returns a named list
# that the internal duremstats() function reads to configure the C++ computation
# and post-processing.
# They are designed for use inside formulas:
#   remstats(reh, start_effects = ~ activeTie() + activeOutdegreeSender("std"))
# but can also be called directly, e.g. to inspect the default configuration.

#' Active-state statistics for Duration Relational Event Models
#'
#' Constructor functions for active-state effects used inside formulas passed
#' to \code{\link{remstats}} (when \code{reh} is a \code{remify_durem} object).
#' They capture properties of the currently
#' \emph{active} event network — events that have started but not yet ended —
#' at each time point in a duration relational event sequence.
#'
#' Each function returns a configuration list consumed internally.
#' The functions are passed inside a formula:
#' \preformatted{
#'   remstats(reh,
#'     start_effects = ~ activeTie() + activeOutdegreeSender(scaling = "std"),
#'     end_effects   = ~ activeOutdegreeSender())
#' }
#'
#' \strong{Directed-network effects:}
#' \describe{
#'   \item{\code{activeTie()}}{
#'     Whether there is currently an active event from actor \eqn{i} to actor
#'     \eqn{j} (binary, 0/1).
#'     \emph{Note:} in \code{end_effects} this is always 1 by definition
#'     (only currently active dyads are at risk of ending) and should not
#'     be included as a predictor.}
#'   \item{\code{activeReciprocalTie()}}{
#'     Whether there is currently an active event from actor \eqn{j} to actor
#'     \eqn{i} (binary, 0/1). Captures reciprocity in the active network.}
#'   \item{\code{activeOutdegreeSender()}}{
#'     Number of currently active events in which actor \eqn{i} (sender) is
#'     involved as sender (out-degree in the active-event network).}
#'   \item{\code{activeIndegreeReceiver()}}{
#'     Number of currently active events in which actor \eqn{j} (receiver) is
#'     involved as receiver.}
#'   \item{\code{activeTotaldegreeSender()}}{
#'     Total active degree of actor \eqn{i}: active events in which \eqn{i}
#'     appears as either sender or receiver.}
#'   \item{\code{activeTotaldegreeReceiver()}}{
#'     Total active degree of actor \eqn{j}.}
#'   \item{\code{activeTotaldegreeDyad()}}{
#'     Sum of the total active degrees of actors \eqn{i} and \eqn{j}:
#'     \eqn{\deg(i) + \deg(j)}.}
#'   \item{\code{activeSharedPartners_otp()}}{
#'     \emph{(Advanced)} Number of actors \eqn{h} for whom \eqn{i \to h} and
#'     \eqn{h \to j} are both currently active (outgoing two-path).
#'     Rarely informative when the active network is sparse.}
#'   \item{\code{activeSharedPartners_itp()}}{
#'     \emph{(Advanced)} Incoming two-path: actors \eqn{h} with \eqn{h \to i}
#'     and \eqn{j \to h} both active.}
#'   \item{\code{activeSharedPartners_osp()}}{
#'     \emph{(Advanced)} Outgoing shared partners: actors \eqn{h} with
#'     \eqn{i \to h} and \eqn{j \to h} both active.}
#'   \item{\code{activeSharedPartners_isp()}}{
#'     \emph{(Advanced)} Incoming shared partners: actors \eqn{h} with
#'     \eqn{h \to i} and \eqn{h \to j} both active.}
#' }
#'
#' \strong{Undirected-network effects:}
#' \describe{
#'   \item{\code{activeTie()}}{
#'     Whether there is currently an active event between actors \eqn{i} and
#'     \eqn{j}.
#'     \emph{Note:} in \code{end_effects} this is always 1 by definition
#'     and should not be included as a predictor.}
#'   \item{\code{activeDegreeMin()}}{
#'     Minimum of the active degrees of \eqn{i} and \eqn{j}:
#'     \eqn{\min(\deg(i), \deg(j))}.}
#'   \item{\code{activeDegreeMax()}}{
#'     Maximum of the active degrees of \eqn{i} and \eqn{j}:
#'     \eqn{\max(\deg(i), \deg(j))}.}
#'   \item{\code{activeDegreeDyad()}}{
#'     Sum of the active degrees of \eqn{i} and \eqn{j}:
#'     \eqn{\deg(i) + \deg(j)}.}
#'   \item{\code{activeSharedPartners()}}{
#'     \emph{(Advanced)} Number of actors \eqn{h} for whom both \eqn{(i,h)}
#'     and \eqn{(j,h)} are currently active. Rarely informative when the
#'     active network is sparse.}
#' }
#'
#' @param scaling Scaling applied to the raw statistic before returning:
#'   \describe{
#'     \item{\code{"none"}}{Raw counts (default).}
#'     \item{\code{"std"}}{Per-time-point standardisation:
#'       \eqn{(x - \bar{x}) / \mathrm{sd}(x)}, computed over the \eqn{D}
#'       dyads in the fixed risk set.}
#'   }
#' @param consider_type Character (or logical). How event types are handled:
#'   \describe{
#'     \item{\code{"ignore"} or \code{FALSE}}{Aggregate over all event types
#'       (default). Counts all currently active events regardless of type.}
#'     \item{\code{"separate"} or \code{TRUE}}{Compute one statistic per event
#'       type. Only active events of that type contribute. Output effect names
#'       are suffixed with the type label, e.g.
#'       \code{activeOutdegreeSender.X.start}.}
#'     \item{\code{"interact"}}{Compute one statistic per (past-event type x
#'       dyad type) combination (\eqn{C^2} slices). Requires
#'       \code{extend_riskset_by_type = TRUE} in the \code{remify} call;
#'       otherwise silently coerced to \code{"separate"}.}
#'   }
#'
#' @return A named list with elements \code{effect}, \code{scaling}, and
#'   \code{consider_type}, consumed by \code{\link{duremstats}}.
#'
#' @seealso \code{\link{remstats}} for computing the statistics, and
#'   \code{\link{tie_effects}} / \code{\link{actor_effects}} for the standard
#'   (non-duration) effect overviews. Active-state effects are only available
#'   for the tie-oriented model of events with a duration.
#' @name active_effects
NULL

# ── Internal: build an active-effect config list ───────────────────
# Every exported active-effect stub (activeTie(), activeOutdegreeSender(), ...)
# delegates here. Returns the config consumed by .parse_active_effects ->
# .compute_active_stats: a named list with $effect, $scaling, $consider_type.
.active_effect_cfg <- function(effect, scaling, consider_type) {
	scaling <- match.arg(scaling, c("none", "std"))
	list(
		effect        = effect,
		scaling       = scaling,
		consider_type = .validate_consider_type_durem(consider_type)
	)
}

#' @rdname active_effects
#' @export
activeTie <- function(scaling = c("none", "std"),
											consider_type = "ignore")
	.active_effect_cfg("activeTie", scaling, consider_type)

#' @rdname active_effects
#' @export
activeReciprocalTie <- function(scaling = c("none", "std"),
                                consider_type = "ignore")
	.active_effect_cfg("activeReciprocalTie", scaling, consider_type)

#' @rdname active_effects
#' @export
activeOutdegreeSender <- function(scaling = c("none", "std"),
																	consider_type = "ignore")
	.active_effect_cfg("activeOutdegreeSender", scaling, consider_type)

#' @rdname active_effects
#' @export
activeIndegreeReceiver <- function(scaling = c("none", "std"),
																	 consider_type = "ignore")
	.active_effect_cfg("activeIndegreeReceiver", scaling, consider_type)

#' @rdname active_effects
#' @export
activeTotaldegreeSender <- function(scaling = c("none", "std"),
																		consider_type = "ignore")
	.active_effect_cfg("activeTotaldegreeSender", scaling, consider_type)

#' @rdname active_effects
#' @export
activeTotaldegreeReceiver <- function(scaling = c("none", "std"),
																			consider_type = "ignore")
	.active_effect_cfg("activeTotaldegreeReceiver", scaling, consider_type)

#' @rdname active_effects
#' @export
activeTotaldegreeDyad <- function(scaling = c("none", "std"),
                                  consider_type = "ignore")
	.active_effect_cfg("activeTotaldegreeDyad", scaling, consider_type)

#' @rdname active_effects
#' @export
activeSharedPartners_otp <- function(scaling = c("none", "std"),
																		 consider_type = "ignore")
	.active_effect_cfg("activeSharedPartners_otp", scaling, consider_type)

#' @rdname active_effects
#' @export
activeSharedPartners_itp <- function(scaling = c("none", "std"),
																		 consider_type = "ignore")
	.active_effect_cfg("activeSharedPartners_itp", scaling, consider_type)

#' @rdname active_effects
#' @export
activeSharedPartners_osp <- function(scaling = c("none", "std"),
																		 consider_type = "ignore")
	.active_effect_cfg("activeSharedPartners_osp", scaling, consider_type)

#' @rdname active_effects
#' @export
activeSharedPartners_isp <- function(scaling = c("none", "std"),
																		 consider_type = "ignore")
	.active_effect_cfg("activeSharedPartners_isp", scaling, consider_type)

#' @rdname active_effects
#' @export
activeDegreeMin <- function(scaling = c("none", "std"),
                            consider_type = "ignore")
	.active_effect_cfg("activeDegreeMin", scaling, consider_type)

#' @rdname active_effects
#' @export
activeDegreeMax <- function(scaling = c("none", "std"),
                            consider_type = "ignore")
	.active_effect_cfg("activeDegreeMax", scaling, consider_type)

#' @rdname active_effects
#' @export
activeDegreeDyad <- function(scaling = c("none", "std"),
                             consider_type = "ignore")
	.active_effect_cfg("activeDegreeDyad", scaling, consider_type)

#' @rdname active_effects
#' @export
activeSharedPartners <- function(scaling = c("none", "std"),
																 consider_type = "ignore")
	.active_effect_cfg("activeSharedPartners", scaling, consider_type)

Try the remstats package in your browser

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

remstats documentation built on July 15, 2026, 5:07 p.m.