R/group-aware-resampling.R

Defines functions write_gazepoint_group_folds_csv print.gazepoint_group_folds_audit print.gazepoint_group_folds_validation print.gazepoint_group_folds validate_gazepoint_group_folds audit_gazepoint_group_folds create_gazepoint_group_folds .gp3ml_resample_fold_id .gp3ml_resample_overall_status .gp3ml_resample_bind .gp3ml_resample_assign_groups .gp3ml_resample_v .gp3ml_resample_integer

Documented in audit_gazepoint_group_folds create_gazepoint_group_folds print.gazepoint_group_folds print.gazepoint_group_folds_audit print.gazepoint_group_folds_validation validate_gazepoint_group_folds write_gazepoint_group_folds_csv

.gp3ml_resample_integer <- function(x, argument, minimum = 1L) {
  if (
    !is.numeric(x) || length(x) == 0L || anyNA(x) ||
      any(!is.finite(x)) || any(x != as.integer(x)) ||
      any(x < minimum)
  ) {
    stop(
      sprintf(
        "`%s` must contain finite integers greater than or equal to %d.",
        argument,
        minimum
      ),
      call. = FALSE
    )
  }

  as.integer(x)
}


.gp3ml_resample_v <- function(v, generalization_target) {
  v <- .gp3ml_resample_integer(v, "v", minimum = 2L)

  if (identical(
    generalization_target,
    "new_participants_and_new_stimuli"
  )) {
    if (!(length(v) %in% c(1L, 2L))) {
      stop(
        paste0(
          "For simultaneous participant and stimulus generalization, ",
          "`v` must have length one or two."
        ),
        call. = FALSE
      )
    }

    if (length(v) == 1L) {
      v <- rep(v, 2L)
    }

    names(v) <- c("participant", "stimulus")
    return(v)
  }

  if (length(v) != 1L) {
    stop(
      "`v` must be a single integer for this generalization target.",
      call. = FALSE
    )
  }

  names(v) <- "group"
  v
}


.gp3ml_resample_assign_groups <- function(values, v) {
  values <- as.character(values)
  groups <- sort(unique(values))

  if (length(groups) < v) {
    stop(
      sprintf(
        "At least %d distinct groups are required; only %d were found.",
        v,
        length(groups)
      ),
      call. = FALSE
    )
  }

  group_index <- match(values, groups)
  group_sizes <- tabulate(group_index, nbins = length(groups))
  random_ties <- sample.int(length(groups), length(groups))
  assignment_order <- order(-group_sizes, random_ties)

  fold_rows <- integer(v)
  fold_groups <- integer(v)
  group_fold <- integer(length(groups))

  for (group_position in assignment_order) {
    candidates <- which(fold_rows == min(fold_rows))
    candidates <- candidates[
      fold_groups[candidates] == min(fold_groups[candidates])
    ]

    selected <- if (length(candidates) == 1L) {
      candidates
    } else {
      sample(candidates, 1L)
    }

    group_fold[group_position] <- selected
    fold_rows[selected] <- fold_rows[selected] + group_sizes[group_position]
    fold_groups[selected] <- fold_groups[selected] + 1L
  }

  list(
    row_fold = group_fold[group_index],
    mapping = data.frame(
      group = groups,
      fold = group_fold,
      n_rows = group_sizes,
      stringsAsFactors = FALSE
    )
  )
}


.gp3ml_resample_bind <- function(rows) {
  if (length(rows) == 0L) {
    return(data.frame())
  }

  result <- do.call(rbind, rows)
  row.names(result) <- NULL
  result
}


.gp3ml_resample_overall_status <- function(status) {
  if (any(status == "fail")) {
    return("fail")
  }

  if (any(status == "review")) {
    return("review")
  }

  "pass"
}


.gp3ml_resample_fold_id <- function(
    repeat_id,
    fold_id,
    participant_fold = NA_integer_,
    stimulus_fold = NA_integer_) {
  if (!is.na(participant_fold) && !is.na(stimulus_fold)) {
    return(sprintf(
      "Repeat%02d_P%02d_S%02d",
      repeat_id,
      participant_fold,
      stimulus_fold
    ))
  }

  sprintf("Repeat%02d_Fold%02d", repeat_id, fold_id)
}


