Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
^\.Rprofile$
^\.github$
^\.github/.*
^\.codegraph$
^\.codegraph/.*
^.*\.Rcheck$
^.*\.tar\.gz$
^\.yamllint\.yml$
^AGENTS\.md$
^ARCHITECTURE\.md$
Expand Down
8 changes: 8 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,11 @@
**Prevention:**
1. `try()` 블둝 μ™ΈλΆ€μ—μ„œ κ²°κ³Ό 객체λ₯Ό μ‚¬μš©ν•  λ•ŒλŠ” 항상 ν•΄λ‹Ή 객체가 μƒμ„±λ˜μ—ˆλŠ”μ§€ 확인해야 ν•©λ‹ˆλ‹€ (`exists('model')`).
2. 객체의 ν”„λ‘œνΌν‹°μ— μ•ˆμ „ν•˜κ²Œ μ ‘κ·Όν•˜λ €λ©΄, 객체가 μ‘΄μž¬ν•˜κ³  μ˜ˆμƒλ˜λŠ” νƒ€μž…μΈμ§€ κ²€μ¦ν•˜λŠ” λ‘œμ§μ„ κ²°ν•©ν•΄μ•Ό ν•©λ‹ˆλ‹€ (예: `(!exists('model') || !isTRUE(model@OptimInfo$secondordertest))`).

