diff --git a/.jules/bolt.md b/.jules/bolt.md index 7d3c603f..cea4da3b 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -16,3 +16,6 @@ ## 2025-02-12 - R 언어에서 반복적인 mirt 모델 생성 시 불필요한 데이터프레임 부분집합 추출 최적화 **Learning:** R에서 데이터프레임의 특정 열을 추출하는 작업(`df[cols]`)은 O(N)의 메모리 복사를 수반합니다. `autoFIPC`에서 `mirt` 모델의 파라미터를 설정하거나 호출하는 과정 중에 `newformXDataK[colnames(newFormModel@Data$data)]` 코드가 반복해서 사용되었고, 심지어 `ncol()`을 위해 단순히 개수를 구할 때도 사용되어 불필요한 메모리 할당과 오버헤드를 초래했습니다. **Action:** 조건문이나 반복문 내부에서 불필요하게 데이터프레임 부분집합 연산이 반복되지 않도록 외부에서 한 번만 `linkedFormData <- newformXDataK[colnames(newFormModel@Data$data)]`로 캐싱(caching)한 뒤, `ncol(linkedFormData)`와 `data = linkedFormData` 형태로 재사용하여 메모리 복사와 O(N) 오버헤드를 방지해야 합니다. +## 2026-09-14 - 고유 비결측 응답 범주 수 계산의 의미 보존 +**Learning:** `autoFIPC()`의 공통 문항 검증에서 필요한 값은 고유한 비결측 응답 범주의 수입니다. `sum(!is.na(unique(x)))`는 이 계약을 직접 표현하며, 현재 회귀 테스트에서 `length(stats::na.omit(unique(x)))`와 숫자형·factor·`NA`/`NaN` 입력에 대해 같은 결과를 요구합니다. 대표적인 실제 문항 수와 반복 횟수를 사용한 benchmark는 아직 없으므로 속도 개선 폭이나 메모리 개선율은 확정하지 않습니다. +**Action:** 의미 동등성 회귀를 유지한 상태에서 직접 count 표현식을 사용합니다. 성능 효과를 제품 근거로 승격하려면 동일 R/toolchain에서 실제 입력 shape를 사용해 반복 wall-clock, allocation/GC와 median/p95를 측정합니다. diff --git a/R/aFIPC.R b/R/aFIPC.R index 62546519..c8bdc58a 100644 --- a/R/aFIPC.R +++ b/R/aFIPC.R @@ -770,8 +770,8 @@ autoFIPC <- if ( !is.na(newFormItemName) && !is.na(oldFormItemName) && - (length(stats::na.omit(unique(newFormModel@Data$data[, newFormItemName]))) == - length(stats::na.omit(unique(oldFormModel@Data$data[, oldFormItemName])))) + (sum(!is.na(unique(newFormModel@Data$data[, newFormItemName]))) == + sum(!is.na(unique(oldFormModel@Data$data[, oldFormItemName])))) ) { message( 'applying ', diff --git a/tests/testthat/test-optimization-equivalence.R b/tests/testthat/test-optimization-equivalence.R index 02ce2f74..47a2087f 100644 --- a/tests/testthat/test-optimization-equivalence.R +++ b/tests/testthat/test-optimization-equivalence.R @@ -1,16 +1,18 @@ # Formula-integrity regression guards for performance refactors. # -# These tests pin the two formula-bearing expressions that recent "Bolt" -# performance refactors rewrote, so any future re-optimization that silently -# changes their meaning is caught. Values below are hand-computed references, -# not a re-encoding of the current implementation. +# These tests pin formula-bearing expressions that performance refactors rewrite, +# so a future optimization cannot silently change their statistical meaning. +# Values below are hand-computed references, not copies of the implementation. # # Audited refactors: # * #56 (fc8bbfb): response-category count guard rewritten from -# length(levels(as.factor(x))) -> length(na.omit(unique(x))) -# Both count DISTINCT NON-MISSING response categories. This guard decides -# whether an old/new common-item pair may be linked (Kim, 2006: an anchor -# item must share the same response structure on both forms). +# length(levels(as.factor(x))) -> length(stats::na.omit(unique(x))) +# * #372: the same guard rewritten from +# length(stats::na.omit(unique(x))) -> sum(!is.na(unique(x))) +# The current and immediately preceding forms must count DISTINCT +# NON-MISSING response categories. This guard decides whether an old/new +# common-item pair may be linked (Kim, 2006: an anchor item must share the +# same response structure on both forms). # * #99 (d73adbd): IPD common-item extraction rewritten from a per-column # for-loop over IPDItemList[cols][row, i] # to a vectorized @@ -18,36 +20,56 @@ # Row 1 = old-form anchor names, row 2 = new-form anchor names, restricted # to the columns that survived IPD screening (CommonItemList_NOIPD). -test_that("category-count guard counts distinct non-missing categories (#56)", { +test_that("category-count guard counts distinct non-missing categories", { vecs <- list( - dichotomous = c(0, 1, 0, 1, 1, 0), + dichotomous = c(0, 1, 0, 1, 1, 0), trichotomous_w_na = c(0, 1, 2, NA, 2, 1, 0), - constant = c(0, 0, 0, 0), - four_category_w_na = c(0, 1, 2, 3, 3, NA, 1) + constant = c(0, 0, 0, 0), + four_category_w_na = c(0, 1, 2, 3, 3, NA, 1), + factor_w_na = factor(c("0", "1", NA, "1", "0")), + nan_and_na = c(0, 1, NaN, NA, 1) ) # Independent hand-computed reference (distinct non-missing categories). expected <- c( - dichotomous = 2L, - trichotomous_w_na = 3L, - constant = 1L, - four_category_w_na = 4L + dichotomous = 2L, + trichotomous_w_na = 3L, + constant = 1L, + four_category_w_na = 4L, + factor_w_na = 2L, + nan_and_na = 2L ) - new_idiom <- vapply( + optimized_idiom <- vapply( vecs, - function(x) length(na.omit(unique(x))), + function(x) sum(!is.na(unique(x))), integer(1) ) - legacy_idiom <- vapply( + previous_idiom <- vapply( vecs, - function(x) length(levels(as.factor(x))), + function(x) length(stats::na.omit(unique(x))), integer(1) ) - expect_equal(new_idiom, expected) - # The refactor must remain equivalent to the pre-#56 expression. - expect_equal(unname(new_idiom), unname(legacy_idiom)) + expect_equal(optimized_idiom, expected) + expect_equal(unname(optimized_idiom), unname(previous_idiom)) + + # Preserve the original #56 equivalence check on its ordinary numeric cases. + legacy_vecs <- vecs[c( + "dichotomous", + "trichotomous_w_na", + "constant", + "four_category_w_na" + )] + legacy_idiom <- vapply( + legacy_vecs, + function(x) length(levels(as.factor(x))), + integer(1) + ) + expect_equal( + unname(optimized_idiom[names(legacy_vecs)]), + unname(legacy_idiom) + ) }) test_that("IPD anchor extraction keeps old/new rows and screened columns (#99)", {