#' Create deterministic group-aware Gazepoint resampling folds
#'
#' Creates repeated grouped assessment folds that preserve the grouping
#' structure implied by an explicit generalization target. A passing
#' feature-provenance manifest is required, and every analysis-assessment
#' pair is evaluated using the leakage audit.
#'
#' @param data A data frame containing the outcome, predictors, and grouping
#'   identifiers.
#' @param outcome Name of the outcome column.
#' @param predictors Character vector naming intended predictors.
#' @param feature_manifest A feature manifest containing all intended
#'   predictors.
#' @param generalization_target One of `"new_trials_known_participants"`,
#'   `"new_participants"`, `"new_stimuli"`, or
#'   `"new_participants_and_new_stimuli"`.
#' @param participant_id Optional participant-identifier column.
#' @param trial_id Optional trial-identifier column.
#' @param stimulus_id Optional stimulus-identifier column.
#' @param v Number of group folds. For simultaneous participant and stimulus
#'   generalization, a length-two vector specifies participant and stimulus
#'   fold counts.
#' @param repeats Number of repeated fold assignments.
#' @param seed Integer random seed. The caller's random-number state is
#'   restored.
#' @param source_row_id Name of the source-row identifier added to returned
#'   partitions.
#'
#'
#' @examples
#' example_data <- expand.grid(
#'   participant_id = sprintf("P%02d", 1:6),
#'   stimulus_id = sprintf("S%02d", 1:4),
#'   repetition = 1:2,
#'   KEEP.OUT.ATTRS = FALSE,
#'   stringsAsFactors = FALSE
#' )
#' example_data$trial_id <- paste0(
#'   example_data$stimulus_id,
#'   "_T",
#'   example_data$repetition
#' )
#' participant_number <- as.integer(
#'   sub("P", "", example_data$participant_id)
#' )
#' stimulus_number <- as.integer(
#'   sub("S", "", example_data$stimulus_id)
#' )
#' example_data$outcome <- factor(
#'   ifelse(
#'     (participant_number + stimulus_number) %% 2L == 0L,
#'     "review",
#'     "pass"
#'   ),
#'   levels = c("pass", "review")
#' )
#' row_index <- seq_len(nrow(example_data))
#' example_data$fixation_duration <- 180 + row_index
#' example_data$pupil_change <- round(
#'   sin(row_index / 7),
#'   4
#' )
#' example_data$repetition <- NULL
#' manifest <- create_gazepoint_feature_manifest(
#'   features = c("fixation_duration", "pupil_change"),
#'   scientific_source = c(
#'     "Gazepoint fixation export",
#'     "Gazepoint pupil export"
#'   ),
#'   source_table = c("fixations", "pupil"),
#'   transformation = c(
#'     "Trial-level mean",
#'     "Trial-level change"
#'   ),
#'   availability_stage = "during_exposure",
#'   prediction_time_available = TRUE,
#'   preprocessing_scope = "none",
#'   fold_local_required = FALSE
#' )
#' folds <- create_gazepoint_group_folds(
#'   data = example_data,
#'   outcome = "outcome",
#'   predictors = c("fixation_duration", "pupil_change"),
#'   feature_manifest = manifest,
#'   generalization_target = "new_participants",
#'   participant_id = "participant_id",
#'   trial_id = "trial_id",
#'   stimulus_id = "stimulus_id",
#'   v = 3L,
#'   repeats = 1L,
#'   seed = 101L
#' )
#' folds
#' @return An object of class `gazepoint_group_folds`.
#'
#' @details
#' For new trials among known participants, participant-trial units are
#' assigned separately within each participant. For simultaneous participant
#' and stimulus generalization, crossed participant-stimulus assessment blocks
#' are created; cross-block rows are excluded from that fold. Each source row
#' appears in assessment exactly once per repeat.
#'
#' This function does not perform preprocessing, feature selection, tuning,
#' nested resampling, or model fitting.
#'
#' @export
create_gazepoint_group_folds <- function(
    data,
    outcome,
    predictors,
    feature_manifest,
    generalization_target,
    participant_id = NULL,
    trial_id = NULL,
    stimulus_id = NULL,
    v = 5L,
    repeats = 1L,
    seed = 1L,
    source_row_id = ".gp3ml_source_row") {
  if (!is.data.frame(data)) {
    stop("`data` must be a data frame.", call. = FALSE)
  }

  if (nrow(data) < 2L) {
    stop("`data` must contain at least two rows.", call. = FALSE)
  }

  outcome <- .gp3ml_split_scalar_column(
    outcome,
    "outcome",
    allow_null = FALSE
  )
  source_row_id <- .gp3ml_split_scalar_column(
    source_row_id,
    "source_row_id",
    allow_null = FALSE
  )
  participant_id <- .gp3ml_split_scalar_column(
    participant_id,
    "participant_id"
  )
  trial_id <- .gp3ml_split_scalar_column(trial_id, "trial_id")
  stimulus_id <- .gp3ml_split_scalar_column(stimulus_id, "stimulus_id")

  if (
    !is.character(predictors) || length(predictors) == 0L ||
      anyNA(predictors) || any(!nzchar(trimws(predictors)))
  ) {
    stop(
      "`predictors` must contain non-empty column names.",
      call. = FALSE
    )
  }

  predictors <- trimws(predictors)

  if (anyDuplicated(predictors)) {
    stop("`predictors` must contain unique column names.", call. = FALSE)
  }

  if (outcome %in% predictors) {
    stop("`outcome` must not be included in `predictors`.", call. = FALSE)
  }

  identifier_predictors <- intersect(
    predictors,
    c(participant_id, trial_id, stimulus_id, source_row_id)
  )

  if (length(identifier_predictors) > 0L) {
    stop(
      sprintf(
        "Identifier columns must not be predictors: %s.",
        paste(identifier_predictors, collapse = ", ")
      ),
      call. = FALSE
    )
  }

  targets <- .gp3ml_split_targets()

  if (
    !is.character(generalization_target) ||
      length(generalization_target) != 1L ||
      is.na(generalization_target) ||
      !(generalization_target %in% targets)
  ) {
    stop(
      sprintf(
        "`generalization_target` must be one of: %s.",
        paste(targets, collapse = ", ")
      ),
      call. = FALSE
    )
  }

  v <- .gp3ml_resample_v(v, generalization_target)
  repeats <- .gp3ml_resample_integer(repeats, "repeats", minimum = 1L)
  seed <- .gp3ml_resample_integer(seed, "seed", minimum = 0L)

  if (length(repeats) != 1L) {
    stop("`repeats` must be a single integer.", call. = FALSE)
  }

  if (length(seed) != 1L) {
    stop("`seed` must be a single integer.", call. = FALSE)
  }

  identifiers_ok <- switch(
    generalization_target,
    new_trials_known_participants =
      !is.null(participant_id) && !is.null(trial_id),
    new_participants = !is.null(participant_id),
    new_stimuli = !is.null(stimulus_id),
    new_participants_and_new_stimuli =
      !is.null(participant_id) && !is.null(stimulus_id)
  )

  if (!identifiers_ok) {
    stop(
      sprintf(
        paste0(
          "Required grouping identifiers were not supplied for ",
          "`generalization_target = \\\"%s\\\"`."
        ),
        generalization_target
      ),
      call. = FALSE
    )
  }

  if (source_row_id %in% names(data)) {
    stop(
      sprintf(
        "`data` already contains the reserved source-row column `%s`.",
        source_row_id
      ),
      call. = FALSE
    )
  }

  .gp3ml_split_require_columns(
    data,
    c(outcome, predictors, participant_id, trial_id, stimulus_id)
  )

  manifest_result <- .gp3ml_split_manifest(feature_manifest, predictors)

  participant <- if (!is.null(participant_id)) {
    .gp3ml_split_group_values(data, participant_id, "participant_id")
  } else {
    NULL
  }

  trial <- if (!is.null(trial_id)) {
    .gp3ml_split_group_values(data, trial_id, "trial_id")
  } else {
    NULL
  }

  stimulus <- if (!is.null(stimulus_id)) {
    .gp3ml_split_group_values(data, stimulus_id, "stimulus_id")
  } else {
    NULL
  }

  if (
    identical(generalization_target, "new_participants") &&
      length(unique(participant)) < v[[1L]]
  ) {
    stop("`v` exceeds the number of distinct participants.", call. = FALSE)
  }

  if (
    identical(generalization_target, "new_stimuli") &&
      length(unique(stimulus)) < v[[1L]]
  ) {
    stop("`v` exceeds the number of distinct stimuli.", call. = FALSE)
  }

  if (identical(
    generalization_target,
    "new_participants_and_new_stimuli"
  )) {
    if (length(unique(participant)) < v[["participant"]]) {
      stop(
        "Participant `v` exceeds the number of distinct participants.",
        call. = FALSE
      )
    }

    if (length(unique(stimulus)) < v[["stimulus"]]) {
      stop(
        "Stimulus `v` exceeds the number of distinct stimuli.",
        call. = FALSE
      )
    }
  }

  trial_units <- if (identical(
    generalization_target,
    "new_trials_known_participants"
  )) {
    .gp3ml_split_trial_units(participant, trial)
  } else {
    NULL
  }

  if (!is.null(trial_units)) {
    trial_counts <- vapply(
      split(trial_units, participant),
      function(values) length(unique(values)),
      integer(1)
    )

    insufficient <- names(trial_counts)[trial_counts < v[[1L]]]

    if (length(insufficient) > 0L) {
      stop(
        sprintf(
          paste0(
            "Each participant must have at least %d distinct ",
            "participant-trial units. Insufficient participants: %s."
          ),
          v[[1L]],
          paste(insufficient, collapse = ", ")
        ),
        call. = FALSE
      )
    }
  }

  had_seed <- exists(
    ".Random.seed",
    envir = .GlobalEnv,
    inherits = FALSE
  )
  previous_seed <- if (had_seed) {
    get(".Random.seed", envir = .GlobalEnv, inherits = FALSE)
  } else {
    NULL
  }

  on.exit(
    .gp3ml_split_restore_rng(had_seed, previous_seed),
    add = TRUE
  )

  set.seed(seed)

  source_rows <- seq_len(nrow(data))
  fold_data <- data
  fold_data[[source_row_id]] <- source_rows

  folds <- list()
  assignment_rows <- list()
  summary_rows <- list()
  group_count_rows <- list()
  mapping_rows <- list()

  folds_per_repeat <- if (identical(
    generalization_target,
    "new_participants_and_new_stimuli"
  )) {
    prod(v)
  } else {
    v[[1L]]
  }

  for (repeat_index in seq_len(repeats)) {
    group_fold <- rep(NA_integer_, nrow(data))
    participant_fold <- rep(NA_integer_, nrow(data))
    stimulus_fold <- rep(NA_integer_, nrow(data))
    split_unit <- rep(NA_character_, nrow(data))

    if (identical(generalization_target, "new_participants")) {
      assigned <- .gp3ml_resample_assign_groups(participant, v[[1L]])
      group_fold <- assigned$row_fold
      participant_fold <- assigned$row_fold
      split_unit <- participant

      mapping <- assigned$mapping
      mapping[["repeat"]] <- repeat_index
      mapping$unit <- "participant"
      mapping_rows[[length(mapping_rows) + 1L]] <- mapping[
        , c("repeat", "unit", "group", "fold", "n_rows"), drop = FALSE
      ]
    } else if (identical(generalization_target, "new_stimuli")) {
      assigned <- .gp3ml_resample_assign_groups(stimulus, v[[1L]])
      group_fold <- assigned$row_fold
      stimulus_fold <- assigned$row_fold
      split_unit <- stimulus

      mapping <- assigned$mapping
      mapping[["repeat"]] <- repeat_index
      mapping$unit <- "stimulus"
      mapping_rows[[length(mapping_rows) + 1L]] <- mapping[
        , c("repeat", "unit", "group", "fold", "n_rows"), drop = FALSE
      ]
    } else if (identical(
      generalization_target,
      "new_trials_known_participants"
    )) {
      split_unit <- trial_units

      for (participant_value in sort(unique(participant))) {
        selected <- participant == participant_value
        assigned <- .gp3ml_resample_assign_groups(
          trial_units[selected],
          v[[1L]]
        )
        group_fold[selected] <- assigned$row_fold

        mapping <- assigned$mapping
        mapping[["repeat"]] <- repeat_index
        mapping$unit <- "participant_trial"
        mapping$participant <- participant_value
        mapping_rows[[length(mapping_rows) + 1L]] <- mapping[
          , c(
            "repeat", "unit", "participant", "group", "fold", "n_rows"
          ),
          drop = FALSE
        ]
      }
    } else {
      participant_assignment <- .gp3ml_resample_assign_groups(
        participant,
        v[["participant"]]
      )
      stimulus_assignment <- .gp3ml_resample_assign_groups(
        stimulus,
        v[["stimulus"]]
      )

      participant_fold <- participant_assignment$row_fold
      stimulus_fold <- stimulus_assignment$row_fold
      split_unit <- paste0(
        nchar(participant),
        ":",
        participant,
        "|",
        nchar(stimulus),
        ":",
        stimulus
      )

      participant_mapping <- participant_assignment$mapping
      participant_mapping[["repeat"]] <- repeat_index
      participant_mapping$unit <- "participant"
      mapping_rows[[length(mapping_rows) + 1L]] <- participant_mapping[
        , c("repeat", "unit", "group", "fold", "n_rows"), drop = FALSE
      ]

      stimulus_mapping <- stimulus_assignment$mapping
      stimulus_mapping[["repeat"]] <- repeat_index
      stimulus_mapping$unit <- "stimulus"
      mapping_rows[[length(mapping_rows) + 1L]] <- stimulus_mapping[
        , c("repeat", "unit", "group", "fold", "n_rows"), drop = FALSE
      ]
    }

    fold_specification <- if (identical(
      generalization_target,
      "new_participants_and_new_stimuli"
    )) {
      expand.grid(
        participant_fold = seq_len(v[["participant"]]),
        stimulus_fold = seq_len(v[["stimulus"]]),
        KEEP.OUT.ATTRS = FALSE,
        stringsAsFactors = FALSE
      )
    } else {
      data.frame(
        group_fold = seq_len(v[[1L]]),
        stringsAsFactors = FALSE
      )
    }

    for (fold_index in seq_len(nrow(fold_specification))) {
      if (identical(
        generalization_target,
        "new_participants_and_new_stimuli"
      )) {
        held_participant_fold <- fold_specification$participant_fold[
          fold_index
        ]
        held_stimulus_fold <- fold_specification$stimulus_fold[
          fold_index
        ]
        participant_held <- participant_fold == held_participant_fold
        stimulus_held <- stimulus_fold == held_stimulus_fold

        partition <- ifelse(
          participant_held & stimulus_held,
          "assessment",
          ifelse(
            !participant_held & !stimulus_held,
            "analysis",
            "excluded"
          )
        )
      } else {
        held_group_fold <- fold_specification$group_fold[fold_index]
        partition <- ifelse(
          group_fold == held_group_fold,
          "assessment",
          "analysis"
        )
        held_participant_fold <- if (identical(
          generalization_target,
          "new_participants"
        )) {
          held_group_fold
        } else {
          NA_integer_
        }
        held_stimulus_fold <- if (identical(
          generalization_target,
          "new_stimuli"
        )) {
          held_group_fold
        } else {
          NA_integer_
        }
      }

      fold_identifier <- .gp3ml_resample_fold_id(
        repeat_index,
        fold_index,
        held_participant_fold,
        held_stimulus_fold
      )

      analysis_indices <- source_rows[partition == "analysis"]
      assessment_indices <- source_rows[partition == "assessment"]
      excluded_indices <- source_rows[partition == "excluded"]

      if (
        length(analysis_indices) == 0L ||
          length(assessment_indices) == 0L
      ) {
        stop(
          sprintf(
            "Fold `%s` produced an empty analysis or assessment partition.",
            fold_identifier
          ),
          call. = FALSE
        )
      }

      analysis <- fold_data[analysis_indices, , drop = FALSE]
      assessment <- fold_data[assessment_indices, , drop = FALSE]
      excluded <- fold_data[excluded_indices, , drop = FALSE]
      row.names(analysis) <- NULL
      row.names(assessment) <- NULL
      row.names(excluded) <- NULL

      leakage_audit <- audit_gazepoint_ml_leakage(
        analysis = analysis,
        assessment = assessment,
        outcome = outcome,
        predictors = predictors,
        participant_id = participant_id,
        trial_id = trial_id,
        stimulus_id = stimulus_id,
        generalization_target = generalization_target
      )

      folds[[fold_identifier]] <- structure(
        list(
          `repeat` = repeat_index,
          fold = fold_index,
          fold_id = fold_identifier,
          participant_fold = held_participant_fold,
          stimulus_fold = held_stimulus_fold,
          analysis = analysis,
          assessment = assessment,
          excluded = excluded,
          analysis_indices = analysis_indices,
          assessment_indices = assessment_indices,
          excluded_indices = excluded_indices,
          leakage_audit = leakage_audit
        ),
        class = "gazepoint_group_fold"
      )

      assignment_rows[[length(assignment_rows) + 1L]] <- data.frame(
        check.names = FALSE,
        `repeat` = repeat_index,
        fold = fold_index,
        fold_id = fold_identifier,
        source_row = source_rows,
        partition = partition,
        split_unit = split_unit,
        group_fold = group_fold,
        participant_fold = participant_fold,
        stimulus_fold = stimulus_fold,
        stringsAsFactors = FALSE
      )

      summary_rows[[length(summary_rows) + 1L]] <- data.frame(
        check.names = FALSE,
        `repeat` = repeat_index,
        fold = fold_index,
        fold_id = fold_identifier,
        participant_fold = held_participant_fold,
        stimulus_fold = held_stimulus_fold,
        n_total = nrow(data),
        n_analysis = length(analysis_indices),
        n_assessment = length(assessment_indices),
        n_excluded = length(excluded_indices),
        assessment_prop_all = length(assessment_indices) / nrow(data),
        assessment_prop_retained = length(assessment_indices) /
          (length(analysis_indices) + length(assessment_indices)),
        leakage_status = leakage_audit$status,
        stringsAsFactors = FALSE
      )

      counts <- .gp3ml_split_group_counts(
        data,
        partition,
        participant_id,
        trial_id,
        stimulus_id
      )
      counts[["repeat"]] <- repeat_index
      counts$fold <- fold_index
      counts$fold_id <- fold_identifier
      group_count_rows[[length(group_count_rows) + 1L]] <- counts[
        , c(
          "repeat", "fold", "fold_id", "partition", "unit", "n_groups"
        ),
        drop = FALSE
      ]
    }
  }

  result <- structure(
    list(
      folds = folds,
      assignments = .gp3ml_resample_bind(assignment_rows),
      fold_summary = .gp3ml_resample_bind(summary_rows),
      group_counts = .gp3ml_resample_bind(group_count_rows),
      group_mapping = .gp3ml_resample_bind(mapping_rows),
      feature_manifest = manifest_result$manifest,
      feature_manifest_validation = manifest_result$validation,
      metadata = list(
        outcome = outcome,
        predictors = predictors,
        participant_id = participant_id,
        trial_id = trial_id,
        stimulus_id = stimulus_id,
        generalization_target = generalization_target,
        v = v,
        repeats = repeats,
        seed = seed,
        source_row_id = source_row_id,
        n_source_rows = nrow(data),
        n_folds_per_repeat = folds_per_repeat,
        n_folds_total = folds_per_repeat * repeats
      ),
      call = match.call()
    ),
    class = "gazepoint_group_folds"
  )

  result$metadata$n_folds_per_repeat <- as.integer(
    result$metadata$n_folds_per_repeat
  )

  result$metadata$n_folds_total <- as.integer(
    result$metadata$n_folds_total
  )

  result$audit <- audit_gazepoint_group_folds(result)
  result$validation <- validate_gazepoint_group_folds(result)
  result
}


