R/data_prep_validation.R

Defines functions resolve_dyad_roles resolve_incomplete_dyads format_duplicate_combinations resolve_dsm_role_order validate_dyad_data

Documented in resolve_dsm_role_order validate_dyad_data

#' Validate dyadic input data
#'
#' Checks whether `data` has a valid long-format dyadic structure and returns it
#' as a tibble with an additional `dyadMLM_data` class. Cross-sectional data
#' may contain at most one row per member within each dyad. Intensive
#' longitudinal data may contain at most one row per member and measurement
#' occasion within each dyad.
#'
#' @param data A long-format data frame or tibble.
#' @param dyad Column identifying the dyad.
#' @param member Column identifying the two people or members within each dyad,
#'   such as a person ID.
#' @param role Optional column identifying a stable member role, such as gender.
#'   Non-missing values must be consistent within each `dyad` x `member`. In
#'   repeated-measures data, an observed role is propagated to missing rows for
#'   the same member within a dyad. If no role is supplied, all dyads are
#'   treated as exchangeable.
#' @param time Optional column identifying time or measurement order.
#' @param predictors Optional variables to select and store as metadata for
#'   temporal predictor decomposition and model-helper functions.
#' @param lag1_predictors Optional subset of `predictors` for which lag-1
#'   model-ready columns should be created. Requires a finite, integer-valued
#'   numeric `time` variable.
#' @param model_types Requested model-ready column families. Can contain one or
#'   more of `"apim"`, `"dim"`, and `"dsm"`. `"none"` indicates no
#'   model-specific predictor construction and must be used alone.
#' @param dsm_role_order For `model_types = "dsm"`, a character vector giving
#'   the two distinguishable roles in the order used for directional
#'   differences. Required when DSM columns are requested and must be `NULL`
#'   otherwise.
#' @param temporal_decomposition Requested temporal predictor decomposition
#'   strategy for predictors. `"none"` leaves predictors undecomposed before
#'   model-specific columns are constructed. `"2l"` indicates a two-level
#'   temporal predictor decomposition into within-person and between-person
#'   components. `"auto"` resolves to `"2l"` when both `time` and
#'   `predictors` are supplied, and to `"none"` otherwise.
#'   Model-specific helpers may apply additional conventions, such as grand-mean
#'   centering raw DIM and DSM dyad means.
#' @param incomplete_dyads How to handle dyads with fewer than two unique
#'   members across all rows in `data`. `"error"` stops with an error and
#'   `"drop"` removes the entire dyad. A dyad with more than two unique members
#'   is invalid and always causes an error, regardless of this setting.
#' @param missing_role How to handle dyads in which at least one member has no
#'   non-missing `role` value on any row. A consistent non-missing role observed
#'   for a member is propagated to that member's other rows before this policy
#'   is applied. `"error"` stops with an error and `"drop"` removes the entire
#'   dyad. Conflicting non-missing roles always cause an error. Ignored when no
#'   `role` column is supplied.
#'
#' @return A tibble with class `dyadMLM_data` and metadata about the dyad,
#'   member, optional role, and optional time columns.
#' @importFrom rlang .data :=
#'
#' @keywords internal
validate_dyad_data <- function(
    data,
    dyad,
    member,
    role = NULL,
    time = NULL,
    predictors = NULL,
    lag1_predictors = NULL,
    model_types = "apim",
    dsm_role_order = NULL,
    temporal_decomposition = c("auto", "2l", "none"),
    incomplete_dyads = c("error", "drop"),
    missing_role = c("error", "drop")
  ) {

  # Validate data frame input.
  if (!inherits(data, "data.frame")) {
    stop("`data` must be a data frame or tibble.", call. = FALSE)
  }

  if (inherits(data, "dyadMLM_data")) {
    stop(
      "`data` has already been prepared by dyadMLM. ",
      "`prepare_dyad_data()` expects raw data; start from the original data frame instead.",
      call. = FALSE
    )
  }

  out <- tibble::as_tibble(data)

  # Validate that package-owned columns are not already present.
  reserved_columns <- names(out)[startsWith(names(out), dyad_reserved_prefix)]
  if (length(reserved_columns) > 0) {
    stop(
      "`data` must not contain columns starting with `",
      dyad_reserved_prefix,
      "`; these names are reserved by dyadMLM. Reserved column(s): ",
      paste(reserved_columns, collapse = ", "),
      ".",
      call. = FALSE
    )
  }

  # Extract and match (non column-based) arguments
  incomplete_dyads <- rlang::arg_match(incomplete_dyads)
  missing_role <- rlang::arg_match(missing_role)

  model_types <- normalize_model_types(model_types)
  temporal_decomposition <- rlang::arg_match(temporal_decomposition)

  # Extract structural column names.
  dyad <- rlang::enquo(dyad)
  if (rlang::quo_is_missing(dyad)) {
    stop("`dyad` must be supplied.", call. = FALSE)
  }
  stop_if_data_frame(dyad, "dyad", out)
  dyad_name <- rlang::as_name(dyad)

  member <- rlang::enquo(member)
  if (rlang::quo_is_missing(member)) {
    stop("`member` must be supplied.", call. = FALSE)
  }
  stop_if_data_frame(member, "member", out)
  member_name <- rlang::as_name(member)

  role <- rlang::enquo(role)
  has_role <- !rlang::quo_is_null(role)
  role_name <- NULL

  if (has_role) {
    stop_if_data_frame(role, "role", out)
    role_name <- rlang::as_name(role)
  }

  dsm_role_order <- resolve_dsm_role_order(
    dsm_role_order = dsm_role_order,
    model_types = model_types,
    has_role = has_role
  )

  time <- rlang::enquo(time)
  has_time <- !rlang::quo_is_null(time)
  time_name <- NULL

  if (has_time) {
    stop_if_data_frame(time, "time", out)
    time_name <- rlang::as_name(time)
  }

  # Validate that structural columns exist.
  if (!dyad_name %in% names(out)) {
    stop(
      "`dyad` must refer to an existing column in `data`. Column `",
      dyad_name, "` was not found.",
      call. = FALSE
    )
  }

  if (!member_name %in% names(out)) {
    stop(
      "`member` must refer to an existing column in `data`. Column `",
      member_name, "` was not found.",
      call. = FALSE
    )
  }

  if (has_role && !role_name %in% names(out)) {
    stop(
      "`role` must refer to an existing column in `data`. Column `",
      role_name, "` was not found.",
      call. = FALSE
    )
  }

  if (has_time && !time_name %in% names(out)) {
    stop(
      "`time` must refer to an existing column in `data`. Column `",
      time_name, "` was not found.",
      call. = FALSE
    )
  }

  # Validate that required structural columns are complete.
  if (any(is.na(out[[dyad_name]]))) {
    stop(
      "`dyad` must not contain missing values. Found ",
      sum(is.na(out[[dyad_name]])),
      " affected row(s); fill or remove them before preparing the data.",
      call. = FALSE
    )
  }

  if (any(is.na(out[[member_name]]))) {
    stop(
      "`member` must not contain missing values. Found ",
      sum(is.na(out[[member_name]])),
      " affected row(s); fill or remove them before preparing the data.",
      call. = FALSE
    )
  }

  if (has_time && any(is.na(out[[time_name]]))) {
    stop(
      "`time` must not contain missing values. Found ",
      sum(is.na(out[[time_name]])),
      " affected row(s); fill or remove them before preparing the data.",
      call. = FALSE
    )
  }


  # Extract and validate user-owned model columns!!!
  predictors_quo <- rlang::enquo(predictors)
  predictor_names <- select_dyad_columns(out, predictors_quo, "predictors")

  columns_to_check <- unique(c(
    dyad_name,
    member_name,
    time_name,
    predictor_names
  ))
  numeric_columns_to_check <- dplyr::select(
    out[columns_to_check],
    tidyselect::where(is.numeric)
  )
  infinite_value_locations <- which(
    is.infinite(as.matrix(numeric_columns_to_check)),
    arr.ind = TRUE
  )

  if (nrow(infinite_value_locations) > 0L) {
    affected_values <- paste0(
      "`",
      names(numeric_columns_to_check)[infinite_value_locations[, "col"]],
      "` at input row ",
      infinite_value_locations[, "row"]
    )

    stop(
      "Infinite values are not allowed in numeric `dyad`, `member`, or ",
      "`time` columns or numeric columns selected by `predictors`. ",
      "Affected value(s): ",
      paste(affected_values, collapse = ", "),
      ". Replace invalid measurements with `NA` if they represent missing values.",
      call. = FALSE
    )
  }

  # Avoid different predictors resolving to the same sanitized name later.
  make_dyad_suffixes(
    predictor_names,
    label_type = "`predictors`",
    rename_hint = "variables"
  )

  lag1_predictors_quo <- rlang::enquo(lag1_predictors)
  lag1_predictor_names <- select_dyad_columns(
    out,
    lag1_predictors_quo,
    "lag1_predictors"
  )

  predictors_not_selected <- setdiff(lag1_predictor_names, predictor_names)
  if (length(predictors_not_selected) > 0) {
    stop(
      "`lag1_predictors` must select only variables already selected by `predictors`. ",
      "Not selected by `predictors`: ",
      paste(predictors_not_selected, collapse = ", "),
      ".",
      call. = FALSE
    )
  }

  if (length(lag1_predictor_names) > 0) {
    if (!has_time) {
      stop("`lag1_predictors` requires `time` to be supplied.", call. = FALSE)
    }

    time_values <- out[[time_name]]
    is_integer_time <- is.numeric(time_values) &&
      all(is.finite(time_values)) &&
      all(time_values == floor(time_values))

    if (!is_integer_time) {
      stop(
        "`lag1_predictors` requires `time` to be a finite, integer-valued numeric measurement index. ",
        "Create an occasion index such as 1, 2, 3, ... and supply it as `time`.",
        call. = FALSE
      )
    }
  }

  # Resolve dyads with fewer than two observed members.
  out_list <- resolve_incomplete_dyads(
    out = out,
    dyad_name = dyad_name,
    member_name = member_name,
    incomplete_dyads = incomplete_dyads
  )
  out <- out_list$out
  dropped_incomplete_dyads <- out_list$dropped_incomplete_dyads

  # Resolve sparse or missing role information.
  dropped_missing_role_dyads <- out[[dyad_name]][0]
  if (has_role) {
    out_list <- resolve_dyad_roles(
      out = out,
      dyad_name = dyad_name,
      member_name = member_name,
      role_name = role_name,
      missing_role = missing_role
    )
    out <- out_list$out
    dropped_missing_role_dyads <- out_list$dropped_missing_role_dyads
  }

  # Validate that each member has at most one row per dyad or dyad-time.
  if (has_time) {
    if (anyDuplicated(out[c(dyad_name, time_name, member_name)]) > 0) {
      stop(
        "Each `member` must appear at most once per `dyad`-`time` combination. ",
        "Affected combination(s): ",
        format_duplicate_combinations(
          out,
          c(dyad_name, time_name, member_name)
        ),
        ". Remove or combine these duplicate rows.",
        call. = FALSE
      )
    }
  } else {
    if (anyDuplicated(out[c(dyad_name, member_name)]) > 0) {
      stop(
        "Each `member` must appear at most once per `dyad`. ",
        "Affected combination(s): ",
        format_duplicate_combinations(out, c(dyad_name, member_name)),
        ". If these are repeated measurements, supply `time` so rows are validated within each `dyad`-`time` combination.",
        call. = FALSE
      )
    }
  }

  n_groups <- length(unique(out[[dyad_name]]))

  if (n_groups < 2) {
    stop("At least 2 complete dyads are required after validation and any requested dropping.", call. = FALSE)
  }


  # Resolve temporal predictor decomposition.
  if (temporal_decomposition == "2l") {
    if (!has_time) {
      stop("`temporal_decomposition = \"2l\"` requires `time` to be supplied.", call. = FALSE)
    }

    if (length(predictor_names) == 0) {
      stop("`temporal_decomposition = \"2l\"` requires `predictors` to be supplied.", call. = FALSE)
    }
  }

  if (temporal_decomposition == "auto") {
    temporal_decomposition <- if (has_time && length(predictor_names) > 0) "2l" else "none"
  }

  # Check if predictors are numeric in certain cases where needed.
  if (temporal_decomposition == "2l") {
    predictor_is_numeric <- vapply(out[predictor_names], is.numeric, logical(1))
    non_numeric_predictors <- predictor_names[!predictor_is_numeric]

    if (length(non_numeric_predictors) > 0) {
      stop(
        "`predictors` used with `temporal_decomposition = \"2l\"` must be numeric. ",
        "Non-numeric predictor(s): ",
        paste(non_numeric_predictors, collapse = ", "),
        ". Use numeric predictors, or use `temporal_decomposition = \"none\"` only when the requested model types allow undecomposed non-numeric predictors.",
        call. = FALSE
      )
    }
  }

  if (any(model_types %in% c("dim", "dsm")) && length(predictor_names) > 0) {
    predictor_is_numeric <- vapply(out[predictor_names], is.numeric, logical(1))
    non_numeric_predictors <- predictor_names[!predictor_is_numeric]

    if (length(non_numeric_predictors) > 0) {
      stop(
        "`predictors` used with `model_types = \"dim\"` or `model_types = \"dsm\"` must be numeric. ",
        "DIM and DSM predictor construction computes numeric dyadic predictor scores. ",
        "Non-numeric predictor(s): ",
        paste(non_numeric_predictors, collapse = ", "),
        ".",
        call. = FALSE
      )
    }
  }

  attr(out, "dyadMLM") <- list(
    dyad = dyad_name,
    member = member_name,
    role = role_name,
    time = time_name,
    predictors = predictor_names,
    lag1_predictors = lag1_predictor_names,
    n_dyads = n_groups,
    longitudinal = has_time,
    temporal_decomposition = temporal_decomposition,
    model_types = model_types,
    dsm_role_order = dsm_role_order,
    dropped_missing_role_dyads = dropped_missing_role_dyads,
    dropped_incomplete_dyads = dropped_incomplete_dyads
  )

  class(out) <- unique(c("dyadMLM_data", class(out)))

  out
}

