R/106-multimodal-m3-evidence-0-10.R

Defines functions print.eye_multimodal_m3_validation validate_multimodal_m3 print.eye_multimodal_m3_functional_bridge multimodal_m3_functional_bridge print.eye_multimodal_m3_negative_controls multimodal_m3_negative_controls .ep10_m3_phase_randomize_trials .ep10_m3_phase_randomize .ep10_m3_permute_within .ep10_m3_person_correlations .ep10_m3_person_summaries print.eye_multimodal_m3_information multimodal_m3_process_information .ep10_m3_pair_delta .ep10_m3_theta_variance .ep10_m3_response_loo print.eye_multimodal_m3_ablation multimodal_m3_ablation .ep10_m3_fit_subset .ep10_m3_subset_stan_data .ep10_m3_ablation_definitions .ep10_m3_sampler_audit print.eye_multimodal_m3_ppc multimodal_m3_ppc .ep10_m3_ppp_channel print.eye_multimodal_m3_identifiability audit_multimodal_m3_identifiability .ep10_m3_vector_variation .ep10_m3_channel_design

Documented in audit_multimodal_m3_identifiability multimodal_m3_ablation multimodal_m3_functional_bridge multimodal_m3_negative_controls multimodal_m3_ppc multimodal_m3_process_information validate_multimodal_m3

# eyeprocess 0.10 - M3 evidence, identifiability, PPC, ablation, information

.ep10_m3_channel_design <- function(d, observed) {
    .ep10_m2_channel_design(d, observed)
}

.ep10_m3_vector_variation <- function(x, log_scale = FALSE) {
    x <- x[!is.na(x)]
    if (length(x) < 2L) return(FALSE)
    if (isTRUE(log_scale)) x <- log(x)
    is.finite(stats::var(as.numeric(x))) && stats::var(as.numeric(x)) > 0
}

#' Audit structural and measurement support for M3
#'
#' Performs a conservative pre-fit audit for the four-channel response, RT,
#' fixation-count and pupil reference model. The audit checks design
#' connectivity, channel coverage and variation, pupil nuisance availability,
#' blink/interpolation burden, device/session representation and explicit M3
#' identification constraints. It is a support screen, not proof of global
#' identifiability or construct validity.
#'
#' @param x M3-compatible data, simulation, fit, or measurement object.
#' @param pupil_scale Pupil transformation used by the reference likelihood.
#' @param min_persons,min_items Conservative design thresholds.
#' @param max_pupil_missing,max_blink_rate,max_interpolation_rate Warning thresholds.
#' @return An `eye_multimodal_m3_identifiability` object.
#' @export
audit_multimodal_m3_identifiability <- function(
    x,
    pupil_scale = c("z", "raw"),
    min_persons = 20L,
    min_items = 5L,
    max_pupil_missing = 0.50,
    max_blink_rate = 0.30,
    max_interpolation_rate = 0.30
) {
    pupil_scale <- match.arg(pupil_scale)
    data <- .ep10_m3_as_data(x, pupil_scale = pupil_scale)
    d <- data$raw

    designs <- lapply(data$observed, function(obs) .ep10_m3_channel_design(d, obs))
    names(designs) <- names(data$observed)

    y <- d$response[data$observed$response]
    variation <- c(
        response = length(y) > 1L && length(unique(y)) == 2L,
        rt = .ep10_m3_vector_variation(d$rt[data$observed$rt], log_scale = TRUE),
        gaze = .ep10_m3_vector_variation(d$gaze[data$observed$gaze]),
        pupil = .ep10_m3_vector_variation(d$pupil[data$observed$pupil])
    )

    connected <- vapply(
        designs,
        function(z) is.finite(z$components[[1L]]) && z$components[[1L]] == 1L,
        logical(1L)
    )
    present <- vapply(designs, function(z) z$n_observed[[1L]] > 0L, logical(1L))

    pupil_missing <- mean(!data$observed$pupil)
    blink_rate <- if (all(is.na(d$pupil_blink))) NA_real_ else mean(d$pupil_blink, na.rm = TRUE)
    interpolation_rate <- if (all(is.na(d$pupil_interpolated))) NA_real_ else mean(d$pupil_interpolated, na.rm = TRUE)

    quality_checks <- c(
        pupil_missingness = pupil_missing <= max_pupil_missing,
        blink_burden = is.na(blink_rate) || blink_rate <= max_blink_rate,
        interpolation_burden = is.na(interpolation_rate) || interpolation_rate <= max_interpolation_rate
    )

    n_person <- length(data$person_levels)
    n_item <- length(data$item_levels)
    size_ok <- n_person >= as.integer(min_persons) && n_item >= as.integer(min_items)

    pupil_by_item <- split(d$pupil[data$observed$pupil], d$item_id[data$observed$pupil])
    pupil_by_person <- split(d$pupil[data$observed$pupil], d$person_id[data$observed$pupil])
    variable_pupil_items <- sum(vapply(pupil_by_item, .ep10_m3_vector_variation, logical(1L)))
    variable_pupil_persons <- sum(vapply(pupil_by_person, .ep10_m3_vector_variation, logical(1L)))

    checks <- data.frame(
        check = c(
            "sample_size",
            paste0(names(present), "_presence"),
            paste0(names(connected), "_design_connected"),
            paste0(names(variation), "_variation"),
            names(quality_checks),
            "person_latent_means_fixed_zero",
            "response_discrimination_fixed_one",
            "rt_person_loading_fixed_minus_one",
            "gaze_person_loading_fixed_one",
            "pupil_person_loading_fixed_one",
            "pupil_nuisance_missing_values_not_imputed"
        ),
        pass = c(
            size_ok,
            unname(present),
            unname(connected),
            unname(variation),
            unname(quality_checks),
            rep(TRUE, 6L)
        ),
        stringsAsFactors = FALSE
    )

    supported <- all(c(size_ok, present, connected, variation))
    warning_free <- all(checks$pass)

    device_table <- if (all(is.na(d$device))) {
        data.frame(device = NA_character_, n = nrow(d), pupil_missing = pupil_missing, stringsAsFactors = FALSE)
    } else {
        do.call(rbind, lapply(split(seq_len(nrow(d)), d$device), function(ind) {
            data.frame(
                device = as.character(d$device[ind[[1L]]]),
                n = length(ind),
                pupil_missing = mean(is.na(d$pupil[ind])),
                pupil_mean = if (all(is.na(d$pupil[ind]))) NA_real_ else mean(d$pupil[ind], na.rm = TRUE),
                stringsAsFactors = FALSE
            )
        }))
    }

    out <- list(
        model = "M3",
        supported = supported,
        warning_free = warning_free,
        checks = checks,
        n_person = n_person,
        n_item = n_item,
        design = designs,
        variation = variation,
        missing_fraction = vapply(data$observed, function(z) mean(!z), numeric(1L)),
        pupil = list(
            missing_fraction = pupil_missing,
            blink_rate = blink_rate,
            interpolation_rate = interpolation_rate,
            variable_items = variable_pupil_items,
            variable_persons = variable_pupil_persons,
            nuisance = data$nuisance,
            transformation = data$pupil_transform
        ),
        device = device_table,
        statement = paste(
            "This is a conservative structural/data-support audit.",
            "Passing does not establish global identifiability, empirical construct validity,",
            "or robustness to informative pupil/gaze missingness, device artefacts, or unmeasured luminance."
        )
    )
    class(out) <- c("eye_multimodal_m3_identifiability", "list")
    out
}

