From 1eb428162413f04911dea65c192f1438fdb83056 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:11:10 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9A=A1=20Bolt:=20unique=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EA=B8=B8=EC=9D=B4=20=EA=B3=84=EC=82=B0=20=EB=B3=91?= =?UTF-8?q?=EB=AA=A9=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `length(stats::na.omit(unique(x)))`를 `sum(!is.na(unique(x)))`로 대체했습니다. - R의 `stats::na.omit()` 함수 내부의 S3 메서드 디스패치 및 `na.action` 속성 할당에 따른 오버헤드를 제거하였습니다. - 반복문 내부에서 자주 호출되는 로직의 실행 속도 및 메모리 효율성을 개선하였습니다. --- .jules/bolt.md | 3 +++ R/aFIPC.R | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 7d3c603f..8c47bc65 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) 오버헤드를 방지해야 합니다. +## 2024-09-13 - R 언어에서 고유값 개수 산출 시 stats::na.omit 호출 오버헤드 최적화 +**Learning:** R에서 데이터의 고유한 비결측치 개수를 구할 때 `length(stats::na.omit(unique(x)))`를 사용하면 `na.omit()` 내부의 S3 메서드 디스패치와 추가적인 `na.action` 속성 할당으로 인해 불필요한 메모리 및 실행 오버헤드가 발생합니다. 반복문 안에서 자주 호출될수록 그 비효율성은 누적됩니다. +**Action:** 복잡한 객체 할당을 수반하는 `na.omit()` 대신 논리 인덱싱의 합계를 구하는 `sum(!is.na(unique(x)))` 방식을 사용하여 S3 메서드 오버헤드 없이 O(N) 탐색을 최적화하고 성능을 크게 향상시켜야 합니다. 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 ', From 53cd2dab3800217d2f47dda19eee94b9b87c486a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 04:30:05 +0900 Subject: [PATCH 2/5] test(perf): pin unique-count equivalence --- .../testthat/test-optimization-equivalence.R | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/tests/testthat/test-optimization-equivalence.R b/tests/testthat/test-optimization-equivalence.R index 02ce2f74..4c73d30f 100644 --- a/tests/testthat/test-optimization-equivalence.R +++ b/tests/testthat/test-optimization-equivalence.R @@ -1,16 +1,17 @@ # 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))) +# All three 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,25 +19,34 @@ # 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) + ) + previous_idiom <- vapply( + vecs, + function(x) length(stats::na.omit(unique(x))), integer(1) ) legacy_idiom <- vapply( @@ -45,9 +55,9 @@ test_that("category-count guard counts distinct non-missing categories (#56)", { 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)) + expect_equal(unname(optimized_idiom), unname(legacy_idiom)) }) test_that("IPD anchor extraction keeps old/new rows and screened columns (#99)", { @@ -72,7 +82,7 @@ test_that("IPD anchor extraction keeps old/new rows and screened columns (#99)", legacy_old <- character(length(CommonItemList_NOIPD)) legacy_new <- character(length(CommonItemList_NOIPD)) for (i in seq_along(CommonItemList_NOIPD)) { - legacy_old[i] <- as.character(IPDItemList[CommonItemList_NOIPD][1, i]) + legacy_old[i] <- as.character(IPDItemList[CommonItemItemList_NOIPD][1, i]) legacy_new[i] <- as.character(IPDItemList[CommonItemList_NOIPD][2, i]) } expect_identical(actual_old, legacy_old) From b9262a68b8ee35b36e95eeeb2dc1cf0964fcade0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 04:30:20 +0900 Subject: [PATCH 3/5] fix(test): preserve IPD anchor fixture --- tests/testthat/test-optimization-equivalence.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testthat/test-optimization-equivalence.R b/tests/testthat/test-optimization-equivalence.R index 4c73d30f..b85631df 100644 --- a/tests/testthat/test-optimization-equivalence.R +++ b/tests/testthat/test-optimization-equivalence.R @@ -82,7 +82,7 @@ test_that("IPD anchor extraction keeps old/new rows and screened columns (#99)", legacy_old <- character(length(CommonItemList_NOIPD)) legacy_new <- character(length(CommonItemList_NOIPD)) for (i in seq_along(CommonItemList_NOIPD)) { - legacy_old[i] <- as.character(IPDItemList[CommonItemItemList_NOIPD][1, i]) + legacy_old[i] <- as.character(IPDItemList[CommonItemList_NOIPD][1, i]) legacy_new[i] <- as.character(IPDItemList[CommonItemList_NOIPD][2, i]) } expect_identical(actual_old, legacy_old) From a2ff7fd9f29c2bb6b4b7ddd697b5db741e89385e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 04:30:42 +0900 Subject: [PATCH 4/5] test(perf): separate current and legacy count contracts --- .../testthat/test-optimization-equivalence.R | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/tests/testthat/test-optimization-equivalence.R b/tests/testthat/test-optimization-equivalence.R index b85631df..47a2087f 100644 --- a/tests/testthat/test-optimization-equivalence.R +++ b/tests/testthat/test-optimization-equivalence.R @@ -9,9 +9,10 @@ # 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))) -# All three 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). +# 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 @@ -49,15 +50,26 @@ test_that("category-count guard counts distinct non-missing categories", { function(x) length(stats::na.omit(unique(x))), integer(1) ) + + 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( - vecs, + legacy_vecs, function(x) length(levels(as.factor(x))), integer(1) ) - - expect_equal(optimized_idiom, expected) - expect_equal(unname(optimized_idiom), unname(previous_idiom)) - expect_equal(unname(optimized_idiom), unname(legacy_idiom)) + expect_equal( + unname(optimized_idiom[names(legacy_vecs)]), + unname(legacy_idiom) + ) }) test_that("IPD anchor extraction keeps old/new rows and screened columns (#99)", { From 70722c5aab3debbf5fa199053541c9e9f65130c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 04:31:04 +0900 Subject: [PATCH 5/5] docs(perf): bound unique-count optimization claims --- .jules/bolt.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 8c47bc65..cea4da3b 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -16,6 +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) 오버헤드를 방지해야 합니다. -## 2024-09-13 - R 언어에서 고유값 개수 산출 시 stats::na.omit 호출 오버헤드 최적화 -**Learning:** R에서 데이터의 고유한 비결측치 개수를 구할 때 `length(stats::na.omit(unique(x)))`를 사용하면 `na.omit()` 내부의 S3 메서드 디스패치와 추가적인 `na.action` 속성 할당으로 인해 불필요한 메모리 및 실행 오버헤드가 발생합니다. 반복문 안에서 자주 호출될수록 그 비효율성은 누적됩니다. -**Action:** 복잡한 객체 할당을 수반하는 `na.omit()` 대신 논리 인덱싱의 합계를 구하는 `sum(!is.na(unique(x)))` 방식을 사용하여 S3 메서드 오버헤드 없이 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를 측정합니다.