##########################################################################
# End Main
##########################################################################

#' Resolve the DSM role order
#'
#' Checks the relationship between `model_types`, `role`, and `dsm_role_order`,
#' validates the requested role order, and returns its stored representation.
#'
#' @param dsm_role_order The requested DSM role order.
#' @param model_types The normalized model types.
#' @param has_role Whether a role column was supplied.
#'
#' @return The validated, unnamed role order, or `NULL`.
#'
#' @keywords internal
resolve_dsm_role_order <- function(dsm_role_order, model_types, has_role) {
  has_dsm <- "dsm" %in% model_types

  if (!has_dsm && !is.null(dsm_role_order)) {
    stop(
      "`dsm_role_order` can only be supplied when `model_types` includes \"dsm\".",
      call. = FALSE
    )
  }

  if (!has_dsm) {
    return(NULL)
  }

  if (!has_role) {
    stop(
      "`model_types = \"dsm\"` requires `role` to be supplied.",
      call. = FALSE
    )
  }

  if (is.null(dsm_role_order)) {
    stop(
      paste0(
        "`model_types = \"dsm\"` requires `dsm_role_order` to be supplied. ",
        "For exchangeable dyads, use `model_types = \"dim\"` instead."
      ),
      call. = FALSE
    )
  }

  if (!is.character(dsm_role_order) ||
      length(dsm_role_order) != 2L ||
      anyNA(dsm_role_order) ||
      any(!nzchar(trimws(dsm_role_order))) ||
      anyDuplicated(trimws(dsm_role_order))) {
    stop(
      paste0(
        "`dsm_role_order` must be a character vector containing exactly two ",
        "distinct, non-missing, non-empty role values, for example ",
        "`c(\"male\", \"female\")`."
      ),
      call. = FALSE
    )
  }

  unname(trimws(dsm_role_order))
}