print.eye_multimodal_m3_identifiability <- function(x, ...) {
    cat(
        "<eye_multimodal_m3_identifiability>\n",
        "  persons: ", x$n_person, "\n",
        "  items: ", x$n_item, "\n",
        "  supported: ", x$supported, "\n",
        "  warning-free: ", x$warning_free, "\n",
        "  missing: ", paste(names(x$missing_fraction), sprintf("%.3f", x$missing_fraction), sep = "=", collapse = ", "), "\n",
        "  pupil blink/interpolation: ", sprintf("%.3f", x$pupil$blink_rate), " / ", sprintf("%.3f", x$pupil$interpolation_rate), "\n",
        "  boundary: ", x$statement, "\n",
        sep = ""
    )
    invisible(x)
}

.ep10_m3_ppp_channel <- function(fit, observed_name, replicate_name, channel) {
    I <- length(fit$data$item_levels)
    obs <- .ep10_m2_extract_item_draws(fit, observed_name, I)
    rep <- .ep10_m2_extract_item_draws(fit, replicate_name, I)
    ppp <- colMeans(rep >= obs)

    mask <- fit$data$observed[[channel]]
    d <- fit$data$raw
    counts <- tabulate(
        d$item_index[mask],
        nbins = I
    )
    ppp[counts == 0L] <- NA_real_

    data.frame(
        item_id = fit$data$item_levels,
        channel = channel,
        n_observed = counts,
        ppp = as.numeric(ppp),
        lower_tail = !is.na(ppp) & ppp < 0.05,
        upper_tail = !is.na(ppp) & ppp > 0.95,
        flagged = !is.na(ppp) & (ppp < 0.05 | ppp > 0.95),
        stringsAsFactors = FALSE
    )
}

#' Posterior predictive checks for the M3 four-channel model
#'
#' Extends the M2 W/L/M discrepancy family with a standardized pupil residual
#' discrepancy P. Tail flags are diagnostics, not construct validation.
#'
#' @param x An M3 fit.
#' @return An `eye_multimodal_m3_ppc`.
#' @export
multimodal_m3_ppc <- function(x) {
    if (!inherits(x, "eye_multimodal_m3_fit")) stop("`x` must be an M3 fit.", call. = FALSE)
    tab <- rbind(
        .ep10_m3_ppp_channel(x, "W_obs", "W_rep", "response"),
        .ep10_m3_ppp_channel(x, "L_obs", "L_rep", "rt"),
        .ep10_m3_ppp_channel(x, "M_obs", "M_rep", "gaze"),
        .ep10_m3_ppp_channel(x, "P_obs", "P_rep", "pupil")
    )

    pupil_rep <- posterior::as_draws_matrix(x$fit$draws("pupil_rep"))
    obs_rows <- which(x$data$observed$pupil)
    pupil_observed <- x$data$raw$pupil_model[obs_rows]
    pupil_global <- data.frame(
        statistic = c("mean", "sd", "q10", "median", "q90"),
        observed = c(mean(pupil_observed), stats::sd(pupil_observed), stats::quantile(pupil_observed, .1), stats::median(pupil_observed), stats::quantile(pupil_observed, .9)),
        replicated_median = c(
            stats::median(rowMeans(pupil_rep)),
            stats::median(apply(pupil_rep, 1L, stats::sd)),
            stats::median(apply(pupil_rep, 1L, stats::quantile, probs = .1)),
            stats::median(apply(pupil_rep, 1L, stats::median)),
            stats::median(apply(pupil_rep, 1L, stats::quantile, probs = .9))
        ),
        stringsAsFactors = FALSE
    )

    out <- list(
        table = tab,
        flag_rate = if (any(tab$n_observed > 0L)) mean(tab$flagged[tab$n_observed > 0L]) else NA_real_,
        flag_rate_by_channel = tapply(
            seq_len(nrow(tab)), tab$channel,
            function(ind) {
                usable <- ind[tab$n_observed[ind] > 0L]
                if (!length(usable)) NA_real_ else mean(tab$flagged[usable])
            }
        ),
        pupil_global = pupil_global,
        interpretation = paste(
            "PPC evaluates model-data discrepancy for the four observed channels.",
            "A well-fitting pupil channel is not evidence that it measures cognitive load, effort, or arousal."
        )
    )
    class(out) <- c("eye_multimodal_m3_ppc", "list")
    out
}