#' Aggregate leakage audits across group-aware folds
#'
#' @param x A `gazepoint_group_folds` object.
#'
#'
#' @examples
#' example_data <- expand.grid(
#'   participant_id = sprintf("P%02d", 1:6),
#'   stimulus_id = sprintf("S%02d", 1:4),
#'   repetition = 1:2,
#'   KEEP.OUT.ATTRS = FALSE,
#'   stringsAsFactors = FALSE
#' )
#' example_data$trial_id <- paste0(
#'   example_data$stimulus_id,
#'   "_T",
#'   example_data$repetition
#' )
#' participant_number <- as.integer(
#'   sub("P", "", example_data$participant_id)
#' )
#' stimulus_number <- as.integer(
#'   sub("S", "", example_data$stimulus_id)
#' )
#' example_data$outcome <- factor(
#'   ifelse(
#'     (participant_number + stimulus_number) %% 2L == 0L,
#'     "review",
#'     "pass"
#'   ),
#'   levels = c("pass", "review")
#' )
#' row_index <- seq_len(nrow(example_data))
#' example_data$fixation_duration <- 180 + row_index
#' example_data$pupil_change <- round(
#'   sin(row_index / 7),
#'   4
#' )
#' example_data$repetition <- NULL
#' manifest <- create_gazepoint_feature_manifest(
#'   features = c("fixation_duration", "pupil_change"),
#'   scientific_source = c(
#'     "Gazepoint fixation export",
#'     "Gazepoint pupil export"
#'   ),
#'   source_table = c("fixations", "pupil"),
#'   transformation = c(
#'     "Trial-level mean",
#'     "Trial-level change"
#'   ),
#'   availability_stage = "during_exposure",
#'   prediction_time_available = TRUE,
#'   preprocessing_scope = "none",
#'   fold_local_required = FALSE
#' )
#' folds <- create_gazepoint_group_folds(
#'   data = example_data,
#'   outcome = "outcome",
#'   predictors = c("fixation_duration", "pupil_change"),
#'   feature_manifest = manifest,
#'   generalization_target = "new_participants",
#'   participant_id = "participant_id",
#'   trial_id = "trial_id",
#'   stimulus_id = "stimulus_id",
#'   v = 3L,
#'   repeats = 1L,
#'   seed = 101L
#' )
#' audit <- audit_gazepoint_group_folds(folds)
#' audit
#' @return An object of class `gazepoint_group_folds_audit`.
#'
#' @export
audit_gazepoint_group_folds <- function(x) {
  if (!inherits(x, "gazepoint_group_folds")) {
    stop("`x` must be a `gazepoint_group_folds` object.", call. = FALSE)
  }

  if (!is.list(x$folds) || length(x$folds) == 0L) {
    stop("`x` does not contain fold audit results.", call. = FALSE)
  }

  summary_rows <- list()
  check_rows <- list()

  for (fold_object in x$folds) {
    audit <- fold_object$leakage_audit

    if (
      is.null(audit) || is.null(audit$status) ||
        !is.data.frame(audit$checks)
    ) {
      stop(
        sprintf(
          "Fold `%s` does not contain a compatible leakage audit.",
          fold_object$fold_id
        ),
        call. = FALSE
      )
    }

    summary_rows[[length(summary_rows) + 1L]] <- data.frame(
      check.names = FALSE,
      `repeat` = fold_object[["repeat"]],
      fold = fold_object$fold,
      fold_id = fold_object$fold_id,
      status = audit$status,
      n_pass = sum(audit$checks$status == "pass"),
      n_review = sum(audit$checks$status == "review"),
      n_fail = sum(audit$checks$status == "fail"),
      stringsAsFactors = FALSE
    )

    checks <- audit$checks
    checks[["repeat"]] <- fold_object[["repeat"]]
    checks$fold <- fold_object$fold
    checks$fold_id <- fold_object$fold_id
    check_rows[[length(check_rows) + 1L]] <- checks[
      , c(
        "repeat",
        "fold",
        "fold_id",
        setdiff(names(checks), c("repeat", "fold", "fold_id"))
      ),
      drop = FALSE
    ]
  }

  summary <- .gp3ml_resample_bind(summary_rows)
  checks <- .gp3ml_resample_bind(check_rows)
  issues <- checks[checks$status != "pass", , drop = FALSE]
  row.names(issues) <- NULL

  structure(
    list(
      status = .gp3ml_resample_overall_status(summary$status),
      summary = summary,
      checks = checks,
      issues = issues,
      call = match.call()
    ),
    class = "gazepoint_group_folds_audit"
  )
}