## 2024-07-10 - Unvalidated Boolean Flags Lead to Unhandled Downstream Coercion
**Vulnerability:** Several boolean flags controlling the fundamental algorithmic flow in `autoFIPC` (`tryFitwholeNewItems`, `checkIPD`, `tryEM`, etc.) were used in `if` statements without explicit type validation. If a malicious or unexpected input (e.g., a vector of length > 1, an `NA`, or a character string) was passed to these arguments, R would throw an unhandled `condition has length > 1` exception (crashing the process) or coerce the value unexpectedly (potentially leading to incorrect statistical linking paths or infinite loops).
**Learning:** Control-flow parameters must be strictly verified before usage. R's dynamic typing means `if (flag)` will fail catastrophically if `flag` is `NA` or `length > 1`.
**Prevention:**
1. Always implement explicit, short-circuited runtime type validation for scalar boolean inputs.
2. The standard secure pattern in R is `if (!is.logical(x) || length(x) != 1 || is.na(x)) stop("...")`. The `||` operator correctly avoids evaluating `is.na(x)` if the length is strictly checked first.
3. IRT fallback estimators must not convert `secondordertest = NA` into `TRUE`; with `SE = TRUE`, require a passing second-order test and finite covariance estimates before returning a model.
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ Package: aFIPC
Type: Package
Title: Automated Fixed Item Parameter Linking
Version: 0.1.0
Author: Seongho Bae [aut, cre]
Maintainer: Seongho Bae <seongho@kw.ac.kr>
Authors@R: person(given = "Seongho", family = "Bae", role = c("aut", "cre"),
email = "seongho@kw.ac.kr")
Expand Down
1 change: 0 additions & 1 deletion NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,3 @@
export(autoFIPC)
export(surveyFA)
import(mirt)
importFrom(stats,factanal)
24 changes: 20 additions & 4 deletions R/aFIPC.R
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@
#'
#' @examples
#' \dontrun{
#' autoFIPC() ## FIXME
#' autoFIPC(
#' newformXData = new_model,
#' oldformYData = old_model,
#' newformCommonItemNames = common_new,
#' oldformCommonItemNames = common_old,
#' confirmCommonItems = TRUE
#' )
#' }
autoFIPC <-
function(
Expand Down Expand Up @@ -62,7 +68,7 @@ autoFIPC <-
}, error = function(e) FALSE, warning = function(w) FALSE)
if (!isTRUE(ok)) return(FALSE)
required_slots <- c("OptimInfo", "ParObjects")
return(all(required_slots %in% slotNames(x)))
return(all(required_slots %in% methods::slotNames(x)))
}
if (!is.data.frame(newformXData) && !is.matrix(newformXData) && !isRealMirtModel(newformXData)) {
stop("Security Error: newformXData must be a data.frame, matrix, or a valid fitted mirt model")
Expand All @@ -84,6 +90,16 @@ autoFIPC <-
else if (is.data.frame(oldformYData) || is.matrix(oldformYData)) nItems <- ncol(as.data.frame(oldformYData))
if (!is.na(nItems) && !(length(itemtype) == 1 || length(itemtype) == nItems)) stop(sprintf('Security Error: itemtype must be length 1 or length %d (number of items).', nItems))

# boolean parameter validation
if (!is.logical(tryFitwholeNewItems) || length(tryFitwholeNewItems) != 1 || is.na(tryFitwholeNewItems)) stop("Security Error: tryFitwholeNewItems must be a single non-NA logical value")
if (!is.logical(tryFitwholeOldItems) || length(tryFitwholeOldItems) != 1 || is.na(tryFitwholeOldItems)) stop("Security Error: tryFitwholeOldItems must be a single non-NA logical value")
if (!is.logical(checkIPD) || length(checkIPD) != 1 || is.na(checkIPD)) stop("Security Error: checkIPD must be a single non-NA logical value")
if (!is.logical(tryEM) || length(tryEM) != 1 || is.na(tryEM)) stop("Security Error: tryEM must be a single non-NA logical value")
if (!is.logical(freeMEAN) || length(freeMEAN) != 1 || is.na(freeMEAN)) stop("Security Error: freeMEAN must be a single non-NA logical value")
if (!is.logical(forceNormalZeroOne) || length(forceNormalZeroOne) != 1 || is.na(forceNormalZeroOne)) stop("Security Error: forceNormalZeroOne must be a single non-NA logical value")
if (!is.logical(parameterOverwrite) || length(parameterOverwrite) != 1 || is.na(parameterOverwrite)) stop("Security Error: parameterOverwrite must be a single non-NA logical value")
if (!is.logical(empiricalhist) || length(empiricalhist) != 1 || is.na(empiricalhist)) stop("Security Error: empiricalhist must be a single non-NA logical value")

# checking configure
if (length(newformCommonItemNames) != length(oldformCommonItemNames)) {
stop('Common Items are not equal')
Expand Down Expand Up @@ -753,8 +769,8 @@ autoFIPC <-
if (
!is.na(newFormItemName) &&
!is.na(oldFormItemName) &&
(length(na.omit(unique(newFormModel@Data$data[, newFormItemName]))) ==
length(na.omit(unique(oldFormModel@Data$data[, oldFormItemName]))))
(length(stats::na.omit(unique(newFormModel@Data$data[, newFormItemName]))) ==
length(stats::na.omit(unique(oldFormModel@Data$data[, oldFormItemName]))))
) {
message(
'applying ',
Expand Down
296 changes: 288 additions & 8 deletions R/surveyFA.R
Original file line number Diff line number Diff line change
@@ -1,11 +1,291 @@
#' @title surveyFA
#' @description Stub for surveyFA
#' @param ... Arguments passed to the function
#' @return NULL
#' @importFrom stats factanal
#' @description Fallback calibration helper used when direct model estimation in
#' `autoFIPC()` fails.
#' @param data A response matrix or data frame.
#' @param autofix When TRUE, remove least-fitting items and retry calibration.
#' @param forceUIRT Kept for legacy compatibility; the fallback is only executed
#' in this mode.
#' @param forceNormalEM Force normal EM/MMLE estimation for the fallback attempt.
#' @param forceMHRM Force MHRM-based estimation when other approaches fail.
#' @param unstable Apply a more permissive retry sequence including QMCEM/MHRM.
#' @param SE Whether to request standard errors from `mirt`.
#' @param itemtype IRT item type for fallback estimation.
#' @param maxItemRemovals Maximum number of items to remove in autofix mode.
#' @param pThreshold p-value cutoff for selecting the first misfit item.
#' @param ... Additional arguments reserved for compatibility.
#' @return A fitted `mirt` model object with finite log-likelihood and, when
#' `SE = TRUE`, a passing second-order test plus finite covariance estimates.
#' @export
surveyFA <- function(...) {
# Stub function to satisfy R CMD check until real implementation is provided
message("surveyFA is currently a stub.")
NULL
surveyFA <- function(
data,
autofix = TRUE,
forceUIRT = TRUE,
forceNormalEM = FALSE,
forceMHRM = FALSE,
unstable = FALSE,
SE = TRUE,
itemtype = "2PL",
maxItemRemovals = 3L,
pThreshold = 0.05,
...
) {
validate_logical_scalar <- function(value, name) {
if (!is.logical(value) || length(value) != 1L || is.na(value)) {
stop(
sprintf("Security Error: %s must be a single non-NA logical value", name),
call. = FALSE
)
}
}

validate_logical_scalar(autofix, "autofix")
validate_logical_scalar(forceUIRT, "forceUIRT")
validate_logical_scalar(forceNormalEM, "forceNormalEM")
validate_logical_scalar(forceMHRM, "forceMHRM")
validate_logical_scalar(unstable, "unstable")
validate_logical_scalar(SE, "SE")

if (!forceUIRT) {
stop(
"surveyFA requires forceUIRT=TRUE; fallback requires explicit legacy-mode activation.",
call. = FALSE
)
}

if (!is.data.frame(data) && !is.matrix(data)) {
stop("surveyFA requires a response matrix or data frame.", call. = FALSE)
}

if (!is.character(itemtype) || length(itemtype) != 1L || is.na(itemtype)) {
stop("surveyFA requires itemtype to be a single non-NA character value.", call. = FALSE)
}

if (
!is.numeric(maxItemRemovals) ||
length(maxItemRemovals) != 1L ||
is.na(maxItemRemovals) ||
maxItemRemovals < 0
) {
stop("surveyFA requires maxItemRemovals to be a non-negative numeric scalar.", call. = FALSE)
}
maxItemRemovals <- as.integer(maxItemRemovals)

if (
!is.numeric(pThreshold) ||
length(pThreshold) != 1L ||
is.na(pThreshold) ||
pThreshold <= 0 ||
pThreshold > 1
) {
stop("surveyFA requires pThreshold to be in (0, 1].", call. = FALSE)
}

response_data <- as.data.frame(data)
response_data <-
response_data[, vapply(response_data, function(column) {
nunique <- length(unique(stats::na.omit(column)))
nunique >= 2L
}, logical(1L))]

if (nrow(response_data) == 0L || ncol(response_data) < 2L) {
stop("surveyFA needs at least two non-constant response columns.", call. = FALSE)
}

has_finite_covariance <- function(model) {
covariance <- tryCatch(
{
direct <- model@vcov
if (is.null(direct) || length(direct) == 0L) {
stats::vcov(model)
} else {
direct
}
},
error = function(e) {
tryCatch(stats::vcov(model), error = function(err) NA)
}
)

covariance <- suppressWarnings(as.matrix(covariance))
is.numeric(covariance) &&
length(covariance) > 0L &&
all(dim(covariance) > 0L) &&
all(is.finite(covariance))
}

is_acceptable_model <- function(model) {
if (!inherits(model, "SingleGroupClass")) {
return(FALSE)
}

converged <- tryCatch(model@OptimInfo$converged, error = function(e) FALSE)
if (!isTRUE(converged)) {
return(FALSE)
}

log_likelihood <- tryCatch(
model@Fit$logLik[1],
error = function(e) NA_real_
)
if (!is.finite(log_likelihood)) {
return(FALSE)
}

second_order <- tryCatch(
model@OptimInfo$secondordertest,
error = function(e) NA
)
if (is.logical(second_order) && isFALSE(second_order)) {
return(FALSE)
}
if (SE && !identical(second_order, TRUE)) {
return(FALSE)
}
if (SE && !has_finite_covariance(model)) {
return(FALSE)
}

TRUE
}

try_fit <- function(response_data, method_name) {
fit_args <- list(
data = response_data,
model = 1,
itemtype = itemtype,
SE = SE,
GenRandomPars = FALSE
)

if (method_name == "EM") {
fit_args$method <- "EM"
fit_args$technical <- list(NCYCLES = 1e5)
fit_args$empiricalhist <- TRUE
} else if (method_name == "QMCEM") {
fit_args$method <- "QMCEM"
fit_args$technical <- list(NCYCLES = 1e5)
} else if (method_name == "MHRM") {
fit_args$method <- "MHRM"
fit_args$technical <- list(NCYCLES = 1e5, MHRM_SE_draws = 200000)
} else {
return(NA)
}

if (method_name == "MHRM") {
fit <- NA
for (attempt in seq_len(3L)) {
fit <- tryCatch(
do.call(mirt::mirt, fit_args),
error = function(err) {
warning(
"fallback surveyFA mirt MHRM attempt #",
attempt,
" failed: ",
err$message,
call. = FALSE
)
NA
}
)
if (inherits(fit, "SingleGroupClass")) {
break
}
}
fit
} else {
tryCatch(
do.call(mirt::mirt, fit_args),
error = function(err) {
warning(err$message, call. = FALSE)
NA
}
)
}
}

select_bad_item <- function(fitted_model, response_data) {
fit_df <- tryCatch(
suppressWarnings(mirt::itemfit(fitted_model, fit_stats = "S_X2")),
error = function(e) NA,
warning = function(w) {
tryCatch(
mirt::itemfit(fitted_model, fit_stats = "PV_Q1*"),
error = function(err) NA
)
}
)

p_col_candidates <- c("p", "p.value", "pval", "P")
if (!is.data.frame(fit_df)) {
return(NA_character_)
}

active <- intersect(names(response_data), rownames(fit_df))
if (length(active) == 0L) {
return(NA_character_)
}

fit_df <- fit_df[active, , drop = FALSE]
p_col <- intersect(p_col_candidates, colnames(fit_df))
if (length(p_col) > 0L) {
p_values <- suppressWarnings(as.numeric(fit_df[[p_col[1L]]]))
names(p_values) <- rownames(fit_df)
if (any(!is.na(p_values))) {
p_values[is.na(p_values)] <- 1
candidate <- names(sort(p_values, decreasing = FALSE))[1L]
if (!is.na(candidate) && p_values[[candidate]] < pThreshold) {
return(candidate)
}
}
}

v <- vapply(
response_data[active],
function(x) stats::var(as.numeric(x), na.rm = TRUE),
numeric(1L)
)
names(v) <- active
v[!is.finite(v)] <- -Inf
candidate <- names(which.min(v))
if (length(candidate) == 0L) NA_character_ else candidate
}

removed <- character()
methods <- c("QMCEM", "EM", "MHRM")
if (forceNormalEM) {
methods <- c("EM", "QMCEM", "MHRM")
} else if (forceMHRM) {
methods <- c("MHRM", "QMCEM", "EM")
} else if (unstable) {
methods <- c("QMCEM", "MHRM", "EM")
}

for (removal_round in seq_len(maxItemRemovals + 1L)) {
fitted <- NA
for (method_name in methods) {
fitted <- try_fit(response_data, method_name)
if (is_acceptable_model(fitted)) {
return(fitted)
}
}

if (!autofix || ncol(response_data) <= 2L || removal_round > maxItemRemovals) {
break
}

bad_item <- select_bad_item(fitted, response_data)
if (identical(bad_item, NA_character_) || bad_item %in% removed) {
break
}

response_data <- response_data[, names(response_data) != bad_item, drop = FALSE]
removed <- c(removed, bad_item)
}

stop(
"surveyFA fallback could not estimate a valid model after bounded recovery attempts. ",
"Removed items: ",
if (length(removed) == 0L) "none" else paste(removed, collapse = ", "),
call. = FALSE
)
}
Loading
Loading