print.eye_multimodal_m3_ppc <- function(x, ...) {
    cat("<eye_multimodal_m3_ppc>\n",
        "  item-channel checks: ", nrow(x$table), "\n",
        "  tail-flag rate: ", sprintf("%.3f", x$flag_rate), "\n",
        "  by channel: ", paste(names(x$flag_rate_by_channel), sprintf("%.3f", x$flag_rate_by_channel), sep = "=", collapse = ", "), "\n",
        sep = "")
    invisible(x)
}

.ep10_m3_sampler_audit <- function(x) {
    vars <- c(
        "z_person", "sigma_person", "L_person", "z_item", "mu_item", "sigma_item", "L_item",
        "nu", "s", "sigma_pupil", "gamma_pupil", "theta", "tau", "omega", "rho", "b", "beta", "m", "kappa",
        "corr_person", "corr_item"
    )
    s <- x$fit$summary(variables = vars)
    finite_rhat <- s$rhat[is.finite(s$rhat)]
    finite_bulk <- s$ess_bulk[is.finite(s$ess_bulk)]
    finite_tail <- s$ess_tail[is.finite(s$ess_tail)]

    dg <- tryCatch(
        as.data.frame(x$fit$diagnostic_summary()),
        error = function(e) NULL
    )

    get_diag <- function(candidates) {
        if (is.null(dg)) return(NULL)
        nm <- intersect(candidates, names(dg))
        if (!length(nm)) return(NULL)
        dg[[nm[[1L]]]]
    }

    div_values <- get_diag(c("num_divergent", "num_divergences", "divergent"))
    depth_values <- get_diag(c("num_max_treedepth", "max_treedepth"))
    ebfmi_values <- get_diag(c("ebfmi", "E-BFMI"))

    div <- if (is.null(div_values)) NA_integer_ else as.integer(sum(div_values, na.rm = TRUE))
    depth <- if (is.null(depth_values)) NA_integer_ else as.integer(sum(depth_values, na.rm = TRUE))
    ebfmi <- if (is.null(ebfmi_values) || !any(is.finite(ebfmi_values))) {
        NA_real_
    } else {
        min(ebfmi_values[is.finite(ebfmi_values)])
    }

    list(
        max_rhat = if (length(finite_rhat)) max(finite_rhat) else NA_real_,
        min_ess_bulk = if (length(finite_bulk)) min(finite_bulk) else NA_real_,
        min_ess_tail = if (length(finite_tail)) min(finite_tail) else NA_real_,
        divergences = div,
        max_treedepth_hits = depth,
        min_ebfmi = ebfmi
    )
}

.ep10_m3_ablation_definitions <- function() {
    data.frame(
        model = c("R", "R_RT", "R_GAZE", "R_PUPIL", "R_RT_GAZE", "R_RT_PUPIL", "R_GAZE_PUPIL", "FULL"),
        rt = c(FALSE, TRUE, FALSE, FALSE, TRUE, TRUE, FALSE, TRUE),
        gaze = c(FALSE, FALSE, TRUE, FALSE, TRUE, FALSE, TRUE, TRUE),
        pupil = c(FALSE, FALSE, FALSE, TRUE, FALSE, TRUE, TRUE, TRUE),
        channels = c(
            "response", "response + RT", "response + gaze", "response + pupil",
            "response + RT + gaze", "response + RT + pupil", "response + gaze + pupil", "response + RT + gaze + pupil"
        ),
        stringsAsFactors = FALSE
    )
}