#' Validate group-aware Gazepoint resampling folds
#'
#' @param x A `gazepoint_group_folds` object.
#'
#'
#' @examples
#' example_data <- expand.grid(
#'   participant_id = sprintf("P%02d", 1:6),
#'   stimulus_id = sprintf("S%02d", 1:4),
#'   repetition = 1:2,
#'   KEEP.OUT.ATTRS = FALSE,
#'   stringsAsFactors = FALSE
#' )
#' example_data$trial_id <- paste0(
#'   example_data$stimulus_id,
#'   "_T",
#'   example_data$repetition
#' )
#' participant_number <- as.integer(
#'   sub("P", "", example_data$participant_id)
#' )
#' stimulus_number <- as.integer(
#'   sub("S", "", example_data$stimulus_id)
#' )
#' example_data$outcome <- factor(
#'   ifelse(
#'     (participant_number + stimulus_number) %% 2L == 0L,
#'     "review",
#'     "pass"
#'   ),
#'   levels = c("pass", "review")
#' )
#' row_index <- seq_len(nrow(example_data))
#' example_data$fixation_duration <- 180 + row_index
#' example_data$pupil_change <- round(
#'   sin(row_index / 7),
#'   4
#' )
#' example_data$repetition <- NULL
#' manifest <- create_gazepoint_feature_manifest(
#'   features = c("fixation_duration", "pupil_change"),
#'   scientific_source = c(
#'     "Gazepoint fixation export",
#'     "Gazepoint pupil export"
#'   ),
#'   source_table = c("fixations", "pupil"),
#'   transformation = c(
#'     "Trial-level mean",
#'     "Trial-level change"
#'   ),
#'   availability_stage = "during_exposure",
#'   prediction_time_available = TRUE,
#'   preprocessing_scope = "none",
#'   fold_local_required = FALSE
#' )
#' folds <- create_gazepoint_group_folds(
#'   data = example_data,
#'   outcome = "outcome",
#'   predictors = c("fixation_duration", "pupil_change"),
#'   feature_manifest = manifest,
#'   generalization_target = "new_participants",
#'   participant_id = "participant_id",
#'   trial_id = "trial_id",
#'   stimulus_id = "stimulus_id",
#'   v = 3L,
#'   repeats = 1L,
#'   seed = 101L
#' )
#' validation <- validate_gazepoint_group_folds(folds)
#' validation
#' @return An object of class `gazepoint_group_folds_validation`.
#'
#' @export
validate_gazepoint_group_folds <- function(x) {
  if (!inherits(x, "gazepoint_group_folds")) {
    stop("`x` must be a `gazepoint_group_folds` object.", call. = FALSE)
  }

  required <- c(
    "folds",
    "assignments",
    "fold_summary",
    "group_counts",
    "group_mapping",
    "feature_manifest_validation",
    "audit",
    "metadata"
  )
  missing_components <- setdiff(required, names(x))

  if (length(missing_components) > 0L) {
    stop(
      sprintf(
        "Fold object is missing components: %s.",
        paste(missing_components, collapse = ", ")
      ),
      call. = FALSE
    )
  }

  checks <- list()
  add_check <- function(check_id, status, message, remediation) {
    checks[[length(checks) + 1L]] <<- data.frame(
      check_id = check_id,
      status = status,
      message = message,
      remediation = remediation,
      stringsAsFactors = FALSE
    )
  }

  expected_fold_count <- x$metadata$n_folds_total
  observed_fold_count <- length(x$folds)
  fold_count_ok <- identical(
    as.integer(observed_fold_count),
    as.integer(expected_fold_count)
  )
  add_check(
    "fold_count",
    if (fold_count_ok) "pass" else "fail",
    sprintf(
      "Expected %d folds and found %d.",
      expected_fold_count,
      observed_fold_count
    ),
    if (fold_count_ok) "None." else "Recreate the fold plan."
  )

  fold_ids <- vapply(x$folds, `[[`, character(1), "fold_id")
  ids_ok <- !anyDuplicated(fold_ids) && !anyNA(fold_ids) &&
    all(nzchar(fold_ids))
  add_check(
    "fold_ids_unique",
    if (ids_ok) "pass" else "fail",
    if (ids_ok) {
      "Fold identifiers are unique and non-empty."
    } else {
      "Fold identifiers are duplicated or invalid."
    },
    if (ids_ok) "None." else "Recreate fold identifiers."
  )

  valid_partitions <- c("analysis", "assessment", "excluded")
  partitions_ok <- is.data.frame(x$assignments) &&
    nrow(x$assignments) > 0L &&
    all(x$assignments$partition %in% valid_partitions)
  add_check(
    "assignment_partitions_valid",
    if (partitions_ok) "pass" else "fail",
    if (partitions_ok) {
      "Assignment partitions use valid labels."
    } else {
      "Assignment partitions contain invalid labels."
    },
    if (partitions_ok) "None." else "Recreate the assignment table."
  )

  assignment_key <- interaction(
    x$assignments[["repeat"]],
    x$assignments$fold,
    drop = TRUE,
    lex.order = TRUE
  )
  assignment_groups <- split(x$assignments, assignment_key)
  expected_rows <- seq_len(x$metadata$n_source_rows)
  accounting_ok <- length(assignment_groups) == expected_fold_count &&
    all(vapply(
      assignment_groups,
      function(assignments) {
        identical(sort(assignments$source_row), expected_rows) &&
          !anyDuplicated(assignments$source_row)
      },
      logical(1)
    ))
  add_check(
    "source_rows_accounted_per_fold",
    if (accounting_ok) "pass" else "fail",
    if (accounting_ok) {
      "Every fold accounts for each source row exactly once."
    } else {
      "Source-row accounting is incomplete or duplicated."
    },
    if (accounting_ok) {
      "None."
    } else {
      "Reconstruct fold assignments from the source data."
    }
  )

  non_empty_ok <- all(vapply(
    x$folds,
    function(fold_object) {
      nrow(fold_object$analysis) > 0L &&
        nrow(fold_object$assessment) > 0L
    },
    logical(1)
  ))
  add_check(
    "analysis_assessment_non_empty",
    if (non_empty_ok) "pass" else "fail",
    if (non_empty_ok) {
      "All folds have non-empty analysis and assessment partitions."
    } else {
      "At least one fold has an empty analysis or assessment partition."
    },
    if (non_empty_ok) "None." else "Reduce `v` or revise grouping."
  )

  target <- x$metadata$generalization_target
  excluded_ok <- identical(
    target,
    "new_participants_and_new_stimuli"
  ) || all(x$assignments$partition != "excluded")
  add_check(
    "excluded_rows_compatible",
    if (excluded_ok) "pass" else "fail",
    if (excluded_ok) {
      "Excluded rows are compatible with the generalization target."
    } else {
      "Unexpected excluded rows were found."
    },
    if (excluded_ok) "None." else "Recreate folds using the target."
  )

  assessment_counts <- stats::aggregate(
    as.integer(x$assignments$partition == "assessment"),
    by = list(
      `repeat` = x$assignments[["repeat"]],
      source_row = x$assignments$source_row
    ),
    FUN = sum
  )
  names(assessment_counts)[3L] <- "n_assessment"
  coverage_ok <- nrow(assessment_counts) ==
    x$metadata$repeats * x$metadata$n_source_rows &&
    all(assessment_counts$n_assessment == 1L)
  add_check(
    "assessment_coverage_once_per_repeat",
    if (coverage_ok) "pass" else "fail",
    if (coverage_ok) {
      "Every source row appears in assessment exactly once per repeat."
    } else {
      "Assessment coverage is incomplete or duplicated."
    },
    if (coverage_ok) "None." else "Recreate the fold assignments."
  )

  materialized_ok <- all(vapply(
    x$folds,
    function(fold_object) {
      assignments <- x$assignments[
        x$assignments[["repeat"]] == fold_object[["repeat"]] &
          x$assignments$fold == fold_object$fold,
        ,
        drop = FALSE
      ]

      identical(
        sort(assignments$source_row[assignments$partition == "analysis"]),
        sort(fold_object$analysis_indices)
      ) && identical(
        sort(assignments$source_row[assignments$partition == "assessment"]),
        sort(fold_object$assessment_indices)
      ) && identical(
        sort(assignments$source_row[assignments$partition == "excluded"]),
        sort(fold_object$excluded_indices)
      )
    },
    logical(1)
  ))
  add_check(
    "materialized_partitions_match_assignments",
    if (materialized_ok) "pass" else "fail",
    if (materialized_ok) {
      "Materialized partitions match the assignment table."
    } else {
      "Materialized partitions and assignments disagree."
    },
    if (materialized_ok) "None." else "Recreate the fold object."
  )

  manifest_status <- x$feature_manifest_validation$status
  add_check(
    "feature_manifest_passed",
    manifest_status,
    sprintf("Feature-manifest validation status is `%s`.", manifest_status),
    if (identical(manifest_status, "pass")) {
      "None."
    } else {
      "Resolve feature-provenance issues."
    }
  )

  audit_status <- x$audit$status
  add_check(
    "fold_leakage_audit",
    audit_status,
    sprintf("Aggregated fold leakage-audit status is `%s`.", audit_status),
    if (identical(audit_status, "pass")) {
      "None."
    } else {
      "Review the fold-level leakage-audit issues."
    }
  )

  checks <- .gp3ml_resample_bind(checks)
  issues <- checks[checks$status != "pass", , drop = FALSE]
  row.names(issues) <- NULL
  levels <- c("pass", "review", "fail")
  summary <- data.frame(
    status = levels,
    n_checks = vapply(
      levels,
      function(status) sum(checks$status == status),
      integer(1)
    ),
    stringsAsFactors = FALSE
  )

  structure(
    list(
      status = .gp3ml_resample_overall_status(checks$status),
      summary = summary,
      checks = checks,
      issues = issues,
      assessment_coverage = assessment_counts,
      call = match.call()
    ),
    class = "gazepoint_group_folds_validation"
  )
}