format_duplicate_combinations <- function(data, columns, max_combinations = 5L) {
  duplicated_rows <- duplicated(data[columns]) |
    duplicated(data[columns], fromLast = TRUE)
  combinations <- unique(data[duplicated_rows, columns, drop = FALSE])
  shown <- utils::head(combinations, max_combinations)
  labels <- character(nrow(shown))

  for (i in seq_len(nrow(shown))) {
    values <- vapply(
      shown[i, , drop = FALSE],
      function(value) as.character(value[1L]),
      character(1L)
    )
    labels[[i]] <- paste0(
      "`", columns, "` = ", values,
      collapse = ", "
    )
  }

  if (nrow(combinations) > nrow(shown)) {
    labels <- c(labels, paste0("... and ", nrow(combinations) - nrow(shown), " more"))
  }
  return(paste(labels, collapse = "; "))
}

resolve_incomplete_dyads <- function(out, dyad_name, member_name, incomplete_dyads) {

  group_member_counts <- out |>
    dplyr::group_by(.data[[dyad_name]]) |>
    dplyr::summarise(
      .dy_n_members = length(unique(.data[[member_name]])),
      .groups = "drop"
    )

  # Groups with more than two members are never valid dyads.
  too_large_groups <- group_member_counts |>
    dplyr::filter(.data$.dy_n_members > 2) |>
    dplyr::pull(dplyr::all_of(dyad_name))
  if (length(too_large_groups) > 0) {
    stop(
      paste0(
        "Each `dyad` must contain at most two unique members. ",
        "Found ",
        format_group_count(
          too_large_groups,
          singular = "dyad with more than two members",
          plural = "dyads with more than two members"
        ),
        "."
      ),
      call. = FALSE
    )
  }

  # Groups with fewer than two members are handled by policy.
  incomplete_groups <- group_member_counts |>
    dplyr::filter(.data$.dy_n_members < 2) |>
    dplyr::pull(dplyr::all_of(dyad_name))

  # Return early if all groups are complete.
  if (length(incomplete_groups) == 0) {
    return(list(out = out, dropped_incomplete_dyads = incomplete_groups))
  }

  if (incomplete_dyads == "error") {
    stop(
      paste0(
        "Each `dyad` must contain exactly two unique members. ",
        "Found ",
        format_group_count(
          incomplete_groups,
          singular = "incomplete dyad",
          plural = "incomplete dyads"
        ),
        ". `dyadMLM` cannot create rows for completely unobserved members ",
        "because their `member` identifiers and, when supplied, `role` values ",
        "cannot be inferred. Add the missing member rows or use ",
        "`incomplete_dyads = \"drop\"` ",
        "to drop these dyads."
      ),
      call. = FALSE
    )
  }

  if (incomplete_dyads == "drop") {
    message(
      paste0(
        "Dropped ",
        format_group_count(
          incomplete_groups,
          singular = "incomplete dyad",
          plural = "incomplete dyads"
        ),
        "."
      )
    )
    out <- out[!out[[dyad_name]] %in% incomplete_groups, , drop = FALSE]
    return(list(out = out, dropped_incomplete_dyads = incomplete_groups))
  }

  out
}