.ep10_m3_subset_stan_data <- function(
    data, use_rt, use_gaze, use_pupil, prior_profile = "regularized",
    nuisance = stats::setNames(rep(TRUE, 8L), .ep10_m3_nuisance_names)
) {
    d <- data$raw
    rr <- which(data$observed$response)
    tr <- if (use_rt) which(data$observed$rt) else integer()
    gr <- if (use_gaze) which(data$observed$gaze) else integer()
    pr <- if (use_pupil) which(data$observed$pupil) else integer()

    if (!length(rr)) stop("M3 ablation requires observed response values for the common target.", call. = FALSE)
    if (isTRUE(use_rt) && !length(tr)) stop("Requested M3 ablation model contains RT but no RT values are observed.", call. = FALSE)
    if (isTRUE(use_gaze) && !length(gr)) stop("Requested M3 ablation model contains gaze but no gaze values are observed.", call. = FALSE)
    if (isTRUE(use_pupil) && !length(pr)) stop("Requested M3 ablation model contains pupil but no pupil values are observed.", call. = FALSE)

    active <- c(rt = use_rt, gaze = use_gaze, pupil = use_pupil)
    active_names <- names(active)[active]
    indices <- stats::setNames(rep(0L, 3L), c("rt", "gaze", "pupil"))
    if (length(active_names)) indices[active_names] <- seq_along(active_names) + 1L
    K <- 1L + length(active_names)

    if (is.null(names(nuisance)) || !all(.ep10_m3_nuisance_names %in% names(nuisance))) {
        stop("`nuisance` must name all M3 pupil nuisance terms.", call. = FALSE)
    }
    nuisance <- as.logical(nuisance[.ep10_m3_nuisance_names])
    use_cov <- as.integer(data$nuisance$available & !data$nuisance$degenerate & nuisance)
    if (!use_pupil) use_cov[] <- 0L
    X <- if (length(pr)) data$nuisance_matrix[pr, , drop = FALSE] else matrix(numeric(), nrow = 0L, ncol = 8L)

    list(
        J = length(data$person_levels), I = length(data$item_levels), K = K,
        use_rt = as.integer(use_rt), use_gaze = as.integer(use_gaze), use_pupil = as.integer(use_pupil),
        idx_rt = indices[["rt"]], idx_gaze = indices[["gaze"]], idx_pupil = indices[["pupil"]],
        N_response = length(rr), person_response = as.integer(d$person_index[rr]), item_response = as.integer(d$item_index[rr]), y_response = as.integer(d$response[rr]),
        N_rt = length(tr), person_rt = as.integer(d$person_index[tr]), item_rt = as.integer(d$item_index[tr]), log_rt = log(as.numeric(d$rt[tr])),
        N_gaze = length(gr), person_gaze = as.integer(d$person_index[gr]), item_gaze = as.integer(d$item_index[gr]), gaze = as.integer(d$gaze[gr]),
        N_pupil = length(pr), person_pupil = as.integer(d$person_index[pr]), item_pupil = as.integer(d$item_index[pr]), pupil = as.numeric(d$pupil_model[pr]),
        X_pupil = unname(as.matrix(X)), use_pupil_covariate = use_cov,
        prior_profile = if (identical(prior_profile, "regularized")) 1L else 2L
    )
}

.ep10_m3_fit_subset <- function(
    data, definition, prior_profile = "regularized",
    nuisance = stats::setNames(rep(TRUE, 8L), .ep10_m3_nuisance_names),
    chains = 4L, parallel_chains = chains,
    iter_warmup = 750L, iter_sampling = 750L, seed = 20260815L,
    adapt_delta = .95, max_treedepth = 12L, refresh = 100L,
    quiet_compile = TRUE, init = 0, model_object = NULL
) {
    .ep10_m2_require_backend()
    if (is.null(model_object)) model_object <- .ep10_m3_compile("ablation", quiet = quiet_compile)
    stan_data <- .ep10_m3_subset_stan_data(
        data, definition$rt, definition$gaze, definition$pupil,
        prior_profile = prior_profile, nuisance = nuisance
    )
    fit <- model_object$sample(
        data = stan_data, chains = as.integer(chains), parallel_chains = as.integer(parallel_chains),
        iter_warmup = as.integer(iter_warmup), iter_sampling = as.integer(iter_sampling), seed = as.integer(seed),
        adapt_delta = adapt_delta, max_treedepth = as.integer(max_treedepth), refresh = as.integer(refresh), init = init
    )
    structure(list(
        model = definition$model, fit = fit, data = data, stan_data = stan_data,
        channels = definition$channels,
        pupil_nuisance = stats::setNames(
            as.logical(stan_data$use_pupil_covariate),
            .ep10_m3_nuisance_names
        ),
        sampling_controls = list(chains = chains, iter_warmup = iter_warmup, iter_sampling = iter_sampling, max_treedepth = max_treedepth),
        interpretation = "Ablation fit on a common response target; omitted sensors are not causal interventions."
    ), class = c("eye_multimodal_m3_subset_fit", "list"))
}

#' Fit the complete M3 response-anchored channel-ablation lattice
#'
#' Fits all eight response-anchored combinations of RT, gaze and pupil. Models
#' use one common subset-likelihood implementation to make the target and prior
#' family explicit. This is inferential ablation, not a causal intervention on sensors.
#'
#' @param x M3-compatible data.
#' @param models Optional subset of the eight model identifiers.
#' @param nuisance Named logical vector selecting pupil nuisance terms. The same
#'   selection is applied to every ablation model containing pupil.
#' @param ... Sampling controls forwarded to the subset fitter.
#' @return An `eye_multimodal_m3_ablation`.
#' @export
multimodal_m3_ablation <- function(
    x, models = NULL,
    nuisance = stats::setNames(rep(TRUE, 8L), .ep10_m3_nuisance_names),
    ...
) {
    data <- .ep10_m3_as_data(x)
    definitions <- .ep10_m3_ablation_definitions()
    if (!is.null(models)) {
        bad <- setdiff(models, definitions$model)
        if (length(bad)) stop("Unknown M3 ablation models: ", paste(bad, collapse = ", "), call. = FALSE)
        definitions <- definitions[match(models, definitions$model), , drop = FALSE]
    }
    mod <- .ep10_m3_compile("ablation")
    fits <- lapply(
        seq_len(nrow(definitions)),
        function(k) .ep10_m3_fit_subset(
            data, definitions[k, , drop = FALSE], model_object = mod,
            nuisance = nuisance, ...
        )
    )
    names(fits) <- definitions$model
    out <- list(
        fits = fits,
        definitions = definitions,
        pupil_nuisance = stats::setNames(
            as.logical(nuisance[.ep10_m3_nuisance_names]),
            .ep10_m3_nuisance_names
        ),
        target = "held-out response cells among observed persons/items",
        interpretation = paste(
            "All models retain response as the common prediction target.",
            "Channel gains may be zero or negative; contributions are not assumed additive and ablation is not causal."
        )
    )
    class(out) <- c("eye_multimodal_m3_ablation", "list")
    out
}