#' Print group-aware Gazepoint resampling folds
#'
#' @param x A `gazepoint_group_folds` object.
#' @param ... Additional arguments, currently unused.
#'
#'
#' @examples
#' example_data <- expand.grid(
#'   participant_id = sprintf("P%02d", 1:6),
#'   stimulus_id = sprintf("S%02d", 1:4),
#'   repetition = 1:2,
#'   KEEP.OUT.ATTRS = FALSE,
#'   stringsAsFactors = FALSE
#' )
#' example_data$trial_id <- paste0(
#'   example_data$stimulus_id,
#'   "_T",
#'   example_data$repetition
#' )
#' participant_number <- as.integer(
#'   sub("P", "", example_data$participant_id)
#' )
#' stimulus_number <- as.integer(
#'   sub("S", "", example_data$stimulus_id)
#' )
#' example_data$outcome <- factor(
#'   ifelse(
#'     (participant_number + stimulus_number) %% 2L == 0L,
#'     "review",
#'     "pass"
#'   ),
#'   levels = c("pass", "review")
#' )
#' row_index <- seq_len(nrow(example_data))
#' example_data$fixation_duration <- 180 + row_index
#' example_data$pupil_change <- round(
#'   sin(row_index / 7),
#'   4
#' )
#' example_data$repetition <- NULL
#' manifest <- create_gazepoint_feature_manifest(
#'   features = c("fixation_duration", "pupil_change"),
#'   scientific_source = c(
#'     "Gazepoint fixation export",
#'     "Gazepoint pupil export"
#'   ),
#'   source_table = c("fixations", "pupil"),
#'   transformation = c(
#'     "Trial-level mean",
#'     "Trial-level change"
#'   ),
#'   availability_stage = "during_exposure",
#'   prediction_time_available = TRUE,
#'   preprocessing_scope = "none",
#'   fold_local_required = FALSE
#' )
#' folds <- create_gazepoint_group_folds(
#'   data = example_data,
#'   outcome = "outcome",
#'   predictors = c("fixation_duration", "pupil_change"),
#'   feature_manifest = manifest,
#'   generalization_target = "new_participants",
#'   participant_id = "participant_id",
#'   trial_id = "trial_id",
#'   stimulus_id = "stimulus_id",
#'   v = 3L,
#'   repeats = 1L,
#'   seed = 101L
#' )
#' print(folds)
#' @return `x`, invisibly.
#'
#' @export
print.gazepoint_group_folds <- function(x, ...) {
  cat("<gazepoint_group_folds>\n")
  cat("Target: ", x$metadata$generalization_target, "\n", sep = "")
  cat("Repeats: ", x$metadata$repeats, "\n", sep = "")
  cat(
    "Folds per repeat: ",
    x$metadata$n_folds_per_repeat,
    "\n",
    sep = ""
  )
  cat("Total folds: ", x$metadata$n_folds_total, "\n", sep = "")
  cat("Status: ", toupper(x$validation$status), "\n", sep = "")
  invisible(x)
}