resolve_dyad_roles <- function(out, dyad_name, member_name, role_name, missing_role) {
  known_roles <- out[[role_name]][!is.na(out[[role_name]])]
  role_labels <- as.character(known_roles)

  if (any(!nzchar(trimws(role_labels)))) {
    stop(
      "`role` values must not be empty or whitespace-only. Use `NA` for ",
      "unknown roles so `missing_role` can handle them.",
      call. = FALSE
    )
  }

  # Reject role labels that contain the reserved composition separator.
  if (any(grepl(dyad_composition_sep, role_labels, fixed = TRUE))) {
    offending_roles <- unique(known_roles[
      grepl(dyad_composition_sep, as.character(known_roles), fixed = TRUE)
    ])
    stop(
      "`role` values must not contain `", dyad_composition_sep,
      "`; this separator is reserved by dyadMLM. Offending value(s): ",
      paste(sort(as.character(offending_roles)), collapse = ", "),
      ". Rename these role values before preparing the data.",
      call. = FALSE
    )
  }

  # Ignore missing role rows when checking whether known roles are consistent.
  known_member_roles <- unique(
    out[!is.na(out[[role_name]]), c(dyad_name, member_name, role_name), drop = FALSE]
  )

  if (anyDuplicated(known_member_roles[c(dyad_name, member_name)]) > 0) {
    stop(
      "Within each `dyad`, each `member` must have only one non-missing `role` value. ",
      "Dyad-member pair(s) with conflicting roles: ",
      format_duplicate_combinations(
        known_member_roles,
        c(dyad_name, member_name)
      ),
      ". Correct the `role` values for these pairs.",
      call. = FALSE
    )
  }

  # Rename the known role column before joining it back to the full data.
  known_member_roles[[dyad_resolved_role_col]] <- known_member_roles[[role_name]]
  known_member_roles[[role_name]] <- NULL

  # Create a lookup table with one resolved role per observed member.
  member_roles <- dplyr::left_join(
    unique(out[c(dyad_name, member_name)]),
    known_member_roles,
    by = c(dyad_name, member_name)
  )

  # Find dyads where at least one member has no known role.
  missing_role_groups <- unique(
    member_roles[[dyad_name]][is.na(member_roles[[dyad_resolved_role_col]])]
  )

  if (length(missing_role_groups) > 0) {
    if (missing_role == "error") {
      stop(
        paste0(
          "Each `member` must have at least one non-missing `role` within each `dyad` so roles can be resolved across rows. ",
          "Found incomplete role information in ",
          format_group_count(missing_role_groups),
          ". Fill in `role` values or use `missing_role = \"drop\"` to drop these dyads."
        ),
        call. = FALSE
      )
    }

    if (missing_role == "drop") {
      out <- out[!out[[dyad_name]] %in% missing_role_groups, , drop = FALSE]
      message(
        paste0(
          "Dropped ",
          format_group_count(
            missing_role_groups,
            singular = "dyad with incomplete role information",
            plural = "dyads with incomplete role information"
          ),
          "."
        )
      )
    }
  }

  # Join the resolved member roles back to every original row.
  out <- dplyr::left_join(out, member_roles, by = c(dyad_name, member_name))

  out[[role_name]] <- out[[dyad_resolved_role_col]]
  out[[dyad_resolved_role_col]] <- NULL

  return(list(out = out, dropped_missing_role_dyads = missing_role_groups))
}

Try the dyadMLM package in your browser

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

dyadMLM documentation built on Oct. 28, 2026, 1:06 a.m.