print.eye_multimodal_m3_ablation <- function(x, ...) {
    cat("<eye_multimodal_m3_ablation>\n",
        "  models: ", paste(names(x$fits), collapse = ", "), "\n",
        "  target: ", x$target, "\n",
        "  boundary: ", x$interpretation, "\n", sep = "")
    invisible(x)
}

.ep10_m3_response_loo <- function(fit) {
    .ep10_m2_require_backend(require_loo = TRUE)
    ll <- as.array(fit$fit$draws("log_lik_response", format = "draws_array"))
    loo::loo(ll, r_eff = loo::relative_eff(exp(ll)))
}

.ep10_m3_theta_variance <- function(fit) {
    theta <- posterior::as_draws_matrix(fit$fit$draws("theta"))
    apply(theta, 2L, stats::var)
}

.ep10_m3_pair_delta <- function(pointwise, a, b) {
    za <- pointwise[[a]]; zb <- pointwise[[b]]
    if (is.null(za) || is.null(zb) || length(za) != length(zb)) return(c(delta = NA_real_, se = NA_real_))
    d <- za - zb
    c(delta = sum(d), se = if (length(d) > 1L) sqrt(length(d) * stats::var(d)) else NA_real_)
}

#' Quantify M3 process information, pupil increment, redundancy and sensor value
#'
#' Uses response-target PSIS-LOO and posterior ability variance across the
#' eight-channel ablation lattice. It additionally reports paired pupil gains,
#' a non-additivity/redundancy contrast, a channel-conflict screen, and optional
#' information per usable pupil observation or sensor cost. Positive values do
#' not establish construct validity or causal sensor value.
#'
#' @param x An M3 ablation object.
#' @param pupil_cost Optional positive relative pupil-sensor cost.
#' @param decisive_z Absolute delta/SE ratio used only as a descriptive evidence flag.
#' @return An `eye_multimodal_m3_information` object.
#' @export
multimodal_m3_process_information <- function(x, pupil_cost = 1, decisive_z = 2) {
    if (!inherits(x, "eye_multimodal_m3_ablation")) stop("`x` must come from `multimodal_m3_ablation()`.", call. = FALSE)
    required_models <- .ep10_m3_ablation_definitions()$model
    absent_models <- setdiff(required_models, names(x$fits))
    if (length(absent_models)) {
        stop(
            "M3 process information requires the complete eight-model ablation lattice; missing: ",
            paste(absent_models, collapse = ", "),
            call. = FALSE
        )
    }
    if (!is.numeric(pupil_cost) || length(pupil_cost) != 1L || !is.finite(pupil_cost) || pupil_cost <= 0) stop("`pupil_cost` must be one positive finite number.", call. = FALSE)
    .ep10_m2_require_backend(require_loo = TRUE)

    loo_objects <- lapply(x$fits, .ep10_m3_response_loo)
    elpd <- vapply(loo_objects, function(z) z$estimates["elpd_loo", "Estimate"], numeric(1L))
    elpd_se <- vapply(loo_objects, function(z) z$estimates["elpd_loo", "SE"], numeric(1L))
    pointwise <- lapply(loo_objects, function(z) as.numeric(z$pointwise[, "elpd_loo"]))
    kmax <- vapply(loo_objects, function(z) {
        k <- tryCatch(loo::pareto_k_values(z), error = function(e) numeric())
        if (!length(k)) NA_real_ else max(k, na.rm = TRUE)
    }, numeric(1L))
    theta_var <- lapply(x$fits, .ep10_m3_theta_variance)
    mean_var <- vapply(theta_var, mean, numeric(1L))
    response_ref <- elpd[["R"]]
    var_ref <- mean_var[["R"]]

    table <- data.frame(
        model = names(x$fits),
        channels = x$definitions$channels[match(names(x$fits), x$definitions$model)],
        response_elpd_loo = unname(elpd), response_elpd_se = unname(elpd_se),
        delta_elpd_vs_response = unname(elpd - response_ref),
        mean_theta_posterior_variance = unname(mean_var),
        theta_variance_reduction_vs_response = if (is.finite(var_ref) && var_ref > 0) unname(1 - mean_var / var_ref) else NA_real_,
        max_pareto_k = unname(kmax), stringsAsFactors = FALSE
    )

    pairs <- data.frame(
        without_pupil = c("R", "R_RT", "R_GAZE", "R_RT_GAZE"),
        with_pupil = c("R_PUPIL", "R_RT_PUPIL", "R_GAZE_PUPIL", "FULL"),
        context = c("response", "response + RT", "response + gaze", "response + RT + gaze"),
        stringsAsFactors = FALSE
    )
    pair_stats <- t(vapply(seq_len(nrow(pairs)), function(k) .ep10_m3_pair_delta(pointwise, pairs$with_pupil[[k]], pairs$without_pupil[[k]]), numeric(2L)))
    incremental_pupil <- cbind(pairs, delta_response_elpd = pair_stats[, "delta"], se = pair_stats[, "se"])
    incremental_pupil$z <- incremental_pupil$delta_response_elpd / incremental_pupil$se
    incremental_pupil$evidence <- ifelse(
        !is.finite(incremental_pupil$z), "inconclusive",
        ifelse(incremental_pupil$z >= decisive_z, "supports_incremental_pupil_information",
               ifelse(incremental_pupil$z <= -decisive_z, "supports_pupil_instability_or_harm", "no_clear_incremental_pupil_information"))
    )

    d_full <- incremental_pupil$delta_response_elpd[incremental_pupil$context == "response + RT + gaze"]
    d_r <- incremental_pupil$delta_response_elpd[incremental_pupil$context == "response"]
    d_rt <- .ep10_m3_pair_delta(pointwise, "R_RT", "R")[["delta"]]
    d_gaze <- .ep10_m3_pair_delta(pointwise, "R_GAZE", "R")[["delta"]]
    nonadditivity <- data.frame(
        metric = c("full_vs_sum_single_additions", "pupil_increment_after_rt_gaze_vs_alone"),
        value = c(elpd[["FULL"]] - response_ref - d_rt - d_gaze - d_r, d_full - d_r),
        interpretation = c("zero is additive on the response ELPD scale; nonzero indicates redundancy/synergy/dependence", "negative suggests pupil value is attenuated after RT+gaze; positive suggests conditional complementarity"),
        stringsAsFactors = FALSE
    )

    full_fit <- x$fits[["FULL"]]
    pupil_usable <- if (is.null(full_fit)) NA_integer_ else full_fit$stan_data$N_pupil
    sensor_value <- data.frame(
        metric = c("delta_elpd_full_pupil", "delta_elpd_per_usable_pupil", "delta_elpd_per_relative_cost"),
        value = c(d_full, if (is.finite(pupil_usable) && pupil_usable > 0) d_full / pupil_usable else NA_real_, d_full / pupil_cost),
        stringsAsFactors = FALSE
    )

    validation <- lapply(x$fits, function(z) {
        s <- z$fit$summary()
        c(max_rhat = max(s$rhat[is.finite(s$rhat)], na.rm = TRUE), min_bulk_ess = min(s$ess_bulk[is.finite(s$ess_bulk)], na.rm = TRUE))
    })
    validation <- do.call(rbind, validation)
    conflict <- data.frame(
        model = rownames(validation),
        max_rhat = validation[, "max_rhat"], min_bulk_ess = validation[, "min_bulk_ess"],
        response_elpd = elpd[rownames(validation)], stringsAsFactors = FALSE
    )
    rownames(conflict) <- NULL

    full_verdict <- incremental_pupil$evidence[incremental_pupil$context == "response + RT + gaze"]
    if (!length(full_verdict)) full_verdict <- "inconclusive"

    out <- list(
        table = table, incremental_pupil = incremental_pupil, nonadditivity = nonadditivity,
        sensor_value = sensor_value, channel_conflict = conflict, loo = loo_objects,
        theta_variance = theta_var, verdict = full_verdict,
        target = x$target,
        interpretation = paste(
            "Process information is response-target predictive/uncertainty evidence under the fitted models.",
            "It is not Fisher-information additivity, construct validity, causal sensor utility, or new-person/new-item transport.",
            "The package is allowed to conclude that pupil adds no clear defensible information."
        )
    )
    class(out) <- c("eye_multimodal_m3_information", "list")
    out
}