#' Print group-aware fold validation
#'
#' @param x A `gazepoint_group_folds_validation` object.
#' @param ... Additional arguments, currently unused.
#'
#'
#' @examples
#' example_data <- expand.grid(
#'   participant_id = sprintf("P%02d", 1:6),
#'   stimulus_id = sprintf("S%02d", 1:4),
#'   repetition = 1:2,
#'   KEEP.OUT.ATTRS = FALSE,
#'   stringsAsFactors = FALSE
#' )
#' example_data$trial_id <- paste0(
#'   example_data$stimulus_id,
#'   "_T",
#'   example_data$repetition
#' )
#' participant_number <- as.integer(
#'   sub("P", "", example_data$participant_id)
#' )
#' stimulus_number <- as.integer(
#'   sub("S", "", example_data$stimulus_id)
#' )
#' example_data$outcome <- factor(
#'   ifelse(
#'     (participant_number + stimulus_number) %% 2L == 0L,
#'     "review",
#'     "pass"
#'   ),
#'   levels = c("pass", "review")
#' )
#' row_index <- seq_len(nrow(example_data))
#' example_data$fixation_duration <- 180 + row_index
#' example_data$pupil_change <- round(
#'   sin(row_index / 7),
#'   4
#' )
#' example_data$repetition <- NULL
#' manifest <- create_gazepoint_feature_manifest(
#'   features = c("fixation_duration", "pupil_change"),
#'   scientific_source = c(
#'     "Gazepoint fixation export",
#'     "Gazepoint pupil export"
#'   ),
#'   source_table = c("fixations", "pupil"),
#'   transformation = c(
#'     "Trial-level mean",
#'     "Trial-level change"
#'   ),
#'   availability_stage = "during_exposure",
#'   prediction_time_available = TRUE,
#'   preprocessing_scope = "none",
#'   fold_local_required = FALSE
#' )
#' folds <- create_gazepoint_group_folds(
#'   data = example_data,
#'   outcome = "outcome",
#'   predictors = c("fixation_duration", "pupil_change"),
#'   feature_manifest = manifest,
#'   generalization_target = "new_participants",
#'   participant_id = "participant_id",
#'   trial_id = "trial_id",
#'   stimulus_id = "stimulus_id",
#'   v = 3L,
#'   repeats = 1L,
#'   seed = 101L
#' )
#' validation <- validate_gazepoint_group_folds(folds)
#' print(validation)
#' @return `x`, invisibly.
#'
#' @export
print.gazepoint_group_folds_validation <- function(x, ...) {
  cat("<gazepoint_group_folds_validation>\n")
  cat("Overall status: ", toupper(x$status), "\n", sep = "")
  cat("Non-passing checks: ", nrow(x$issues), "\n", sep = "")
  print(x$summary, row.names = FALSE, right = FALSE)
  invisible(x)
}


#' Print aggregated group-fold leakage auditing
#'
#' @param x A `gazepoint_group_folds_audit` object.
#' @param ... Additional arguments, currently unused.
#'
#'
#' @examples
#' example_data <- expand.grid(
#'   participant_id = sprintf("P%02d", 1:6),
#'   stimulus_id = sprintf("S%02d", 1:4),
#'   repetition = 1:2,
#'   KEEP.OUT.ATTRS = FALSE,
#'   stringsAsFactors = FALSE
#' )
#' example_data$trial_id <- paste0(
#'   example_data$stimulus_id,
#'   "_T",
#'   example_data$repetition
#' )
#' participant_number <- as.integer(
#'   sub("P", "", example_data$participant_id)
#' )
#' stimulus_number <- as.integer(
#'   sub("S", "", example_data$stimulus_id)
#' )
#' example_data$outcome <- factor(
#'   ifelse(
#'     (participant_number + stimulus_number) %% 2L == 0L,
#'     "review",
#'     "pass"
#'   ),
#'   levels = c("pass", "review")
#' )
#' row_index <- seq_len(nrow(example_data))
#' example_data$fixation_duration <- 180 + row_index
#' example_data$pupil_change <- round(
#'   sin(row_index / 7),
#'   4
#' )
#' example_data$repetition <- NULL
#' manifest <- create_gazepoint_feature_manifest(
#'   features = c("fixation_duration", "pupil_change"),
#'   scientific_source = c(
#'     "Gazepoint fixation export",
#'     "Gazepoint pupil export"
#'   ),
#'   source_table = c("fixations", "pupil"),
#'   transformation = c(
#'     "Trial-level mean",
#'     "Trial-level change"
#'   ),
#'   availability_stage = "during_exposure",
#'   prediction_time_available = TRUE,
#'   preprocessing_scope = "none",
#'   fold_local_required = FALSE
#' )
#' folds <- create_gazepoint_group_folds(
#'   data = example_data,
#'   outcome = "outcome",
#'   predictors = c("fixation_duration", "pupil_change"),
#'   feature_manifest = manifest,
#'   generalization_target = "new_participants",
#'   participant_id = "participant_id",
#'   trial_id = "trial_id",
#'   stimulus_id = "stimulus_id",
#'   v = 3L,
#'   repeats = 1L,
#'   seed = 101L
#' )
#' audit <- audit_gazepoint_group_folds(folds)
#' print(audit)
#' @return `x`, invisibly.
#'
#' @export
print.gazepoint_group_folds_audit <- function(x, ...) {
  cat("<gazepoint_group_folds_audit>\n")
  cat("Overall status: ", toupper(x$status), "\n", sep = "")
  cat("Audited folds: ", nrow(x$summary), "\n", sep = "")
  cat("Non-passing checks: ", nrow(x$issues), "\n", sep = "")
  invisible(x)
}