print.eye_multimodal_m3_information <- function(x, ...) {
    cat("<eye_multimodal_m3_information>\n",
        "  target: ", x$target, "\n",
        "  incremental pupil verdict after RT+gaze: ", x$verdict, "\n",
        "  non-additivity: explicitly evaluated\n\n", sep = "")
    print(x$incremental_pupil, row.names = FALSE)
    invisible(x)
}

.ep10_m3_person_summaries <- function(d) {
    persons <- sort(unique(d$person_id))
    do.call(rbind, lapply(persons, function(p) {
        z <- d[d$person_id == p, , drop = FALSE]
        safe <- function(v, transform = identity) if (all(is.na(v))) NA_real_ else mean(transform(v[!is.na(v)]))
        data.frame(person_id = p,
                   response_mean = safe(z$response), log_rt_mean = safe(z$rt, log), gaze_mean = safe(z$gaze), pupil_mean = safe(z$pupil),
                   stringsAsFactors = FALSE)
    }))
}

.ep10_m3_person_correlations <- function(d, label) {
    p <- .ep10_m3_person_summaries(d)
    pairs <- list(response_rt = c("response_mean", "log_rt_mean"), response_gaze = c("response_mean", "gaze_mean"),
                  response_pupil = c("response_mean", "pupil_mean"), rt_gaze = c("log_rt_mean", "gaze_mean"),
                  rt_pupil = c("log_rt_mean", "pupil_mean"), gaze_pupil = c("gaze_mean", "pupil_mean"))
    do.call(rbind, lapply(names(pairs), function(nm) {
        cc <- stats::complete.cases(p[, pairs[[nm]], drop = FALSE])
        data.frame(dataset = label, pair = nm,
                   correlation = if (sum(cc) >= 3L) stats::cor(p[[pairs[[nm]][1L]]][cc], p[[pairs[[nm]][2L]]][cc]) else NA_real_,
                   n = sum(cc), stringsAsFactors = FALSE)
    }))
}

.ep10_m3_permute_within <- function(dat, column, group = "item_id") {
    out <- dat
    ids <- split(seq_len(nrow(out)), out[[group]])
    for (ind in ids) {
        obs <- ind[!is.na(out[[column]][ind])]
        if (length(obs) > 1L) out[[column]][obs] <- sample(out[[column]][obs], length(obs), replace = FALSE)
    }
    out
}

.ep10_m3_phase_randomize <- function(x) {
    obs <- which(!is.na(x))
    if (length(obs) < 6L) return(sample(x, length(x), replace = FALSE))

    z <- x[obs] - mean(x[obs])
    f <- stats::fft(z)
    n <- length(f)

    # For a real series, randomize positive-frequency phases and construct the
    # negative-frequency coefficients by conjugacy. DC and the Nyquist bin
    # (when n is even) remain real. This approximately preserves the spectrum.
    upper <- if (n %% 2L == 0L) n / 2L else (n + 1L) / 2L
    positive <- if (upper >= 2L) seq.int(2L, upper) else integer()

    if (length(positive)) {
        phase <- stats::runif(length(positive), -pi, pi)
        f[positive] <- Mod(f[positive]) * exp(1i * phase)
        negative <- n - positive + 2L
        f[negative] <- Conj(f[positive])
    }

    if (n %% 2L == 0L) {
        nyquist <- n / 2L + 1L
        f[nyquist] <- Re(f[nyquist])
    }
    f[1L] <- Re(f[1L])

    out <- x
    out[obs] <- Re(stats::fft(f, inverse = TRUE) / n) + mean(x[obs])
    out
}

.ep10_m3_phase_randomize_trials <- function(d) {
    out <- d
    groups <- split(seq_len(nrow(d)), d$person_id)
    for (ind in groups) {
        ord <- if (!all(is.na(d$time_on_task[ind]))) {
            order(d$time_on_task[ind], d$source_row[ind], na.last = TRUE)
        } else {
            order(d$source_row[ind])
        }
        ordered <- ind[ord]
        out$pupil[ordered] <- .ep10_m3_phase_randomize(d$pupil[ordered])
    }
    out
}

#' Generate M3 multimodal falsification controls
#'
#' Includes within-item RT/gaze/pupil permutations, within-person pupil
#' permutation across items, pupil phase randomization, luminance-only pseudo-pupil, and an
#' irrelevant synthetic channel. Controls preserve selected marginals while
#' deliberately breaking alignment; they are not causal interventions or
#' misconduct detectors.
#'
#' @param x M3-compatible data.
#' @param seed Deterministic seed.
#' @return An `eye_multimodal_m3_negative_controls`.
#' @export
multimodal_m3_negative_controls <- function(x, seed = 20260815L) {
    data <- .ep10_m3_as_data(x)
    d <- data$raw
    set.seed(as.integer(seed))
    controls <- list(
        observed = d,
        gaze_within_item = .ep10_m3_permute_within(d, "gaze"),
        rt_within_item = .ep10_m3_permute_within(d, "rt"),
        pupil_within_item = .ep10_m3_permute_within(d, "pupil"),
        pupil_within_person = .ep10_m3_permute_within(d, "pupil", "person_id")
    )
    controls$pupil_phase_randomized <- .ep10_m3_phase_randomize_trials(d)
    lum <- d
    if (!all(is.na(lum$luminance))) {
        sc <- scale(lum$luminance); lum$pupil <- as.numeric(sc) + stats::rnorm(nrow(lum), 0, .15)
    } else {
        lum$pupil <- stats::rnorm(nrow(lum))
    }
    controls$luminance_only_pupil <- lum
    irr <- d; irr$pupil <- stats::rnorm(nrow(irr)); controls$irrelevant_pupil <- irr

    diagnostics <- do.call(rbind, Map(.ep10_m3_person_correlations, controls, names(controls)))
    provenance <- data.frame(
        control = setdiff(names(controls), "observed"),
        purpose = c(
            "break gaze-person alignment within item", "break RT-person alignment within item", "break pupil-person alignment within item",
            "break pupil-item alignment within person", "destroy within-person ordered pupil-series phase structure while retaining its spectrum approximately",
            "test measurement artefact masquerading as pupil value", "test irrelevant channel false-positive value"
        ),
        interpretation = "falsification diagnostic only; not causal and not a behavioral classifier",
        stringsAsFactors = FALSE
    )
    out <- list(datasets = controls, diagnostics = diagnostics, provenance = provenance, seed = as.integer(seed),
                interpretation = "Meaningless or misaligned pupil channels should not systematically appear psychometrically valuable.")
    class(out) <- c("eye_multimodal_m3_negative_controls", "list")
    out
}

print.eye_multimodal_m3_negative_controls <- function(x, ...) {
    cat("<eye_multimodal_m3_negative_controls>\n",
        "  controls: ", paste(setdiff(names(x$datasets), "observed"), collapse = ", "), "\n",
        "  seed: ", x$seed, "\n",
        "  boundary: ", x$interpretation, "\n", sep = "")
    invisible(x)
}