#' Write group-aware resampling tables to CSV
#'
#' @param x A `gazepoint_group_folds` object.
#' @param directory Output directory.
#' @param prefix Non-empty filename prefix.
#' @param tables Character vector selecting summary tables.
#' @param include_fold_data Logical. Whether every materialized fold partition
#'   should also be written.
#' @param overwrite Logical. Whether existing files may be replaced.
#' @param na Character representation of missing values.
#'
#'
#' @examples
#' example_data <- expand.grid(
#'   participant_id = sprintf("P%02d", 1:6),
#'   stimulus_id = sprintf("S%02d", 1:4),
#'   repetition = 1:2,
#'   KEEP.OUT.ATTRS = FALSE,
#'   stringsAsFactors = FALSE
#' )
#' example_data$trial_id <- paste0(
#'   example_data$stimulus_id,
#'   "_T",
#'   example_data$repetition
#' )
#' participant_number <- as.integer(
#'   sub("P", "", example_data$participant_id)
#' )
#' stimulus_number <- as.integer(
#'   sub("S", "", example_data$stimulus_id)
#' )
#' example_data$outcome <- factor(
#'   ifelse(
#'     (participant_number + stimulus_number) %% 2L == 0L,
#'     "review",
#'     "pass"
#'   ),
#'   levels = c("pass", "review")
#' )
#' row_index <- seq_len(nrow(example_data))
#' example_data$fixation_duration <- 180 + row_index
#' example_data$pupil_change <- round(
#'   sin(row_index / 7),
#'   4
#' )
#' example_data$repetition <- NULL
#' manifest <- create_gazepoint_feature_manifest(
#'   features = c("fixation_duration", "pupil_change"),
#'   scientific_source = c(
#'     "Gazepoint fixation export",
#'     "Gazepoint pupil export"
#'   ),
#'   source_table = c("fixations", "pupil"),
#'   transformation = c(
#'     "Trial-level mean",
#'     "Trial-level change"
#'   ),
#'   availability_stage = "during_exposure",
#'   prediction_time_available = TRUE,
#'   preprocessing_scope = "none",
#'   fold_local_required = FALSE
#' )
#' folds <- create_gazepoint_group_folds(
#'   data = example_data,
#'   outcome = "outcome",
#'   predictors = c("fixation_duration", "pupil_change"),
#'   feature_manifest = manifest,
#'   generalization_target = "new_participants",
#'   participant_id = "participant_id",
#'   trial_id = "trial_id",
#'   stimulus_id = "stimulus_id",
#'   v = 3L,
#'   repeats = 1L,
#'   seed = 101L
#' )
#' output_directory <- tempfile()
#' paths <- write_gazepoint_group_folds_csv(
#'   x = folds,
#'   directory = output_directory,
#'   tables = c("fold_summary", "group_counts")
#' )
#' basename(unname(paths))
#' unlink(output_directory, recursive = TRUE)
#' @return A named character vector of normalized output paths, invisibly.
#'
#' @export
write_gazepoint_group_folds_csv <- function(
    x,
    directory,
    prefix = "gazepoint_group_folds",
    tables = c(
      "assignments",
      "fold_summary",
      "group_counts",
      "group_mapping",
      "validation_checks",
      "validation_issues",
      "audit_summary",
      "audit_checks",
      "audit_issues"
    ),
    include_fold_data = FALSE,
    overwrite = FALSE,
    na = "") {
  if (!inherits(x, "gazepoint_group_folds")) {
    stop("`x` must be a `gazepoint_group_folds` object.", call. = FALSE)
  }

  if (
    !is.character(directory) || length(directory) != 1L ||
      is.na(directory) || !nzchar(trimws(directory))
  ) {
    stop("`directory` must be a single non-empty path.", call. = FALSE)
  }

  if (
    !is.character(prefix) || length(prefix) != 1L ||
      is.na(prefix) || !nzchar(trimws(prefix))
  ) {
    stop("`prefix` must be a single non-empty string.", call. = FALSE)
  }

  if (grepl("[/\\\\]", prefix)) {
    stop("`prefix` must not contain directory separators.", call. = FALSE)
  }

  valid_tables <- c(
    "assignments",
    "fold_summary",
    "group_counts",
    "group_mapping",
    "validation_checks",
    "validation_issues",
    "audit_summary",
    "audit_checks",
    "audit_issues"
  )

  if (
    !is.character(tables) || length(tables) == 0L || anyNA(tables) ||
      any(!(tables %in% valid_tables))
  ) {
    stop(
      sprintf(
        "`tables` must use values from: %s.",
        paste(valid_tables, collapse = ", ")
      ),
      call. = FALSE
    )
  }

  if (
    !is.logical(include_fold_data) || length(include_fold_data) != 1L ||
      is.na(include_fold_data)
  ) {
    stop("`include_fold_data` must be TRUE or FALSE.", call. = FALSE)
  }

  if (!is.logical(overwrite) || length(overwrite) != 1L || is.na(overwrite)) {
    stop("`overwrite` must be TRUE or FALSE.", call. = FALSE)
  }

  if (!is.character(na) || length(na) != 1L || is.na(na)) {
    stop(
      "`na` must be a single non-missing character value.",
      call. = FALSE
    )
  }

  tables <- unique(tables)
  directory <- path.expand(directory)

  if (!dir.exists(directory)) {
    created <- dir.create(directory, recursive = TRUE)

    if (!created && !dir.exists(directory)) {
      stop(
        sprintf("Could not create output directory: %s.", directory),
        call. = FALSE
      )
    }
  }

  output_data <- list(
    assignments = x$assignments,
    fold_summary = x$fold_summary,
    group_counts = x$group_counts,
    group_mapping = x$group_mapping,
    validation_checks = x$validation$checks,
    validation_issues = x$validation$issues,
    audit_summary = x$audit$summary,
    audit_checks = x$audit$checks,
    audit_issues = x$audit$issues
  )[tables]

  if (include_fold_data) {
    for (fold_object in x$folds) {
      for (partition_name in c("analysis", "assessment", "excluded")) {
        output_name <- paste(fold_object$fold_id, partition_name, sep = "_")
        output_data[[output_name]] <- fold_object[[partition_name]]
      }
    }
  }

  files <- file.path(
    directory,
    paste0(prefix, "_", names(output_data), ".csv")
  )
  names(files) <- names(output_data)
  existing <- files[file.exists(files)]

  if (length(existing) > 0L && !overwrite) {
    stop(
      sprintf(
        "Output files already exist: %s.",
        paste(existing, collapse = ", ")
      ),
      call. = FALSE
    )
  }

  for (table_name in names(output_data)) {
    utils::write.csv(
      output_data[[table_name]],
      file = files[[table_name]],
      row.names = FALSE,
      na = na,
      fileEncoding = "UTF-8"
    )
  }

  normalized <- vapply(
    files,
    normalizePath,
    character(1),
    winslash = "/",
    mustWork = TRUE
  )

  invisible(normalized)
}

Try the gp3ml package in your browser

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

gp3ml documentation built on Aug. 23, 2026, 5:11 p.m.