#' Bridge existing functional pupil outputs into the scalar M3 reference layer
#'
#' This bridge deliberately requires an analyst-supplied, already-derived
#' trial-level functional score. It does not silently reduce a raw time series.
#' Existing `functional_pupil_irt_spec()`, deconvolution, and confound tools
#' remain the authoritative trajectory-level machinery.
#'
#' @param data Trial-level M3 data.
#' @param score Trial-level functional/trajectory score or its column name.
#' @param pupil Name of the output pupil column.
#' @param provenance Free-text derivation/provenance note.
#' @return An `eye_multimodal_m3_functional_bridge` data object.
#' @export
multimodal_m3_functional_bridge <- function(data, score, pupil = "pupil", provenance = NULL) {
    if (!is.data.frame(data)) stop("`data` must be a trial-level data frame.", call. = FALSE)
    if (is.character(score) && length(score) == 1L && score %in% names(data)) {
        value <- data[[score]]; source <- score
    } else {
        value <- score; source <- "supplied_vector"
    }
    value <- suppressWarnings(as.numeric(value))
    if (length(value) != nrow(data) || any(!is.na(value) & !is.finite(value))) stop("Functional score must provide one finite-or-NA value per trial.", call. = FALSE)
    out_data <- data; out_data[[pupil]] <- value
    out <- list(
        data = out_data, score_source = source, pupil_column = pupil, provenance = provenance,
        representation = "functional_score",
        boundary = paste(
            "The bridge records an externally justified scalar trajectory representation.",
            "It does not claim that the scalar preserves all functional pupil information or identify a psychological construct."
        )
    )
    class(out) <- c("eye_multimodal_m3_functional_bridge", "list")
    out
}

print.eye_multimodal_m3_functional_bridge <- function(x, ...) {
    cat("<eye_multimodal_m3_functional_bridge>\n",
        "  rows: ", nrow(x$data), "\n",
        "  score source: ", x$score_source, "\n",
        "  pupil column: ", x$pupil_column, "\n",
        "  boundary: ", x$boundary, "\n", sep = "")
    invisible(x)
}

#' Validate an M3 simulation or fitted four-channel model
#'
#' Combines structural support, sampler diagnostics, PPC, pupil-confound
#' availability, device/missingness summaries and explicit interpretive
#' boundaries. Validation of synthetic or computational behavior is not
#' empirical construct validation.
#'
#' @param x M3 simulation or fit.
#' @param include_ppc Include M3 PPC for fits.
#' @param rhat_max,ess_min,ebfmi_min Diagnostic thresholds.
#' @return An `eye_multimodal_m3_validation`.
#' @export
validate_multimodal_m3 <- function(x, include_ppc = TRUE, rhat_max = 1.05, ess_min = 50, ebfmi_min = 0.30) {
    if (inherits(x, "eye_multimodal_m3_simulation")) {
        audit <- audit_multimodal_m3_identifiability(x)
        out <- list(
            valid = isTRUE(audit$supported), type = "simulation", model = "M3", checks = audit$checks,
            identifiability = audit, diagnostics = NULL, ppc = NULL,
            pupil_truth = x$truth$pupil,
            boundary = "Synthetic consistency/support is not empirical construct validation."
        )
        class(out) <- c("eye_multimodal_m3_validation", "list")
        return(out)
    }
    if (!inherits(x, "eye_multimodal_m3_fit")) stop("`x` must be an M3 simulation or fit.", call. = FALSE)
    diagnostics <- .ep10_m3_sampler_audit(x)
    diag_checks <- data.frame(
        check = c("max_rhat", "min_ess_bulk", "min_ess_tail", "divergences", "max_treedepth_hits", "min_ebfmi"),
        value = c(diagnostics$max_rhat, diagnostics$min_ess_bulk, diagnostics$min_ess_tail, diagnostics$divergences, diagnostics$max_treedepth_hits, diagnostics$min_ebfmi),
        pass = c(
            is.finite(diagnostics$max_rhat) && diagnostics$max_rhat <= rhat_max,
            is.finite(diagnostics$min_ess_bulk) && diagnostics$min_ess_bulk >= ess_min,
            is.finite(diagnostics$min_ess_tail) && diagnostics$min_ess_tail >= ess_min,
            is.na(diagnostics$divergences) || diagnostics$divergences == 0L,
            is.na(diagnostics$max_treedepth_hits) || diagnostics$max_treedepth_hits == 0L,
            is.na(diagnostics$min_ebfmi) || diagnostics$min_ebfmi >= ebfmi_min
        ), stringsAsFactors = FALSE
    )
    ppc <- if (isTRUE(include_ppc)) multimodal_m3_ppc(x) else NULL
    ppc_ok <- is.null(ppc) || ppc$flag_rate <= .20
    ident <- x$audit
    checks <- rbind(
        data.frame(check = paste0("ident_", ident$checks$check), value = NA_real_, pass = ident$checks$pass, stringsAsFactors = FALSE),
        diag_checks,
        data.frame(check = "ppc_tail_flag_rate_le_0.20", value = if (is.null(ppc)) NA_real_ else ppc$flag_rate, pass = ppc_ok, stringsAsFactors = FALSE)
    )
    out <- list(
        valid = all(checks$pass), type = "fit", model = "M3", checks = checks, identifiability = ident,
        diagnostics = diagnostics, ppc = ppc, nuisance = x$data$nuisance,
        missingness = ident$missing_fraction, device = ident$device,
        boundary = paste(
            "Computational validation evaluates estimation and model-data fit.",
            "It does not establish cognitive-load validity, causal sensor value, MNAR robustness, or device equivalence."
        )
    )
    class(out) <- c("eye_multimodal_m3_validation", "list")
    out
}

print.eye_multimodal_m3_validation <- function(x, ...) {
    cat("<eye_multimodal_m3_validation>\n",
        "  type: ", x$type, "\n",
        "  valid: ", x$valid, "\n",
        "  checks passed: ", sum(x$checks$pass), "/", nrow(x$checks), "\n",
        "  boundary: ", x$boundary, "\n", sep = "")
    invisible(x)
}

Try the eyeprocess package in your browser

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

eyeprocess documentation built on Sept. 28, 2026, 5:08 p.m.