From fba44db3ab07be384af5ee1a6a608122545f99e9 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:54:06 +0930 Subject: [PATCH 01/28] Moving grid construction to new branch --- R/calculate_adjacency_score.R | 10 +- R/design_utils.R | 83 +++++++++ R/metrics.R | 48 ++++-- man/build_design_matrix.Rd | 37 ++++ tests/testthat/test-build_design_matrix.R | 175 +++++++++++++++++++ tests/testthat/test-grid-orientation.R | 201 ++++++++++++++++++++++ 6 files changed, 532 insertions(+), 22 deletions(-) create mode 100644 man/build_design_matrix.Rd create mode 100644 tests/testthat/test-build_design_matrix.R create mode 100644 tests/testthat/test-grid-orientation.R diff --git a/R/calculate_adjacency_score.R b/R/calculate_adjacency_score.R index 137f4210..0e76871c 100755 --- a/R/calculate_adjacency_score.R +++ b/R/calculate_adjacency_score.R @@ -252,11 +252,11 @@ calculate_adjacency_score <- function( ) { ring_type <- match.arg(ring_type) - design_matrix <- matrix( - layout_df[[swap]], - nrow = max(as_numeric_factor(layout_df[[row_column]]), na.rm = TRUE), - ncol = max(as_numeric_factor(layout_df[[col_column]]), na.rm = TRUE), - byrow = TRUE + design_matrix <- build_design_matrix( + layout_df, + swap, + row_column = row_column, + col_column = col_column ) per_cell <- adjacency_score_vec( diff --git a/R/design_utils.R b/R/design_utils.R index d5d8d131..683dbd9c 100644 --- a/R/design_utils.R +++ b/R/design_utils.R @@ -863,3 +863,86 @@ random_initialise <- function(design, optimise, seed = NULL, ...) { #' @rdname initialise_design_df #' @export initialize_design_df <- initialise_design_df + +#' Build a Spatial Design Matrix from a Data Frame +#' +#' @description +#' Places each treatment value at the grid position given by its `row_column` +#' and `col_column` coordinates, returning a character matrix of dimensions +#' `max(row)` by `max(col)`. Cells with no corresponding row in `df` are `NA`. +#' +#' Unlike filling via `matrix(..., byrow = )`, this reads the coordinates rather +#' than assuming an ordering, so it is correct for any row ordering of `df` and +#' for factor coordinate columns whose level order is not numeric. +#' +#' Coordinates are used as-is, never renumbered: a gap in the coordinates is a +#' real gap in the field (a missing plot, or a buffer that was removed), so +#' collapsing it would make non-adjacent plots into neighbours. Callers must +#' therefore cope with `NA` cells. +#' +#' @param df A data frame with columns named by `swap`, `row_column`, +#' `col_column`. +#' @param swap Column name of the treatment variable. +#' @param row_column Column name of the row position variable (default `"row"`). +#' @param col_column Column name of the column position variable +#' (default `"col"`). +#' +#' @return A character matrix of dimensions `max(row)` by `max(col)`. +#' +#' @keywords internal +build_design_matrix <- function( + df, + swap, + row_column = "row", + col_column = "col" +) { + rows <- as_numeric_factor(df[[row_column]]) + cols <- as_numeric_factor(df[[col_column]]) + + if (anyNA(rows) || anyNA(cols)) { + stop( + "Cannot place the design on a grid: `", + row_column, + "` and `", + col_column, + "` must be numeric, or coercible to numeric.", + call. = FALSE + ) + } + # Used directly as matrix indices, so they must be positive whole numbers. + if ( + any(rows < 1 | cols < 1) || + any(rows != trunc(rows) | cols != trunc(cols)) + ) { + stop( + "`", + row_column, + "` and `", + col_column, + "` must be positive whole numbers to index a grid.", + call. = FALSE + ) + } + idx <- cbind(rows, cols) + # Duplicated coordinates would silently overwrite each other. Multi-site + # designs reuse row/col per site, so they must be split before scoring. + if (anyDuplicated(idx)) { + stop( + "Duplicate (", + row_column, + ", ", + col_column, + ") coordinates: the design cannot be placed on a single grid. ", + "Split multi-site designs by site first.", + call. = FALSE + ) + } + + design_matrix <- matrix( + NA_character_, + nrow = max(rows), + ncol = max(cols) + ) + design_matrix[idx] <- as.character(df[[swap]]) + return(design_matrix) +} diff --git a/R/metrics.R b/R/metrics.R index 02c20faf..37e51224 100644 --- a/R/metrics.R +++ b/R/metrics.R @@ -231,10 +231,11 @@ objective_function_piepho <- function(design, row_column = "row", col_column = "col", ...) { - design_matrix <- matrix( - design[[swap]], - nrow = max(as_numeric_factor(design[[row_column]]), na.rm = TRUE), - ncol = max(as_numeric_factor(design[[col_column]]), na.rm = TRUE) + design_matrix <- build_design_matrix( + design, + swap, + row_column = row_column, + col_column = col_column ) ed <- calculate_ed(design_matrix, current_score_obj$ed, swapped_items) @@ -243,7 +244,11 @@ objective_function_piepho <- function(design, nb <- calculate_nb(design_matrix, pair_mapping) nb_score <- nb$var - design[[swap]] <- as.factor(design_matrix) + # Coerce for calculate_balance_score()'s table(). Deliberately NOT + # as.factor(design_matrix): flattening the grid is column-major, which only + # matches `design` when the data frame happens to be in column-major order, + # and would otherwise scramble the treatments against their coordinates. + design[[swap]] <- as.factor(design[[swap]]) bal_score <- calculate_balance_score(design, swap, spatial_cols) adj_score <- calculate_adjacency_score(design, swap, row_column, col_column) @@ -337,26 +342,35 @@ calculate_nb <- function(design_matrix, pair_mapping = NULL) { for (row_ in 1:n_rows) { for (col_ in 1:n_cols) { node <- design_matrix[row_, col_] + # Empty cells (a missing plot, or a removed buffer) have no pairs to + # contribute; the pair_mapping path in calculate_nb() drops them too. + if (is.na(node)) { + next + } if (row_ < n_rows) { bottom <- design_matrix[row_ + 1, col_] - if (node < bottom) { - pair_str <- paste0(node, ",", bottom) - } else { - pair_str <- paste0(bottom, ",", node) + if (!is.na(bottom)) { + if (node < bottom) { + pair_str <- paste0(node, ",", bottom) + } else { + pair_str <- paste0(bottom, ",", node) + } + + env_add_one(nb, pair_str) } - - env_add_one(nb, pair_str) } if (col_ < n_cols) { right <- design_matrix[row_, col_ + 1] - if (node < right) { - pair_str <- paste0(node, ",", right) - } else { - pair_str <- paste0(right, ",", node) + if (!is.na(right)) { + if (node < right) { + pair_str <- paste0(node, ",", right) + } else { + pair_str <- paste0(right, ",", node) + } + + env_add_one(nb, pair_str) } - - env_add_one(nb, pair_str) } } } diff --git a/man/build_design_matrix.Rd b/man/build_design_matrix.Rd new file mode 100644 index 00000000..f6540282 --- /dev/null +++ b/man/build_design_matrix.Rd @@ -0,0 +1,37 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/design_utils.R +\name{build_design_matrix} +\alias{build_design_matrix} +\title{Build a Spatial Design Matrix from a Data Frame} +\usage{ +build_design_matrix(df, swap, row_column = "row", col_column = "col") +} +\arguments{ +\item{df}{A data frame with columns named by \code{swap}, \code{row_column}, +\code{col_column}.} + +\item{swap}{Column name of the treatment variable.} + +\item{row_column}{Column name of the row position variable (default \code{"row"}).} + +\item{col_column}{Column name of the column position variable +(default \code{"col"}).} +} +\value{ +A character matrix of dimensions \code{max(row)} by \code{max(col)}. +} +\description{ +Places each treatment value at the grid position given by its \code{row_column} +and \code{col_column} coordinates, returning a character matrix of dimensions +\code{max(row)} by \code{max(col)}. Cells with no corresponding row in \code{df} are \code{NA}. + +Unlike filling via \code{matrix(..., byrow = )}, this reads the coordinates rather +than assuming an ordering, so it is correct for any row ordering of \code{df} and +for factor coordinate columns whose level order is not numeric. + +Coordinates are used as-is, never renumbered: a gap in the coordinates is a +real gap in the field (a missing plot, or a buffer that was removed), so +collapsing it would make non-adjacent plots into neighbours. Callers must +therefore cope with \code{NA} cells. +} +\keyword{internal} diff --git a/tests/testthat/test-build_design_matrix.R b/tests/testthat/test-build_design_matrix.R new file mode 100644 index 00000000..6e384cc1 --- /dev/null +++ b/tests/testthat/test-build_design_matrix.R @@ -0,0 +1,175 @@ +# build_design_matrix() places treatments by their (row, col) coordinates rather +# than assuming a data frame ordering. These tests pin that it is correct for +# *both* orderings the package produces: speed() sorts row-major, while +# initialise_design_df() emits column-major. + +test_that("row-major and column-major frames give the same grid", { + # Same physical 2x3 layout, described two different ways. + # col 1 col 2 col 3 + # row 1: a c b + # row 2: b a c + row_major <- data.frame( + row = c(1, 1, 1, 2, 2, 2), + col = c(1, 2, 3, 1, 2, 3), + trt = c("a", "c", "b", "b", "a", "c") + ) + col_major <- data.frame( + row = c(1, 2, 1, 2, 1, 2), + col = c(1, 1, 2, 2, 3, 3), + trt = c("a", "b", "c", "a", "b", "c") + ) + + expected <- matrix( + c("a", "b", "c", "a", "b", "c"), + nrow = 2, + ncol = 3 + ) + expect_equal(build_design_matrix(row_major, "trt"), expected) + expect_equal(build_design_matrix(col_major, "trt"), expected) +}) + +test_that("shuffled row order does not change the grid", { + df <- data.frame( + row = rep(1:3, each = 4), + col = rep(1:4, times = 3), + trt = LETTERS[1:12] + ) + expect_equal( + build_design_matrix(df[sample.int(12), ], "trt"), + build_design_matrix(df, "trt") + ) +}) + +test_that("non-square grids keep their orientation", { + df <- data.frame( + row = c(1, 1, 2, 2, 3, 3), + col = c(1, 2, 1, 2, 1, 2), + trt = c("a", "b", "c", "d", "e", "f") + ) + m <- build_design_matrix(df, "trt") + + expect_equal(dim(m), c(3L, 2L)) + expect_equal(m[3, 1], "e") + expect_equal(m[1, 2], "b") +}) + +test_that("factor coordinates with lexical level order are handled", { + # as.factor() on characters orders levels 1, 10, 11, 2, ... A fill that + # relied on order() following the levels would build a permuted grid. + df <- data.frame( + row = factor(as.character(1:11)), + col = factor(rep("1", 11)), + trt = LETTERS[1:11] + ) + m <- build_design_matrix(df, "trt") + + expect_equal(dim(m), c(11L, 1L)) + expect_equal(as.vector(m), LETTERS[1:11]) +}) + +test_that("gaps in the coordinates become NA cells, not a collapsed grid", { + # Rows 1, 2, 4, 5 exist; row 3 does not (e.g. a road). Plots in rows 2 and 4 + # must not become neighbours. + df <- data.frame( + row = rep(c(1, 2, 4, 5), each = 2), + col = rep(1:2, times = 4), + trt = c("A", "B", "C", "C", "C", "C", "A", "B") + ) + m <- build_design_matrix(df, "trt") + + expect_equal(dim(m), c(5L, 2L)) + expect_true(all(is.na(m[3, ]))) + expect_equal(m[2, ], c("C", "C")) + expect_equal(m[4, ], c("C", "C")) +}) + +test_that("coordinates that do not start at 1 are preserved, not shifted", { + # add_buffers() offsets the real design's coordinates; dropping the buffer + # rows leaves them starting above 1. + df <- data.frame( + row = c(2, 2, 3, 3), + col = c(2, 3, 2, 3), + trt = c("A", "B", "B", "A") + ) + m <- build_design_matrix(df, "trt") + + expect_equal(dim(m), c(3L, 3L)) + expect_true(all(is.na(m[1, ]))) + expect_true(all(is.na(m[, 1]))) + expect_equal(m[2, 2], "A") +}) + +test_that("NA treatments are placed as NA", { + df <- data.frame( + row = c(1, 1, 2, 2), + col = c(1, 2, 1, 2), + trt = c("A", NA, "B", "A") + ) + expect_true(is.na(build_design_matrix(df, "trt")[1, 2])) +}) + +test_that("non-numeric coordinates give an informative error", { + df <- data.frame( + row = c("R1", "R1"), + col = c("C1", "C2"), + trt = c("A", "B") + ) + expect_error( + build_design_matrix(df, "trt"), + "must be numeric, or coercible to numeric" + ) +}) + +test_that("non-positive or fractional coordinates give an informative error", { + zero_based <- data.frame( + row = c(0, 0, 1, 1), + col = c(1, 2, 1, 2), + trt = c("A", "B", "B", "A") + ) + expect_error( + build_design_matrix(zero_based, "trt"), + "positive whole numbers" + ) + + fractional <- data.frame( + row = c(1, 1, 1.5, 1.5), + col = c(1, 2, 1, 2), + trt = c("A", "B", "B", "A") + ) + expect_error( + build_design_matrix(fractional, "trt"), + "positive whole numbers" + ) +}) + +test_that("duplicate coordinates error rather than silently overwriting", { + # Multi-site designs reuse row/col per site. Placing them on one grid would + # keep only the last site written. + met <- data.frame( + site = rep(c("s1", "s2"), each = 4), + row = rep(c(1, 1, 2, 2), times = 2), + col = rep(c(1, 2, 1, 2), times = 2), + trt = c("A", "B", "B", "A", "A", "A", "B", "B") + ) + expect_error( + build_design_matrix(met, "trt"), + "Duplicate \\(row, col\\) coordinates" + ) +}) + +test_that("column names are reported in errors", { + df <- data.frame( + range = c("R1", "R1"), + bed = c("C1", "C2"), + variety = c("A", "B") + ) + expect_error( + build_design_matrix( + df, + "variety", + row_column = "range", + col_column = "bed" + ), + "`range` and `bed`" + ) +}) diff --git a/tests/testthat/test-grid-orientation.R b/tests/testthat/test-grid-orientation.R new file mode 100644 index 00000000..34db365f --- /dev/null +++ b/tests/testthat/test-grid-orientation.R @@ -0,0 +1,201 @@ +# Regression tests for the grid-orientation fix. Every expected value here is +# derived from the (row, col) coordinates by hand, never by reshaping the +# treatment column -- reshaping is the bug these tests exist to catch. + +# --- calculate_adjacency_score() composability ------------------------------- + +test_that("adjacency score is the same for either input ordering", { + # Same physical 2x3 layout described row-major and column-major. + row_major <- data.frame( + row = c(1, 1, 1, 2, 2, 2), + col = c(1, 2, 3, 1, 2, 3), + trt = c("a", "c", "b", "b", "a", "c") + ) + col_major <- data.frame( + row = c(1, 2, 1, 2, 1, 2), + col = c(1, 1, 2, 2, 3, 3), + trt = c("a", "b", "c", "a", "b", "c") + ) + + expect_equal( + calculate_adjacency_score(row_major, "trt"), + calculate_adjacency_score(col_major, "trt") + ) +}) + +test_that("calculate_adjacency_score() composes with initialise_design_df()", { + # initialise_design_df() emits column-major data. A byrow = TRUE fill scored + # this layout as 6 when the correct answer is 0. + # col 1 col 2 col 3 + # row 1: a c b + # row 2: b a c + df <- initialise_design_df( + items = rep(c("a", "b", "c"), 2), + nrows = 2, + ncols = 3 + ) + expect_equal(calculate_adjacency_score(df, "treatment"), 0) +}) + +test_that("adjacency score counts like-treatment edges on a known layout", { + # row 1: A A A + # row 2: B B B + # row 3: A A A + # Horizontal like-pairs: 2 per row x 3 rows = 6. No vertical like-pairs. + df <- data.frame( + row = rep(1:3, each = 3), + col = rep(1:3, times = 3), + treatment = c("A", "A", "A", "B", "B", "B", "A", "A", "A") + ) + expect_equal(calculate_adjacency_score(df, "treatment"), 6) +}) + +# --- objective_function_piepho() --------------------------------------------- + +test_that("piepho score does not depend on the input row ordering", { + # This is the property the whole change buys: the score describes the layout, + # not the order the rows happen to be in. + col_major <- initialise_design_df( + items = rep(c("a", "b", "c"), 4), + nrows = 2, + ncols = 6 + ) + row_major <- col_major[order(col_major$row, col_major$col), ] + rownames(row_major) <- NULL + pm <- create_pair_mapping(col_major$treatment) + + from_col_major <- objective_function_piepho( + col_major, + "treatment", + c("row", "col"), + pair_mapping = pm + ) + from_row_major <- objective_function_piepho( + row_major, + "treatment", + c("row", "col"), + pair_mapping = pm + ) + + expect_equal(from_row_major$score, from_col_major$score) + expect_equal(from_row_major$components, from_col_major$components) +}) + +test_that("piepho components match hand-derived values on a non-square grid", { + # 2x6 layout from initialise_design_df(rep(c("a","b","c"), 4), 2, 6): + # col 1 2 3 4 5 6 + # row 1: a c b a c b + # row 2: b a c b a c + # Every treatment appears 4 times, twice per row and never twice in a column, + # so the balance score is the row term only: 3 treatments x var(c(2,2)) = 0 + # across columns, and rowVars over the 2 rows gives 2 in total. + df <- initialise_design_df( + items = rep(c("a", "b", "c"), 4), + nrows = 2, + ncols = 6 + ) + df <- df[order(df$row, df$col), ] + pm <- create_pair_mapping(df$treatment) + + res <- objective_function_piepho( + df, + "treatment", + c("row", "col"), + pair_mapping = pm + ) + + # No like-treatment neighbours anywhere in this layout. + expect_equal(res$components[["adjacency"]], 0) + expect_equal( + res$components[["adjacency"]], + calculate_adjacency_score(df, "treatment") + ) + # Balance must be computed on the real treatment column. + expect_equal( + res$components[["balance"]], + calculate_balance_score(df, "treatment", c("row", "col")) + ) +}) + +test_that("piepho does not overwrite the treatment column it was given", { + # The objective used to write a column-major flatten of the grid back into + # `design`, which permuted the treatments against their coordinates before + # the balance and adjacency components were computed. + df <- initialise_design_df( + items = rep(LETTERS[1:4], 6), + nrows = 4, + ncols = 6 + ) + df <- df[order(df$row, df$col), ] + pm <- create_pair_mapping(df$treatment) + + res <- objective_function_piepho( + df, + "treatment", + c("row", "col"), + pair_mapping = pm + ) + + expect_equal( + res$components[["balance"]], + calculate_balance_score(df, "treatment", c("row", "col")) + ) + expect_equal( + res$components[["adjacency"]], + calculate_adjacency_score(df, "treatment") + ) +}) + +# --- sparse grids ------------------------------------------------------------ + +test_that("neighbour balance skips empty cells, with and without a mapping", { + # Rows 1, 2, 4, 5 exist; row 3 is a gap. Pairs, by coordinate: + # horizontal A-B, C-C, C-C, A-B; vertical A-C, B-C (rows 1-2), A-C, B-C + # (rows 4-5). Nothing crosses the gap. + df <- data.frame( + row = rep(c(1, 2, 4, 5), each = 2), + col = rep(1:2, times = 4), + trt = c("A", "B", "C", "C", "C", "C", "A", "B") + ) + m <- build_design_matrix(df, "trt") + + with_mapping <- calculate_nb(m, create_pair_mapping(df$trt)) + without_mapping <- calculate_nb(m) + + expect_equal(as.integer(with_mapping$nb[["C,C"]]), 2L) + expect_equal(as.integer(without_mapping$nb[["C,C"]]), 2L) + # No pair may involve an empty cell. + expect_false(any(grepl("NA", names(with_mapping$nb), fixed = TRUE))) + expect_false(any(grepl("NA", names(without_mapping$nb), fixed = TRUE))) +}) + +test_that("piepho runs on a design with missing plots", { + df <- data.frame( + row = rep(c(1, 2, 4, 5), each = 2), + col = rep(1:2, times = 4), + treatment = c("A", "B", "C", "D", "D", "C", "A", "B") + ) + # Both the default (NULL) and supplied pair_mapping paths must work. + expect_no_error( + objective_function_piepho(df, "treatment", c("row", "col")) + ) + expect_no_error( + objective_function_piepho( + df, + "treatment", + c("row", "col"), + pair_mapping = create_pair_mapping(df$treatment) + ) + ) +}) + +test_that("adjacency score ignores gaps rather than closing them", { + # C-C pairs exist within rows 2 and 4 but not between them: the gap at row 3 + # must not be collapsed. + df <- data.frame( + row = rep(c(1, 2, 4, 5), each = 2), + col = rep(1:2, times = 4), + trt = c("A", "B", "C", "C", "C", "C", "A", "B") + ) + expect_equal(calculate_adjacency_score(df, "trt"), 2) +}) From cc1e99f34927dcf124f556b37de1e793355e261d Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:02:45 +0930 Subject: [PATCH 02/28] Fixing warnings --- R/design_utils.R | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/R/design_utils.R b/R/design_utils.R index 683dbd9c..f083b402 100644 --- a/R/design_utils.R +++ b/R/design_utils.R @@ -896,8 +896,9 @@ build_design_matrix <- function( row_column = "row", col_column = "col" ) { - rows <- as_numeric_factor(df[[row_column]]) - cols <- as_numeric_factor(df[[col_column]]) + # Coercion of non-numeric labels warns; the check below reports it properly. + rows <- suppressWarnings(as_numeric_factor(df[[row_column]])) + cols <- suppressWarnings(as_numeric_factor(df[[col_column]])) if (anyNA(rows) || anyNA(cols)) { stop( From 6087cf08715ebaba31e670f2b3f2410af28ae106 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:02:57 +0930 Subject: [PATCH 03/28] Updating NEWS --- NEWS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/NEWS.md b/NEWS.md index bd9af625..4762b4a8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -7,6 +7,17 @@ replicate spans and spread across blocks, neighbour balance, and opt-in efficiency). ([#73](https://github.com/biometryhub/speed/issues/73)) +## Bug Fixes + +- `objective_function_piepho()` now builds the design grid from the `row`/`col` coordinates rather + than the data frame's row order, and no longer overwrites the treatment column with a flattened + grid. All four score components are computed on the actual layout, and the score no longer depends + on how the input rows are ordered. Designs generated with this objective should be regenerated. +- `calculate_adjacency_score()` is now correct for any row ordering of its input, including the + column-major output of `initialise_design_df()`. +- `calculate_nb()` no longer errors on designs with missing plots when `pair_mapping` is not + supplied. + # speed 0.0.9 ## Major Changes From b1d7adcfa8e86c1c7b12a598756f9265f4929b51 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:04:03 +0930 Subject: [PATCH 04/28] Adding plan for grid construction branch --- REVIEW-NOTES-OTHER.md | 385 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 REVIEW-NOTES-OTHER.md diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md new file mode 100644 index 00000000..4c169b92 --- /dev/null +++ b/REVIEW-NOTES-OTHER.md @@ -0,0 +1,385 @@ +# Review notes: grid construction and core metrics + +**Scope:** `R/design_utils.R` (`build_design_matrix()`), `R/calculate_adjacency_score.R`, +`R/metrics.R`. Branch **`bugfix/grid-orientation`** off `main`. Contains the most consequential +correctness work in any of these notes. + +**Companion files** — one per workstream: + +| File | Workstream | +|---|---| +| `REVIEW-NOTES.md` | `feature/incidence` (PR #97) — `R/incidence.R` | +| `REVIEW-NOTES-SUMMARY.md` | the merged `summary()` work — `R/summary.R` | +| `REVIEW-NOTES-PR91.md` | PR #91 `info-objective` | +| **this file** | grid construction / core metrics | + +**Last verified:** 2026-08-04, R 4.6.1, `pkgload::load_all()`. `main` at `a36d302`, +`feature/incidence` at `1536991`. All numbers measured, not inferred. + +> ✅ **Status: G1–G4 implemented on `bugfix/grid-orientation`.** `build_design_matrix()` is written +> fresh off `main` (validated coordinates, no renumbering, duplicate-coordinate guard), wired into +> `calculate_adjacency_score()` and `objective_function_piepho()` **including the G2 write-back fix**, +> with `.calculate_nb()` made NA-tolerant. Two new test files pin the behaviour. **G5 and G6 are still +> open.** The findings below are kept as the rationale for the change and as review material. +> +> `feature/incidence` still carries its own earlier copy of this work, which must be stripped — +> in that state it is a **regression**, not a fix (G2). + +--- + +## A1. Summary + +- 🔴 **G2** — the coordinate-based grid refactor on `feature/incidence` makes + `objective_function_piepho()` **worse**: it fixes two score components and corrupts the other two. + Must not merge as-is. +- 🟠 **G1** — two functions on `main` assume **opposite** data orderings and neither reads + coordinates. `calculate_adjacency_score()` returns 6 where the truth is 0 when handed + `initialise_design_df()`'s own output. +- 🟡 **G3** — `build_design_matrix()` doesn't validate its coordinates; a `row` value of 0 causes + silent data loss. +- 🟡 **G4** — coordinate placement can produce **sparse** grids, and `.calculate_nb()` errors on them. + This is the genuine fragility cost of the approach, and the default code path hits it. +- 🟡 **G5** — `calculate_adjacency_score(ring_dists = c(1, 2))` errors on `main`; the documented + default is unusable. +- 🟡 **G6** — `calculate_efficiency_factor()` fails post-buffer (KNOWN_ISSUES #1b). + +## A2. Decisions + +### 🔷 D6. Does a buffer break adjacency? — **answer this first; it determines G3** + +*(Cross-cutting: also affects `summary()` — see S3 in `REVIEW-NOTES-SUMMARY.md`.)* + +Coordinate-based construction forces this into the open, and there's no implementation-neutral answer. +`add_buffers()` shifts or scales coordinates: `type = "edge"` gives inner rows `2..n+1`; +`type = "row"` gives inner rows `2, 4, 6, 8`. Once buffer rows are dropped the inner design's +coordinates are non-contiguous. Two ways to rebuild, verified on a design with rows 1, 2, 4, 5 (a road +where row 3 would be): + +``` +raw coordinates (gap kept) ranked coordinates (gap removed) + A B A B + C C C C + NA NA C C <- now counted as adjacent + C C A B + A B +adjacency = 2 adjacency = 4 +``` + +Ranking invents two C–C adjacencies across the road. + +- **Raw coordinates** — plots either side of a buffer or gap are *not* neighbours. Agronomically the + defensible reading, and my recommendation. Cost: grids can be **sparse**, which some code can't + handle (G4). +- **Ranked coordinates** (`match(x, sort(unique(x)))`) — they *are* neighbours. Cost: silently changes + the geometry and destroys real physical gaps. + +⚠️ **`main` already made this choice implicitly, in the ranked direction** — `summary()`'s +`length(unique())` dimension fix rebuilds a row-buffered 4-row design from rows 2, 4, 6, 8 as a +contiguous 4×4 grid. So this isn't a greenfield decision; it's a question of whether to keep an +unstated one. Whichever way it goes, `summary()` and the objective functions must agree. + +**Recommendation:** raw coordinates everywhere, plus renumbering *inside* `add_buffers()` if you want +buffered designs to stay contiguous — fix it where the offset is introduced, not in every consumer. + +### 🔷 D1. Extract the grid work from `feature/incidence` into this branch? — **recommended: yes** + +Of the four `R/` files `feature/incidence` touches, only `R/incidence.R` is the feature; the other +three (`R/design_utils.R`, `R/calculate_adjacency_score.R`, `R/metrics.R`) are this workstream. The +grid work is a correctness fix affecting anyone who used `objective_function_piepho()`, and it's +currently gated on the API review of two new functions. + +Note this is a *larger* change than it first looks — it has to include G2 and G4, or piepho gets worse +rather than better. + +- **Yes** → `bugfix/grid-orientation` off `main`; rebase `feature/incidence` on it. +- **No** → keep it in PR #97, but G2 and G4 are still mandatory before merge. + +## A3. Findings + +### G1 🟠 Two functions, opposite ordering assumptions + +Neither reads coordinates; each hardcodes an assumption about data order, and they disagree: + +| Function | Fill on `main` | Correct inside `speed()` (row-major)? | Correct on raw `initialise_design_df()` (column-major)? | +|---|---|---|---| +| `calculate_adjacency_score()` | `matrix(..., byrow = TRUE)` | ✅ | ❌ | +| `objective_function_piepho()` | `matrix(...)` column-major | ❌ | ✅ | + +Each is wrong exactly where the other is right. `speed()` sorts row-major at +[R/speed.R:195](R/speed.R#L195); `initialise_design_df()` emits column-major via +`expand.grid(row = 1:nrows, col = 1:ncols)` ([R/design_utils.R:294](R/design_utils.R#L294)). + +**Measured:** `calculate_adjacency_score()` on a 2×3 design straight from `initialise_design_df()` +returns **6** where the truth is **0**. The function is exported and its own examples use hand-written +row-major data, so they pass and the inconsistency is invisible. Two exported functions in the same +package that silently disagree about layout. + +`build_design_matrix()` fixes `calculate_adjacency_score()` cleanly, with no side effects. Piepho is +not so simple — see G2. + +### G2 🔴 The piepho refactor scrambles the treatment column + +[R/metrics.R:244](R/metrics.R#L244), unchanged by the branch: + +```r +design[[swap]] <- as.factor(design_matrix) # write the flattened grid back +bal_score <- calculate_balance_score(design, swap, spatial_cols) +adj_score <- calculate_adjacency_score(design, swap, row_column, col_column) +``` + +Flattening a matrix in R is **column-major**. On `main` the grid was *also* filled column-major, so +this round-tripped exactly — verified `identical()`, i.e. line 244 was a **no-op**. With +coordinate-based filling the grid is the true layout, so flattening it column-major no longer matches a +row-major data frame, and the treatment column is silently permuted before `bal_score` and `adj_score` +are computed on it. + +Measured on a 2×6 in **row-major** order — what `speed()` actually passes: + +| | neighbour_balance | even_distribution | balance | adjacency | score | +|---|---|---|---|---|---| +| `main` | 1.3333 ❌ | 0.2357 ❌ | 2 ✅ | 0 ✅ | 3.569 | +| `feature/incidence` as-is | 0.3333 ✅ | 0.1975 ✅ | **8** ❌ | **6** ❌ | **14.53** | +| with the fix below | 0.3333 ✅ | 0.1975 ✅ | 2 ✅ | 0 ✅ | **2.531** | + +Ground truth for that layout is `balance = 2`, `adjacency = 0`. The branch trades two wrong components +for two different wrong ones. On a 4×6 it's worse: the branch reports `balance 9.333, adjacency 0` +where the truth is `balance 36, adjacency 20`. Inside a real +`speed(obj_function = objective_function_piepho)` run the optimiser drives the **corrupted** objective +to near-zero adjacency, producing a design with 20 like-treatment adjacencies. + +**Fix — delete the write-back.** Its only surviving effect was factor coercion: + +```r +design[[swap]] <- as.factor(design[[swap]]) # was: as.factor(design_matrix) +``` + +Verified this restores every component to truth on 2×6, 4×6 and 3×3, and — the real prize — makes +piepho **order-invariant**: the same physical layout supplied row-major or column-major now scores +identically (2.530786 both ways). That invariance is the entire point of coordinate-based construction +and it does not hold until line 244 is fixed. It also removes one of the two sparse-grid failures in +G4 (the `replacement has 10 rows, data has 8` error came from this line). + +### G3 🟡 `build_design_matrix()` doesn't validate its coordinates + +It uses `row`/`col` directly as matrix indices, so it needs positive integers. Verified failure: a +`row` value of `0` gives `number of items to replace is not a multiple of replacement length` plus +silent data loss, because index 0 is dropped by matrix indexing. Negative values error less helpfully. + +Fix by **validating, not transforming** — see D6 for why ranking is unsafe. Let sparse-but-valid +coordinates through as `NA` cells (G4). + +**Not a new problem:** non-numeric coordinate labels (`"R1"`, `"C1"`) make `as_numeric_factor()` return +`NA`, and *both* the old `matrix()` approach and the new one warn identically, because the old code +already used it for dimensions. Shared pre-existing limitation, not a regression. + +### G4 🟡 Sparse grids are a new input class, and some code can't take them + +This is the real fragility cost of coordinate placement. The `byrow` fill **can never** produce `NA`; +coordinate placement can, whenever the lattice has a hole — a road, an irregular trial edge, or a +dropped buffer row under D6's raw-coordinate option. + +Verified on a grid with rows 1, 2, 4, 5: + +| Consumer | Sparse grid | Notes | +|---|---|---| +| `calculate_adjacency_score()` | ✅ returns 2 | `adjacency_score_vec()` is documented to treat NA pads as 0 | +| `calculate_nb(m, pair_mapping)` | ✅ | NA pairs fail the mapping lookup and `table()` drops them | +| `calculate_nb(m)` — **no** mapping | ❌ **errors** | `.calculate_nb()` does `if (node < bottom)`, which is `NA` | +| `calculate_ed()` | ⚠️ runs | Distances reflect the gap, arguably correct — but unpinned | +| `objective_function_piepho()` | ❌ **errors both ways** | via `.calculate_nb()`, and separately via G2's line 244 | + +`pair_mapping` defaults to `NULL`, so the NA-intolerant path is the **default** one. Verified that a +real `add_buffers(d, "edge")` design with buffer rows dropped reaches it: + +```r +objective_function_piepho(inner, "treatment", c("row", "col")) +#> ERROR: missing value where TRUE/FALSE needed +``` + +**Fix:** guard `.calculate_nb()` ([R/metrics.R:330](R/metrics.R#L330)) against `NA` neighbours — skip +the pair, matching what the `pair_mapping` path already does. Add a sparse-grid test for +`calculate_ed()`: it runs, but the behaviour is unpinned and it uses `NA` internally as a "not this +treatment" sentinel, so the interaction deserves an explicit check rather than an assumption. + +Until G2 and G4 are both done, `build_design_matrix()` cannot safely be wired into piepho for anything +but a dense 1-based grid. + +### G5 🟡 `ring_weights` doesn't recycle + +Verified live on `main`: + +```r +calculate_adjacency_score(d, "trt", ring_dists = c(1, 2)) +#> ERROR: length(dists) == length(weights) is not TRUE +``` + +`adjacency_score_vec()` asserts equal lengths but `ring_weights` defaults to scalar `1`, so the +documented default is unusable with multi-ring `ring_dists`. Recycle `weights` to `length(dists)`. + +### G6 🟡 `calculate_efficiency_factor()` fails post-buffer (KNOWN_ISSUES #1b) + +Related root cause to G3. It derives `n_rows`/`n_cols` from `max()` then fills with +`for (i in 1:n_rows) for (j in 1:n_cols)` assuming `n_rows * n_cols == n_plots` — which a buffered or +sparse design violates. `summary()`'s `.efficiency_factor()` wrapper catches the error and degrades to +`available = FALSE`, so it fails safely rather than fabricating a number (see S4 in +`REVIEW-NOTES-SUMMARY.md`). + +Making it handle a sparse plot set is a bigger job than the other items here — the row/col indicator +matrices assume a complete lattice. **Keep it separable**; possibly its own small PR. + +## A4. Plan: `bugfix/grid-orientation` + +Branch created off `main`. Order matters: **D6 → G3 → G4 → G1/G2**. Wiring the call sites before the +consumers tolerate sparse grids reproduces G4's errors. + +| Step | Status | +|---|---| +| A4.1 `build_design_matrix()` | ✅ done | +| A4.2 `.calculate_nb()` NA tolerance (G4) | ✅ done | +| A4.3 call sites, incl. the G2 write-back fix | ✅ done | +| A4.4 `ring_weights` recycling (G5) | ⬜ open | +| A4.5 tests | ✅ core regression tests done; see notes below | +| A4.6 NEWS | ✅ done | +| G6 `calculate_efficiency_factor()` post-buffer | ⬜ open — own PR | + +**D6 was implemented as "raw coordinates"** — the recommended option. Coordinates are validated and +used as-is, never renumbered, so a gap in the coordinates stays a gap in the grid. If you decide the +other way, A4.1 is the only function that changes. + +### A4.1 Add `build_design_matrix()` to `R/design_utils.R` ✅ + +Differences from the `feature/incidence` version: coordinates computed once into locals, explicit +validation (G3), and a duplicate-coordinate guard. Written for **D6 = raw coordinates**. The coercion +is wrapped in `suppressWarnings()` so a non-numeric coordinate column produces the explicit error +below rather than an `NAs introduced by coercion` warning first. + +```r +build_design_matrix <- function( + df, + swap, + row_column = "row", + col_column = "col" +) { + rows <- as_numeric_factor(df[[row_column]]) + cols <- as_numeric_factor(df[[col_column]]) + if (anyNA(rows) || anyNA(cols)) { + stop( + "Cannot place the design on a grid: `", row_column, "` and `", + col_column, "` must be numeric, or coercible to numeric.", + call. = FALSE + ) + } + # Used directly as matrix indices, so they must be positive whole numbers. + # Deliberately not renumbered: a gap in the coordinates is a real gap in the + # field, and collapsing it would make non-adjacent plots neighbours. + if (any(rows < 1 | cols < 1) || any(rows != trunc(rows) | cols != trunc(cols))) { + stop( + "`", row_column, "` and `", col_column, + "` must be positive whole numbers to index a grid.", + call. = FALSE + ) + } + idx <- cbind(rows, cols) + if (anyDuplicated(idx)) { + stop( + "Duplicate (", row_column, ", ", col_column, ") coordinates: the design ", + "cannot be placed on a single grid. Split multi-site designs by site first.", + call. = FALSE + ) + } + design_matrix <- matrix(NA_character_, nrow = max(rows), ncol = max(cols)) + design_matrix[idx] <- as.character(df[[swap]]) + return(design_matrix) +} +``` + +Two behaviour changes to watch: the duplicate guard errors where `main` silently truncated, and the +positive-integer guard errors where `main` produced a partially-filled matrix. **Check the MET examples +in `?speed` still run** — they reuse `row`/`col` across sites, and piepho now routes through here. + +### A4.2 Make the consumers NA-tolerant (G4) + +- `.calculate_nb()` — skip pairs where either cell is `NA`. +- `calculate_ed()` — add a sparse-grid test. + +### A4.3 Point the call sites at it + +- **`objective_function_piepho()`** ([R/metrics.R:234](R/metrics.R#L234)) — the grid build **and** the + G2 write-back fix. These must land together; the grid change alone is a regression. +- `calculate_adjacency_score()` ([R/calculate_adjacency_score.R:255](R/calculate_adjacency_score.R#L255)) + — G1. Safe on its own. +- `.neighbour_balance()` ([R/summary.R:950](R/summary.R#L950)) — **only if** you're folding the summary + fix in here rather than into `bugfix/summary-neighbour-balance` (see S-D1 in + `REVIEW-NOTES-SUMMARY.md`). Under D6's raw-coordinate option this changes buffered-design results. + +### A4.4 Fix `ring_weights` recycling (G5) + +### A4.5 Tests + +- New `tests/testthat/test-build_design_matrix.R`: row-major input, column-major input, non-square, + factor coordinates with lexical level order, sparse coordinates, `NA` treatment cells, and the three + new errors (non-numeric, non-positive-integer, duplicate coordinates). +- **Order-invariance for piepho** — the same physical layout as a row-major and a column-major frame + must score identically. Verified this fails today and passes with G2's fix (2.530786 both ways). + **This is the single highest-value test in the change**; it's the assertion that actually captures + what coordinate-based construction buys. +- `objective_function_piepho()` on a non-square grid asserting **hand-derived** values for all four + components, not just `expect_type()`. Use G2's table as the fixture. The existing piepho tests assert + types only, which is why 1667 tests pass either side of the refactor. +- `objective_function_piepho()` on a sparse grid, with and without `pair_mapping` (G4). +- `calculate_adjacency_score(initialise_design_df(...), "treatment")` — the direct-call case that + returns 6 instead of 0 today (G1). +- `calculate_adjacency_score(d, "trt", ring_dists = c(1, 2))` runs (G5). + +### A4.6 NEWS + +```markdown +## Bug Fixes + +- `objective_function_piepho()` now builds the design grid from the `row`/`col` coordinates rather + than assuming the data frame's row order, and no longer overwrites the treatment column with a + flattened grid. All four score components are now computed on the actual layout, and the score no + longer depends on the row ordering of the input. Designs generated with this objective should be + regenerated. +- `calculate_adjacency_score()` is now robust to any row ordering of its input, including the + column-major output of `initialise_design_df()`. +- `calculate_nb()` no longer errors on designs with missing plots when `pair_mapping` is not supplied. +- `calculate_adjacency_score()` now recycles `ring_weights` against `ring_dists`. +``` + +### A4.7 Out of scope, recorded + +- **Removing the sort at [R/speed.R:195](R/speed.R#L195).** Once grids are coordinate-based *and* G2 is + fixed, the sort is no longer needed for correctness — the order-invariance test is what proves that. + But `generate_neighbour`, `random_initialise`, `print.design` and `autoplot` may rely on row order. + Leave it; note as a later simplification. Don't bundle it with a bug fix. +- **Hot-loop performance.** Measured on a 700-plot design (28×25), 2000 builds: `matrix()` + **415 µs/build** vs `build_design_matrix()` **1180 µs/build** — **2.84×**, about 7.6 s extra per + 10,000 iterations per level. Real but not disqualifying, and avoidable: the row/col vectors never + change during the SA loop, only `swap` does, so the validated `cbind(rows, cols)` index can be + computed once per level and passed in. Do it after correctness, and benchmark rather than assume. +- **G6** — `calculate_efficiency_factor()` post-buffer; own PR. + +## A5. One pre-existing issue worth keeping visible + +**Lexical factor levels defeat the row-major sort.** `to_factor()` runs at +[R/speed.R:185](R/speed.R#L185) *before* the sort, so a **character** row column with ≥10 rows gets +levels `1, 10, 11, 2, …` and `order()` follows them. Verified on an 11-row design: the grid `main` +reconstructs is `A J K E C D A F G H I` where the actual layout is `A E C D A F G H I J K`. Any +grid-based metric is then computed on a layout that isn't the design. + +Coordinate-based construction fixes this; the sort alone never could. It's also the clearest argument +for why the order-invariance test is worth more than any number of fixed-input assertions. + +--- + +## Corrections to my own earlier findings + +| Earlier claim | Corrected | +|---|---| +| Piepho goes **3.569 → 2.531**; the branch fixes it | **Invalid comparison** — `main` measured row-major, branch column-major. On the same row-major input the branch gives **14.53**. Corrected table in G2. | +| The branch's piepho refactor is a clean bug fix | **No** — it fixes NB/ED and breaks balance/adjacency via the line-244 write-back (G2). Net regression until that line changes. | +| **Rank** the coordinates to handle buffer offsets | **Unsafe** — ranking destroys real physical gaps and silently changes which plots are neighbours (measured: adjacency 2 → 4). Validate instead; see G3 and D6. | +| `calculate_nb()` stringifies `NA` into a literal `"NA,A"` pair | **Wrong for the `pair_mapping` path** — NA pairs are dropped cleanly. The no-mapping path errors instead (G4). | +| Non-numeric coordinate labels are a new fragility | **No** — both old and new approaches warn identically; pre-existing shared limitation (G3). | +| Adjacency scoring is broken for all non-square `speed()` designs | **Was wrong**, and was already corrected before this consolidation. `speed()`'s row-major sort matches `byrow = TRUE`. The genuine defects are piepho, direct calls, and lexical factor levels (A5). | From 2666f86c47605f01383143a0bd1692db54ed625a Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:35:44 +0930 Subject: [PATCH 05/28] Updating plan --- REVIEW-NOTES-OTHER.md | 222 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 202 insertions(+), 20 deletions(-) diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index 4c169b92..4d7e8db8 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -13,14 +13,19 @@ correctness work in any of these notes. | `REVIEW-NOTES-PR91.md` | PR #91 `info-objective` | | **this file** | grid construction / core metrics | -**Last verified:** 2026-08-04, R 4.6.1, `pkgload::load_all()`. `main` at `a36d302`, +**Last verified:** 2026-08-06, R 4.6.1, `pkgload::load_all()`. `bugfix/grid-orientation` at `ef9bf17`, `feature/incidence` at `1536991`. All numbers measured, not inferred. > ✅ **Status: G1–G4 implemented on `bugfix/grid-orientation`.** `build_design_matrix()` is written > fresh off `main` (validated coordinates, no renumbering, duplicate-coordinate guard), wired into > `calculate_adjacency_score()` and `objective_function_piepho()` **including the G2 write-back fix**, -> with `.calculate_nb()` made NA-tolerant. Two new test files pin the behaviour. **G5 and G6 are still -> open.** The findings below are kept as the rationale for the change and as review material. +> with `.calculate_nb()` made NA-tolerant. Two new test files pin the behaviour. The findings below are +> kept as the rationale for the change and as review material. +> +> ⬜ **Still open: G5, G6, G7, G8, and the `.neighbour_balance()` call site (S1).** G7 and S1 are new +> to this revision and are the same bug class as G1 — two further grid-construction sites that were +> missed because neither `R/summary.R` nor `calculate_efficiency_factor()` was in the original diff. +> Both are in scope here; G6 and G8 are not. > > `feature/incidence` still carries its own earlier copy of this work, which must be stripped — > in that state it is a **regression**, not a fix (G2). @@ -32,16 +37,28 @@ correctness work in any of these notes. - 🔴 **G2** — the coordinate-based grid refactor on `feature/incidence` makes `objective_function_piepho()` **worse**: it fixes two score components and corrupts the other two. Must not merge as-is. -- 🟠 **G1** — two functions on `main` assume **opposite** data orderings and neither reads - coordinates. `calculate_adjacency_score()` returns 6 where the truth is 0 when handed - `initialise_design_df()`'s own output. +- 🟠 **G1** — **four** functions on `main` assume a data ordering and none reads coordinates; two + assume row-major and two column-major, so they disagree with each other. + `calculate_adjacency_score()` returns 6 where the truth is 0 when handed + `initialise_design_df()`'s own output. (Originally written as two functions — see G7 and S1.) - 🟡 **G3** — `build_design_matrix()` doesn't validate its coordinates; a `row` value of 0 causes silent data loss. - 🟡 **G4** — coordinate placement can produce **sparse** grids, and `.calculate_nb()` errors on them. This is the genuine fragility cost of the approach, and the default code path hits it. - 🟡 **G5** — `calculate_adjacency_score(ring_dists = c(1, 2))` errors on `main`; the documented default is unusable. -- 🟡 **G6** — `calculate_efficiency_factor()` fails post-buffer (KNOWN_ISSUES #1b). +- 🟡 **G6** — `calculate_efficiency_factor()` fails post-buffer (KNOWN_ISSUES #1b). This is only the + **sparse-lattice** half of that function's grid problem; the ordering half is G7. +- 🟠 **G7** — `calculate_efficiency_factor()` builds its row/column indicator matrix positionally and + ignores the coordinates. On a **dense** grid it does not error — it silently returns a *different + number* for the same design in a different row order (measured 0.111 vs 0.625 on a 2×6). Same failure + mode as G1, in a third function; G1's table was missing it. +- 🟠 **S1** — `.neighbour_balance()` ([R/summary.R:950](R/summary.R#L950)) has the identical bug and + reports self-adjacencies that do not exist (measured 6 where the truth is 0). Fully documented as + **S1 / S-D1** in `REVIEW-NOTES-SUMMARY.md`; recorded here because the fix now belongs in this branch. +- 🔵 **G8** — `calculate_efficiency_factor()`'s `Z` omits the intercept, so it projects onto a subspace + one dimension smaller than the row + column model. Harmless for equireplicate designs, not for + unequal replication. Statistical, not orientation — **out of scope here**. ## A2. Decisions @@ -96,23 +113,30 @@ rather than better. ## A3. Findings -### G1 🟠 Two functions, opposite ordering assumptions +### G1 🟠 Four functions, opposing ordering assumptions -Neither reads coordinates; each hardcodes an assumption about data order, and they disagree: +None reads coordinates; each hardcodes an assumption about data order, and they disagree: | Function | Fill on `main` | Correct inside `speed()` (row-major)? | Correct on raw `initialise_design_df()` (column-major)? | |---|---|---|---| | `calculate_adjacency_score()` | `matrix(..., byrow = TRUE)` | ✅ | ❌ | | `objective_function_piepho()` | `matrix(...)` column-major | ❌ | ✅ | +| `calculate_efficiency_factor()` | positional `plot_index` loop, row-major (G7) | ✅ | ❌ | +| `.neighbour_balance()` | `matrix(...)` column-major (S1) | ❌ | ✅ | -Each is wrong exactly where the other is right. `speed()` sorts row-major at +They split two-and-two, each pair wrong exactly where the other is right. `speed()` sorts row-major at [R/speed.R:195](R/speed.R#L195); `initialise_design_df()` emits column-major via `expand.grid(row = 1:nrows, col = 1:ncols)` ([R/design_utils.R:294](R/design_utils.R#L294)). **Measured:** `calculate_adjacency_score()` on a 2×3 design straight from `initialise_design_df()` returns **6** where the truth is **0**. The function is exported and its own examples use hand-written -row-major data, so they pass and the inconsistency is invisible. Two exported functions in the same -package that silently disagree about layout. +row-major data, so they pass and the inconsistency is invisible. Exported functions in the same package +that silently disagree about layout. + +**Revised 2026-08-06: it is four functions, not two.** `calculate_efficiency_factor()` (G7) and +`.neighbour_balance()` (S1) make the same class of assumption and were missed because neither +`R/summary.R` nor the efficiency code was in the original review diff. The count in the original +finding was an undercount, not a wrong call. `build_design_matrix()` fixes `calculate_adjacency_score()` cleanly, with no side effects. Piepho is not so simple — see G2. @@ -227,6 +251,88 @@ sparse design violates. `summary()`'s `.efficiency_factor()` wrapper catches the Making it handle a sparse plot set is a bigger job than the other items here — the row/col indicator matrices assume a complete lattice. **Keep it separable**; possibly its own small PR. +⚠️ **Scope correction (2026-08-06).** As written this finding covers only the *sparse* case, and its +"fails safely" conclusion is true only there. The same loop has a second failure mode on a **dense** +grid where it does not error at all — see **G7**, which is in scope for this branch. Fixing G7 does not +fix G6: coordinates make the fill order-independent, but the indicator matrices still assume a complete +lattice. + +### G7 🟠 `calculate_efficiency_factor()` is order-dependent on dense grids + +[R/metrics.R:694-707](R/metrics.R#L694-L707) walks a `plot_index` counter through nested +`for (i in 1:n_rows) for (j in 1:n_cols)` loops to build the row and column indicator matrices. +`row_column`/`col_column` are used **only** for `max()` to get the dimensions — the coordinate values +themselves are never read. So the function assumes a complete rectangular grid in row-major order, and +silently scores a different layout when it doesn't get one. + +**Measured**, same physical design supplied two ways: + +| Design | Column-major | Row-major | True A-efficiency | +|---|---|---|---| +| 2×6, 4 trt, r=3 | **0.111111** ❌ | 0.625000 ✅ | 0.625000 | +| 4×3, 3 trt, r=4 | **1.500000** ❌ | 0.937500 ✅ | 0.937500 | + +The 4×3 case returns an efficiency factor **greater than 1**, which is not a possible value — a useful +canary, since it means the failure is not always silent. + +For a square grid the two orderings are a clean transpose and `Z`'s column space is unchanged, so the +result is identical and correct; the bug only bites on non-square grids. Verified correct against an +independent eigenvalue computation for `speed()` output (3×8: 0.659612 both ways), because +[R/speed.R:195](R/speed.R#L195) sorts row-major — the same accident of ordering that hid G1. + +**Where it bites today:** direct calls with `initialise_design_df()` output, which is column-major. +That includes the function's own documented example at +[R/metrics.R:656-662](R/metrics.R#L656-L662) — a 3×4 grid, scored as though it were laid out +differently. + +**Fix:** build `Z` from `model.matrix(~ factor(row) + factor(col))` (or the coordinates directly) +instead of the positional loop. Cheap, and independent of G6. + +### S1 🟠 `.neighbour_balance()` reports adjacencies that don't exist + +Full write-up is **S1 / S-D1** in `REVIEW-NOTES-SUMMARY.md`; summarised here because the fix now +belongs in this branch rather than a separate one. + +[R/summary.R:950](R/summary.R#L950) rebuilds the grid with `matrix(df[[swap]], nrow, ncol)` — a +column-major fill of a data frame `speed()` sorts row-major. **Measured** on a 3×8, 6-treatment design +optimised to a genuine zero: + +``` +summary() grid (matrix fill) true field layout (build_design_matrix) +C B E E C A E F C D C B D A E F +D D F D E A D A F E D F C E B A +C A F F B C B B A C E D B F A B + +self-adjacency reported by summary(): 6 +self-adjacency in the actual field : 0 +``` + +Every figure in the block — `min`, `max`, `pair_var`, `n_zero_pairs` — is computed on the scrambled +grid. The optimiser is doing its job and `summary()` misreports it. + +**Why it lands here now:** S-D1 offered (A) adopt `build_design_matrix()` "once it exists on `main`" or +(B) a local fix in `R/summary.R`. Option A was the recommendation and its stated blocker is satisfied — +`build_design_matrix()` is on this branch. `REVIEW-NOTES-SUMMARY.md`'s own plan is now stale on this +point. + +⚠️ The existing test rebuilds its expectation with the *same* `matrix()` call the implementation uses, +so it is self-fulfilling and passes against the bug. It must be rewritten, not just re-run — see +`REVIEW-NOTES-SUMMARY.md`. + +### G8 🔵 `Z` omits the intercept — out of scope, recorded + +[R/metrics.R:694-710](R/metrics.R#L694-L710) builds `Z` from row indicators `1..R-1` and column +indicators `1..C-1` with **no column of ones**. Its column space therefore has dimension `R+C-2` and +does not contain the intercept, where the row + column model space has dimension `R+C-1`. `A_RC` is +consequently not the mean-adjusted treatment information matrix. + +**Measured:** for equireplicate designs this cancels exactly — the returned value matched the harmonic +mean of the canonical efficiency factors to machine precision on five non-square designs (3×8, 4×6, +6×4, 2×10, 5×6). Under **unequal replication** it does not: on the 25×12 p-rep example the function +returns **0.267052** where a properly adjusted `C` gives **0.268757**. + +Statistical, not orientation. Fix it alongside the upper-bound work (A4.7), not here. + ## A4. Plan: `bugfix/grid-orientation` Branch created off `main`. Order matters: **D6 → G3 → G4 → G1/G2**. Wiring the call sites before the @@ -236,11 +342,18 @@ consumers tolerate sparse grids reproduces G4's errors. |---|---| | A4.1 `build_design_matrix()` | ✅ done | | A4.2 `.calculate_nb()` NA tolerance (G4) | ✅ done | -| A4.3 call sites, incl. the G2 write-back fix | ✅ done | +| A4.3a call sites — piepho + adjacency, incl. the G2 write-back fix | ✅ done | +| A4.3b call site — `.neighbour_balance()` (S1) | ⬜ open — unblocked, folded in here | +| A4.3c call site — `calculate_efficiency_factor()`'s `Z` (G7) | ⬜ open | | A4.4 `ring_weights` recycling (G5) | ⬜ open | -| A4.5 tests | ✅ core regression tests done; see notes below | -| A4.6 NEWS | ✅ done | -| G6 `calculate_efficiency_factor()` post-buffer | ⬜ open — own PR | +| A4.5 tests | 🟡 partial — G1/G2/G4 pinned; G5, G7, S1 outstanding | +| A4.6 NEWS | 🟡 partial — covers A4.3a only | +| G6 `calculate_efficiency_factor()` sparse lattice | ⬜ open — own PR | +| G8 `Z` omits the intercept | ⬜ open — own PR, with the upper-bound work (A4.7) | + +A4.3 was previously ticked ✅ while one of its three listed call sites was untouched; it is split into +a/b/c above so the tick is accurate. `test-grid-orientation.R` currently references neither +`calculate_efficiency_factor()` nor `.neighbour_balance()` — verified by grep, 2026-08-06. **D6 was implemented as "raw coordinates"** — the recommended option. Coordinates are validated and used as-is, never renumbered, so a gap in the coordinates stays a gap in the grid. If you decide the @@ -308,9 +421,14 @@ in `?speed` still run** — they reuse `row`/`col` across sites, and piepho now G2 write-back fix. These must land together; the grid change alone is a regression. - `calculate_adjacency_score()` ([R/calculate_adjacency_score.R:255](R/calculate_adjacency_score.R#L255)) — G1. Safe on its own. -- `.neighbour_balance()` ([R/summary.R:950](R/summary.R#L950)) — **only if** you're folding the summary - fix in here rather than into `bugfix/summary-neighbour-balance` (see S-D1 in - `REVIEW-NOTES-SUMMARY.md`). Under D6's raw-coordinate option this changes buffered-design results. +- `.neighbour_balance()` ([R/summary.R:950](R/summary.R#L950)) — S1. **Decided: fold in here.** S-D1's + option A was the recommendation, gated on `build_design_matrix()` existing; it now does (A4.1), so the + gate is lifted and there is no reason to open `bugfix/summary-neighbour-balance` for it. Under D6's + raw-coordinate option this changes buffered-design results. The self-fulfilling test noted in S1 must + be rewritten at the same time. +- `calculate_efficiency_factor()` ([R/metrics.R:694-707](R/metrics.R#L694-L707)) — G7. Replace the + positional `plot_index` loop with coordinate-driven indicators. Does **not** resolve G6 (sparse + lattice) or G8 (missing intercept); both stay out of scope. ### A4.4 Fix `ring_weights` recycling (G5) @@ -330,6 +448,13 @@ in `?speed` still run** — they reuse `row`/`col` across sites, and piepho now - `calculate_adjacency_score(initialise_design_df(...), "treatment")` — the direct-call case that returns 6 instead of 0 today (G1). - `calculate_adjacency_score(d, "trt", ring_dists = c(1, 2))` runs (G5). +- **Order-invariance for `calculate_efficiency_factor()`** (G7) — the same physical layout as a + row-major and a column-major frame must return the same value. Fails today on any non-square grid + (2×6: 0.111 vs 0.625). Pair it with a fixed-value assertion against an independently computed + A-efficiency, and a regression test that the 4×3 case no longer returns a value `> 1`. +- **`.neighbour_balance()` on a non-square design** (S1) — a design optimised to zero self-adjacency + must report zero. Build the expectation from the **coordinates**, never from the same `matrix()` call + the implementation uses; the existing test does the latter and therefore passes against the bug. ### A4.6 NEWS @@ -345,8 +470,17 @@ in `?speed` still run** — they reuse `row`/`col` across sites, and piepho now column-major output of `initialise_design_df()`. - `calculate_nb()` no longer errors on designs with missing plots when `pair_mapping` is not supplied. - `calculate_adjacency_score()` now recycles `ring_weights` against `ring_dists`. +- `calculate_efficiency_factor()` now builds its row and column indicators from the `row`/`col` + coordinates rather than assuming the data frame's row order, so it returns the same value for a + design regardless of how its rows are ordered. Previously it could return an incorrect value, or one + greater than 1, for a non-square design not supplied in row-major order. +- `summary()` no longer reports incorrect neighbour-balance counts (self-adjacency, pair minimum, + maximum, variance and zero-count) for designs whose grid is not square. ``` +The last two bullets are new (G7, S1); the first four were already added for A4.3a. The S1 wording is +shared with `REVIEW-NOTES-SUMMARY.md` — keep one copy, in whichever branch lands first. + ### A4.7 Out of scope, recorded - **Removing the sort at [R/speed.R:195](R/speed.R#L195).** Once grids are coordinate-based *and* G2 is @@ -358,7 +492,51 @@ in `?speed` still run** — they reuse `row`/`col` across sites, and piepho now 10,000 iterations per level. Real but not disqualifying, and avoidable: the row/col vectors never change during the SA loop, only `swap` does, so the validated `cbind(rows, cols)` index can be computed once per level and passed in. Do it after correctness, and benchmark rather than assume. -- **G6** — `calculate_efficiency_factor()` post-buffer; own PR. +- **G6** — `calculate_efficiency_factor()` sparse lattice / post-buffer; own PR. +- **G8** — the missing intercept in `Z`; own PR, naturally paired with the upper-bound work below since + both touch the same statistics rather than the grid. +- **A-efficiency upper bound in `summary()`.** 🔷 **Decided 2026-08-06: separate branch, not this one.** + There is a closed-form upper bound on the average efficiency factor depending only on + `(replication, nrow, ncol)` — no matrices, essentially free — that lets `summary()` report how close a + design gets to the best achievable A-efficiency: + + `UB = (1/(t-1)) * sum_i [ 1 - minSumSq(r_i, nrow)/(ncol*r_i) - minSumSq(r_i, ncol)/(nrow*r_i) + r_i/n ]` + + where `minSumSq(r, K)` is the even-split minimum of `sum n_ik^2`. **Measured:** holds as a bound on + every design tested, equals exactly 1.000 for 4×4 and 5×5 Latin squares (which are A-optimal), and + tracks the optimiser — a 5×6 10-treatment design moves 0.000 → 0.620 → 0.774 against a bound of 0.815 + as iterations go 0 → 200 → 5000. + + Report it as **"% of upper bound"**, never "% of optimal": `A/UB = 1` proves A-optimality, but + `A/UB < 1` does not prove sub-optimality, because the bound may be unattainable. + + **Explicitly declined:** reporting the raw A-value (average pairwise variance). It is already computed + and discarded at [R/metrics.R:733-742](R/metrics.R#L733-L742), but it is in σ² units, only comparable + across designs with identical replication, and actively misleading when the design is disconnected — + measured, an unoptimised 5×6 reports an A-value of 0.503 against the optimised design's 0.862, which + looks better and is a `ginv` artefact of the rank deficiency. +- **Disconnected designs get a healthy-looking efficiency.** On the 25×12 p-rep example the true average + efficiency factor is 0 (`rank(C) = 251` against `t-1 = 252`), but `pseudo_inverse()`'s `1e-10` + tolerance drops the null direction and `summary()` prints `0.2671` with no caveat — on the same + screen as its own `DISCONNECTED - 1 treatment contract(s) not estimable` line. The two outputs + contradict each other unless the reader joins them up. Suppress or annotate the efficiency value when + `connectedness$connected` is `FALSE`. Belongs with the `summary()` presentation work, not here. + +### A4.8 Terminology note — "A-efficiency" vs "E" + +Recorded because it came up as a suspected mislabelling and is not one. `calculate_efficiency_factor()` +returns `(2/r_h) / apv`, the **average efficiency factor** — the harmonic mean of the canonical +efficiency factors, i.e. **A-efficiency**, the measure paired with A-optimality. Verified against an +independent eigenvalue computation on five equireplicate designs (exact to machine precision), and the +package contains no `eigen()` call, so it cannot be computing an E-efficiency (the *minimum* canonical +efficiency factor) at all. `summary()`'s "A-efficiency" label is correct. + +The confusion is a symbol collision: Williams & Piepho write the average efficiency factor as **`E`** +(for **E**fficiency, often `E_A`) — see the comment in `Mario speed-eg3-jac12463.R`, *"The average +efficiency factor is E = 0.411"*. That `E` is not E-optimality. Separately, when all canonical +efficiency factors are equal (Latin squares, BIBDs) A-, D- and E-efficiency coincide exactly, so +agreement with another package on one design proves nothing about which criterion it used. Worth a +sentence in `?calculate_efficiency_factor` naming the synonym so this doesn't recur. ## A5. One pre-existing issue worth keeping visible @@ -383,3 +561,7 @@ for why the order-invariance test is worth more than any number of fixed-input a | `calculate_nb()` stringifies `NA` into a literal `"NA,A"` pair | **Wrong for the `pair_mapping` path** — NA pairs are dropped cleanly. The no-mapping path errors instead (G4). | | Non-numeric coordinate labels are a new fragility | **No** — both old and new approaches warn identically; pre-existing shared limitation (G3). | | Adjacency scoring is broken for all non-square `speed()` designs | **Was wrong**, and was already corrected before this consolidation. `speed()`'s row-major sort matches `byrow = TRUE`. The genuine defects are piepho, direct calls, and lexical factor levels (A5). | +| G1: **two** functions assume opposite orderings | **Undercount** — it is four. `calculate_efficiency_factor()` (G7) and `.neighbour_balance()` (S1) do the same thing and were outside the reviewed diff. Table in G1 corrected. | +| G6 covers `calculate_efficiency_factor()`'s grid problem, and it "fails safely" | **Only half.** G6 is the sparse case, where it errors and the wrapper degrades cleanly. On a dense non-square grid in the wrong order it returns a wrong number silently (G7) — including values `> 1`. "Fails safely" is true of G6, not of the function. | +| A4.3 (call sites) is ✅ done | **Two of three sites only.** `.neighbour_balance()` was listed as conditional and never done; `calculate_efficiency_factor()` was not listed at all. Split into A4.3a/b/c. | +| S-D1 option A is blocked on `build_design_matrix()` reaching `main` | **No longer** — it is on this branch (A4.1). The `bugfix/summary-neighbour-balance` plan in `REVIEW-NOTES-SUMMARY.md` is stale; S1 folds in here. | From 7c4d201bf902352266a3268058dc48260de80994 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:37:49 +0930 Subject: [PATCH 06/28] Make G6 self-contained on the buffer coordinate offset KNOWN_ISSUES.md #1 is being reduced to a pointer, so record here why a buffered design breaks the complete-lattice assumption. Co-Authored-By: Claude Opus 5 (1M context) --- REVIEW-NOTES-OTHER.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index 4d7e8db8..b2c6d30e 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -248,6 +248,12 @@ sparse design violates. `summary()`'s `.efficiency_factor()` wrapper catches the `available = FALSE`, so it fails safely rather than fabricating a number (see S4 in `REVIEW-NOTES-SUMMARY.md`). +**Why a buffered design violates it:** `add_buffers()` (R/buffers.R) shifts or scales the real +design's coordinates — `type = "edge"` does `design$row <- design$row + 1` before appending the buffer +rows, `type = "row"` doubles them. Stripping the buffer rows back out does not undo the offset, so the +remaining plots no longer occupy a contiguous 1-indexed lattice and `n_rows * n_cols` exceeds +`n_plots`. Fixing it at the source, inside `add_buffers()`, is the direction recorded in D6. + Making it handle a sparse plot set is a bigger job than the other items here — the row/col indicator matrices assume a complete lattice. **Keep it separable**; possibly its own small PR. From f5d68f537c34b313499fefcf1db4d4b80c363bb1 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:57:21 +0930 Subject: [PATCH 07/28] Fixing grid orientation bugs --- NEWS.md | 17 ++++ R/calculate_adjacency_score.R | 5 + R/metrics.R | 32 +++--- R/summary.R | 25 +++-- REVIEW-NOTES-OTHER.md | 97 ++++++++++++++----- man/dot-neighbour_balance.Rd | 23 +++-- .../testthat/test-calculate_adjacency_score.R | 7 ++ .../test-calculate_efficiency_factor.R | 36 +++++-- tests/testthat/test-grid-orientation.R | 84 ++++++++++++++++ tests/testthat/test-speed.R | 41 +++----- tests/testthat/test-summary.R | 93 ++++++++++++++---- 11 files changed, 348 insertions(+), 112 deletions(-) diff --git a/NEWS.md b/NEWS.md index 4762b4a8..f0ec8f8a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -17,6 +17,23 @@ column-major output of `initialise_design_df()`. - `calculate_nb()` no longer errors on designs with missing plots when `pair_mapping` is not supplied. +- `calculate_efficiency_factor()` now builds its row and column indicators from the `row`/`col` + coordinates rather than the data frame's row order, so it returns the same value for a design + however its rows are ordered. Previously a non-square design not supplied in row-major order could + return an incorrect value, including one greater than 1. +- `calculate_adjacency_score()` now recycles a single `ring_weights` value across every entry of + `ring_dists`, so the documented default is usable with more than one ring. +- `summary()` no longer reports incorrect neighbour-balance figures (self-adjacency, pair minimum, + maximum, variance and zero-count) for designs whose grid is not square. Neighbour balance is now + read from the plot coordinates, so plots separated by a buffer row or column are no longer counted + as neighbours. + +## Minor Changes + +- A design whose `row`/`col` columns cannot be read as numbers now fails with a single message + naming the problem, instead of several coercion warnings followed by an `invalid 'nrow' value` + error. Designs whose plots share a `row`/`col` coordinate, such as an unsplit multi-site design, + are also reported explicitly rather than silently keeping one plot per position. # speed 0.0.9 diff --git a/R/calculate_adjacency_score.R b/R/calculate_adjacency_score.R index 0e76871c..02bf15cc 100755 --- a/R/calculate_adjacency_score.R +++ b/R/calculate_adjacency_score.R @@ -137,6 +137,11 @@ adjacency_score_vec <- function( relationship = NULL ) { ring_type <- match.arg(ring_type) + # A single weight applies to every ring, so the scalar default stays usable + # with a multi-ring `dists`. Any other length mismatch is still an error. + if (length(weights) == 1L) { + weights <- rep(weights, length(dists)) + } stopifnot(length(dists) == length(weights)) nr <- nrow(design_matrix) nc <- ncol(design_matrix) diff --git a/R/metrics.R b/R/metrics.R index 37e51224..c3bc152f 100644 --- a/R/metrics.R +++ b/R/metrics.R @@ -694,32 +694,26 @@ calculate_efficiency_factor <- function( # Design parameters encoded_items <- as.integer(as.factor(design_df[[item]])) n_treatments <- length(unique(encoded_items)) - n_rows <- max(as_numeric_factor(design_df[[row_column]]), na.rm = TRUE) - n_cols <- max(as_numeric_factor(design_df[[col_column]]), na.rm = TRUE) + rows <- as_numeric_factor(design_df[[row_column]]) + cols <- as_numeric_factor(design_df[[col_column]]) + n_rows <- max(rows, na.rm = TRUE) + n_cols <- max(cols, na.rm = TRUE) n_plots <- nrow(design_df) # Create design matrix X for treatments X <- matrix(0, nrow = n_plots, ncol = n_treatments) - for (i in 1:n_plots) { - X[i, encoded_items[i]] <- 1 - } + X[cbind(seq_len(n_plots), encoded_items)] <- 1 - # Create design matrix Z for rows and columns - # Row and col effects (excluding last row and col to avoid singularity) + # Create design matrix Z for rows and columns, indexed by each plot's actual + # coordinates. A positional fill would assume the data frame is a complete + # grid in row-major order and silently score a different layout otherwise. + # Row and col effects exclude the last row and col to avoid singularity. Z_row <- matrix(0, nrow = n_plots, ncol = n_rows - 1) Z_col <- matrix(0, nrow = n_plots, ncol = n_cols - 1) - plot_index <- 1 - for (i in 1:n_rows) { - for (j in 1:n_cols) { - if (i < n_rows) { - Z_row[plot_index, i] <- 1 - } - if (j < n_cols) { - Z_col[plot_index, j] <- 1 - } - plot_index <- plot_index + 1 - } - } + in_row <- which(rows < n_rows) + in_col <- which(cols < n_cols) + Z_row[cbind(in_row, rows[in_row])] <- 1 + Z_col[cbind(in_col, cols[in_col])] <- 1 # Combine row and column design matrices Z <- cbind(Z_row, Z_col) diff --git a/R/summary.R b/R/summary.R index 9f2dd415..32560766 100644 --- a/R/summary.R +++ b/R/summary.R @@ -234,7 +234,7 @@ summary.design <- function( } else if (!has_grid) { list(available = FALSE, reason = "no row/column factors") } else { - .neighbour_balance(df, swap, layout$nrow, layout$ncol) + .neighbour_balance(df, swap, rc, cc) } ) @@ -938,16 +938,23 @@ print.summary.design <- function(x, ...) { #' whereas a distinct pair that never neighbours is an imbalance. Lumping them #' together hides self-adjacency behind the same `min 0` as the harmless case. #' -#' Takes `nrow`/`ncol` from the caller's `layout` (counted via -#' `length(unique(...))`) rather than deriving them from `max(row)`/`max(col)`: -#' buffer plots (`add_buffers()`) can shift row/col numbering so it no longer -#' starts at 1, which would otherwise reshape the grid with the wrong -#' dimensions. Assumes `rc`/`cc` are present in `df`; callers should check -#' `has_grid` first (see `summary.design()`). +#' The grid is built by [build_design_matrix()], which places each plot at its +#' own `rc`/`cc` coordinates. Reshaping the treatment column with `matrix()` +#' instead would assume the data frame's row order matches the fill order, which +#' is false for any non-square design (`speed()` sorts row-major; `matrix()` +#' fills column-major) and produced adjacency counts for a layout that wasn't +#' the design. #' +#' Coordinates are read as-is, so a design whose plots are separated by a buffer +#' row or column (`add_buffers()` offsets and scales them) keeps that separation: +#' plots either side of a buffer are not counted as neighbours. Assumes `rc`/`cc` +#' are present in `df`; callers should check `has_grid` first (see +#' `summary.design()`). +#' +#' @param rc,cc Row and column column names. #' @keywords internal -.neighbour_balance <- function(df, swap, nrow, ncol) { - dm <- matrix(df[[swap]], nrow = nrow, ncol = ncol) +.neighbour_balance <- function(df, swap, rc, cc) { + dm <- build_design_matrix(df, swap, row_column = rc, col_column = cc) pair_mapping <- create_pair_mapping(df[[swap]]) nb <- calculate_nb(dm, pair_mapping) diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index b2c6d30e..42c59051 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -13,22 +13,23 @@ correctness work in any of these notes. | `REVIEW-NOTES-PR91.md` | PR #91 `info-objective` | | **this file** | grid construction / core metrics | -**Last verified:** 2026-08-06, R 4.6.1, `pkgload::load_all()`. `bugfix/grid-orientation` at `ef9bf17`, -`feature/incidence` at `1536991`. All numbers measured, not inferred. - -> ✅ **Status: G1–G4 implemented on `bugfix/grid-orientation`.** `build_design_matrix()` is written -> fresh off `main` (validated coordinates, no renumbering, duplicate-coordinate guard), wired into -> `calculate_adjacency_score()` and `objective_function_piepho()` **including the G2 write-back fix**, -> with `.calculate_nb()` made NA-tolerant. Two new test files pin the behaviour. The findings below are -> kept as the rationale for the change and as review material. +**Last verified:** 2026-08-06, R 4.6.1, `pkgload::load_all()`. All numbers measured, not inferred. + +> ✅ **Everything in scope for this branch is implemented.** `build_design_matrix()` is written fresh +> off `main` (validated coordinates, no renumbering, duplicate-coordinate guard) and wired into all +> four grid-construction sites — `calculate_adjacency_score()`, `objective_function_piepho()` +> (**including the G2 write-back fix**), `calculate_efficiency_factor()`'s `Z` (G7) and +> `.neighbour_balance()` (S1). `.calculate_nb()` is NA-tolerant (G4) and a scalar `ring_weights` +> recycles across every `ring_dist` (G5). **Full suite: 1668 pass, 0 fail, 0 warn.** > -> ⬜ **Still open: G5, G6, G7, G8, and the `.neighbour_balance()` call site (S1).** G7 and S1 are new -> to this revision and are the same bug class as G1 — two further grid-construction sites that were -> missed because neither `R/summary.R` nor `calculate_efficiency_factor()` was in the original diff. -> Both are in scope here; G6 and G8 are not. +> The findings below are kept as the rationale for the change and as review material. > -> `feature/incidence` still carries its own earlier copy of this work, which must be stripped — -> in that state it is a **regression**, not a fix (G2). +> ⬜ **Still open, each its own PR: G6, G8, G9.** None is an ordering bug. **G9 is new** — the +> `initialise_design_df()` fill order, found because it is what made the paper-comparison test fail +> once G7 was fixed. It is worked around in the tests, not fixed in the package. +> +> ✅ `feature/incidence` has been stripped of its earlier copy of this work (commit `655dac1` there), +> so the two branches no longer overlap in any file. --- @@ -59,6 +60,10 @@ correctness work in any of these notes. - 🔵 **G8** — `calculate_efficiency_factor()`'s `Z` omits the intercept, so it projects onto a subspace one dimension smaller than the row + column model. Harmless for equireplicate designs, not for unequal replication. Statistical, not orientation — **out of scope here**. +- 🟠 **G9** — `initialise_design_df()` assigns `items` **down columns**, undocumented, so a design + transcribed one grid row per line is stored transposed. The package's own paper-comparison test was + wrong in exactly this way and passed only because G7 cancelled it out. **Out of scope here**; + worked around in the tests. ## A2. Decisions @@ -67,7 +72,7 @@ correctness work in any of these notes. *(Cross-cutting: also affects `summary()` — see S3 in `REVIEW-NOTES-SUMMARY.md`.)* Coordinate-based construction forces this into the open, and there's no implementation-neutral answer. -`add_buffers()` shifts or scales coordinates: `type = "edge"` gives inner rows `2..n+1`; +`add_buffers()` shifts or scales coordinates: `type = "ed*ge"` gives inner rows `2..n+1`; `type = "row"` gives inner rows `2, 4, 6, 8`. Once buffer rows are dropped the inner design's coordinates are non-contiguous. Two ways to rebuild, verified on a design with rows 1, 2, 4, 5 (a road where row 3 would be): @@ -325,6 +330,42 @@ point. so it is self-fulfilling and passes against the bug. It must be rewritten, not just re-run — see `REVIEW-NOTES-SUMMARY.md`. +### G9 🟠 `initialise_design_df()` fills `items` down columns, and nothing says so + +Found while fixing G7, because it is what made the paper-comparison test fail. + +`initialise_design_df()` builds its grid with `expand.grid(row = 1:nrows, col = 1:ncols)` +([R/design_utils.R:294](R/design_utils.R#L294)), which varies `row` fastest, then assigns +`df$treatment <- items` positionally. So `items` is read **down columns**. Nothing in `?initialise_design_df` +says this, and the natural way to write a design out — one grid row per source line — produces a +*different design* from the one on the page. + +The package's own test suite fell into it. `test-calculate_efficiency_factor.R` writes four published +designs visually, 4 rows of 9, and asserted the paper's efficiency values. **Measured:** + +| Design | as written | grid actually matching the paper | paper's value | +|---|---|---|---| +| 1 | 0.644 | **0.834** | 0.834 | +| 2 | 0.683 | **0.783** | 0.783 | +| 3 | 0.540 | **0.827** | 0.827 | + +The test passed on `main` only because **two conventions cancelled**: `initialise_design_df()` stored +the design column-major, and `calculate_efficiency_factor()` read it back positionally row-major +(G7), recovering the design the author had written. Fixing G7 broke the cancellation and exposed +both. Supplying the items column-major — `as.vector(matrix(items, nrow, ncol, byrow = TRUE))` — +reproduces every published value exactly, which is the confirmation that the G7 fix is right and the +storage was wrong. + +**Fixed in the tests** via a local `by_row()` helper, so the literals stay readable against the +paper. **Not fixed in the package** — that is a user-facing API question and belongs on its own +branch: + +- At minimum, document the fill order in `?initialise_design_df` with a worked example. +- Better, add `byrow = FALSE` to `initialise_design_df()`, mirroring `matrix()`. Anyone transcribing + a published design will want `byrow = TRUE`, and today they silently get a different design. + +Worth checking other tests and vignettes for the same latent transposition before that lands. + ### G8 🔵 `Z` omits the intercept — out of scope, recorded [R/metrics.R:694-710](R/metrics.R#L694-L710) builds `Z` from row indicators `1..R-1` and column @@ -349,17 +390,23 @@ consumers tolerate sparse grids reproduces G4's errors. | A4.1 `build_design_matrix()` | ✅ done | | A4.2 `.calculate_nb()` NA tolerance (G4) | ✅ done | | A4.3a call sites — piepho + adjacency, incl. the G2 write-back fix | ✅ done | -| A4.3b call site — `.neighbour_balance()` (S1) | ⬜ open — unblocked, folded in here | -| A4.3c call site — `calculate_efficiency_factor()`'s `Z` (G7) | ⬜ open | -| A4.4 `ring_weights` recycling (G5) | ⬜ open | -| A4.5 tests | 🟡 partial — G1/G2/G4 pinned; G5, G7, S1 outstanding | -| A4.6 NEWS | 🟡 partial — covers A4.3a only | +| A4.3b call site — `.neighbour_balance()` (S1) | ✅ done | +| A4.3c call site — `calculate_efficiency_factor()`'s `Z` (G7) | ✅ done | +| A4.4 `ring_weights` recycling (G5) | ✅ done | +| A4.5 tests | ✅ done — G1/G2/G4/G5/G7/S1 all pinned | +| A4.6 NEWS | ✅ done | | G6 `calculate_efficiency_factor()` sparse lattice | ⬜ open — own PR | | G8 `Z` omits the intercept | ⬜ open — own PR, with the upper-bound work (A4.7) | +| G9 `initialise_design_df()` fill order | ⬜ open — own PR; worked around in the tests | + +**Full suite: 1668 pass, 0 fail, 0 warnings** (2026-08-06). Two pre-existing tests needed changing, +both because they asserted the behaviour being fixed: -A4.3 was previously ticked ✅ while one of its three listed call sites was untouched; it is split into -a/b/c above so the tick is accurate. `test-grid-orientation.R` currently references neither -`calculate_efficiency_factor()` nor `.neighbour_balance()` — verified by grep, 2026-08-06. +- `test-calculate_adjacency_score.R` — "rejects mismatched dists/weights" asserted that a scalar + `weights` against a multi-ring `dists` errors. That is precisely what G5 makes legal. Rewritten to + assert the scalar case now works *and* that a genuine length mismatch still errors. +- `test-calculate_efficiency_factor.R` — the paper-comparison test, via G9. See that finding; the + published values reproduce exactly once the designs are stored the way they are written. **D6 was implemented as "raw coordinates"** — the recommended option. Coordinates are validated and used as-is, never renumbered, so a gap in the coordinates stays a gap in the grid. If you decide the @@ -570,4 +617,6 @@ for why the order-invariance test is worth more than any number of fixed-input a | G1: **two** functions assume opposite orderings | **Undercount** — it is four. `calculate_efficiency_factor()` (G7) and `.neighbour_balance()` (S1) do the same thing and were outside the reviewed diff. Table in G1 corrected. | | G6 covers `calculate_efficiency_factor()`'s grid problem, and it "fails safely" | **Only half.** G6 is the sparse case, where it errors and the wrapper degrades cleanly. On a dense non-square grid in the wrong order it returns a wrong number silently (G7) — including values `> 1`. "Fails safely" is true of G6, not of the function. | | A4.3 (call sites) is ✅ done | **Two of three sites only.** `.neighbour_balance()` was listed as conditional and never done; `calculate_efficiency_factor()` was not listed at all. Split into A4.3a/b/c. | -| S-D1 option A is blocked on `build_design_matrix()` reaching `main` | **No longer** — it is on this branch (A4.1). The `bugfix/summary-neighbour-balance` plan in `REVIEW-NOTES-SUMMARY.md` is stale; S1 folds in here. | +| S-D1 option A is blocked on `build_design_matrix()` reaching `main` | **No longer** — it is on this branch (A4.1). The `bugfix/summary-neighbour-balance` plan in `REVIEW-NOTES-SUMMARY.md` is stale; S1 folds in here, and is now done. | +| G7: an efficiency factor `> 1` is a canary for the ordering bug | **Too narrow.** `> 1` signals rank deficiency, whichever way it arises. Measured: degenerate fixtures where treatment is confounded with row (which is what `initialise_design_df(rep(LETTERS[1:k], m), ...)` produces — see G9) return values `> 1` in **row-major** order too, on `main`. It is a canary for "something is wrong", not specifically for ordering. | +| G8 is "harmless for equireplicate designs" | **Stands, re-verified.** On a properly randomised equireplicate 3×8 design `calculate_efficiency_factor()` matched the harmonic mean of the canonical efficiency factors exactly (0.7040535). Earlier doubt came from degenerate fixtures (G9), not from G8. | diff --git a/man/dot-neighbour_balance.Rd b/man/dot-neighbour_balance.Rd index f155f651..489b10fc 100644 --- a/man/dot-neighbour_balance.Rd +++ b/man/dot-neighbour_balance.Rd @@ -4,7 +4,10 @@ \alias{.neighbour_balance} \title{Neighbour-balance diagnostics} \usage{ -.neighbour_balance(df, swap, nrow, ncol) +.neighbour_balance(df, swap, rc, cc) +} +\arguments{ +\item{rc, cc}{Row and column column names.} } \description{ Builds the treatment grid and counts how often each treatment pair ends up @@ -20,11 +23,17 @@ zero self-adjacency is the desirable outcome the optimiser works towards, whereas a distinct pair that never neighbours is an imbalance. Lumping them together hides self-adjacency behind the same \verb{min 0} as the harmless case. -Takes \code{nrow}/\code{ncol} from the caller's \code{layout} (counted via -\code{length(unique(...))}) rather than deriving them from \code{max(row)}/\code{max(col)}: -buffer plots (\code{add_buffers()}) can shift row/col numbering so it no longer -starts at 1, which would otherwise reshape the grid with the wrong -dimensions. Assumes \code{rc}/\code{cc} are present in \code{df}; callers should check -\code{has_grid} first (see \code{summary.design()}). +The grid is built by \code{\link[=build_design_matrix]{build_design_matrix()}}, which places each plot at its +own \code{rc}/\code{cc} coordinates. Reshaping the treatment column with \code{matrix()} +instead would assume the data frame's row order matches the fill order, which +is false for any non-square design (\code{speed()} sorts row-major; \code{matrix()} +fills column-major) and produced adjacency counts for a layout that wasn't +the design. + +Coordinates are read as-is, so a design whose plots are separated by a buffer +row or column (\code{add_buffers()} offsets and scales them) keeps that separation: +plots either side of a buffer are not counted as neighbours. Assumes \code{rc}/\code{cc} +are present in \code{df}; callers should check \code{has_grid} first (see +\code{summary.design()}). } \keyword{internal} diff --git a/tests/testthat/test-calculate_adjacency_score.R b/tests/testthat/test-calculate_adjacency_score.R index 0c390513..38906224 100644 --- a/tests/testthat/test-calculate_adjacency_score.R +++ b/tests/testthat/test-calculate_adjacency_score.R @@ -130,7 +130,14 @@ test_that("adjacency_score_vec applies per-ring weights", { }) test_that("adjacency_score_vec rejects mismatched dists/weights", { + # A length-one `weights` is recycled across every ring, so the scalar default + # stays usable with a multi-ring `dists`. Any other mismatch is still an error. expect_error(adjacency_score_vec( + matrix(1, 2, 2), + dists = c(1, 2, 3), + weights = c(1, 2) + )) + expect_no_error(adjacency_score_vec( matrix(1, 2, 2), dists = c(1, 2), weights = 1 diff --git a/tests/testthat/test-calculate_efficiency_factor.R b/tests/testthat/test-calculate_efficiency_factor.R index 3e246bf5..7be688d9 100644 --- a/tests/testthat/test-calculate_efficiency_factor.R +++ b/tests/testthat/test-calculate_efficiency_factor.R @@ -1,6 +1,24 @@ +# The published designs below are written out visually, one grid row per source +# line, so they can be read against the paper. `initialise_design_df()` assigns +# `items` *down columns* - `expand.grid(row, col)` varies `row` fastest - so a +# row-major literal has to be transposed before it is handed over, or the design +# stored at those coordinates is not the one written here. +# +# This used to be invisible: `calculate_efficiency_factor()` also filled its +# grid positionally in row-major order, so the two conventions cancelled and the +# paper's values came back from a design the package had not actually stored. +# Now that it reads the coordinates, the transpose has to be explicit. +by_row <- function(items, nrows, ncols) { + initialise_design_df( + as.vector(matrix(items, nrow = nrows, ncol = ncols, byrow = TRUE)), + nrows, + ncols + ) +} + test_that("calculate_efficiency_factor provides the same results as the paper", { # fmt: skip - df_design1 <- initialise_design_df(c( + df_design1 <- by_row(c( 7, 5, 6, 9, 4, 1, 3, 2, 8, 5, 6, 3, 1, 7, 8, 2, 4, 9, 8, 9, 5, 6, 3, 4, 1, 7, 2, @@ -13,7 +31,7 @@ test_that("calculate_efficiency_factor provides the same results as the paper", ) # fmt: skip - df_design2 <- initialise_design_df(c( + df_design2 <- by_row(c( 8, 5, 7, 2, 4, 1, 6, 9, 3, 1, 9, 8, 6, 3, 2, 4, 7, 5, 7, 6, 4, 9, 5, 8, 3, 2, 1, @@ -26,7 +44,7 @@ test_that("calculate_efficiency_factor provides the same results as the paper", ) # fmt: skip - df_design3 <- initialise_design_df(c( + df_design3 <- by_row(c( 9, 8, 1, 4, 3, 7, 5, 2, 6, 7, 5, 6, 2, 9, 1, 3, 8, 4, 2, 4, 3, 5, 6, 8, 9, 7, 1, @@ -39,7 +57,7 @@ test_that("calculate_efficiency_factor provides the same results as the paper", ) # fmt: skip - df_design4 <- initialise_design_df(c( + df_design4 <- by_row(c( 47, 16, 43, 42, 37, 35, 1, 59, 24, 19, 4, 18, 40, 28, 51, 29, 54, 57, 12, 25, 6, 57, 47, 32, 39, 17, 31, 50, 15, 5, 55, 51, 9, 54, 41, 3, 23, 18, 45, 36, 49, 7, 8, 60, 41, 29, 3, 58, 26, 52, 2, 15, 28, 27, @@ -55,14 +73,14 @@ test_that("calculate_efficiency_factor provides the same results as the paper", test_that("calculate_efficiency_factor provides better result for an optimised design", { # fmt: skip - df_design_initial <- initialise_design_df(c( + df_design_initial <- by_row(c( 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6 ), 3, 4) # fmt: skip - df_design_optimised <- initialise_design_df(c( + df_design_optimised <- by_row(c( 1, 2, 4, 3, 5, 1, 6, 2, 3, 6, 5, 4 @@ -76,21 +94,21 @@ test_that("calculate_efficiency_factor provides better result for an optimised d test_that("calculate_efficiency_factor provides same result for mathematically identical designs", { # fmt: skip - df_design1 <- initialise_design_df(c( + df_design1 <- by_row(c( 1, 2, 4, 3, 5, 1, 6, 2, 3, 6, 5, 4 ), 3, 4) # fmt: skip - df_design2 <- initialise_design_df(c( + df_design2 <- by_row(c( "a", "b", "d", "c", "e", "a", "f", "b", "c", "f", "e", "d" ), 3, 4) # fmt: skip - df_design3 <- initialise_design_df(c( + df_design3 <- by_row(c( "b", "a", "c", "d", "e", "b", "f", "a", "d", "f", "e", "c" diff --git a/tests/testthat/test-grid-orientation.R b/tests/testthat/test-grid-orientation.R index 34db365f..47421abb 100644 --- a/tests/testthat/test-grid-orientation.R +++ b/tests/testthat/test-grid-orientation.R @@ -199,3 +199,87 @@ test_that("adjacency score ignores gaps rather than closing them", { ) expect_equal(calculate_adjacency_score(df, "trt"), 2) }) + +# --- ring_weights recycling -------------------------------------------------- + +test_that("a single ring_weight applies to every ring_dist", { + # ring_weights defaults to the scalar 1, so the documented default was + # unusable with a multi-ring ring_dists: adjacency_score_vec() asserted the + # two were the same length. + df <- data.frame( + row = rep(1:3, each = 3), + col = rep(1:3, times = 3), + trt = c("A", "B", "C", "B", "C", "A", "C", "A", "B") + ) + + expect_no_error( + scalar_weight <- calculate_adjacency_score(df, "trt", ring_dists = c(1, 2)) + ) + expect_equal( + scalar_weight, + calculate_adjacency_score( + df, + "trt", + ring_dists = c(1, 2), + ring_weights = c(1, 1) + ) + ) +}) + +test_that("a genuine ring_weights length mismatch is still an error", { + df <- data.frame( + row = rep(1:3, each = 3), + col = rep(1:3, times = 3), + trt = c("A", "B", "C", "B", "C", "A", "C", "A", "B") + ) + expect_error( + calculate_adjacency_score( + df, + "trt", + ring_dists = c(1, 2, 3), + ring_weights = c(1, 2) + ) + ) +}) + +# --- calculate_efficiency_factor() ------------------------------------------- + +test_that("efficiency factor does not depend on the input row ordering", { + # It used to walk a plot counter through nested row/col loops, so it assumed + # a complete grid in row-major order and silently scored a different layout + # for anything else - including initialise_design_df()'s column-major output. + col_major <- initialise_design_df( + items = rep(LETTERS[1:3], 4), + nrows = 4, + ncols = 3 + ) + row_major <- col_major[order(col_major$row, col_major$col), ] + rownames(row_major) <- NULL + + expect_equal( + calculate_efficiency_factor(col_major, treatment), + calculate_efficiency_factor(row_major, treatment) + ) + # Row-major was already correct, so its value must be unchanged. Column-major + # returned 1.5 - impossible for an efficiency factor, and the symptom that + # made this visible. + expect_equal(calculate_efficiency_factor(row_major, treatment), 0.9375) +}) + +test_that("efficiency factor is unchanged by shuffling a real design", { + d <- speed( + initialise_design_df(items = rep(LETTERS[1:6], 4), nrows = 3, ncols = 8), + swap = "treatment", + iterations = 500, + seed = 11, + quiet = TRUE + ) + ordered <- calculate_efficiency_factor(d$design_df, treatment) + shuffled <- calculate_efficiency_factor( + d$design_df[rev(seq_len(nrow(d$design_df))), ], + treatment + ) + + expect_equal(ordered, shuffled) + expect_lte(ordered, 1) +}) diff --git a/tests/testthat/test-speed.R b/tests/testthat/test-speed.R index 270ce854..4a3a79ad 100644 --- a/tests/testthat/test-speed.R +++ b/tests/testthat/test-speed.R @@ -913,39 +913,28 @@ test_that("autoplot handles factor columns with custom column names", { ) }) -test_that("autoplot fails with factor columns with character levels", { - # Sample data with factor columns that have character levels - # Too hard to predict plot layout with character levels +test_that("speed reports non-numeric row/col labels clearly", { + # Row/col labels like "R1"/"C1" cannot index a grid. This used to surface as + # five coercion warnings followed by a cryptic "invalid 'nrow' value (too + # large or NA)" from matrix(); build_design_matrix() now names the actual + # problem once, with no warnings. test_data <- data.frame( row = factor(rep(paste0("R", 1:5), times = 4)), col = factor(rep(paste0("C", 1:4), each = 5)), treatment = rep(LETTERS[1:4], 5) ) - expect_warning( - expect_warning( - expect_warning( - expect_warning( - expect_warning( - expect_error( - speed( - data = test_data, - swap = "treatment", - iterations = 100, - seed = 42, - quiet = TRUE - ), - "invalid 'nrow' value \\(too large or NA\\)" - ), - "NAs introduced by coercion" - ), - "no non-missing arguments to max; returning -Inf" - ), - "no non-missing arguments to max; returning -Inf" + expect_no_warning( + expect_error( + speed( + data = test_data, + swap = "treatment", + iterations = 100, + seed = 42, + quiet = TRUE ), - "NAs introduced by coercion to integer range" - ), - "NAs introduced by coercion" + "must be numeric, or coercible to numeric" + ) ) }) diff --git a/tests/testthat/test-summary.R b/tests/testthat/test-summary.R index ad8bb1a9..bf3baeb3 100644 --- a/tests/testthat/test-summary.R +++ b/tests/testthat/test-summary.R @@ -296,9 +296,12 @@ test_that("buffers are excluded from summary() and print() entirely", { }) test_that("neighbour balance is unaffected by buffers (KNOWN_ISSUES.md #1a)", { - # add_buffers("edge") shifts row/col by 1; .neighbour_balance() must use the - # (offset-invariant) layout$nrow/ncol rather than max(row)/max(col), or it - # reshapes the treatment grid with the wrong dimensions. + # add_buffers("edge") shifts row/col by 1. The grid is built from the + # coordinates, so the offset just leaves an empty leading row and column, + # which contribute no pairs. "edge" only offsets - it inserts no gap between + # the design's own plots - so the counts must match the unbuffered design + # exactly. (A "row" or "block" buffer does insert a gap, and there the counts + # are expected to differ: plots either side of a buffer are not neighbours.) d <- data.frame( row = rep(1:4, times = 3), col = rep(1:3, each = 4), @@ -727,21 +730,45 @@ test_that("neighbour balance separates self-adjacency from distinct-pair counts" expect_true(nb$min_pair_count >= 0) expect_true(nb$self_adjacent >= 0) - # Cross-check directly against create_pair_mapping()/calculate_nb() over all - # 6 possible pairs for this 3-treatment design. - dm <- matrix(r$design_df$treatment, nrow = 4, ncol = 3) - pm <- create_pair_mapping(r$design_df$treatment) - raw <- calculate_nb(dm, pm) - all_counts <- setNames(rep(0L, length(unique(pm))), unique(pm)) - all_counts[names(raw$nb)] <- raw$nb - self <- c("A,A", "B,B", "C,C") - distinct <- setdiff(names(all_counts), self) - - expect_equal(nb$self_adjacent, sum(all_counts[self])) - expect_equal(nb$min_pair_count, min(all_counts[distinct])) - expect_equal(nb$max_pair_count, max(all_counts[distinct])) - expect_equal(nb$pair_var, var(all_counts[distinct])) - expect_equal(nb$n_zero_pairs, sum(all_counts[distinct] == 0)) + # Cross-check by walking the (row, col) coordinates directly. Deliberately + # NOT matrix(treatment, nrow, ncol): that is the same reshape the + # implementation used to perform, so an expectation built from it validated + # the code against a copy of its own mistake and passed against the bug. + coords <- r$design_df + rr <- as.numeric(as.character(coords$row)) + cc <- as.numeric(as.character(coords$col)) + trt <- as.character(coords$treatment) + at <- function(i, j) { + k <- which(rr == i & cc == j) + if (length(k) == 1) trt[k] else NA_character_ + } + pm_levels <- sort(unique(trt)) + counts <- list() + for (k in seq_along(trt)) { + for (nb_ij in list(c(rr[k], cc[k] + 1), c(rr[k] + 1, cc[k]))) { + other <- at(nb_ij[1], nb_ij[2]) + if (!is.na(other)) { + key <- paste(sort(c(trt[k], other)), collapse = ",") + prev <- counts[[key]] + counts[[key]] <- if (is.null(prev)) 1L else prev + 1L + } + } + } + self_keys <- paste(pm_levels, pm_levels, sep = ",") + all_pairs <- combn(pm_levels, 2, function(p) paste(p, collapse = ",")) + get <- function(k) if (is.null(counts[[k]])) 0L else counts[[k]] + self_total <- sum(vapply(self_keys, get, integer(1))) + pair_counts <- vapply(all_pairs, get, integer(1)) + + expect_equal(nb$self_adjacent, self_total) + expect_equal(nb$min_pair_count, min(pair_counts)) + expect_equal(nb$max_pair_count, max(pair_counts)) + expect_equal(nb$pair_var, var(pair_counts)) + expect_equal(nb$n_zero_pairs, sum(pair_counts == 0)) + + # The design is 4x3, so a column-major reshape would disagree - which is the + # whole point of the coordinate-based grid. + expect_equal(nb$self_adjacent, calculate_adjacency_score(coords, "treatment")) }) test_that("a non-zero self-adjacency count is highlighted in the printed output", { @@ -994,3 +1021,33 @@ test_that(".efficiency_factor reports a reason when the computation fails", { expect_false(ef$available) expect_equal(ef$reason, "could not be computed for this design") }) + +test_that("neighbour balance reads coordinates, not the data frame order", { + # A 3x8 design optimised to a genuine zero self-adjacency. A column-major + # reshape of a row-major frame scrambles it and invents adjacencies, so this + # is the regression test for that class of bug: the reported figure must + # agree with the objective's own adjacency score for the same design. + d <- speed( + initialise_design_df(items = rep(LETTERS[1:6], 4), nrows = 3, ncols = 8), + swap = "treatment", + iterations = 3000, + seed = 11, + quiet = TRUE + ) + nb <- summary(d)$per_level[[1]]$evaluation$neighbour + + expect_equal(nb$self_adjacent, 0) + expect_equal( + nb$self_adjacent, + calculate_adjacency_score(d$design_df, "treatment") + ) + + # Row order must not matter: the same design with its rows reversed is the + # same field layout, and must report the same neighbour balance. + reversed <- d + reversed$design_df <- d$design_df[rev(seq_len(nrow(d$design_df))), ] + expect_equal( + summary(reversed)$per_level[[1]]$evaluation$neighbour, + nb + ) +}) From 8c4b0496ea3285eed9a5d32a4625eb5b9a79685a Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:33:26 +0930 Subject: [PATCH 08/28] Updating NEWS and plan --- NEWS.md | 27 +- REVIEW-NOTES-OTHER.md | 721 +++++++++++------------------------------- 2 files changed, 187 insertions(+), 561 deletions(-) diff --git a/NEWS.md b/NEWS.md index f0ec8f8a..cef809b4 100644 --- a/NEWS.md +++ b/NEWS.md @@ -9,31 +9,20 @@ ## Bug Fixes -- `objective_function_piepho()` now builds the design grid from the `row`/`col` coordinates rather - than the data frame's row order, and no longer overwrites the treatment column with a flattened - grid. All four score components are computed on the actual layout, and the score no longer depends - on how the input rows are ordered. Designs generated with this objective should be regenerated. -- `calculate_adjacency_score()` is now correct for any row ordering of its input, including the - column-major output of `initialise_design_df()`. +- Design metrics are now built from each plot's `row`/`col` coordinates rather than the order of the + rows in the data frame, so they describe the actual layout. This corrects + `calculate_adjacency_score()`, `calculate_efficiency_factor()`, `objective_function_piepho()` and + `summary()`'s neighbour balance; designs generated with `objective_function_piepho()` should be + regenerated. - `calculate_nb()` no longer errors on designs with missing plots when `pair_mapping` is not supplied. -- `calculate_efficiency_factor()` now builds its row and column indicators from the `row`/`col` - coordinates rather than the data frame's row order, so it returns the same value for a design - however its rows are ordered. Previously a non-square design not supplied in row-major order could - return an incorrect value, including one greater than 1. - `calculate_adjacency_score()` now recycles a single `ring_weights` value across every entry of - `ring_dists`, so the documented default is usable with more than one ring. -- `summary()` no longer reports incorrect neighbour-balance figures (self-adjacency, pair minimum, - maximum, variance and zero-count) for designs whose grid is not square. Neighbour balance is now - read from the plot coordinates, so plots separated by a buffer row or column are no longer counted - as neighbours. + `ring_dists`, so the default is usable with more than one ring. ## Minor Changes -- A design whose `row`/`col` columns cannot be read as numbers now fails with a single message - naming the problem, instead of several coercion warnings followed by an `invalid 'nrow' value` - error. Designs whose plots share a `row`/`col` coordinate, such as an unsplit multi-site design, - are also reported explicitly rather than silently keeping one plot per position. +- Designs whose `row`/`col` columns are not numeric, or where two plots share a coordinate, now fail + with a message naming the problem. # speed 0.0.9 diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index 42c59051..28e05500 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -1,8 +1,7 @@ # Review notes: grid construction and core metrics **Scope:** `R/design_utils.R` (`build_design_matrix()`), `R/calculate_adjacency_score.R`, -`R/metrics.R`. Branch **`bugfix/grid-orientation`** off `main`. Contains the most consequential -correctness work in any of these notes. +`R/metrics.R`. Branch **`bugfix/grid-orientation`** off `main`. **Companion files** — one per workstream: @@ -13,322 +12,135 @@ correctness work in any of these notes. | `REVIEW-NOTES-PR91.md` | PR #91 `info-objective` | | **this file** | grid construction / core metrics | -**Last verified:** 2026-08-06, R 4.6.1, `pkgload::load_all()`. All numbers measured, not inferred. +**Last verified:** 2026-08-06, R 4.6.1, `pkgload::load_all()`, branch at `f5d68f5`. All numbers +measured, not inferred. Resolved findings are deleted rather than annotated — see git history and the +`NEWS.md` entries for what they were. -> ✅ **Everything in scope for this branch is implemented.** `build_design_matrix()` is written fresh -> off `main` (validated coordinates, no renumbering, duplicate-coordinate guard) and wired into all -> four grid-construction sites — `calculate_adjacency_score()`, `objective_function_piepho()` -> (**including the G2 write-back fix**), `calculate_efficiency_factor()`'s `Z` (G7) and -> `.neighbour_balance()` (S1). `.calculate_nb()` is NA-tolerant (G4) and a scalar `ring_weights` -> recycles across every `ring_dist` (G5). **Full suite: 1668 pass, 0 fail, 0 warn.** +> ✅ **All grid-ordering work has landed.** `build_design_matrix()` is wired into +> `calculate_adjacency_score()`, `objective_function_piepho()` and `.neighbour_balance()`; +> `calculate_efficiency_factor()` indexes its own indicator matrices by coordinate. Full suite: +> **1668 pass, 0 fail, 0 warn.** > -> The findings below are kept as the rationale for the change and as review material. +> 🔴 **One live blocker: G10.** D6 has now been decided as **ranked** coordinates, and the code +> implements **raw**. A row-buffered design currently reports neighbour counts for a layout nobody +> will analyse. This must be settled before the branch merges, because it is a behaviour change either +> way and the NEWS entry currently describes the raw behaviour. > -> ⬜ **Still open, each its own PR: G6, G8, G9.** None is an ordering bug. **G9 is new** — the -> `initialise_design_df()` fill order, found because it is what made the paper-comparison test fail -> once G7 was fixed. It is worked around in the tests, not fixed in the package. -> -> ✅ `feature/incidence` has been stripped of its earlier copy of this work (commit `655dac1` there), -> so the two branches no longer overlap in any file. +> ⬜ **Deferred, each its own PR: G8, G9.** Neither is an ordering bug. --- -## A1. Summary - -- 🔴 **G2** — the coordinate-based grid refactor on `feature/incidence` makes - `objective_function_piepho()` **worse**: it fixes two score components and corrupts the other two. - Must not merge as-is. -- 🟠 **G1** — **four** functions on `main` assume a data ordering and none reads coordinates; two - assume row-major and two column-major, so they disagree with each other. - `calculate_adjacency_score()` returns 6 where the truth is 0 when handed - `initialise_design_df()`'s own output. (Originally written as two functions — see G7 and S1.) -- 🟡 **G3** — `build_design_matrix()` doesn't validate its coordinates; a `row` value of 0 causes - silent data loss. -- 🟡 **G4** — coordinate placement can produce **sparse** grids, and `.calculate_nb()` errors on them. - This is the genuine fragility cost of the approach, and the default code path hits it. -- 🟡 **G5** — `calculate_adjacency_score(ring_dists = c(1, 2))` errors on `main`; the documented - default is unusable. -- 🟡 **G6** — `calculate_efficiency_factor()` fails post-buffer (KNOWN_ISSUES #1b). This is only the - **sparse-lattice** half of that function's grid problem; the ordering half is G7. -- 🟠 **G7** — `calculate_efficiency_factor()` builds its row/column indicator matrix positionally and - ignores the coordinates. On a **dense** grid it does not error — it silently returns a *different - number* for the same design in a different row order (measured 0.111 vs 0.625 on a 2×6). Same failure - mode as G1, in a third function; G1's table was missing it. -- 🟠 **S1** — `.neighbour_balance()` ([R/summary.R:950](R/summary.R#L950)) has the identical bug and - reports self-adjacencies that do not exist (measured 6 where the truth is 0). Fully documented as - **S1 / S-D1** in `REVIEW-NOTES-SUMMARY.md`; recorded here because the fix now belongs in this branch. -- 🔵 **G8** — `calculate_efficiency_factor()`'s `Z` omits the intercept, so it projects onto a subspace - one dimension smaller than the row + column model. Harmless for equireplicate designs, not for - unequal replication. Statistical, not orientation — **out of scope here**. -- 🟠 **G9** — `initialise_design_df()` assigns `items` **down columns**, undocumented, so a design - transcribed one grid row per line is stored transposed. The package's own paper-comparison test was - wrong in exactly this way and passed only because G7 cancelled it out. **Out of scope here**; - worked around in the tests. - -## A2. Decisions - -### 🔷 D6. Does a buffer break adjacency? — **answer this first; it determines G3** - -*(Cross-cutting: also affects `summary()` — see S3 in `REVIEW-NOTES-SUMMARY.md`.)* - -Coordinate-based construction forces this into the open, and there's no implementation-neutral answer. -`add_buffers()` shifts or scales coordinates: `type = "ed*ge"` gives inner rows `2..n+1`; -`type = "row"` gives inner rows `2, 4, 6, 8`. Once buffer rows are dropped the inner design's -coordinates are non-contiguous. Two ways to rebuild, verified on a design with rows 1, 2, 4, 5 (a road -where row 3 would be): - -``` -raw coordinates (gap kept) ranked coordinates (gap removed) - A B A B - C C C C - NA NA C C <- now counted as adjacent - C C A B - A B -adjacency = 2 adjacency = 4 -``` - -Ranking invents two C–C adjacencies across the road. - -- **Raw coordinates** — plots either side of a buffer or gap are *not* neighbours. Agronomically the - defensible reading, and my recommendation. Cost: grids can be **sparse**, which some code can't - handle (G4). -- **Ranked coordinates** (`match(x, sort(unique(x)))`) — they *are* neighbours. Cost: silently changes - the geometry and destroys real physical gaps. - -⚠️ **`main` already made this choice implicitly, in the ranked direction** — `summary()`'s -`length(unique())` dimension fix rebuilds a row-buffered 4-row design from rows 2, 4, 6, 8 as a -contiguous 4×4 grid. So this isn't a greenfield decision; it's a question of whether to keep an -unstated one. Whichever way it goes, `summary()` and the objective functions must agree. - -**Recommendation:** raw coordinates everywhere, plus renumbering *inside* `add_buffers()` if you want -buffered designs to stay contiguous — fix it where the offset is introduced, not in every consumer. - -### 🔷 D1. Extract the grid work from `feature/incidence` into this branch? — **recommended: yes** - -Of the four `R/` files `feature/incidence` touches, only `R/incidence.R` is the feature; the other -three (`R/design_utils.R`, `R/calculate_adjacency_score.R`, `R/metrics.R`) are this workstream. The -grid work is a correctness fix affecting anyone who used `objective_function_piepho()`, and it's -currently gated on the API review of two new functions. - -Note this is a *larger* change than it first looks — it has to include G2 and G4, or piepho gets worse -rather than better. - -- **Yes** → `bugfix/grid-orientation` off `main`; rebase `feature/incidence` on it. -- **No** → keep it in PR #97, but G2 and G4 are still mandatory before merge. - -## A3. Findings - -### G1 🟠 Four functions, opposing ordering assumptions +## A1. Landed on this branch -None reads coordinates; each hardcodes an assumption about data order, and they disagree: +Kept as a one-line inventory for the PR description. The full write-ups are in git history. -| Function | Fill on `main` | Correct inside `speed()` (row-major)? | Correct on raw `initialise_design_df()` (column-major)? | -|---|---|---|---| -| `calculate_adjacency_score()` | `matrix(..., byrow = TRUE)` | ✅ | ❌ | -| `objective_function_piepho()` | `matrix(...)` column-major | ❌ | ✅ | -| `calculate_efficiency_factor()` | positional `plot_index` loop, row-major (G7) | ✅ | ❌ | -| `.neighbour_balance()` | `matrix(...)` column-major (S1) | ❌ | ✅ | - -They split two-and-two, each pair wrong exactly where the other is right. `speed()` sorts row-major at -[R/speed.R:195](R/speed.R#L195); `initialise_design_df()` emits column-major via -`expand.grid(row = 1:nrows, col = 1:ncols)` ([R/design_utils.R:294](R/design_utils.R#L294)). - -**Measured:** `calculate_adjacency_score()` on a 2×3 design straight from `initialise_design_df()` -returns **6** where the truth is **0**. The function is exported and its own examples use hand-written -row-major data, so they pass and the inconsistency is invisible. Exported functions in the same package -that silently disagree about layout. - -**Revised 2026-08-06: it is four functions, not two.** `calculate_efficiency_factor()` (G7) and -`.neighbour_balance()` (S1) make the same class of assumption and were missed because neither -`R/summary.R` nor the efficiency code was in the original review diff. The count in the original -finding was an undercount, not a wrong call. - -`build_design_matrix()` fixes `calculate_adjacency_score()` cleanly, with no side effects. Piepho is -not so simple — see G2. - -### G2 🔴 The piepho refactor scrambles the treatment column - -[R/metrics.R:244](R/metrics.R#L244), unchanged by the branch: +| Was | Now | +|---|---| +| **G1** four functions each assumed a data ordering, two row-major and two column-major | all four read coordinates via `build_design_matrix()` or a coordinate-indexed fill | +| **G2** `objective_function_piepho()` wrote a column-major flattened grid back over the treatment column | write-back deleted; all four score components computed on the real layout, and piepho is order-invariant | +| **G3** `build_design_matrix()` didn't validate coordinates | explicit non-numeric / non-positive-integer / duplicate-coordinate errors | +| **G4** `.calculate_nb()` errored on sparse grids, the default path | `NA` neighbours are skipped, matching the `pair_mapping` path | +| **G5** a scalar `ring_weights` errored against multi-ring `ring_dists` | recycled across every ring | +| **G6** `calculate_efficiency_factor()` couldn't compute for a buffered design (`KNOWN_ISSUES` #1b) | **resolved as a side effect of G7** — see A1.1 | +| **G7** `calculate_efficiency_factor()` filled `Z` positionally, returning a different value per row ordering (0.111 vs 0.625 on a 2×6, and values `> 1`) | `Z` is indexed by each plot's own coordinates | +| **S1** `.neighbour_balance()` reported self-adjacencies that didn't exist (6 where the truth was 0) | reads coordinates; the 4×3 fixture now returns the hand-derived truth (self 0, pair min/max 5/6) | +| **A5** lexical factor levels (`1, 10, 11, 2, …`) defeated the row-major sort, so grid metrics scored a layout that wasn't the design | coordinate construction is immune; the sort is no longer load-bearing (see A4) | + +### A1.1 G6 fell out of the G7 fix — verify before closing `KNOWN_ISSUES` #1b + +Unplanned, so it is worth confirming rather than assuming. `calculate_efficiency_factor()` now indexes +`Z_row`/`Z_col` by `rows`/`cols` directly, which tolerates the coordinate offset `add_buffers()` +introduces: the empty leading indicator column makes `ZtZ` singular, the existing `kappa()` check +routes to `pseudo_inverse()`, and the empty column contributes nothing. **Measured** on a 4×3 design: + +| | efficiency | +|---|---| +| unbuffered | 0.9375 | +| `add_buffers("edge")` — rows 2-5, cols 2-4 | **0.9375** | +| `add_buffers("row")` — rows 2, 4, 6, 8 | **0.9375** | +| 4×6 with one plot removed mid-grid (a genuine hole) | **0.7009**, no error | -```r -design[[swap]] <- as.factor(design_matrix) # write the flattened grid back -bal_score <- calculate_balance_score(design, swap, spatial_cols) -adj_score <- calculate_adjacency_score(design, swap, row_column, col_column) -``` +All correct: buffers are not part of the statistical design, so the value should equal the unbuffered +design's, and it does. `#1b` recorded this as uncomputable, and `.efficiency_factor()`'s `tryCatch` no +longer has a known failure to absorb. -Flattening a matrix in R is **column-major**. On `main` the grid was *also* filled column-major, so -this round-tripped exactly — verified `identical()`, i.e. line 244 was a **no-op**. With -coordinate-based filling the grid is the true layout, so flattening it column-major no longer matches a -row-major data frame, and the treatment column is silently permuted before `bal_score` and `adj_score` -are computed on it. +--- -Measured on a 2×6 in **row-major** order — what `speed()` actually passes: +## A2. Decisions -| | neighbour_balance | even_distribution | balance | adjacency | score | -|---|---|---|---|---|---| -| `main` | 1.3333 ❌ | 0.2357 ❌ | 2 ✅ | 0 ✅ | 3.569 | -| `feature/incidence` as-is | 0.3333 ✅ | 0.1975 ✅ | **8** ❌ | **6** ❌ | **14.53** | -| with the fix below | 0.3333 ✅ | 0.1975 ✅ | 2 ✅ | 0 ✅ | **2.531** | +### 🔷 D6. Do buffers break adjacency? — **decided: ranked coordinates** (2026-08-06) -Ground truth for that layout is `balance = 2`, `adjacency = 0`. The branch trades two wrong components -for two different wrong ones. On a 4×6 it's worse: the branch reports `balance 9.333, adjacency 0` -where the truth is `balance 36, adjacency 20`. Inside a real -`speed(obj_function = objective_function_piepho)` run the optimiser drives the **corrupted** objective -to near-zero adjacency, producing a design with 20 like-treatment adjacencies. +**Decision: rank the coordinates, ignoring buffers.** Plots either side of a buffer **are** neighbours. -**Fix — delete the write-back.** Its only surviving effect was factor coercion: +**Rationale (Sam, 2026-08-06):** when a buffered trial is *analysed*, the buffer plots are excluded and +the model is fitted on the remaining plots, which treats them as a contiguous grid — adjacent even +where they are physically separated. The design metrics should describe the layout the analysis will +actually see, not the physical field. Matching the analysis is the point of the metric. -```r -design[[swap]] <- as.factor(design[[swap]]) # was: as.factor(design_matrix) -``` +This also settles S3 in `REVIEW-NOTES-SUMMARY.md`: `main`'s `length(unique(...))` behaviour was the +right *behaviour*, and the only real defect there was that the choice had never been stated. It is now +stated here. -Verified this restores every component to truth on 2×6, 4×6 and 3×3, and — the real prize — makes -piepho **order-invariant**: the same physical layout supplied row-major or column-major now scores -identically (2.530786 both ways). That invariance is the entire point of coordinate-based construction -and it does not hold until line 244 is fixed. It also removes one of the two sparse-grid failures in -G4 (the `replacement has 10 rows, data has 8` error came from this line). +**Cost, accepted:** ranking cannot distinguish a buffer from a genuine hole, so a real physical gap — a +road, an irregular trial edge — is also collapsed, and two plots either side of it are counted as +neighbours. A design with a partial hole (one missing plot mid-row) still produces a sparse grid after +ranking, so G4's `NA` tolerance stays necessary. -### G3 🟡 `build_design_matrix()` doesn't validate its coordinates +Rejected alternative — **raw coordinates**, gaps preserved. Agronomically the more literal reading, and +what is implemented today, but it describes a layout no analysis uses. See G10. -It uses `row`/`col` directly as matrix indices, so it needs positive integers. Verified failure: a -`row` value of `0` gives `number of items to replace is not a multiple of replacement length` plus -silent data loss, because index 0 is dropped by matrix indexing. Negative values error less helpfully. +--- -Fix by **validating, not transforming** — see D6 for why ranking is unsafe. Let sparse-but-valid -coordinates through as `NA` cells (G4). +## A3. Open findings -**Not a new problem:** non-numeric coordinate labels (`"R1"`, `"C1"`) make `as_numeric_factor()` return -`NA`, and *both* the old `matrix()` approach and the new one warn identically, because the old code -already used it for dimensions. Shared pre-existing limitation, not a regression. +### G10 🔴 `build_design_matrix()` uses raw coordinates, contradicting D6 -### G4 🟡 Sparse grids are a new input class, and some code can't take them +The branch implemented D6 in the **raw** direction before it was decided, and the decision went the +other way. Everything about reading coordinates instead of row order is correct and stays; only the +treatment of gaps is wrong. -This is the real fragility cost of coordinate placement. The `byrow` fill **can never** produce `NA`; -coordinate placement can, whenever the lattice has a hole — a road, an irregular trial edge, or a -dropped buffer row under D6's raw-coordinate option. +`build_design_matrix()` ([R/design_utils.R](R/design_utils.R)) places plots at `max(rows)` × `max(cols)` +and comments that coordinates are *"deliberately not renumbered"*. `add_buffers()` never undoes its own +offset — it does `design$row <- design$row + 1` for `"edge"` and `design$row <- 2 * design$row` for +`"row"` ([R/buffers.R:24-47](R/buffers.R#L24-L47)) — so a de-buffered design arrives with +non-contiguous coordinates. -Verified on a grid with rows 1, 2, 4, 5: +**Measured** on the 4×3 design, `add_buffers("row")` then buffer rows dropped (inner rows 2, 4, 6, 8): -| Consumer | Sparse grid | Notes | +| Coordinates | self-adj | pair min / max | |---|---|---| -| `calculate_adjacency_score()` | ✅ returns 2 | `adjacency_score_vec()` is documented to treat NA pads as 0 | -| `calculate_nb(m, pair_mapping)` | ✅ | NA pairs fail the mapping lookup and `table()` drops them | -| `calculate_nb(m)` — **no** mapping | ❌ **errors** | `.calculate_nb()` does `if (node < bottom)`, which is `NA` | -| `calculate_ed()` | ⚠️ runs | Distances reflect the gap, arguably correct — but unpinned | -| `objective_function_piepho()` | ❌ **errors both ways** | via `.calculate_nb()`, and separately via G2's line 244 | - -`pair_mapping` defaults to `NULL`, so the NA-intolerant path is the **default** one. Verified that a -real `add_buffers(d, "edge")` design with buffer rows dropped reaches it: - -```r -objective_function_piepho(inner, "treatment", c("row", "col")) -#> ERROR: missing value where TRUE/FALSE needed -``` - -**Fix:** guard `.calculate_nb()` ([R/metrics.R:330](R/metrics.R#L330)) against `NA` neighbours — skip -the pair, matching what the `pair_mapping` path already does. Add a sparse-grid test for -`calculate_ed()`: it runs, but the behaviour is unpinned and it uses `NA` internally as a "not this -treatment" sentinel, so the interaction deserves an explicit check rather than an assumption. - -Until G2 and G4 are both done, `build_design_matrix()` cannot safely be wired into piepho for anything -but a dense 1-based grid. - -### G5 🟡 `ring_weights` doesn't recycle - -Verified live on `main`: - -```r -calculate_adjacency_score(d, "trt", ring_dists = c(1, 2)) -#> ERROR: length(dists) == length(weights) is not TRUE -``` - -`adjacency_score_vec()` asserts equal lengths but `ring_weights` defaults to scalar `1`, so the -documented default is unusable with multi-ring `ring_dists`. Recycle `weights` to `length(dists)`. - -### G6 🟡 `calculate_efficiency_factor()` fails post-buffer (KNOWN_ISSUES #1b) - -Related root cause to G3. It derives `n_rows`/`n_cols` from `max()` then fills with -`for (i in 1:n_rows) for (j in 1:n_cols)` assuming `n_rows * n_cols == n_plots` — which a buffered or -sparse design violates. `summary()`'s `.efficiency_factor()` wrapper catches the error and degrades to -`available = FALSE`, so it fails safely rather than fabricating a number (see S4 in -`REVIEW-NOTES-SUMMARY.md`). - -**Why a buffered design violates it:** `add_buffers()` (R/buffers.R) shifts or scales the real -design's coordinates — `type = "edge"` does `design$row <- design$row + 1` before appending the buffer -rows, `type = "row"` doubles them. Stripping the buffer rows back out does not undo the offset, so the -remaining plots no longer occupy a contiguous 1-indexed lattice and `n_rows * n_cols` exceeds -`n_plots`. Fixing it at the source, inside `add_buffers()`, is the direction recorded in D6. - -Making it handle a sparse plot set is a bigger job than the other items here — the row/col indicator -matrices assume a complete lattice. **Keep it separable**; possibly its own small PR. - -⚠️ **Scope correction (2026-08-06).** As written this finding covers only the *sparse* case, and its -"fails safely" conclusion is true only there. The same loop has a second failure mode on a **dense** -grid where it does not error at all — see **G7**, which is in scope for this branch. Fixing G7 does not -fix G6: coordinates make the fill order-independent, but the indicator matrices still assume a complete -lattice. - -### G7 🟠 `calculate_efficiency_factor()` is order-dependent on dense grids - -[R/metrics.R:694-707](R/metrics.R#L694-L707) walks a `plot_index` counter through nested -`for (i in 1:n_rows) for (j in 1:n_cols)` loops to build the row and column indicator matrices. -`row_column`/`col_column` are used **only** for `max()` to get the dimensions — the coordinate values -themselves are never read. So the function assumes a complete rectangular grid in row-major order, and -silently scores a different layout when it doesn't get one. - -**Measured**, same physical design supplied two ways: - -| Design | Column-major | Row-major | True A-efficiency | -|---|---|---|---| -| 2×6, 4 trt, r=3 | **0.111111** ❌ | 0.625000 ✅ | 0.625000 | -| 4×3, 3 trt, r=4 | **1.500000** ❌ | 0.937500 ✅ | 0.937500 | - -The 4×3 case returns an efficiency factor **greater than 1**, which is not a possible value — a useful -canary, since it means the failure is not always silent. - -For a square grid the two orderings are a clean transpose and `Z`'s column space is unchanged, so the -result is identical and correct; the bug only bites on non-square grids. Verified correct against an -independent eigenvalue computation for `speed()` output (3×8: 0.659612 both ways), because -[R/speed.R:195](R/speed.R#L195) sorts row-major — the same accident of ordering that hid G1. - -**Where it bites today:** direct calls with `initialise_design_df()` output, which is column-major. -That includes the function's own documented example at -[R/metrics.R:656-662](R/metrics.R#L656-L662) — a 3×4 grid, scored as though it were laid out -differently. - -**Fix:** build `Z` from `model.matrix(~ factor(row) + factor(col))` (or the coordinates directly) -instead of the positional loop. Cheap, and independent of G6. - -### S1 🟠 `.neighbour_balance()` reports adjacencies that don't exist - -Full write-up is **S1 / S-D1** in `REVIEW-NOTES-SUMMARY.md`; summarised here because the fix now -belongs in this branch rather than a separate one. - -[R/summary.R:950](R/summary.R#L950) rebuilds the grid with `matrix(df[[swap]], nrow, ncol)` — a -column-major fill of a data frame `speed()` sorts row-major. **Measured** on a 3×8, 6-treatment design -optimised to a genuine zero: - -``` -summary() grid (matrix fill) true field layout (build_design_matrix) -C B E E C A E F C D C B D A E F -D D F D E A D A F E D F C E B A -C A F F B C B B A C E D B F A B - -self-adjacency reported by summary(): 6 -self-adjacency in the actual field : 0 -``` - -Every figure in the block — `min`, `max`, `pair_var`, `n_zero_pairs` — is computed on the scrambled -grid. The optimiser is doing its job and `summary()` misreports it. - -**Why it lands here now:** S-D1 offered (A) adopt `build_design_matrix()` "once it exists on `main`" or -(B) a local fix in `R/summary.R`. Option A was the recommendation and its stated blocker is satisfied — -`build_design_matrix()` is on this branch. `REVIEW-NOTES-SUMMARY.md`'s own plan is now stale on this -point. - -⚠️ The existing test rebuilds its expectation with the *same* `matrix()` call the implementation uses, -so it is self-fulfilling and passes against the bug. It must be rewritten, not just re-run — see -`REVIEW-NOTES-SUMMARY.md`. +| raw (today) | 0 | **2 / 3** | +| ranked (D6) | 0 | **5 / 6** | +| the same design unbuffered | 0 | 5 / 6 | + +Ranked reproduces the unbuffered design exactly, which is the correct answer under D6 — adding buffers +around a design does not change which of its plots neighbour each other in the analysis. Raw drops +every row-direction adjacency, because every pair of design rows is separated by an empty row. + +`add_buffers("edge")` offsets without inserting gaps, so it is already correct under both conventions +(measured: self 0, min/max 5/6, matching unbuffered). + +**Fix:** rank inside `build_design_matrix()` — `rows <- match(rows, sort(unique(rows)))`, likewise +`cols`, after the existing validation and before `max()`. One function, as A4.1 originally predicted. +Then: + +- `calculate_efficiency_factor()` indexes `Z` by raw coordinate too. It happens to return the right + value anyway (A1.1), because empty indicator columns contribute nothing, but it should rank for + consistency and to keep `ZtZ` non-singular rather than leaning on the `kappa()` fallback. +- **`test-summary.R`'s buffer test comment is now wrong.** It currently asserts that `"edge"` counts + match the unbuffered design *"(A `row` or `block` buffer does insert a gap, and there the counts are + expected to differ: plots either side of a buffer are not neighbours.)"* Under D6 a `"row"` buffer + must **not** change the counts either. Add that as a positive assertion — it is the test that pins + D6. +- **The NEWS bullet must lose its second sentence.** *"Neighbour balance is now read from the plot + coordinates, so plots separated by a buffer row or column are no longer counted as neighbours"* + describes the rejected convention. +- Re-check the `?add_buffers` docs and the buffer vignette section for any claim that buffers separate + plots for scoring purposes. + +Whether to *also* renumber inside `add_buffers()` is now moot for metrics — ranking downstream makes it +unnecessary — but it would still make `print()`/`autoplot()` coordinates tidier. Separate question, +separate branch. ### G9 🟠 `initialise_design_df()` fills `items` down columns, and nothing says so @@ -336,12 +148,12 @@ Found while fixing G7, because it is what made the paper-comparison test fail. `initialise_design_df()` builds its grid with `expand.grid(row = 1:nrows, col = 1:ncols)` ([R/design_utils.R:294](R/design_utils.R#L294)), which varies `row` fastest, then assigns -`df$treatment <- items` positionally. So `items` is read **down columns**. Nothing in `?initialise_design_df` -says this, and the natural way to write a design out — one grid row per source line — produces a -*different design* from the one on the page. +`df$treatment <- items` positionally. So `items` is read **down columns**. Nothing in +`?initialise_design_df` says so, and the natural way to write a design out — one grid row per source +line — produces a *different design* from the one on the page. The package's own test suite fell into it. `test-calculate_efficiency_factor.R` writes four published -designs visually, 4 rows of 9, and asserted the paper's efficiency values. **Measured:** +designs visually, 4 rows of 9, and asserted the paper's values. **Measured:** | Design | as written | grid actually matching the paper | paper's value | |---|---|---|---| @@ -349,209 +161,44 @@ designs visually, 4 rows of 9, and asserted the paper's efficiency values. **Mea | 2 | 0.683 | **0.783** | 0.783 | | 3 | 0.540 | **0.827** | 0.827 | -The test passed on `main` only because **two conventions cancelled**: `initialise_design_df()` stored -the design column-major, and `calculate_efficiency_factor()` read it back positionally row-major -(G7), recovering the design the author had written. Fixing G7 broke the cancellation and exposed -both. Supplying the items column-major — `as.vector(matrix(items, nrow, ncol, byrow = TRUE))` — -reproduces every published value exactly, which is the confirmation that the G7 fix is right and the -storage was wrong. +It passed on `main` only because **two conventions cancelled**: `initialise_design_df()` stored the +design column-major and `calculate_efficiency_factor()` read it back positionally row-major (G7), +recovering the design the author wrote. Fixing G7 broke the cancellation and exposed both. Supplying +the items column-major — `as.vector(matrix(items, nrow, ncol, byrow = TRUE))` — reproduces every +published value exactly, which is the confirmation that the G7 fix is right and the storage was wrong. -**Fixed in the tests** via a local `by_row()` helper, so the literals stay readable against the -paper. **Not fixed in the package** — that is a user-facing API question and belongs on its own -branch: +**Worked around in the tests** via a local `by_row()` helper, so the literals stay readable against the +paper. **Not fixed in the package** — that is a user-facing API question: - At minimum, document the fill order in `?initialise_design_df` with a worked example. -- Better, add `byrow = FALSE` to `initialise_design_df()`, mirroring `matrix()`. Anyone transcribing - a published design will want `byrow = TRUE`, and today they silently get a different design. +- Better, add `byrow = FALSE`, mirroring `matrix()`. Anyone transcribing a published design wants + `byrow = TRUE` and silently gets a different design today. -Worth checking other tests and vignettes for the same latent transposition before that lands. +Check the other tests and the vignettes for the same latent transposition before that lands. -### G8 🔵 `Z` omits the intercept — out of scope, recorded +### G8 🔵 `Z` omits the intercept -[R/metrics.R:694-710](R/metrics.R#L694-L710) builds `Z` from row indicators `1..R-1` and column -indicators `1..C-1` with **no column of ones**. Its column space therefore has dimension `R+C-2` and -does not contain the intercept, where the row + column model space has dimension `R+C-1`. `A_RC` is -consequently not the mean-adjusted treatment information matrix. +`calculate_efficiency_factor()` builds `Z` from row indicators `1..R-1` and column indicators `1..C-1` +with **no column of ones**. Its column space has dimension `R+C-2` and does not contain the intercept, +where the row + column model space has dimension `R+C-1`, so `A_RC` is not the mean-adjusted treatment +information matrix. -**Measured:** for equireplicate designs this cancels exactly — the returned value matched the harmonic +**Measured:** for equireplicate designs it cancels exactly — the returned value matched the harmonic mean of the canonical efficiency factors to machine precision on five non-square designs (3×8, 4×6, -6×4, 2×10, 5×6). Under **unequal replication** it does not: on the 25×12 p-rep example the function -returns **0.267052** where a properly adjusted `C` gives **0.268757**. +6×4, 2×10, 5×6), re-verified on a properly randomised 3×8 (0.7040535). Under **unequal replication** it +does not: on the 25×12 p-rep example the function returns **0.267052** where a properly adjusted `C` +gives **0.268757**. -Statistical, not orientation. Fix it alongside the upper-bound work (A4.7), not here. +Statistical, not orientation. Pair it with the upper-bound work (A4). -## A4. Plan: `bugfix/grid-orientation` +--- -Branch created off `main`. Order matters: **D6 → G3 → G4 → G1/G2**. Wiring the call sites before the -consumers tolerate sparse grids reproduces G4's errors. +## A4. Deferred, recorded -| Step | Status | -|---|---| -| A4.1 `build_design_matrix()` | ✅ done | -| A4.2 `.calculate_nb()` NA tolerance (G4) | ✅ done | -| A4.3a call sites — piepho + adjacency, incl. the G2 write-back fix | ✅ done | -| A4.3b call site — `.neighbour_balance()` (S1) | ✅ done | -| A4.3c call site — `calculate_efficiency_factor()`'s `Z` (G7) | ✅ done | -| A4.4 `ring_weights` recycling (G5) | ✅ done | -| A4.5 tests | ✅ done — G1/G2/G4/G5/G7/S1 all pinned | -| A4.6 NEWS | ✅ done | -| G6 `calculate_efficiency_factor()` sparse lattice | ⬜ open — own PR | -| G8 `Z` omits the intercept | ⬜ open — own PR, with the upper-bound work (A4.7) | -| G9 `initialise_design_df()` fill order | ⬜ open — own PR; worked around in the tests | - -**Full suite: 1668 pass, 0 fail, 0 warnings** (2026-08-06). Two pre-existing tests needed changing, -both because they asserted the behaviour being fixed: - -- `test-calculate_adjacency_score.R` — "rejects mismatched dists/weights" asserted that a scalar - `weights` against a multi-ring `dists` errors. That is precisely what G5 makes legal. Rewritten to - assert the scalar case now works *and* that a genuine length mismatch still errors. -- `test-calculate_efficiency_factor.R` — the paper-comparison test, via G9. See that finding; the - published values reproduce exactly once the designs are stored the way they are written. - -**D6 was implemented as "raw coordinates"** — the recommended option. Coordinates are validated and -used as-is, never renumbered, so a gap in the coordinates stays a gap in the grid. If you decide the -other way, A4.1 is the only function that changes. - -### A4.1 Add `build_design_matrix()` to `R/design_utils.R` ✅ - -Differences from the `feature/incidence` version: coordinates computed once into locals, explicit -validation (G3), and a duplicate-coordinate guard. Written for **D6 = raw coordinates**. The coercion -is wrapped in `suppressWarnings()` so a non-numeric coordinate column produces the explicit error -below rather than an `NAs introduced by coercion` warning first. - -```r -build_design_matrix <- function( - df, - swap, - row_column = "row", - col_column = "col" -) { - rows <- as_numeric_factor(df[[row_column]]) - cols <- as_numeric_factor(df[[col_column]]) - if (anyNA(rows) || anyNA(cols)) { - stop( - "Cannot place the design on a grid: `", row_column, "` and `", - col_column, "` must be numeric, or coercible to numeric.", - call. = FALSE - ) - } - # Used directly as matrix indices, so they must be positive whole numbers. - # Deliberately not renumbered: a gap in the coordinates is a real gap in the - # field, and collapsing it would make non-adjacent plots neighbours. - if (any(rows < 1 | cols < 1) || any(rows != trunc(rows) | cols != trunc(cols))) { - stop( - "`", row_column, "` and `", col_column, - "` must be positive whole numbers to index a grid.", - call. = FALSE - ) - } - idx <- cbind(rows, cols) - if (anyDuplicated(idx)) { - stop( - "Duplicate (", row_column, ", ", col_column, ") coordinates: the design ", - "cannot be placed on a single grid. Split multi-site designs by site first.", - call. = FALSE - ) - } - design_matrix <- matrix(NA_character_, nrow = max(rows), ncol = max(cols)) - design_matrix[idx] <- as.character(df[[swap]]) - return(design_matrix) -} -``` - -Two behaviour changes to watch: the duplicate guard errors where `main` silently truncated, and the -positive-integer guard errors where `main` produced a partially-filled matrix. **Check the MET examples -in `?speed` still run** — they reuse `row`/`col` across sites, and piepho now routes through here. - -### A4.2 Make the consumers NA-tolerant (G4) - -- `.calculate_nb()` — skip pairs where either cell is `NA`. -- `calculate_ed()` — add a sparse-grid test. - -### A4.3 Point the call sites at it - -- **`objective_function_piepho()`** ([R/metrics.R:234](R/metrics.R#L234)) — the grid build **and** the - G2 write-back fix. These must land together; the grid change alone is a regression. -- `calculate_adjacency_score()` ([R/calculate_adjacency_score.R:255](R/calculate_adjacency_score.R#L255)) - — G1. Safe on its own. -- `.neighbour_balance()` ([R/summary.R:950](R/summary.R#L950)) — S1. **Decided: fold in here.** S-D1's - option A was the recommendation, gated on `build_design_matrix()` existing; it now does (A4.1), so the - gate is lifted and there is no reason to open `bugfix/summary-neighbour-balance` for it. Under D6's - raw-coordinate option this changes buffered-design results. The self-fulfilling test noted in S1 must - be rewritten at the same time. -- `calculate_efficiency_factor()` ([R/metrics.R:694-707](R/metrics.R#L694-L707)) — G7. Replace the - positional `plot_index` loop with coordinate-driven indicators. Does **not** resolve G6 (sparse - lattice) or G8 (missing intercept); both stay out of scope. - -### A4.4 Fix `ring_weights` recycling (G5) - -### A4.5 Tests - -- New `tests/testthat/test-build_design_matrix.R`: row-major input, column-major input, non-square, - factor coordinates with lexical level order, sparse coordinates, `NA` treatment cells, and the three - new errors (non-numeric, non-positive-integer, duplicate coordinates). -- **Order-invariance for piepho** — the same physical layout as a row-major and a column-major frame - must score identically. Verified this fails today and passes with G2's fix (2.530786 both ways). - **This is the single highest-value test in the change**; it's the assertion that actually captures - what coordinate-based construction buys. -- `objective_function_piepho()` on a non-square grid asserting **hand-derived** values for all four - components, not just `expect_type()`. Use G2's table as the fixture. The existing piepho tests assert - types only, which is why 1667 tests pass either side of the refactor. -- `objective_function_piepho()` on a sparse grid, with and without `pair_mapping` (G4). -- `calculate_adjacency_score(initialise_design_df(...), "treatment")` — the direct-call case that - returns 6 instead of 0 today (G1). -- `calculate_adjacency_score(d, "trt", ring_dists = c(1, 2))` runs (G5). -- **Order-invariance for `calculate_efficiency_factor()`** (G7) — the same physical layout as a - row-major and a column-major frame must return the same value. Fails today on any non-square grid - (2×6: 0.111 vs 0.625). Pair it with a fixed-value assertion against an independently computed - A-efficiency, and a regression test that the 4×3 case no longer returns a value `> 1`. -- **`.neighbour_balance()` on a non-square design** (S1) — a design optimised to zero self-adjacency - must report zero. Build the expectation from the **coordinates**, never from the same `matrix()` call - the implementation uses; the existing test does the latter and therefore passes against the bug. - -### A4.6 NEWS - -```markdown -## Bug Fixes - -- `objective_function_piepho()` now builds the design grid from the `row`/`col` coordinates rather - than assuming the data frame's row order, and no longer overwrites the treatment column with a - flattened grid. All four score components are now computed on the actual layout, and the score no - longer depends on the row ordering of the input. Designs generated with this objective should be - regenerated. -- `calculate_adjacency_score()` is now robust to any row ordering of its input, including the - column-major output of `initialise_design_df()`. -- `calculate_nb()` no longer errors on designs with missing plots when `pair_mapping` is not supplied. -- `calculate_adjacency_score()` now recycles `ring_weights` against `ring_dists`. -- `calculate_efficiency_factor()` now builds its row and column indicators from the `row`/`col` - coordinates rather than assuming the data frame's row order, so it returns the same value for a - design regardless of how its rows are ordered. Previously it could return an incorrect value, or one - greater than 1, for a non-square design not supplied in row-major order. -- `summary()` no longer reports incorrect neighbour-balance counts (self-adjacency, pair minimum, - maximum, variance and zero-count) for designs whose grid is not square. -``` - -The last two bullets are new (G7, S1); the first four were already added for A4.3a. The S1 wording is -shared with `REVIEW-NOTES-SUMMARY.md` — keep one copy, in whichever branch lands first. - -### A4.7 Out of scope, recorded - -- **Removing the sort at [R/speed.R:195](R/speed.R#L195).** Once grids are coordinate-based *and* G2 is - fixed, the sort is no longer needed for correctness — the order-invariance test is what proves that. - But `generate_neighbour`, `random_initialise`, `print.design` and `autoplot` may rely on row order. - Leave it; note as a later simplification. Don't bundle it with a bug fix. -- **Hot-loop performance.** Measured on a 700-plot design (28×25), 2000 builds: `matrix()` - **415 µs/build** vs `build_design_matrix()` **1180 µs/build** — **2.84×**, about 7.6 s extra per - 10,000 iterations per level. Real but not disqualifying, and avoidable: the row/col vectors never - change during the SA loop, only `swap` does, so the validated `cbind(rows, cols)` index can be - computed once per level and passed in. Do it after correctness, and benchmark rather than assume. -- **G6** — `calculate_efficiency_factor()` sparse lattice / post-buffer; own PR. -- **G8** — the missing intercept in `Z`; own PR, naturally paired with the upper-bound work below since - both touch the same statistics rather than the grid. -- **A-efficiency upper bound in `summary()`.** 🔷 **Decided 2026-08-06: separate branch, not this one.** - There is a closed-form upper bound on the average efficiency factor depending only on - `(replication, nrow, ncol)` — no matrices, essentially free — that lets `summary()` report how close a - design gets to the best achievable A-efficiency: +- **A-efficiency upper bound in `summary()`.** 🔷 **Decided 2026-08-06: its own branch.** A closed-form + bound on the average efficiency factor, depending only on `(replication, nrow, ncol)` — no matrices, + essentially free — so `summary()` can report how close a design gets to the best achievable + A-efficiency: `UB = (1/(t-1)) * sum_i [ 1 - minSumSq(r_i, nrow)/(ncol*r_i) - minSumSq(r_i, ncol)/(nrow*r_i) + r_i/n ]` @@ -560,63 +207,53 @@ shared with `REVIEW-NOTES-SUMMARY.md` — keep one copy, in whichever branch lan tracks the optimiser — a 5×6 10-treatment design moves 0.000 → 0.620 → 0.774 against a bound of 0.815 as iterations go 0 → 200 → 5000. - Report it as **"% of upper bound"**, never "% of optimal": `A/UB = 1` proves A-optimality, but - `A/UB < 1` does not prove sub-optimality, because the bound may be unattainable. - - **Explicitly declined:** reporting the raw A-value (average pairwise variance). It is already computed - and discarded at [R/metrics.R:733-742](R/metrics.R#L733-L742), but it is in σ² units, only comparable - across designs with identical replication, and actively misleading when the design is disconnected — - measured, an unoptimised 5×6 reports an A-value of 0.503 against the optimised design's 0.862, which - looks better and is a `ginv` artefact of the rank deficiency. -- **Disconnected designs get a healthy-looking efficiency.** On the 25×12 p-rep example the true average - efficiency factor is 0 (`rank(C) = 251` against `t-1 = 252`), but `pseudo_inverse()`'s `1e-10` - tolerance drops the null direction and `summary()` prints `0.2671` with no caveat — on the same - screen as its own `DISCONNECTED - 1 treatment contract(s) not estimable` line. The two outputs - contradict each other unless the reader joins them up. Suppress or annotate the efficiency value when - `connectedness$connected` is `FALSE`. Belongs with the `summary()` presentation work, not here. - -### A4.8 Terminology note — "A-efficiency" vs "E" - -Recorded because it came up as a suspected mislabelling and is not one. `calculate_efficiency_factor()` -returns `(2/r_h) / apv`, the **average efficiency factor** — the harmonic mean of the canonical -efficiency factors, i.e. **A-efficiency**, the measure paired with A-optimality. Verified against an -independent eigenvalue computation on five equireplicate designs (exact to machine precision), and the -package contains no `eigen()` call, so it cannot be computing an E-efficiency (the *minimum* canonical -efficiency factor) at all. `summary()`'s "A-efficiency" label is correct. - -The confusion is a symbol collision: Williams & Piepho write the average efficiency factor as **`E`** -(for **E**fficiency, often `E_A`) — see the comment in `Mario speed-eg3-jac12463.R`, *"The average -efficiency factor is E = 0.411"*. That `E` is not E-optimality. Separately, when all canonical -efficiency factors are equal (Latin squares, BIBDs) A-, D- and E-efficiency coincide exactly, so -agreement with another package on one design proves nothing about which criterion it used. Worth a -sentence in `?calculate_efficiency_factor` naming the synonym so this doesn't recur. - -## A5. One pre-existing issue worth keeping visible - -**Lexical factor levels defeat the row-major sort.** `to_factor()` runs at -[R/speed.R:185](R/speed.R#L185) *before* the sort, so a **character** row column with ≥10 rows gets -levels `1, 10, 11, 2, …` and `order()` follows them. Verified on an 11-row design: the grid `main` -reconstructs is `A J K E C D A F G H I` where the actual layout is `A E C D A F G H I J K`. Any -grid-based metric is then computed on a layout that isn't the design. - -Coordinate-based construction fixes this; the sort alone never could. It's also the clearest argument -for why the order-invariance test is worth more than any number of fixed-input assertions. + Report it as **"% of upper bound"**, never "% of optimal": `A/UB = 1` proves A-optimality, `A/UB < 1` + does not prove sub-optimality, because the bound may be unattainable. + + **Explicitly declined:** reporting the raw A-value (average pairwise variance). Already computed and + discarded inside `calculate_efficiency_factor()`, but it is in σ² units, only comparable across + designs with identical replication, and actively misleading when the design is disconnected — + measured, an unoptimised 5×6 reports 0.503 against the optimised design's 0.862, which looks better + and is a `ginv` artefact of the rank deficiency. + + **Reuse check:** PR #91 (`REVIEW-NOTES-PR91.md`) already computes canonical efficiency factors from + the information matrix via `eigen()`, in a function confusingly named `calculate_efficiency_factors` + (plural). Both the bound and G8 want that machinery. Coordinate the two before writing a third + implementation. +- **Removing the sort at [R/speed.R:195](R/speed.R#L195).** With grids coordinate-based it is no longer + needed for correctness — the order-invariance tests are what prove that. But `generate_neighbour()`, + `random_initialise()`, `print.design()` and `autoplot()` may rely on row order. Leave it; note as a + later simplification, not bundled with a bug fix. +- **Hot-loop performance.** Measured on a 700-plot design (28×25), 2000 builds: `matrix()` + **415 µs/build** vs `build_design_matrix()` **1180 µs/build** — **2.84×**, about 7.6 s extra per + 10,000 iterations per level. Real but not disqualifying, and avoidable: the row/col vectors never + change during the SA loop, only `swap` does, so the validated `cbind(rows, cols)` index can be built + once per level and passed in. Do it after correctness, and benchmark rather than assume. Note G10's + ranking is also loop-invariant and belongs in the same hoist. +- **A terminology sentence for `?calculate_efficiency_factor`.** It returns `(2/r_h) / apv`, the + **average efficiency factor** — the harmonic mean of the canonical efficiency factors, i.e. + **A-efficiency**, the measure paired with A-optimality. Verified against an independent eigenvalue + computation on five equireplicate designs (exact to machine precision); nothing in `R/` on `main` + calls `eigen()`, so it cannot be computing an E-efficiency (the *minimum* canonical efficiency + factor). `summary()`'s "A-efficiency" label is correct. + + The confusion is a symbol collision: Williams & Piepho write the average efficiency factor as **`E`** + (for **E**fficiency, often `E_A`) — see `Mario speed-eg3-jac12463.R`, *"The average efficiency factor + is E = 0.411"*. That `E` is not E-optimality. Separately, when all canonical efficiency factors are + equal (Latin squares, BIBDs) A-, D- and E-efficiency coincide exactly, so agreeing with another + package on one design proves nothing about which criterion it used. Name the synonym in the docs so + this doesn't recur. --- -## Corrections to my own earlier findings +## A5. Corrections that still matter + +Corrections to superseded findings have been dropped along with the findings. These bear on open items. | Earlier claim | Corrected | |---|---| -| Piepho goes **3.569 → 2.531**; the branch fixes it | **Invalid comparison** — `main` measured row-major, branch column-major. On the same row-major input the branch gives **14.53**. Corrected table in G2. | -| The branch's piepho refactor is a clean bug fix | **No** — it fixes NB/ED and breaks balance/adjacency via the line-244 write-back (G2). Net regression until that line changes. | -| **Rank** the coordinates to handle buffer offsets | **Unsafe** — ranking destroys real physical gaps and silently changes which plots are neighbours (measured: adjacency 2 → 4). Validate instead; see G3 and D6. | -| `calculate_nb()` stringifies `NA` into a literal `"NA,A"` pair | **Wrong for the `pair_mapping` path** — NA pairs are dropped cleanly. The no-mapping path errors instead (G4). | -| Non-numeric coordinate labels are a new fragility | **No** — both old and new approaches warn identically; pre-existing shared limitation (G3). | -| Adjacency scoring is broken for all non-square `speed()` designs | **Was wrong**, and was already corrected before this consolidation. `speed()`'s row-major sort matches `byrow = TRUE`. The genuine defects are piepho, direct calls, and lexical factor levels (A5). | -| G1: **two** functions assume opposite orderings | **Undercount** — it is four. `calculate_efficiency_factor()` (G7) and `.neighbour_balance()` (S1) do the same thing and were outside the reviewed diff. Table in G1 corrected. | -| G6 covers `calculate_efficiency_factor()`'s grid problem, and it "fails safely" | **Only half.** G6 is the sparse case, where it errors and the wrapper degrades cleanly. On a dense non-square grid in the wrong order it returns a wrong number silently (G7) — including values `> 1`. "Fails safely" is true of G6, not of the function. | -| A4.3 (call sites) is ✅ done | **Two of three sites only.** `.neighbour_balance()` was listed as conditional and never done; `calculate_efficiency_factor()` was not listed at all. Split into A4.3a/b/c. | -| S-D1 option A is blocked on `build_design_matrix()` reaching `main` | **No longer** — it is on this branch (A4.1). The `bugfix/summary-neighbour-balance` plan in `REVIEW-NOTES-SUMMARY.md` is stale; S1 folds in here, and is now done. | -| G7: an efficiency factor `> 1` is a canary for the ordering bug | **Too narrow.** `> 1` signals rank deficiency, whichever way it arises. Measured: degenerate fixtures where treatment is confounded with row (which is what `initialise_design_df(rep(LETTERS[1:k], m), ...)` produces — see G9) return values `> 1` in **row-major** order too, on `main`. It is a canary for "something is wrong", not specifically for ordering. | -| G8 is "harmless for equireplicate designs" | **Stands, re-verified.** On a properly randomised equireplicate 3×8 design `calculate_efficiency_factor()` matched the harmonic mean of the canonical efficiency factors exactly (0.7040535). Earlier doubt came from degenerate fixtures (G9), not from G8. | +| **Rank** the coordinates and you destroy real physical gaps, so validate instead | **Half right, and the wrong half won.** Ranking does collapse genuine gaps — that cost stands and is recorded in D6 — but collapsing them is what the analysis does, so it is the correct behaviour for a design metric. D6 decided ranked; the raw implementation is now G10. | +| D6 recommendation: raw coordinates everywhere, plus renumbering inside `add_buffers()` | **Overturned by D6.** Rank downstream in `build_design_matrix()`; renumbering inside `add_buffers()` becomes optional tidying, not a fix. | +| An efficiency factor `> 1` is a canary for the ordering bug (G7) | **Too narrow.** `> 1` signals rank deficiency however it arises. Measured: degenerate fixtures where treatment is confounded with row — which is what `initialise_design_df(rep(LETTERS[1:k], m), ...)` produces, see G9 — return values `> 1` in **row-major** order too, on `main`. It is a canary for "something is wrong", not for ordering specifically. | +| G8 is harmless for equireplicate designs | **Stands, re-verified.** Earlier doubt came from degenerate fixtures (G9), not from G8. | +| `KNOWN_ISSUES` #1b: `calculate_efficiency_factor()` cannot compute post-buffer (G6) | **No longer true** — resolved incidentally by the G7 coordinate fix. Measured in A1.1. | From 69c516d902eba93a7164c2df2344b9e67853275b Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:12:04 +0930 Subject: [PATCH 09/28] Updated plan --- REVIEW-NOTES-OTHER.md | 161 +++++++++++++++++++++++------------------- 1 file changed, 90 insertions(+), 71 deletions(-) diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index 28e05500..3299be45 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -21,12 +21,17 @@ measured, not inferred. Resolved findings are deleted rather than annotated — > `calculate_efficiency_factor()` indexes its own indicator matrices by coordinate. Full suite: > **1668 pass, 0 fail, 0 warn.** > -> 🔴 **One live blocker: G10.** D6 has now been decided as **ranked** coordinates, and the code -> implements **raw**. A row-buffered design currently reports neighbour counts for a layout nobody -> will analyse. This must be settled before the branch merges, because it is a behaviour change either -> way and the NEWS entry currently describes the raw behaviour. +> ✅ **G10 is withdrawn, not fixed.** It said `build_design_matrix()` must **rank** its coordinates to +> satisfy D6. That was the wrong lever: ranking is nothing more than the inverse of the displacement +> `add_buffers()` applies, and inverting it downstream by inference also collapses genuine holes. D6's +> *rationale* stands; its mechanism moved to `feature/buffers`, which undoes the displacement where it +> is created. `build_design_matrix()` stays **raw** — see D6 and A5. > -> ⬜ **Deferred, each its own PR: G8, G9.** Neither is an ordering bug. +> 📦 **`feature/buffers` merges into this branch** (branched off `f5d68f5`). It carries the coordinate +> restoration that makes raw correct, plus the `add_buffers()` deprecation. See A2.1 — nothing here +> should be actioned without reading it, because two items in this file are already done there. +> +> ⬜ **Deferred, each its own PR: G8, G9, and the S3 class collision (A3.1).** None is an ordering bug. --- @@ -64,83 +69,97 @@ All correct: buffers are not part of the statistical design, so the value should design's, and it does. `#1b` recorded this as uncomputable, and `.efficiency_factor()`'s `tryCatch` no longer has a known failure to absorb. +Note the buffered rows of this table become moot once `add_buffers()` leaves speed (D6), but the holed-grid +row does not — a genuine missing plot is a real input class, and it is the reason `#1b` can be closed on +its merits rather than by removing the feature that exposed it. + --- ## A2. Decisions -### 🔷 D6. Do buffers break adjacency? — **decided: ranked coordinates** (2026-08-06) +### 🔷 D6. Do buffers break adjacency? — **settled: buffers never reach the metrics** (2026-08-06) + +**Answer: a buffered design must score exactly as the same design unbuffered.** Plots either side of a +buffer **are** neighbours. + +**Rationale (Sam):** when a buffered trial is *analysed*, the buffer plots are excluded and the model is +fitted on the remaining plots, which treats them as a contiguous grid — adjacent even where they are +physically separated. A design metric should describe the layout the analysis will see, not the physical +field. -**Decision: rank the coordinates, ignoring buffers.** Plots either side of a buffer **are** neighbours. +**Mechanism — decided twice, and the second answer is the one to keep.** The question was originally +framed as raw-vs-ranked coordinates inside `build_design_matrix()`, and answered "ranked". That framing +was wrong. `add_buffers()` displaces the real plots' coordinates to make room (`row + 1` for `"edge"`, +`row * 2` for `"row"`, `3 * row - 1` for `"double row"`, …) and never undoes it. Ranking is *exactly the +inverse of that displacement* — verified for all five buffer types, ranking the de-buffered coordinates +restores the original `1..n` precisely. So ranking was never a statistical position on buffers; it was +an undo, applied by inference, in the wrong place. -**Rationale (Sam, 2026-08-06):** when a buffered trial is *analysed*, the buffer plots are excluded and -the model is fitted on the remaining plots, which treats them as a contiguous grid — adjacent even -where they are physically separated. The design metrics should describe the layout the analysis will -actually see, not the physical field. Matching the analysis is the point of the metric. +Inferring it downstream also cannot tell a buffer from a real hole, so it collapses genuine physical +gaps — a road, an irregular trial edge — for no benefit. + +**So: the displacement is undone where it is created.** `add_buffers()` records what it did in +`metadata$buffer`, and `.drop_buffer_rows()` inverts it before any metric runs (`feature/buffers`, A2.1). +`build_design_matrix()` keeps **raw** coordinates. That satisfies D6 *and* preserves real gaps — +strictly better than ranking, which bought the first at the cost of the second. + +Measured on a 4×3 design with the restoration in place: `"edge"`, `"row"`, `"col"`, `"double row"` and +`"double col"`, and stacked combinations, all reproduce the unbuffered design's neighbour balance, +replicate span and efficiency exactly. + +**Longer term this stops being speed's problem at all.** `add_buffers()` is deprecated as of 0.0.10 and +moving to \pkg{biometryassist} (see `BUFFERS-HANDOFF.md`). Once it is gone, speed never creates a +displacement, so the restoration goes too and raw coordinates are simply correct with nothing to undo. This also settles S3 in `REVIEW-NOTES-SUMMARY.md`: `main`'s `length(unique(...))` behaviour was the -right *behaviour*, and the only real defect there was that the choice had never been stated. It is now -stated here. +right *behaviour*; the defect was that the choice had never been stated. + +G4's `NA` tolerance stays necessary regardless — a design with a genuine partial hole still produces a +sparse grid under raw coordinates. -**Cost, accepted:** ranking cannot distinguish a buffer from a genuine hole, so a real physical gap — a -road, an irregular trial edge — is also collapsed, and two plots either side of it are counted as -neighbours. A design with a partial hole (one missing plot mid-row) still produces a sparse grid after -ranking, so G4's `NA` tolerance stays necessary. +### A2.1 What arrives when `feature/buffers` merges -Rejected alternative — **raw coordinates**, gaps preserved. Agronomically the more literal reading, and -what is implemented today, but it describes a layout no analysis uses. See G10. +Branched off `f5d68f5`, so it applies cleanly. Two items below are already done there — **do not action +them again**: + +| From `feature/buffers` | Effect here | +|---|---| +| `metadata$buffer` transform record in `add_buffers()`, inverted by `.drop_buffer_rows()` / `.restore_buffer_coords()` | makes D6 true without touching `build_design_matrix()` | +| `test-summary.R` buffer test rewritten | **fixes the stale comment at [test-summary.R:304](tests/testthat/test-summary.R#L304)**, which still claims a `"row"` buffer should change the counts. Now asserts every buffer type and stacked combinations match the unbuffered design | +| `add_buffers()` deprecation warning + `## Deprecations` NEWS section | buffers are leaving speed; `BUFFERS-HANDOFF.md` specifies the biometryassist side | +| `.warn_if_buffers()` in `calculate_adjacency_score()`, `calculate_balance_score()`, `calculate_efficiency_factor()` | a direct metric call on a buffered frame bypasses `.drop_buffer_rows()`, so it warns rather than silently scoring the displaced layout | +| `helper-buffers.R` with `add_buffers_quiet()`, and 45 rewritten test call sites | keeps the deprecation warning out of tests that are about layout | + +One caveat carried forward: the `metadata$buffer` record is an affine `scale`/`shift` pair, which covers +speed's buffer types but **cannot** represent biometryassist's `by =` block buffers, where gaps appear +only at group boundaries. It would need to become a per-axis `new -> old` lookup if speed ever had to +invert one of those. Under the handoff plan it never does. --- ## A3. Open findings -### G10 🔴 `build_design_matrix()` uses raw coordinates, contradicting D6 - -The branch implemented D6 in the **raw** direction before it was decided, and the decision went the -other way. Everything about reading coordinates instead of row order is correct and stays; only the -treatment of gaps is wrong. - -`build_design_matrix()` ([R/design_utils.R](R/design_utils.R)) places plots at `max(rows)` × `max(cols)` -and comments that coordinates are *"deliberately not renumbered"*. `add_buffers()` never undoes its own -offset — it does `design$row <- design$row + 1` for `"edge"` and `design$row <- 2 * design$row` for -`"row"` ([R/buffers.R:24-47](R/buffers.R#L24-L47)) — so a de-buffered design arrives with -non-contiguous coordinates. - -**Measured** on the 4×3 design, `add_buffers("row")` then buffer rows dropped (inner rows 2, 4, 6, 8): - -| Coordinates | self-adj | pair min / max | -|---|---|---| -| raw (today) | 0 | **2 / 3** | -| ranked (D6) | 0 | **5 / 6** | -| the same design unbuffered | 0 | 5 / 6 | - -Ranked reproduces the unbuffered design exactly, which is the correct answer under D6 — adding buffers -around a design does not change which of its plots neighbour each other in the analysis. Raw drops -every row-direction adjacency, because every pair of design rows is separated by an empty row. - -`add_buffers("edge")` offsets without inserting gaps, so it is already correct under both conventions -(measured: self 0, min/max 5/6, matching unbuffered). - -**Fix:** rank inside `build_design_matrix()` — `rows <- match(rows, sort(unique(rows)))`, likewise -`cols`, after the existing validation and before `max()`. One function, as A4.1 originally predicted. -Then: - -- `calculate_efficiency_factor()` indexes `Z` by raw coordinate too. It happens to return the right - value anyway (A1.1), because empty indicator columns contribute nothing, but it should rank for - consistency and to keep `ZtZ` non-singular rather than leaning on the `kappa()` fallback. -- **`test-summary.R`'s buffer test comment is now wrong.** It currently asserts that `"edge"` counts - match the unbuffered design *"(A `row` or `block` buffer does insert a gap, and there the counts are - expected to differ: plots either side of a buffer are not neighbours.)"* Under D6 a `"row"` buffer - must **not** change the counts either. Add that as a positive assertion — it is the test that pins - D6. -- **The NEWS bullet must lose its second sentence.** *"Neighbour balance is now read from the plot - coordinates, so plots separated by a buffer row or column are no longer counted as neighbours"* - describes the rejected convention. -- Re-check the `?add_buffers` docs and the buffer vignette section for any claim that buffers separate - plots for scoring purposes. - -Whether to *also* renumber inside `add_buffers()` is now moot for metrics — ranking downstream makes it -unnecessary — but it would still make `print()`/`autoplot()` coordinates tidier. Separate question, -separate branch. +### A3.1 🟠 Both packages register S3 methods on class `"design"` + +Not an ordering bug and not buffer-specific, but it surfaced from this work and needs its own branch. +speed and \pkg{biometryassist} both use `class(x) == c("design", "list")`, and speed registers methods on +a class name it does not own. **Measured** with both loaded, calling on a **biometryassist** design: + +| Call | Result | +|---|---| +| `summary(des)` | speed's `summary.design` runs → `"This design has no metadata; ... Re-run speed()"` | +| `print(des)` | speed's `print.design` runs → prints `"Optimised Experimental Design"`; wrong, and silent | +| `autoplot(des)` | last package loaded wins (`Registered S3 method overwritten by ...`) | + +biometryassist defines neither `print.design` nor `summary.design`, so speed's capture its objects +unopposed. `print()` is the worst of the three: no error, just plausible wrong output. + +**Fix:** one line at [R/speed.R:441](R/speed.R#L441) — +`class(output) <- c("speed_design", "design", class(output))` — then rename speed's four registrations to +`autoplot.speed_design`, `print.speed_design`, `summary.speed_design`, `print.summary.speed_design`. +Keep `"design"` in the vector so `inherits(x, "design")` still works. This also gives the biometryassist +adapter its discriminator (`inherits(x, "speed_design")`), replacing a `[["design_df"]]` sniff — see +`BUFFERS-HANDOFF.md` change 5. ### G9 🟠 `initialise_design_df()` fills `items` down columns, and nothing says so @@ -228,8 +247,7 @@ Statistical, not orientation. Pair it with the upper-bound work (A4). **415 µs/build** vs `build_design_matrix()` **1180 µs/build** — **2.84×**, about 7.6 s extra per 10,000 iterations per level. Real but not disqualifying, and avoidable: the row/col vectors never change during the SA loop, only `swap` does, so the validated `cbind(rows, cols)` index can be built - once per level and passed in. Do it after correctness, and benchmark rather than assume. Note G10's - ranking is also loop-invariant and belongs in the same hoist. + once per level and passed in. Do it after correctness, and benchmark rather than assume. - **A terminology sentence for `?calculate_efficiency_factor`.** It returns `(2/r_h) / apv`, the **average efficiency factor** — the harmonic mean of the canonical efficiency factors, i.e. **A-efficiency**, the measure paired with A-optimality. Verified against an independent eigenvalue @@ -252,8 +270,9 @@ Corrections to superseded findings have been dropped along with the findings. Th | Earlier claim | Corrected | |---|---| -| **Rank** the coordinates and you destroy real physical gaps, so validate instead | **Half right, and the wrong half won.** Ranking does collapse genuine gaps — that cost stands and is recorded in D6 — but collapsing them is what the analysis does, so it is the correct behaviour for a design metric. D6 decided ranked; the raw implementation is now G10. | -| D6 recommendation: raw coordinates everywhere, plus renumbering inside `add_buffers()` | **Overturned by D6.** Rank downstream in `build_design_matrix()`; renumbering inside `add_buffers()` becomes optional tidying, not a fix. | +| **Rank** the coordinates and you destroy real physical gaps, so validate instead | **Right, and it survived a detour.** Briefly overruled in favour of ranking; reinstated once it was clear that ranking is only the inverse of `add_buffers()`' displacement. Undo the displacement at its source and raw coordinates preserve genuine gaps at no cost. See D6. | +| D6 is a statistical question about whether buffers separate plots | **Not really.** It looked like one, but the only thing making a buffered design score differently was `add_buffers()` rewriting the real plots' coordinates. Verified: ranking the de-buffered coordinates restores the original `1..n` exactly, for all five buffer types. The statistical question is settled trivially — buffers must not change anything — and the rest was an implementation leak. | +| **G10** — `build_design_matrix()` must rank its coordinates to satisfy D6 | **Withdrawn, do not implement.** Ranking in `build_design_matrix()` would infer the undo in the wrong place and collapse real holes as collateral. `feature/buffers` records the displacement in `metadata$buffer` and inverts it in `.drop_buffer_rows()` instead; `build_design_matrix()` stays raw. G10's other three sub-items are also resolved: the `test-summary.R` comment is rewritten on that branch, the NEWS sentence has already been removed here, and `calculate_efficiency_factor()` needs no change (A1.1). | | An efficiency factor `> 1` is a canary for the ordering bug (G7) | **Too narrow.** `> 1` signals rank deficiency however it arises. Measured: degenerate fixtures where treatment is confounded with row — which is what `initialise_design_df(rep(LETTERS[1:k], m), ...)` produces, see G9 — return values `> 1` in **row-major** order too, on `main`. It is a canary for "something is wrong", not for ordering specifically. | | G8 is harmless for equireplicate designs | **Stands, re-verified.** Earlier doubt came from degenerate fixtures (G9), not from G8. | | `KNOWN_ISSUES` #1b: `calculate_efficiency_factor()` cannot compute post-buffer (G6) | **No longer true** — resolved incidentally by the G7 coordinate fix. Measured in A1.1. | From 1d333e9a8fa960b240a463be61812a9920bfe841 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:53:19 +0930 Subject: [PATCH 10/28] Building grid_index outside the loop --- R/calculate_adjacency_score.R | 10 +- R/design_utils.R | 147 +++++++++++++++++----- R/metrics.R | 20 ++- R/speed.R | 23 +++- man/build_design_matrix.Rd | 13 +- man/calculate_adjacency_score.Rd | 8 +- man/grid_index.Rd | 33 +++++ man/objective_function_piepho.Rd | 1 + tests/testthat/test-build_design_matrix.R | 109 ++++++++++++++++ 9 files changed, 321 insertions(+), 43 deletions(-) create mode 100644 man/grid_index.Rd diff --git a/R/calculate_adjacency_score.R b/R/calculate_adjacency_score.R index 02bf15cc..85cbc5fa 100755 --- a/R/calculate_adjacency_score.R +++ b/R/calculate_adjacency_score.R @@ -212,6 +212,10 @@ adjacency_score_vec <- function( #' to `NULL`, which keeps the strict identity match. Pass the raw matrix #' through `prep_relationship()` first; the score functions consume only #' the prepped form. +#' @param grid_index Optional pre-built index from [grid_index()], passed to +#' [build_design_matrix()] to skip coordinate validation. `speed()` supplies +#' one so the annealing loop does not revalidate every iteration; leave it +#' `NULL` for a one-off call. #' #' @return A non-negative numeric value: the number of like-treatment edges #' in the row/column adjacency graph. @@ -253,7 +257,8 @@ calculate_adjacency_score <- function( ring_dists = 1, ring_weights = 1, ring_type = c("manhattan", "chebyshev"), - relationship = NULL + relationship = NULL, + grid_index = NULL ) { ring_type <- match.arg(ring_type) @@ -261,7 +266,8 @@ calculate_adjacency_score <- function( layout_df, swap, row_column = row_column, - col_column = col_column + col_column = col_column, + index = grid_index ) per_cell <- adjacency_score_vec( diff --git a/R/design_utils.R b/R/design_utils.R index f083b402..edde5664 100644 --- a/R/design_utils.R +++ b/R/design_utils.R @@ -864,50 +864,71 @@ random_initialise <- function(design, optimise, seed = NULL, ...) { #' @export initialize_design_df <- initialise_design_df -#' Build a Spatial Design Matrix from a Data Frame +#' Validate a Design's Coordinates and Build its Grid Index #' #' @description -#' Places each treatment value at the grid position given by its `row_column` -#' and `col_column` coordinates, returning a character matrix of dimensions -#' `max(row)` by `max(col)`. Cells with no corresponding row in `df` are `NA`. +#' Coerces and validates the `row_column`/`col_column` coordinates, returning +#' everything [build_design_matrix()] needs to place plots on a grid: the +#' two-column matrix index and the grid's dimensions. #' -#' Unlike filling via `matrix(..., byrow = )`, this reads the coordinates rather -#' than assuming an ordering, so it is correct for any row ordering of `df` and -#' for factor coordinate columns whose level order is not numeric. -#' -#' Coordinates are used as-is, never renumbered: a gap in the coordinates is a -#' real gap in the field (a missing plot, or a buffer that was removed), so -#' collapsing it would make non-adjacent plots into neighbours. Callers must -#' therefore cope with `NA` cells. +#' Split out from [build_design_matrix()] because it is the expensive half and +#' the *invariant* half. During annealing only the treatment column changes - +#' the coordinates never do - so the index can be built once per `speed()` run +#' and reused for every iteration. Measured on a 700-plot design, validation and +#' coercion are ~87% of the cost of a grid build. #' -#' @param df A data frame with columns named by `swap`, `row_column`, -#' `col_column`. -#' @param swap Column name of the treatment variable. +#' @param df A data frame with columns named by `row_column` and `col_column`. #' @param row_column Column name of the row position variable (default `"row"`). #' @param col_column Column name of the column position variable #' (default `"col"`). #' -#' @return A character matrix of dimensions `max(row)` by `max(col)`. +#' @return A list with `idx` (an `nrow(df)` x 2 integer matrix of grid +#' positions), `nrow` and `ncol` (the grid's dimensions), and `n` (the number +#' of plots the index was built for, used to detect a stale index). +#' +#' Signal a Coordinate Problem with a Classed Condition +#' +#' The message is for someone calling a metric directly; the class lets +#' [.single_grid()] report the same problem as a short reason in a `summary()` +#' field without matching on message text. Conditions are only built on failure, +#' so the hot path is unaffected. #' +#' @param class Condition subclass naming the specific problem. +#' @param ... Pasted to form the message. #' @keywords internal -build_design_matrix <- function( - df, - swap, - row_column = "row", - col_column = "col" -) { +.grid_stop <- function(class, ...) { + stop(structure( + class = c(class, "speed_grid_error", "error", "condition"), + list(message = paste0(...), call = NULL) + )) +} + +#' @keywords internal +grid_index <- function(df, row_column = "row", col_column = "col") { + # Checked before coercion: absent columns would otherwise reach max() as + # empty vectors and yield -Inf dimensions with a warning, rather than saying + # what is wrong. A design with no grid at all reaches here from speed(). + missing_cols <- setdiff(c(row_column, col_column), names(df)) + if (length(missing_cols)) { + .grid_stop( + "speed_grid_missing", + "Cannot place the design on a grid: no ", + paste0("`", missing_cols, "`", collapse = " or "), + " column." + ) + } # Coercion of non-numeric labels warns; the check below reports it properly. rows <- suppressWarnings(as_numeric_factor(df[[row_column]])) cols <- suppressWarnings(as_numeric_factor(df[[col_column]])) if (anyNA(rows) || anyNA(cols)) { - stop( + .grid_stop( + "speed_grid_nonnumeric", "Cannot place the design on a grid: `", row_column, "` and `", col_column, - "` must be numeric, or coercible to numeric.", - call. = FALSE + "` must be numeric, or coercible to numeric." ) } # Used directly as matrix indices, so they must be positive whole numbers. @@ -915,35 +936,95 @@ build_design_matrix <- function( any(rows < 1 | cols < 1) || any(rows != trunc(rows) | cols != trunc(cols)) ) { - stop( + .grid_stop( + "speed_grid_notinteger", "`", row_column, "` and `", col_column, - "` must be positive whole numbers to index a grid.", - call. = FALSE + "` must be positive whole numbers to index a grid." ) } idx <- cbind(rows, cols) # Duplicated coordinates would silently overwrite each other. Multi-site # designs reuse row/col per site, so they must be split before scoring. if (anyDuplicated(idx)) { - stop( + .grid_stop( + "speed_grid_duplicate", "Duplicate (", row_column, ", ", col_column, ") coordinates: the design cannot be placed on a single grid. ", - "Split multi-site designs by site first.", + "Split multi-site designs by site first." + ) + } + + return(list( + idx = idx, + nrow = max(rows), + ncol = max(cols), + n = nrow(df) + )) +} + +#' Build a Spatial Design Matrix from a Data Frame +#' +#' @description +#' Places each treatment value at the grid position given by its `row_column` +#' and `col_column` coordinates, returning a character matrix of dimensions +#' `max(row)` by `max(col)`. Cells with no corresponding row in `df` are `NA`. +#' +#' Unlike filling via `matrix(..., byrow = )`, this reads the coordinates rather +#' than assuming an ordering, so it is correct for any row ordering of `df` and +#' for factor coordinate columns whose level order is not numeric. +#' +#' Coordinates are used as-is, never renumbered: a gap in the coordinates is a +#' real gap in the field (a missing plot, or a buffer that was removed), so +#' collapsing it would make non-adjacent plots into neighbours. Callers must +#' therefore cope with `NA` cells. +#' +#' @param df A data frame with columns named by `swap`, `row_column`, +#' `col_column`. +#' @param swap Column name of the treatment variable. +#' @param row_column Column name of the row position variable (default `"row"`). +#' @param col_column Column name of the column position variable +#' (default `"col"`). +#' @param index Optional pre-built index from [grid_index()]. Supplying one skips +#' coordinate coercion and validation, which is the bulk of the work and is +#' invariant during annealing. `speed()` builds one per run; anything calling +#' this once should leave it `NULL`. +#' +#' @return A character matrix of dimensions `max(row)` by `max(col)`. +#' +#' @keywords internal +build_design_matrix <- function( + df, + swap, + row_column = "row", + col_column = "col", + index = NULL +) { + if (is.null(index)) { + index <- grid_index(df, row_column, col_column) + } else if (!identical(index$n, nrow(df))) { + # A stale index would place treatments at the wrong coordinates silently, + # so the one cheap consistency check is worth keeping. + stop( + "`index` was built for ", + index$n, + " plots but `df` has ", + nrow(df), + ". Rebuild it with `grid_index()`.", call. = FALSE ) } design_matrix <- matrix( NA_character_, - nrow = max(rows), - ncol = max(cols) + nrow = index$nrow, + ncol = index$ncol ) - design_matrix[idx] <- as.character(df[[swap]]) + design_matrix[index$idx] <- as.character(df[[swap]]) return(design_matrix) } diff --git a/R/metrics.R b/R/metrics.R index c3bc152f..9e554d53 100644 --- a/R/metrics.R +++ b/R/metrics.R @@ -58,7 +58,13 @@ objective_function <- function(layout_df, ring_args <- list(...) ring_args <- ring_args[intersect( names(ring_args), - c("ring_dists", "ring_weights", "ring_type", "relationship") + c( + "ring_dists", + "ring_weights", + "ring_type", + "relationship", + "grid_index" + ) )] adj_score <- ifelse(adj_weight != 0, do.call( @@ -230,12 +236,14 @@ objective_function_piepho <- function(design, pair_mapping = NULL, row_column = "row", col_column = "col", + grid_index = NULL, ...) { design_matrix <- build_design_matrix( design, swap, row_column = row_column, - col_column = col_column + col_column = col_column, + index = grid_index ) ed <- calculate_ed(design_matrix, current_score_obj$ed, swapped_items) @@ -250,7 +258,13 @@ objective_function_piepho <- function(design, # and would otherwise scramble the treatments against their coordinates. design[[swap]] <- as.factor(design[[swap]]) bal_score <- calculate_balance_score(design, swap, spatial_cols) - adj_score <- calculate_adjacency_score(design, swap, row_column, col_column) + adj_score <- calculate_adjacency_score( + design, + swap, + row_column, + col_column, + grid_index = grid_index + ) return(list( score = round(nb_score + ed_score + bal_score + adj_score, 10), diff --git a/R/speed.R b/R/speed.R index 1b35bd5c..452f2bba 100644 --- a/R/speed.R +++ b/R/speed.R @@ -259,6 +259,23 @@ speed_hierarchical <- function(data, optimise, quiet, seed, ...) { current_design <- layout_df best_design <- current_design + # Only the treatment column changes during annealing, so the validated grid + # index is invariant for the whole run and is built once here rather than on + # every iteration - it is ~87% of the cost of a grid build. Built with + # tryCatch so this stays lazy: a design whose coordinates cannot form a grid + # (duplicates from a MET, non-numeric labels) must still run if its objective + # never needs a grid, and must still raise the same error from the same place + # if it does. `NULL` restores exactly the old behaviour. + dots <- list(...) + grid_idx <- tryCatch( + grid_index( + current_design, + dots$row_column %||% "row", + dots$col_column %||% "col" + ), + error = function(e) return(NULL) + ) + # Sequential optimisation for each hierarchy level all_scores <- list() all_temperatures <- list() @@ -279,7 +296,7 @@ speed_hierarchical <- function(data, optimise, quiet, seed, ...) { # Calculate initial score for this level current_score_obj <- opt$obj_function(current_design, opt$swap, spatial_cols, adj_weight = adj_weight, - bal_weight = bal_weight, ...) + bal_weight = bal_weight, grid_index = grid_idx, ...) current_score <- current_score_obj$score if (!is.numeric(current_score)) { @@ -313,7 +330,7 @@ speed_hierarchical <- function(data, optimise, quiet, seed, ...) { # Calculate new score new_score_obj <- opt$obj_function(new_design$design,opt$swap, spatial_cols, adj_weight = adj_weight, bal_weight = bal_weight, current_score_obj = current_score_obj, - swapped_items = new_design$swapped_items, ...) + swapped_items = new_design$swapped_items, grid_index = grid_idx, ...) new_score <- new_score_obj$score # Decide whether to accept the new design @@ -379,7 +396,7 @@ speed_hierarchical <- function(data, optimise, quiet, seed, ...) { # report faithful score components. treatments[[level]] <- stringi::stri_sort(unique(as.vector(best_design[[opt$swap]])), numeric = TRUE) score_obj <- opt$obj_function(best_design, opt$swap, spatial_cols, adj_weight = adj_weight, - bal_weight = bal_weight, ...) + bal_weight = bal_weight, grid_index = grid_idx, ...) level_scores[level] <- score_obj$score per_level_meta[[level]] <- list( swap = opt$swap, diff --git a/man/build_design_matrix.Rd b/man/build_design_matrix.Rd index f6540282..4feb829b 100644 --- a/man/build_design_matrix.Rd +++ b/man/build_design_matrix.Rd @@ -4,7 +4,13 @@ \alias{build_design_matrix} \title{Build a Spatial Design Matrix from a Data Frame} \usage{ -build_design_matrix(df, swap, row_column = "row", col_column = "col") +build_design_matrix( + df, + swap, + row_column = "row", + col_column = "col", + index = NULL +) } \arguments{ \item{df}{A data frame with columns named by \code{swap}, \code{row_column}, @@ -16,6 +22,11 @@ build_design_matrix(df, swap, row_column = "row", col_column = "col") \item{col_column}{Column name of the column position variable (default \code{"col"}).} + +\item{index}{Optional pre-built index from \code{\link[=grid_index]{grid_index()}}. Supplying one skips +coordinate coercion and validation, which is the bulk of the work and is +invariant during annealing. \code{speed()} builds one per run; anything calling +this once should leave it \code{NULL}.} } \value{ A character matrix of dimensions \code{max(row)} by \code{max(col)}. diff --git a/man/calculate_adjacency_score.Rd b/man/calculate_adjacency_score.Rd index 0eac7010..186a7483 100644 --- a/man/calculate_adjacency_score.Rd +++ b/man/calculate_adjacency_score.Rd @@ -12,7 +12,8 @@ calculate_adjacency_score( ring_dists = 1, ring_weights = 1, ring_type = c("manhattan", "chebyshev"), - relationship = NULL + relationship = NULL, + grid_index = NULL ) } \arguments{ @@ -41,6 +42,11 @@ otherwise. NA-padded cells off the design edge contribute \code{0}. Defaults to \code{NULL}, which keeps the strict identity match. Pass the raw matrix through \code{prep_relationship()} first; the score functions consume only the prepped form.} + +\item{grid_index}{Optional pre-built index from \code{\link[=grid_index]{grid_index()}}, passed to +\code{\link[=build_design_matrix]{build_design_matrix()}} to skip coordinate validation. \code{speed()} supplies +one so the annealing loop does not revalidate every iteration; leave it +\code{NULL} for a one-off call.} } \value{ A non-negative numeric value: the number of like-treatment edges diff --git a/man/grid_index.Rd b/man/grid_index.Rd new file mode 100644 index 00000000..1eeac106 --- /dev/null +++ b/man/grid_index.Rd @@ -0,0 +1,33 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/design_utils.R +\name{grid_index} +\alias{grid_index} +\title{Validate a Design's Coordinates and Build its Grid Index} +\usage{ +grid_index(df, row_column = "row", col_column = "col") +} +\arguments{ +\item{df}{A data frame with columns named by \code{row_column} and \code{col_column}.} + +\item{row_column}{Column name of the row position variable (default \code{"row"}).} + +\item{col_column}{Column name of the column position variable +(default \code{"col"}).} +} +\value{ +A list with \code{idx} (an \code{nrow(df)} x 2 integer matrix of grid +positions), \code{nrow} and \code{ncol} (the grid's dimensions), and \code{n} (the number +of plots the index was built for, used to detect a stale index). +} +\description{ +Coerces and validates the \code{row_column}/\code{col_column} coordinates, returning +everything \code{\link[=build_design_matrix]{build_design_matrix()}} needs to place plots on a grid: the +two-column matrix index and the grid's dimensions. + +Split out from \code{\link[=build_design_matrix]{build_design_matrix()}} because it is the expensive half and +the \emph{invariant} half. During annealing only the treatment column changes - +the coordinates never do - so the index can be built once per \code{speed()} run +and reused for every iteration. Measured on a 700-plot design, validation and +coercion are ~87\% of the cost of a grid build. +} +\keyword{internal} diff --git a/man/objective_function_piepho.Rd b/man/objective_function_piepho.Rd index 699f5090..025dfdaf 100644 --- a/man/objective_function_piepho.Rd +++ b/man/objective_function_piepho.Rd @@ -13,6 +13,7 @@ objective_function_piepho( pair_mapping = NULL, row_column = "row", col_column = "col", + grid_index = NULL, ... ) } diff --git a/tests/testthat/test-build_design_matrix.R b/tests/testthat/test-build_design_matrix.R index 6e384cc1..4946d9be 100644 --- a/tests/testthat/test-build_design_matrix.R +++ b/tests/testthat/test-build_design_matrix.R @@ -173,3 +173,112 @@ test_that("column names are reported in errors", { "`range` and `bed`" ) }) + +test_that("grid_index() returns the index and dimensions build_design_matrix needs", { + d <- initialise_design_df(rep(LETTERS[1:6], 4), 3, 8) + gi <- grid_index(d) + + expect_named(gi, c("idx", "nrow", "ncol", "n")) + expect_equal(gi$nrow, 3) + expect_equal(gi$ncol, 8) + expect_equal(gi$n, nrow(d)) + expect_equal(dim(gi$idx), c(nrow(d), 2L)) +}) + +test_that("a supplied index gives the same grid as validating in place", { + # The index is the only thing hoisted out of the annealing loop, so the two + # paths must be indistinguishable. + for (dims in list(c(3, 8), c(8, 3), c(4, 4), c(2, 6))) { + d <- initialise_design_df(rep(LETTERS[1:4], prod(dims) / 4), dims[[1]], dims[[2]]) + expect_equal( + build_design_matrix(d, "treatment", index = grid_index(d)), + build_design_matrix(d, "treatment"), + info = paste(dims, collapse = "x") + ) + } +}) + +test_that("a supplied index survives the treatment column being reshuffled", { + # What the annealing loop actually does: same coordinates, different treatments. + d <- initialise_design_df(rep(LETTERS[1:6], 4), 3, 8) + gi <- grid_index(d) + set.seed(1) + d$treatment <- sample(d$treatment) + + expect_equal( + build_design_matrix(d, "treatment", index = gi), + build_design_matrix(d, "treatment") + ) +}) + +test_that("a supplied index is checked against the design it is used with", { + # A stale index would place treatments at the wrong coordinates silently. + d <- initialise_design_df(rep(LETTERS[1:6], 4), 3, 8) + gi <- grid_index(d) + + expect_error( + build_design_matrix(d[1:10, ], "treatment", index = gi), + "was built for 24 plots but `df` has 10" + ) +}) + +test_that("calculate_adjacency_score() accepts a pre-built index", { + d <- initialise_design_df(rep(LETTERS[1:6], 4), 3, 8) + expect_equal( + calculate_adjacency_score(d, "treatment", grid_index = grid_index(d)), + calculate_adjacency_score(d, "treatment") + ) +}) + +test_that("speed() scores identically whether or not the index is hoisted", { + # The hoist is a performance change only; guard against it becoming a + # behaviour change. objective_function_piepho() builds a grid every iteration, + # so it is the strongest case. + d <- initialise_design_df(rep(LETTERS[1:6], 4), 3, 8) + args <- list( + swap = "treatment", swap_within = "1", spatial_factors = ~ row + col, + iterations = 200, seed = 42, quiet = TRUE + ) + hoisted <- do.call(speed, c(list(d), args)) + # Calling the objective directly takes the un-hoisted path (index = NULL). + direct <- objective_function( + hoisted$design_df, "treatment", c("row", "col") + ) + expect_equal(direct$score, hoisted$score) + + hoisted_p <- do.call(speed, c(list(d), args, list(obj_function = objective_function_piepho))) + direct_p <- objective_function_piepho( + hoisted_p$design_df, "treatment", c("row", "col") + ) + expect_equal(direct_p$score, hoisted_p$score) +}) + +test_that("a design whose coordinates cannot form a grid still runs when no grid is needed", { + # The index is built lazily (tryCatch -> NULL) so that a design which never + # needs a grid is unaffected. Two sites reusing row/col have duplicate + # coordinates, which grid_index() rejects. + d <- data.frame( + site = rep(c("A", "B"), each = 12), + row = rep(rep(1:4, times = 3), 2), + col = rep(rep(1:3, each = 4), 2), + treatment = rep(rep(LETTERS[1:3], 4), 2) + ) + expect_error(grid_index(d), "Duplicate") + expect_no_error( + r <- speed( + d, swap = "treatment", swap_within = "site", + spatial_factors = ~ row + col + site, iterations = 100, seed = 1, + quiet = TRUE, optimise_params = optim_params(adj_weight = 0) + ) + ) + expect_equal(r$score, 4) +}) + +test_that("grid_index() names a missing coordinate column", { + # A design with no grid at all reaches here from speed(); without this check + # the absent columns reach max() as empty vectors and give -Inf dimensions. + d <- data.frame(a = 1:4, b = 1:4, treatment = LETTERS[1:4]) + + expect_error(grid_index(d), "no `row` or `col` column") + expect_error(grid_index(d, "row", "b"), "no `row` column") +}) From 63520b3325b65d744aa23a24594a4156966b4bd6 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:03:31 +0930 Subject: [PATCH 11/28] Fixing summary errors on METs --- NEWS.md | 3 + R/design_utils.R | 36 ++-- R/summary.R | 103 ++++++++-- REVIEW-NOTES-OTHER.md | 340 +++++++++++++++++++--------------- man/dot-grid_stop.Rd | 20 ++ man/dot-neighbour_balance.Rd | 8 +- man/dot-single_grid.Rd | 34 ++++ man/summary.design.Rd | 4 +- tests/testthat/test-summary.R | 168 ++++++++++++++++- 9 files changed, 529 insertions(+), 187 deletions(-) create mode 100644 man/dot-grid_stop.Rd create mode 100644 man/dot-single_grid.Rd diff --git a/NEWS.md b/NEWS.md index cef809b4..54f71b10 100644 --- a/NEWS.md +++ b/NEWS.md @@ -14,6 +14,9 @@ `calculate_adjacency_score()`, `calculate_efficiency_factor()`, `objective_function_piepho()` and `summary()`'s neighbour balance; designs generated with `objective_function_piepho()` should be regenerated. +- `summary()` no longer errors on designs that cannot be placed on a single grid, such as multi-site + (MET) designs or designs with non-numeric `row`/`col` labels. The affected diagnostics report why + they are unavailable instead, and are no longer computed from pooled grids. - `calculate_nb()` no longer errors on designs with missing plots when `pair_mapping` is not supplied. - `calculate_adjacency_score()` now recycles a single `ring_weights` value across every entry of diff --git a/R/design_utils.R b/R/design_utils.R index edde5664..ae1c9fe2 100644 --- a/R/design_utils.R +++ b/R/design_utils.R @@ -864,6 +864,25 @@ random_initialise <- function(design, optimise, seed = NULL, ...) { #' @export initialize_design_df <- initialise_design_df +#' Signal a Coordinate Problem with a Classed Condition +#' +#' @description +#' The message is for someone calling a metric directly; the class lets +#' `.single_grid()` report the same problem as a short reason in a `summary()` +#' field, without matching on message text. Conditions are only constructed on +#' failure, so the hot path is unaffected. +#' +#' @param class Condition subclass naming the specific problem. +#' @param ... Pasted together to form the message. +#' +#' @keywords internal +.grid_stop <- function(class, ...) { + stop(structure( + class = c(class, "speed_grid_error", "error", "condition"), + list(message = paste0(...), call = NULL) + )) +} + #' Validate a Design's Coordinates and Build its Grid Index #' #' @description @@ -886,23 +905,6 @@ initialize_design_df <- initialise_design_df #' positions), `nrow` and `ncol` (the grid's dimensions), and `n` (the number #' of plots the index was built for, used to detect a stale index). #' -#' Signal a Coordinate Problem with a Classed Condition -#' -#' The message is for someone calling a metric directly; the class lets -#' [.single_grid()] report the same problem as a short reason in a `summary()` -#' field without matching on message text. Conditions are only built on failure, -#' so the hot path is unaffected. -#' -#' @param class Condition subclass naming the specific problem. -#' @param ... Pasted to form the message. -#' @keywords internal -.grid_stop <- function(class, ...) { - stop(structure( - class = c(class, "speed_grid_error", "error", "condition"), - list(message = paste0(...), call = NULL) - )) -} - #' @keywords internal grid_index <- function(df, row_column = "row", col_column = "col") { # Checked before coercion: absent columns would otherwise reach max() as diff --git a/R/summary.R b/R/summary.R index 32560766..09ebc9ff 100644 --- a/R/summary.R +++ b/R/summary.R @@ -73,7 +73,9 @@ #' #' @returns A list of class `"summary.design"`: #' - **hierarchical** - `TRUE` for a multi-level (e.g. split-plot) design. -#' - **layout** - `n_plots`, `nrow`, `ncol`, `row_column`, `col_column`, `has_grid`. +#' - **layout** - `n_plots`, `nrow`, `ncol`, `row_column`, `col_column`, +#' `has_grid` (`TRUE` when the design is reportable as a single grid), and +#' `grid_reason` (why not, or `NA`). `nrow`/`ncol` are `NA` unless `has_grid`. #' - **levels** - character vector of level names (e.g. `"wp"`/`"sp"`; a single #' name for a simple design). #' - **per_level** - one element per level (named by `levels`), each a list with: @@ -135,14 +137,20 @@ summary.design <- function( want_neighbour <- is.null(neighbour) || isTRUE(neighbour) - has_grid <- all(c(rc, cc) %in% names(df)) + # `has_grid` means "reportable as one grid", not merely "row/col columns + # exist". A design with duplicate coordinates spans several grids of possibly + # different shapes, so no single `nrow` x `ncol` describes it - reporting one + # would claim a layout that holds fewer plots than the design has. + grid_ok <- .single_grid(df, rc, cc) + has_grid <- isTRUE(grid_ok) layout <- list( n_plots = nrow(df), nrow = if (has_grid) length(unique(df[[rc]])) else NA_integer_, ncol = if (has_grid) length(unique(df[[cc]])) else NA_integer_, row_column = rc, col_column = cc, - has_grid = has_grid + has_grid = has_grid, + grid_reason = if (has_grid) NA_character_ else grid_ok ) per_level <- lapply(levels, function(lv) { @@ -229,10 +237,10 @@ summary.design <- function( reason = "not requested (set efficiency = TRUE)" ) }, + # Each grid metric checks its own preconditions via .single_grid(), so a + # design that cannot be gridded reports a reason rather than erroring. neighbour = if (!want_neighbour) { list(available = FALSE, reason = "not requested (neighbour = FALSE)") - } else if (!has_grid) { - list(available = FALSE, reason = "no row/column factors") } else { .neighbour_balance(df, swap, rc, cc) } @@ -679,8 +687,11 @@ print.summary.design <- function(x, ...) { #' @param rc,cc Row and column column names. #' @keywords internal .replicate_spans <- function(df, swap, rc, cc) { - if (!all(c(rc, cc) %in% names(df))) { - return(list(available = FALSE, reason = "no row/column factors")) + # Spans are distances within one grid; across grids they are meaningless + # (two sites' row 3 are not one plot apart), so refuse rather than pool. + ok <- .single_grid(df, rc, cc) + if (!isTRUE(ok)) { + return(list(available = FALSE, reason = ok)) } span1 <- function(x) { if (length(x) < 2) { @@ -712,6 +723,64 @@ print.summary.design <- function(x, ...) { )) } +#' Can This Design Be Placed on a Single Grid? +#' +#' @description +#' Predicate form of [grid_index()], for the diagnostics that need a grid. +#' Returns `TRUE`, or a short reason, so a summary can report one metric as +#' unavailable instead of failing outright. +#' +#' Delegates rather than repeating the coordinate rules, so the two cannot +#' drift: [grid_index()] stays the only place that decides what a valid grid is, +#' and signals which rule failed by condition class. Reasons here are phrased for +#' a summary field, where `grid_index()`'s messages - written for someone calling +#' a metric directly - would read as instructions. +#' +#' Duplicate coordinates usually mean the design occupies more than one grid: a +#' multi-environment trial reuses `row`/`col` per site. Every grid metric is +#' wrong for those, not merely uncomputable, because they pool sites that share +#' no edge. Reporting them as unavailable is the honest answer until the metrics +#' can take a grouping factor. +#' +#' @param df Design data frame. +#' @param rc,cc Row and column column names. +#' +#' @returns `TRUE`, or a length-1 character reason. +#' +#' @keywords internal +.single_grid <- function(df, rc, cc) { + return(tryCatch( + { + grid_index(df, row_column = rc, col_column = cc) + TRUE + }, + speed_grid_error = function(e) { + # Stated as a fact rather than an interpretation: duplicate coordinates + # are usually a multi-site design, but a malformed one looks the same. + return(switch( + class(e)[[1]], + speed_grid_missing = "no row/column factors", + speed_grid_nonnumeric = sprintf( + "`%s`/`%s` labels are not numeric", + rc, + cc + ), + speed_grid_notinteger = sprintf( + "`%s`/`%s` are not positive whole numbers", + rc, + cc + ), + speed_grid_duplicate = sprintf( + "duplicate `%s`/`%s` coordinates (e.g. a multi-site design)", + rc, + cc + ), + conditionMessage(e) + )) + } + )) +} + #' Detect a block-type factor for one level of a design #' #' Among *that level's* spatial factors that are not the row or column factor, @@ -900,8 +969,12 @@ print.summary.design <- function(x, ...) { #' @param rc,cc Row and column column names. #' @keywords internal .efficiency_factor <- function(df, swap, rc, cc) { - if (!all(c(rc, cc) %in% names(df))) { - return(list(available = FALSE, reason = "requires a row/column grid")) + # Not just a guard against erroring: on duplicate coordinates + # calculate_efficiency_factor() pools the grids and silently returns a value + # above 1, which is impossible for an efficiency factor. + ok <- .single_grid(df, rc, cc) + if (!isTRUE(ok)) { + return(list(available = FALSE, reason = ok)) } if (length(unique(df[[swap]])) < 3) { return(list(available = FALSE, reason = "requires >= 3 treatments")) @@ -947,13 +1020,19 @@ print.summary.design <- function(x, ...) { #' #' Coordinates are read as-is, so a design whose plots are separated by a buffer #' row or column (`add_buffers()` offsets and scales them) keeps that separation: -#' plots either side of a buffer are not counted as neighbours. Assumes `rc`/`cc` -#' are present in `df`; callers should check `has_grid` first (see -#' `summary.design()`). +#' plots either side of a buffer are not counted as neighbours. +#' +#' Guarded by `.single_grid()`, so a design that cannot be placed on one grid is +#' reported as unavailable rather than propagating [build_design_matrix()]'s +#' error out of `summary()`. #' #' @param rc,cc Row and column column names. #' @keywords internal .neighbour_balance <- function(df, swap, rc, cc) { + ok <- .single_grid(df, rc, cc) + if (!isTRUE(ok)) { + return(list(available = FALSE, reason = ok)) + } dm <- build_design_matrix(df, swap, row_column = rc, col_column = cc) pair_mapping <- create_pair_mapping(df[[swap]]) nb <- calculate_nb(dm, pair_mapping) diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index 3299be45..b36d466d 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -1,7 +1,10 @@ # Review notes: grid construction and core metrics **Scope:** `R/design_utils.R` (`build_design_matrix()`), `R/calculate_adjacency_score.R`, -`R/metrics.R`. Branch **`bugfix/grid-orientation`** off `main`. +`R/metrics.R`, and `R/summary.R` where it consumes a grid. Branch **`bugfix/grid-orientation`** off `main`. + +G12 and G13 touch `R/summary.R` but belong **here, not in `REVIEW-NOTES-SUMMARY.md`** (Sam, 2026-08-06): +this branch made the grid contract strict, so it owns the consequences of that strictness. **Companion files** — one per workstream: @@ -9,9 +12,14 @@ |---|---| | `REVIEW-NOTES.md` | `feature/incidence` (PR #97) — `R/incidence.R` | | `REVIEW-NOTES-SUMMARY.md` | the merged `summary()` work — `R/summary.R` | +| `REVIEW-NOTES-EFFICIENCY.md` | efficiency-factor statistics — needs a new branch | | `REVIEW-NOTES-PR91.md` | PR #91 `info-objective` | | **this file** | grid construction / core metrics | +Anything needing a branch of its own has been moved out: the A-efficiency upper bound and the missing +intercept to `REVIEW-NOTES-EFFICIENCY.md`; the S3 class collision, the `initialise_design_df()` fill +order and the now-redundant row-major sort to `KNOWN_ISSUES.md` (#2, #3, #4). + **Last verified:** 2026-08-06, R 4.6.1, `pkgload::load_all()`, branch at `f5d68f5`. All numbers measured, not inferred. Resolved findings are deleted rather than annotated — see git history and the `NEWS.md` entries for what they were. @@ -31,7 +39,25 @@ measured, not inferred. Resolved findings are deleted rather than annotated — > restoration that makes raw correct, plus the `add_buffers()` deprecation. See A2.1 — nothing here > should be actioned without reading it, because two items in this file are already done there. > -> ⬜ **Deferred, each its own PR: G8, G9, and the S3 class collision (A3.1).** None is an ordering bug. +> 🔴 **Blocker: G13 — nothing in speed represents a design that occupies more than one grid, so MET is +> broken.** `initialise_design_df(designs = )` reuses `row`/`col` per site, so every MET design has +> duplicate coordinates. `main` coped by silently discarding **30 of 80** plots; this branch errors +> instead. Neither works, and coordinate construction did not cause it — see A3. Grid metrics need a +> grouping dimension. +> +> ✅ **G12 has landed.** `summary()` now reports a grid metric as unavailable with a reason instead of +> erroring, via `.single_grid()` over `grid_index()`'s classed conditions. It does **not** make MET work — +> it stops `summary()` dying and stops `.efficiency_factor()` reporting `1.855529` — so G13 stands. See +> A1.2. +> +> ✅ **G11 has landed.** The grid-construction hot-loop cost this branch introduced is gone: coordinate +> validation is split into `grid_index()` and hoisted once per run, grid build is back to parity with the +> `matrix()` reshape it replaced, and whole `speed()` runs are 10-28% faster with bit-identical scores. +> See A1.1. +> +> ⬜ **Moved out, each needing its own branch:** the A-efficiency upper bound and the missing intercept +> (`REVIEW-NOTES-EFFICIENCY.md`); the S3 class collision, the `initialise_design_df()` fill order, the +> redundant row-major sort and the incremental-grid-mutation option (`KNOWN_ISSUES.md` #2, #3, #4, #6). --- @@ -46,32 +72,99 @@ Kept as a one-line inventory for the PR description. The full write-ups are in g | **G3** `build_design_matrix()` didn't validate coordinates | explicit non-numeric / non-positive-integer / duplicate-coordinate errors | | **G4** `.calculate_nb()` errored on sparse grids, the default path | `NA` neighbours are skipped, matching the `pair_mapping` path | | **G5** a scalar `ring_weights` errored against multi-ring `ring_dists` | recycled across every ring | -| **G6** `calculate_efficiency_factor()` couldn't compute for a buffered design (`KNOWN_ISSUES` #1b) | **resolved as a side effect of G7** — see A1.1 | +| **G6** `calculate_efficiency_factor()` couldn't compute for a buffered design (tracked as `KNOWN_ISSUES` #1b, since removed) | resolved as a side effect of G7 — coordinate indexing absorbs the offset `add_buffers()` introduces, and a genuinely holed grid computes too; verified 2026-08-06 | | **G7** `calculate_efficiency_factor()` filled `Z` positionally, returning a different value per row ordering (0.111 vs 0.625 on a 2×6, and values `> 1`) | `Z` is indexed by each plot's own coordinates | | **S1** `.neighbour_balance()` reported self-adjacencies that didn't exist (6 where the truth was 0) | reads coordinates; the 4×3 fixture now returns the hand-derived truth (self 0, pair min/max 5/6) | -| **A5** lexical factor levels (`1, 10, 11, 2, …`) defeated the row-major sort, so grid metrics scored a layout that wasn't the design | coordinate construction is immune; the sort is no longer load-bearing (see A4) | +| **A5** lexical factor levels (`1, 10, 11, 2, …`) defeated the row-major sort, so grid metrics scored a layout that wasn't the design | coordinate construction is immune; the sort is no longer load-bearing (`KNOWN_ISSUES.md` #4) | +| **G11** coordinate validation ran on every iteration, making grid construction 16× the `matrix()` reshape it replaced | validation split into `grid_index()`, hoisted once per `speed()` run; grid build back to parity with `matrix()`, whole runs 10-28% faster — see A1.1 | +| **G12** `summary()` died outright on any design that couldn't be gridded — a MET design or non-numeric `row`/`col` labels — because `.neighbour_balance()` let `build_design_matrix()`'s error escape | `.single_grid()` gates `.neighbour_balance()`, `.efficiency_factor()` and `.replicate_spans()`, each reporting a reason; `has_grid` now means "reportable as one grid", so `layout` stops claiming an `nrow` x `ncol` that holds fewer plots than the design — see A1.2 | + +### A1.1 G11 — validation hoisted out of the annealing loop + +`build_design_matrix()` gained an optional `index =` argument; the coercion and validation half moved +into `grid_index()`, which returns the index and grid dimensions. `speed_hierarchical()` builds one per +run and threads it to the objective functions, `calculate_adjacency_score()` and +`objective_function_piepho()`. + +**Built lazily, on purpose.** `grid_index()` is wrapped in `tryCatch` so a design whose coordinates +cannot form a grid — MET duplicates (G13), non-numeric labels, or no grid columns at all — still runs if +its objective never needs a grid, and still raises the same error from the same place if it does. +`index = NULL` reproduces the old behaviour exactly. Verified against a pre-fix worktree: a two-site +duplicate-coordinate design with `adj_weight = 0` runs and scores 4.000 both before and after. + +**Measured 2026-08-06**, grid build on 700 plots (28×25), 2000 reps: -### A1.1 G6 fell out of the G7 fix — verify before closing `KNOWN_ISSUES` #1b +| | µs/build | vs `matrix()` | +|---|---|---| +| `matrix()` — what `main` did | 15 | 1.00× | +| `build_design_matrix()`, no index | 240 | 16.00× | +| `build_design_matrix()`, index supplied | **15** | **1.00×** | -Unplanned, so it is worth confirming rather than assuming. `calculate_efficiency_factor()` now indexes -`Z_row`/`Z_col` by `rows`/`cols` directly, which tolerates the coordinate offset `add_buffers()` -introduces: the empty leading indicator column makes `ZtZ` singular, the existing `kappa()` check -routes to `pseudo_inverse()`, and the empty column contributes nothing. **Measured** on a 4×3 design: +End-to-end, benchmarked against a clean worktree at `69c516d`. **Scores are bit-identical in every +case** — this is a performance change only: -| | efficiency | +| | before | after | | +|---|---|---|---| +| `objective_function`, 700 plots, 2000 iters | 1.58 s | **1.14 s** | −28% | +| `objective_function`, 700 plots, 5000 iters | 3.89 s | **2.86 s** | −26% | +| `objective_function`, 120 plots, 5000 iters | 2.46 s | **2.17 s** | −12% | +| `objective_function_piepho`, 120 plots, 1000 iters | 1.64 s | **1.47 s** | −10% | + +Larger designs gain most, since grid construction is a bigger share of each iteration. The earlier +prediction that hoisting would land *below* `matrix()` did not hold — it lands at parity, because the +`as.character()` coercion of the treatment column stays per-iteration and is what remains of the cost. +That coercion is *not* hoistable the way validation was: the swap column is the one thing annealing +mutates, so each call re-does the level lookup and allocates a fresh length-`n` character vector. The +below-parity figure came from a micro-benchmark that pre-coerced the column outside the timing loop, which +only holds if nothing is being optimised. Removing that last cost means carrying a mutable grid across +iterations — recorded as `KNOWN_ISSUES.md` #6, deliberately not bundled here because it changes the +objective-function contract this work stayed additive to. + +`grid_index()` also gained a missing-column check, found by this work: a design with no grid columns +reached `max()` with empty vectors and produced `-Inf` dimensions plus two warnings. + +### A1.2 G12 — `summary()` reports a reason instead of dying + +Before: `summary()` on a MET design, or on one with non-numeric `row`/`col` labels, errored outright — +both optimise fine with `adj_weight = 0`, so the design object was valid but not summarisable. `main` +returned numbers for both, wrong ones via the truncation in G13, so this branch had narrowed what +`summary()` accepted. + +`.single_grid(df, rc, cc)` returns `TRUE` or a short reason, and gates `.neighbour_balance()`, +`.efficiency_factor()` and `.replicate_spans()`. Each now reports `available = FALSE` with that reason, +which the print method already handled. + +**It delegates rather than re-implementing the coordinate rules.** The obvious build was a second copy of +`grid_index()`'s four checks, which is exactly the drift risk worth avoiding — G11 had just made +`grid_index()` the single place that decides what a valid grid is. Instead `grid_index()` signals *which* +rule failed by condition class (`speed_grid_missing` / `_nonnumeric` / `_notinteger` / `_duplicate`, all +inheriting `speed_grid_error`, via `.grid_stop()`), and `.single_grid()` maps the class to a short reason. +No message-text matching, no duplicated rules, and the happy path is untouched — conditions are only +constructed on failure. + +Three things fell out that were not in the G12 write-up: + +| | | |---|---| -| unbuffered | 0.9375 | -| `add_buffers("edge")` — rows 2-5, cols 2-4 | **0.9375** | -| `add_buffers("row")` — rows 2, 4, 6, 8 | **0.9375** | -| 4×6 with one plot removed mid-grid (a genuine hole) | **0.7009**, no error | +| `.efficiency_factor()` needed the gate for correctness, not just to avoid an error | it does not error on duplicate coordinates — it pools the grids and returns **1.855529**, impossible for an efficiency factor. Pinned by a test that asserts the underlying `> 1` behaviour, so the reason the gate exists cannot quietly disappear | +| `.replicate_spans()` was also wrong for MET, not merely noisy | it pooled sites, making two sites' row 3 one plot apart. Gating it fixes that and removes the two leaked `NAs introduced by coercion` warnings at the same time | +| `layout` claimed an impossible shape | `has_grid` now means "reportable as **one** grid", so a MET design reports `80 plots` rather than `10 rows x 5 cols (80 plots)` — a grid holding 50. New `layout$grid_reason` carries why. `nrow`/`ncol` are `NA` unless `has_grid`, which is what the existing non-grid test already asserted | + +One test changed rather than being added: the `.efficiency_factor()` "computation fails" case used a 1×1 +grid holding three plots, which the new gate catches earlier as duplicate coordinates. Its `tryCatch` +backstop is now covered by `local_mocked_bindings()` instead, which tests the intent directly. + +Reasons are phrased as facts, not interpretations — `duplicate row/col coordinates (e.g. a multi-site +design)` rather than `spans multiple grids`, because a malformed design looks identical to a MET one. -All correct: buffers are not part of the statistical design, so the value should equal the unbuffered -design's, and it does. `#1b` recorded this as uncomputable, and `.efficiency_factor()`'s `tryCatch` no -longer has a known failure to absorb. +**Coverage added** (`test-summary.R`), where there was none — `grep site tests/testthat/test-summary.R` +previously returned nothing, which is why the suite passed while `summary()` was broken for MET: +`.single_grid()` unit cases including a genuine hole and lexical factor levels, which must *not* be +refused; a MET design through `speed()` → `summary(efficiency = TRUE)`; the `> 1` efficiency pin; +non-numeric labels asserted warning-free; and a split-plot asserting the gate does **not** withhold +metrics from a legitimate hierarchical design at either level. -Note the buffered rows of this table become moot once `add_buffers()` leaves speed (D6), but the holed-grid -row does not — a genuine missing plot is a real input class, and it is the reason `#1b` can be closed on -its merits rather than by removing the feature that exposed it. +Suite after: **1728 pass, 0 fail, 0 error, 0 warn.** --- @@ -108,7 +201,7 @@ Measured on a 4×3 design with the restoration in place: `"edge"`, `"row"`, `"co replicate span and efficiency exactly. **Longer term this stops being speed's problem at all.** `add_buffers()` is deprecated as of 0.0.10 and -moving to \pkg{biometryassist} (see `BUFFERS-HANDOFF.md`). Once it is gone, speed never creates a +moving to \pkg{biometryassist} (see `BUFFERS-HANDOFF.md` in that repo). Once it is gone, speed never creates a displacement, so the restoration goes too and raw coordinates are simply correct with nothing to undo. This also settles S3 in `REVIEW-NOTES-SUMMARY.md`: `main`'s `length(unique(...))` behaviour was the @@ -117,6 +210,23 @@ right *behaviour*; the defect was that the choice had never been stated. G4's `NA` tolerance stays necessary regardless — a design with a genuine partial hole still produces a sparse grid under raw coordinates. +### 🔶 D7. What should the grid metrics report for a multi-grid (MET) design? — **open** + +Blocks the last part of G13. Adjacency and neighbour balance answer themselves — they count edges, edges +never cross a grid boundary, and summing per grid is exact (measured: 20 + 30 = 50). Two components do +not follow: + +| Component | Question | +|---|---| +| `calculate_efficiency_factor()` | An efficiency factor is a property of one experiment's information matrix; there is no meaningful sum. Options: (a) report per-site values, (b) fit one model with site effects added, (c) declare it unavailable for multi-grid designs. | +| `objective_function_piepho()`'s ED | NB sums like any edge count. ED measures evenness of a distribution, so per-grid-then-averaged and pooled are different quantities and the paper's definition assumes a single trial. | + +Note (c) is not merely the conservative option — it is currently *required* as an interim state either +way, because the alternative is continuing to return `1.855529` for a quantity bounded above by 1. + +Whatever is chosen, the same answer should apply to `summary()`'s `efficiency` entry and to +`.neighbour_balance()`, so the two never disagree about what a MET design's diagnostics mean. + ### A2.1 What arrives when `feature/buffers` merges Branched off `f5d68f5`, so it applies cleanly. Two items below are already done there — **do not action @@ -126,7 +236,7 @@ them again**: |---|---| | `metadata$buffer` transform record in `add_buffers()`, inverted by `.drop_buffer_rows()` / `.restore_buffer_coords()` | makes D6 true without touching `build_design_matrix()` | | `test-summary.R` buffer test rewritten | **fixes the stale comment at [test-summary.R:304](tests/testthat/test-summary.R#L304)**, which still claims a `"row"` buffer should change the counts. Now asserts every buffer type and stacked combinations match the unbuffered design | -| `add_buffers()` deprecation warning + `## Deprecations` NEWS section | buffers are leaving speed; `BUFFERS-HANDOFF.md` specifies the biometryassist side | +| `add_buffers()` deprecation warning + `## Deprecations` NEWS section | buffers are leaving speed; the biometryassist repo's `BUFFERS-HANDOFF.md` specifies that side | | `.warn_if_buffers()` in `calculate_adjacency_score()`, `calculate_balance_score()`, `calculate_efficiency_factor()` | a direct metric call on a buffered frame bypasses `.drop_buffer_rows()`, so it warns rather than silently scoring the displaced layout | | `helper-buffers.R` with `add_buffers_quiet()`, and 45 rewritten test call sites | keeps the deprecation warning out of tests that are about layout | @@ -139,132 +249,71 @@ invert one of those. Under the handoff plan it never does. ## A3. Open findings -### A3.1 🟠 Both packages register S3 methods on class `"design"` +### G13 🔴 There is no representation of a design occupying more than one grid, so MET is broken -Not an ordering bug and not buffer-specific, but it surfaced from this work and needs its own branch. -speed and \pkg{biometryassist} both use `class(x) == c("design", "list")`, and speed registers methods on -a class name it does not own. **Measured** with both loaded, calling on a **biometryassist** design: +**One root cause, four symptoms.** `build_design_matrix()` — and `matrix()` before it — models a design +as *a* grid. A multi-environment trial is several grids that share a treatment set and must never share +an edge. `initialise_multiple_designs_df()` ([design_utils.R:539](R/design_utils.R#L539)) reuses `row`/`col` +per site, so **every** MET design built the documented way has duplicate coordinates, and nothing +anywhere records which column separates the grids. -| Call | Result | -|---|---| -| `summary(des)` | speed's `summary.design` runs → `"This design has no metadata; ... Re-run speed()"` | -| `print(des)` | speed's `print.design` runs → prints `"Optimised Experimental Design"`; wrong, and silent | -| `autoplot(des)` | last package loaded wins (`Registered S3 method overwritten by ...`) | - -biometryassist defines neither `print.design` nor `summary.design`, so speed's capture its objects -unopposed. `print()` is the worst of the three: no error, just plausible wrong output. - -**Fix:** one line at [R/speed.R:441](R/speed.R#L441) — -`class(output) <- c("speed_design", "design", class(output))` — then rename speed's four registrations to -`autoplot.speed_design`, `print.speed_design`, `summary.speed_design`, `print.summary.speed_design`. -Keep `"design"` in the vector so `inherits(x, "design")` still works. This also gives the biometryassist -adapter its discriminator (`inherits(x, "speed_design")`), replacing a `[["design_df"]]` sniff — see -`BUFFERS-HANDOFF.md` change 5. - -### G9 🟠 `initialise_design_df()` fills `items` down columns, and nothing says so - -Found while fixing G7, because it is what made the paper-comparison test fail. +**Measured 2026-08-06** on `initialise_design_df(items = c(rep(1:10, 6), rep(11:20, 8)), designs = +list(a = list(nrows = 10, ncols = 3), b = list(nrows = 10, ncols = 5)))` — 80 plots, 10 unique rows, +5 unique cols: -`initialise_design_df()` builds its grid with `expand.grid(row = 1:nrows, col = 1:ncols)` -([R/design_utils.R:294](R/design_utils.R#L294)), which varies `row` fastest, then assigns -`df$treatment <- items` positionally. So `items` is read **down columns**. Nothing in -`?initialise_design_df` says so, and the natural way to write a design out — one grid row per source -line — produces a *different design* from the one on the page. - -The package's own test suite fell into it. `test-calculate_efficiency_factor.R` writes four published -designs visually, 4 rows of 9, and asserted the paper's values. **Measured:** - -| Design | as written | grid actually matching the paper | paper's value | +| Symptom | `main` | this branch, before G12 | now | |---|---|---|---| -| 1 | 0.644 | **0.834** | 0.834 | -| 2 | 0.683 | **0.783** | 0.783 | -| 3 | 0.540 | **0.827** | 0.827 | - -It passed on `main` only because **two conventions cancelled**: `initialise_design_df()` stored the -design column-major and `calculate_efficiency_factor()` read it back positionally row-major (G7), -recovering the design the author wrote. Fixing G7 broke the cancellation and exposed both. Supplying -the items column-major — `as.vector(matrix(items, nrow, ncol, byrow = TRUE))` — reproduces every -published value exactly, which is the confirmation that the G7 fix is right and the storage was wrong. - -**Worked around in the tests** via a local `by_row()` helper, so the literals stay readable against the -paper. **Not fixed in the package** — that is a user-facing API question: - -- At minimum, document the fill order in `?initialise_design_df` with a worked example. -- Better, add `byrow = FALSE`, mirroring `matrix()`. Anyone transcribing a published design wants - `byrow = TRUE` and silently gets a different design today. - -Check the other tests and the vignettes for the same latent transposition before that lands. - -### G8 🔵 `Z` omits the intercept - -`calculate_efficiency_factor()` builds `Z` from row indicators `1..R-1` and column indicators `1..C-1` -with **no column of ones**. Its column space has dimension `R+C-2` and does not contain the intercept, -where the row + column model space has dimension `R+C-1`, so `A_RC` is not the mean-adjusted treatment -information matrix. - -**Measured:** for equireplicate designs it cancels exactly — the returned value matched the harmonic -mean of the canonical efficiency factors to machine precision on five non-square designs (3×8, 4×6, -6×4, 2×10, 5×6), re-verified on a properly randomised 3×8 (0.7040535). Under **unequal replication** it -does not: on the 25×12 p-rep example the function returns **0.267052** where a properly adjusted `C` -gives **0.268757**. - -Statistical, not orientation. Pair it with the upper-bound work (A4). - ---- - -## A4. Deferred, recorded - -- **A-efficiency upper bound in `summary()`.** 🔷 **Decided 2026-08-06: its own branch.** A closed-form - bound on the average efficiency factor, depending only on `(replication, nrow, ncol)` — no matrices, - essentially free — so `summary()` can report how close a design gets to the best achievable - A-efficiency: - - `UB = (1/(t-1)) * sum_i [ 1 - minSumSq(r_i, nrow)/(ncol*r_i) - minSumSq(r_i, ncol)/(nrow*r_i) + r_i/n ]` - - where `minSumSq(r, K)` is the even-split minimum of `sum n_ik^2`. **Measured:** holds as a bound on - every design tested, equals exactly 1.000 for 4×4 and 5×5 Latin squares (which are A-optimal), and - tracks the optimiser — a 5×6 10-treatment design moves 0.000 → 0.620 → 0.774 against a bound of 0.815 - as iterations go 0 → 200 → 5000. - - Report it as **"% of upper bound"**, never "% of optimal": `A/UB = 1` proves A-optimality, `A/UB < 1` - does not prove sub-optimality, because the bound may be unattainable. - - **Explicitly declined:** reporting the raw A-value (average pairwise variance). Already computed and - discarded inside `calculate_efficiency_factor()`, but it is in σ² units, only comparable across - designs with identical replication, and actively misleading when the design is disconnected — - measured, an unoptimised 5×6 reports 0.503 against the optimised design's 0.862, which looks better - and is a `ginv` artefact of the rank deficiency. - - **Reuse check:** PR #91 (`REVIEW-NOTES-PR91.md`) already computes canonical efficiency factors from - the information matrix via `eigen()`, in a function confusingly named `calculate_efficiency_factors` - (plural). Both the bound and G8 want that machinery. Coordinate the two before writing a third - implementation. -- **Removing the sort at [R/speed.R:195](R/speed.R#L195).** With grids coordinate-based it is no longer - needed for correctness — the order-invariance tests are what prove that. But `generate_neighbour()`, - `random_initialise()`, `print.design()` and `autoplot()` may rely on row order. Leave it; note as a - later simplification, not bundled with a bug fix. -- **Hot-loop performance.** Measured on a 700-plot design (28×25), 2000 builds: `matrix()` - **415 µs/build** vs `build_design_matrix()` **1180 µs/build** — **2.84×**, about 7.6 s extra per - 10,000 iterations per level. Real but not disqualifying, and avoidable: the row/col vectors never - change during the SA loop, only `swap` does, so the validated `cbind(rows, cols)` index can be built - once per level and passed in. Do it after correctness, and benchmark rather than assume. -- **A terminology sentence for `?calculate_efficiency_factor`.** It returns `(2/r_h) / apv`, the - **average efficiency factor** — the harmonic mean of the canonical efficiency factors, i.e. - **A-efficiency**, the measure paired with A-optimality. Verified against an independent eigenvalue - computation on five equireplicate designs (exact to machine precision); nothing in `R/` on `main` - calls `eigen()`, so it cannot be computing an E-efficiency (the *minimum* canonical efficiency - factor). `summary()`'s "A-efficiency" label is correct. - - The confusion is a symbol collision: Williams & Piepho write the average efficiency factor as **`E`** - (for **E**fficiency, often `E_A`) — see `Mario speed-eg3-jac12463.R`, *"The average efficiency factor - is E = 0.411"*. That `E` is not E-optimality. Separately, when all canonical efficiency factors are - equal (Latin squares, BIBDs) A-, D- and E-efficiency coincide exactly, so agreeing with another - package on one design proves nothing about which criterion it used. Name the synonym in the docs so - this doesn't recur. +| `.neighbour_balance()` | 50-cell grid from 80 plots: **30 plots silently discarded**, one `data length differs from size of matrix` warning | errors, taking all of `summary()` with it | reported unavailable, with a reason | +| `calculate_adjacency_score()` | garbage from the same truncation | **errors** | **errors** — correct for a direct call, but there is still no way to get the right number | +| `calculate_efficiency_factor()` | pools sites into one row/col model | returns `1.855529`, silently — a value `> 1` is impossible | still `1.855529` on a direct call; withheld in `summary()` | +| sites laid side by side in one grid (`col + 3` for site b, so coordinates *are* unique) | **60** adjacencies vs **50** summing per site — 10 phantom cross-site edges | **identical, 60** | **still 60** | + +Read the last two rows carefully. This is **not** a regression this branch introduced, and it is **not +fixed by validation or by G12's gate**: duplicate coordinates don't break coordinate *indexing*, they just +quietly pool, and the side-by-side case has no duplicates at all, so no error can fire and no gate can +catch it. Both are silent wrong answers on `main` and on this branch. What the branch changed is the first +two rows, from silently wrong to loudly wrong; what G12 changed is that `summary()` survives saying so. +The duplicate-coordinate error is doing its job — it is the only reason any of this is visible — but it is +a diagnostic, not the fix. + +**Adjacency and neighbour balance are summable over grids.** Measured: per-site adjacency 20 + 30 = **50**, +which is the correct whole-design figure. Both count edges, and edges never cross a grid boundary, so +summing per grid is exact rather than an approximation. That makes the fix tractable. + +**Implementation sketch.** + +1. **Carry the grouping column.** Extend `grid_factors` to `list(dim1 = "row", dim2 = "col", by = "site")`. + Verified backwards compatible — `infer_row_col()` reads only `$dim1`/`$dim2`, and `speed()` already + accepts the three-element list without complaint. That tolerance is itself a trap: a mistyped `by` is + silently ignored today, so this needs validation added at the same time. +2. **Record it.** `metadata` currently holds only `row_column` / `col_column` + ([speed.R:401-406](R/speed.R#L401-L406)), which is why `summary()` cannot recover the grouping on its + own. Add `grid_by`. +3. **A list-of-grids primitive.** `build_design_matrices(df, swap, rc, cc, by = NULL)` returning a named + list, length 1 when `by` is `NULL`. `build_design_matrix()` stays exactly as it is — the single-grid + primitive, still strict. Sum `adjacency_score_vec()` and the `calculate_nb()` pair tables across the + list. Note this wants a *list of indices* from `grid_index()` per grid, so it composes with G11's + hoisting rather than reintroducing per-iteration validation. +4. **Auto-detection is the wrong instinct.** Duplicate coordinates with no `by` should keep erroring, and + the current message already names the remedy. Inferring the grouping from a `"site"`-like column name + is how the ordering bugs in G1 happened. If it should be automatic, have + `initialise_design_df(designs = )` record `design_col` as an attribute so it is *transported* rather + than guessed. +5. **Efficiency is not summable** — see D7. G12 already withholds it inside `summary()`; a direct + `calculate_efficiency_factor()` call still returns `1.855529`, so the refusal needs to move into the + function itself once D7 says what the right answer is. +6. **Then relax G12's gate.** `.single_grid()` is deliberately the *only* place `summary()` decides a + design isn't griddable, so once the metrics take a grouping factor, MET designs stop reaching it and it + covers only genuinely un-griddable input. The split-plot test added with G12 is what guards against the + gate over-reaching in the meantime. + +Scope check: items 1-4 are mechanical given the summability result. Item 5 and `objective_function_piepho()`'s +ED component are the parts that need a statistical answer first, and both can be gated to "unavailable" +in the meantime so MET adjacency and neighbour balance land without waiting on them. --- -## A5. Corrections that still matter +## A4. Corrections that still matter Corrections to superseded findings have been dropped along with the findings. These bear on open items. @@ -272,7 +321,8 @@ Corrections to superseded findings have been dropped along with the findings. Th |---|---| | **Rank** the coordinates and you destroy real physical gaps, so validate instead | **Right, and it survived a detour.** Briefly overruled in favour of ranking; reinstated once it was clear that ranking is only the inverse of `add_buffers()`' displacement. Undo the displacement at its source and raw coordinates preserve genuine gaps at no cost. See D6. | | D6 is a statistical question about whether buffers separate plots | **Not really.** It looked like one, but the only thing making a buffered design score differently was `add_buffers()` rewriting the real plots' coordinates. Verified: ranking the de-buffered coordinates restores the original `1..n` exactly, for all five buffer types. The statistical question is settled trivially — buffers must not change anything — and the rest was an implementation leak. | -| **G10** — `build_design_matrix()` must rank its coordinates to satisfy D6 | **Withdrawn, do not implement.** Ranking in `build_design_matrix()` would infer the undo in the wrong place and collapse real holes as collateral. `feature/buffers` records the displacement in `metadata$buffer` and inverts it in `.drop_buffer_rows()` instead; `build_design_matrix()` stays raw. G10's other three sub-items are also resolved: the `test-summary.R` comment is rewritten on that branch, the NEWS sentence has already been removed here, and `calculate_efficiency_factor()` needs no change (A1.1). | -| An efficiency factor `> 1` is a canary for the ordering bug (G7) | **Too narrow.** `> 1` signals rank deficiency however it arises. Measured: degenerate fixtures where treatment is confounded with row — which is what `initialise_design_df(rep(LETTERS[1:k], m), ...)` produces, see G9 — return values `> 1` in **row-major** order too, on `main`. It is a canary for "something is wrong", not for ordering specifically. | -| G8 is harmless for equireplicate designs | **Stands, re-verified.** Earlier doubt came from degenerate fixtures (G9), not from G8. | -| `KNOWN_ISSUES` #1b: `calculate_efficiency_factor()` cannot compute post-buffer (G6) | **No longer true** — resolved incidentally by the G7 coordinate fix. Measured in A1.1. | +| **G10** — `build_design_matrix()` must rank its coordinates to satisfy D6 | **Withdrawn, do not implement.** Ranking in `build_design_matrix()` would infer the undo in the wrong place and collapse real holes as collateral. `feature/buffers` records the displacement in `metadata$buffer` and inverts it in `.drop_buffer_rows()` instead; `build_design_matrix()` stays raw. G10's other three sub-items are also resolved: the `test-summary.R` comment is rewritten on that branch, the NEWS sentence has already been removed here, and `calculate_efficiency_factor()` needs no change (G6). | +| An efficiency factor `> 1` is a canary for the ordering bug (G7) | **Too narrow.** `> 1` signals rank deficiency however it arises. Measured: degenerate fixtures where treatment is confounded with row — which is what `initialise_design_df(rep(LETTERS[1:k], m), ...)` produces, see `KNOWN_ISSUES.md` #3 — return values `> 1` in **row-major** order too, on `main`. It is a canary for "something is wrong", not for ordering specifically. | +| MET only needs a gate in `summary()`; the grid code itself is fine | **Wrong, and too narrow twice over.** The gate (G12) is real but it only stops `summary()` crashing — it does not make MET work, which is the actual requirement. And two silent wrong answers survive any amount of gating: `calculate_efficiency_factor()` returns **1.855529** on a MET frame because duplicate coordinates pool rather than error, and a MET design laid side by side in one grid has *no* duplicate coordinates yet still counts **10 phantom cross-site edges** (60 vs 50). Validation cannot catch either. Grid metrics need a grouping dimension — see G13. | +| `main`'s MET behaviour was "garbage, with a warning" | **Quantified:** the `matrix()` reshape built 50 cells from 80 plots, **silently discarding 30**, with one `data length differs from size of matrix` warning. Worth stating precisely because it is the reason this branch's hard error is an improvement even though it is not the fix. | +| Hot-loop cost of `build_design_matrix()` is **2.84×** (415 → 1180 µs/build) | **Superseded by a cleaner measurement.** On the same 28×25 fixture it is **11.5×** (20 → 230 µs/build); the earlier figure bundled other work into both arms. The ratio is worse than thought and the absolute cost lower, but the actionable finding held up: 87% was loop-invariant validation, and hoisting it recovered parity. See A1.1. | diff --git a/man/dot-grid_stop.Rd b/man/dot-grid_stop.Rd new file mode 100644 index 00000000..30171cdb --- /dev/null +++ b/man/dot-grid_stop.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/design_utils.R +\name{.grid_stop} +\alias{.grid_stop} +\title{Signal a Coordinate Problem with a Classed Condition} +\usage{ +.grid_stop(class, ...) +} +\arguments{ +\item{class}{Condition subclass naming the specific problem.} + +\item{...}{Pasted together to form the message.} +} +\description{ +The message is for someone calling a metric directly; the class lets +\code{.single_grid()} report the same problem as a short reason in a \code{summary()} +field, without matching on message text. Conditions are only constructed on +failure, so the hot path is unaffected. +} +\keyword{internal} diff --git a/man/dot-neighbour_balance.Rd b/man/dot-neighbour_balance.Rd index 489b10fc..e79e22be 100644 --- a/man/dot-neighbour_balance.Rd +++ b/man/dot-neighbour_balance.Rd @@ -32,8 +32,10 @@ the design. Coordinates are read as-is, so a design whose plots are separated by a buffer row or column (\code{add_buffers()} offsets and scales them) keeps that separation: -plots either side of a buffer are not counted as neighbours. Assumes \code{rc}/\code{cc} -are present in \code{df}; callers should check \code{has_grid} first (see -\code{summary.design()}). +plots either side of a buffer are not counted as neighbours. + +Guarded by \code{.single_grid()}, so a design that cannot be placed on one grid is +reported as unavailable rather than propagating \code{\link[=build_design_matrix]{build_design_matrix()}}'s +error out of \code{summary()}. } \keyword{internal} diff --git a/man/dot-single_grid.Rd b/man/dot-single_grid.Rd new file mode 100644 index 00000000..3f1f3ea3 --- /dev/null +++ b/man/dot-single_grid.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/summary.R +\name{.single_grid} +\alias{.single_grid} +\title{Can This Design Be Placed on a Single Grid?} +\usage{ +.single_grid(df, rc, cc) +} +\arguments{ +\item{df}{Design data frame.} + +\item{rc, cc}{Row and column column names.} +} +\value{ +\code{TRUE}, or a length-1 character reason. +} +\description{ +Predicate form of \code{\link[=grid_index]{grid_index()}}, for the diagnostics that need a grid. +Returns \code{TRUE}, or a short reason, so a summary can report one metric as +unavailable instead of failing outright. + +Delegates rather than repeating the coordinate rules, so the two cannot +drift: \code{\link[=grid_index]{grid_index()}} stays the only place that decides what a valid grid is, +and signals which rule failed by condition class. Reasons here are phrased for +a summary field, where \code{grid_index()}'s messages - written for someone calling +a metric directly - would read as instructions. + +Duplicate coordinates usually mean the design occupies more than one grid: a +multi-environment trial reuses \code{row}/\code{col} per site. Every grid metric is +wrong for those, not merely uncomputable, because they pool sites that share +no edge. Reporting them as unavailable is the honest answer until the metrics +can take a grouping factor. +} +\keyword{internal} diff --git a/man/summary.design.Rd b/man/summary.design.Rd index 49f1f30e..3657b0ca 100644 --- a/man/summary.design.Rd +++ b/man/summary.design.Rd @@ -41,7 +41,9 @@ See Details for more information.} A list of class \code{"summary.design"}: \itemize{ \item \strong{hierarchical} - \code{TRUE} for a multi-level (e.g. split-plot) design. -\item \strong{layout} - \code{n_plots}, \code{nrow}, \code{ncol}, \code{row_column}, \code{col_column}, \code{has_grid}. +\item \strong{layout} - \code{n_plots}, \code{nrow}, \code{ncol}, \code{row_column}, \code{col_column}, +\code{has_grid} (\code{TRUE} when the design is reportable as a single grid), and +\code{grid_reason} (why not, or \code{NA}). \code{nrow}/\code{ncol} are \code{NA} unless \code{has_grid}. \item \strong{levels} - character vector of level names (e.g. \code{"wp"}/\code{"sp"}; a single name for a simple design). \item \strong{per_level} - one element per level (named by \code{levels}), each a list with: diff --git a/tests/testthat/test-summary.R b/tests/testthat/test-summary.R index bf3baeb3..744a22b1 100644 --- a/tests/testthat/test-summary.R +++ b/tests/testthat/test-summary.R @@ -919,6 +919,148 @@ test_that("designs without a row/column grid summarise and print without a grid" expect_match(out, "Neighbour:\\s+no row/column factors") }) +test_that(".single_grid accepts one grid and names what is wrong otherwise", { + ok <- data.frame(row = c(1, 1, 2, 2), col = c(1, 2, 1, 2), treatment = "A") + expect_true(.single_grid(ok, "row", "col")) + + # A genuine hole (missing plot) is still a single grid - coordinates stay + # unique, so the metrics must not refuse it. + expect_true(.single_grid(ok[-2, ], "row", "col")) + + # Factor coordinates whose level order is lexical are still fine. + lex <- data.frame( + row = factor(c(1, 2, 10)), + col = factor(c(1, 1, 1)), + treatment = "A" + ) + expect_true(.single_grid(lex, "row", "col")) + + expect_equal(.single_grid(ok, "nope", "col"), "no row/column factors") + expect_match( + .single_grid( + data.frame(row = c("A", "B"), col = c(1, 1)), + "row", + "col" + ), + "labels are not numeric" + ) + expect_match( + .single_grid(data.frame(row = c(0, 1), col = c(1, 1)), "row", "col"), + "not positive whole numbers" + ) + expect_match( + .single_grid(ok[c(1, 1, 2), ], "row", "col"), + "duplicate .row./.col. coordinates" + ) +}) + +test_that("multi-site (MET) designs summarise instead of erroring", { + # initialise_design_df(designs = ) reuses row/col per site, so a MET design has + # duplicate coordinates and cannot be placed on one grid. Every grid metric is + # wrong for it (they pool sites that share no edge), so all must report a + # reason. Before this gate, summary() errored outright. + met <- initialise_design_df( + items = rep(LETTERS[1:3], 6), + designs = list( + a = list(nrows = 3, ncols = 2), + b = list(nrows = 3, ncols = 4) + ) + ) + expect_true(anyDuplicated(met[c("row", "col")]) > 0) + + r <- speed( + met, + swap = "treatment", + swap_within = "site", + spatial_factors = ~ row + col + site, + optimise_params = optim_params(adj_weight = 0), + iterations = 50, + seed = 1, + quiet = TRUE + ) + s <- expect_no_error(summary(r, efficiency = TRUE)) + + # No single nrow x ncol describes two grids of different shapes. + expect_false(s$layout$has_grid) + expect_match(s$layout$grid_reason, "multi-site") + expect_true(is.na(s$layout$nrow)) + expect_equal(s$layout$n_plots, 18) + + e <- s$per_level[[1]]$evaluation + for (metric in c("neighbour", "replicate_span", "efficiency")) { + expect_false(e[[metric]]$available, info = metric) + expect_match(e[[metric]]$reason, "duplicate", info = metric) + } + + # Non-grid metrics still work - only the grid ones are withheld. + expect_equal(s$per_level[[1]]$n_treatments, 3) + + out <- capture_output(print(s)) + expect_match(out, "Layout:\\s+18 plots") + expect_no_match(out, "rows x") + expect_match(out, "Neighbour:\\s+duplicate") +}) + +test_that("efficiency is withheld rather than reported above 1 for MET designs", { + # calculate_efficiency_factor() does not error on duplicate coordinates, it + # pools the grids and returns a value above 1 - impossible for an efficiency + # factor. The gate exists to stop that reaching the user, not just to stop an + # error, so pin the underlying behaviour that makes it necessary. + met <- initialise_design_df( + items = rep(LETTERS[1:3], 6), + designs = list( + a = list(nrows = 3, ncols = 2), + b = list(nrows = 3, ncols = 4) + ) + ) + expect_gt(calculate_efficiency_factor(met, treatment), 1) + expect_false(.efficiency_factor(met, "treatment", "row", "col")$available) +}) + +test_that("non-numeric row/col labels are reported, not coerced silently", { + d <- data.frame( + row = rep(c("A", "B", "C"), each = 4), + col = rep(c("w", "x", "y", "z"), 3), + treatment = rep(LETTERS[1:4], 3) + ) + r <- speed( + d, + swap = "treatment", + spatial_factors = ~ row + col, + optimise_params = optim_params(adj_weight = 0), + iterations = 50, + seed = 1, + quiet = TRUE + ) + + # Previously this errored from build_design_matrix(), and .replicate_spans() + # leaked two "NAs introduced by coercion" warnings on the way. + s <- expect_no_warning(expect_no_error(summary(r))) + + expect_false(s$layout$has_grid) + e <- s$per_level[[1]]$evaluation + expect_match(e$neighbour$reason, "labels are not numeric") + expect_match(e$replicate_span$reason, "labels are not numeric") +}) + +test_that("split-plot designs keep their grid metrics at every level", { + # The gate must not withhold metrics from a legitimate hierarchical design: + # a split-plot shares one grid, and both levels' plots have unique + # coordinates, so nothing here spans multiple grids. + s <- summary(split_plot_design(), efficiency = TRUE) + + expect_true(s$layout$has_grid) + expect_true(is.na(s$layout$grid_reason)) + expect_equal(c(s$layout$nrow, s$layout$ncol), c(6, 4)) + + expect_named(s$per_level, c("wp", "sp")) + for (lv in c("wp", "sp")) { + e <- s$per_level[[lv]]$evaluation + expect_true(e$neighbour$available, info = lv) + expect_true(e$replicate_span$available, info = lv) + } +}) + test_that("the disconnected flag names the affected levels for hierarchical designs", { s <- summary(split_plot_design()) s$flags$disconnected <- c("wp", "sp") @@ -971,7 +1113,7 @@ test_that("evaluation helpers return a reason instead of erroring on unmet assum ef <- .efficiency_factor(no_grid, "treatment", "row", "col") expect_false(ef$available) - expect_equal(ef$reason, "requires a row/column grid") + expect_equal(ef$reason, "no row/column factors") # A contrast needs two treatments to be a contrast. one <- .design_connectedness( @@ -1009,14 +1151,22 @@ test_that(".design_connectedness is trivially connected with no nuisance factors }) test_that(".efficiency_factor reports a reason when the computation fails", { - # A 1x1 "grid" leaves no row/column effects to fit, so - # calculate_efficiency_factor() errors; the wrapper must absorb that. - degenerate <- data.frame( - row = rep(1, 3), - col = rep(1, 3), - treatment = c("A", "B", "C") - ) - ef <- .efficiency_factor(degenerate, "treatment", "row", "col") + # The wrapper must absorb an error from the underlying metric rather than + # propagate it. Mocked rather than provoked with a degenerate design: the + # 1x1 grid that used to error here is now caught earlier by .single_grid() + # (three plots at one coordinate are duplicates), which would leave this + # backstop untested. + d <- data.frame( + row = rep(1:2, 3), + col = rep(1:3, each = 2), + treatment = rep(c("A", "B", "C"), 2) + ) + expect_true(.single_grid(d, "row", "col")) + + local_mocked_bindings( + calculate_efficiency_factor = function(...) stop("cannot compute") + ) + ef <- .efficiency_factor(d, "treatment", "row", "col") expect_false(ef$available) expect_equal(ef$reason, "could not be computed for this design") From b1a5640ba268ff5b462ee0b6e16bb6b49cd00a9d Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:05:05 +0930 Subject: [PATCH 12/28] Updating plan --- REVIEW-NOTES-OTHER.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index b36d466d..17870137 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -56,8 +56,8 @@ measured, not inferred. Resolved findings are deleted rather than annotated — > See A1.1. > > ⬜ **Moved out, each needing its own branch:** the A-efficiency upper bound and the missing intercept -> (`REVIEW-NOTES-EFFICIENCY.md`); the S3 class collision, the `initialise_design_df()` fill order, the -> redundant row-major sort and the incremental-grid-mutation option (`KNOWN_ISSUES.md` #2, #3, #4, #6). +> (`REVIEW-NOTES-EFFICIENCY.md`); the S3 class collision, the `initialise_design_df()` fill order and +> the redundant row-major sort (`KNOWN_ISSUES.md` #2, #3, #4). --- @@ -116,9 +116,11 @@ prediction that hoisting would land *below* `matrix()` did not hold — it lands That coercion is *not* hoistable the way validation was: the swap column is the one thing annealing mutates, so each call re-does the level lookup and allocates a fresh length-`n` character vector. The below-parity figure came from a micro-benchmark that pre-coerced the column outside the timing loop, which -only holds if nothing is being optimised. Removing that last cost means carrying a mutable grid across -iterations — recorded as `KNOWN_ISSUES.md` #6, deliberately not bundled here because it changes the -objective-function contract this work stayed additive to. +only holds if nothing is being optimised. Parity is therefore the floor for a rebuild-per-iteration grid, +and it is accepted: the only way past it is to carry a mutable grid across iterations, which was +considered and **ruled out** (Sam, 2026-08-06). It would need a contract change to the objective-function +signature, and it is capped at under 3% of a run — the grid build is ~15 µs of a ~570 µs iteration, while +`adjacency_score_vec()` is O(n × offsets) per iteration however the grid arrives. `grid_index()` also gained a missing-column check, found by this work: a design with no grid columns reached `max()` with empty vectors and produced `-Inf` dimensions plus two warnings. From 55fcd1c444e210f82ae7dd5f263c0b401f9ac3d2 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:19:44 +0930 Subject: [PATCH 13/28] Adding tests for efficiency factors --- tests/testthat/test-grid-orientation.R | 61 ++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/testthat/test-grid-orientation.R b/tests/testthat/test-grid-orientation.R index 47421abb..76fec041 100644 --- a/tests/testthat/test-grid-orientation.R +++ b/tests/testthat/test-grid-orientation.R @@ -283,3 +283,64 @@ test_that("efficiency factor is unchanged by shuffling a real design", { expect_equal(ordered, shuffled) expect_lte(ordered, 1) }) + +test_that("efficiency factor is unaffected by an offset coordinate origin", { + # add_buffers() displaces the real plots to make room and never undoes it, so + # a de-buffered design arrives with coordinates that neither start at 1 nor + # run consecutively. The gaps leave empty indicator columns in Z, making ZtZ + # singular; the kappa() check routes to pseudo_inverse() and they contribute + # nothing. The positional fill this replaced could not get that far - it ran + # its plot counter past the end of Z and errored (subscript out of bounds). + base <- initialise_design_df( + items = rep(LETTERS[1:3], 4), + nrows = 4, + ncols = 3 + ) + expected <- calculate_efficiency_factor(base, treatment) + + # The displacement each add_buffers() type applies (R/buffers.R), reproduced + # here rather than by calling add_buffers(): the assertion is about the metric + # tolerating offset coordinates, which outlives buffers moving out of speed. + displaced <- list( + edge = transform(base, row = row + 1, col = col + 1), + row = transform(base, row = row * 2), + col = transform(base, col = col * 2), + `double row` = transform(base, row = (3 * row) - 1), + `double col` = transform(base, col = (3 * col) - 1), + # Stacked edge then row: rows 4, 6, 8, 10 and cols 2-4. + `edge + row` = transform(base, row = 2 * (row + 1), col = col + 1) + ) + + # Buffers are not part of the statistical design, so every one of these must + # return the unbuffered design's value. + for (type in names(displaced)) { + expect_equal( + calculate_efficiency_factor(displaced[[type]], treatment), + expected + ) + } +}) + +test_that("efficiency factor computes on a grid with a genuine hole", { + # A plot missing mid-grid is a real input class - an irregular trial edge, a + # road - and is not a buffer, so it must be scored on the coordinates it has + # rather than closed up. Here Z has no empty columns, so this is a separate + # path from the offset case above: solve(), not pseudo_inverse(). + full <- initialise_design_df( + items = rep(LETTERS[1:6], 4), + nrows = 4, + ncols = 6 + ) + holed <- full[!(full$row == 2 & full$col == 3), ] + + expect_no_error(holed_ef <- calculate_efficiency_factor(holed, treatment)) + expect_lte(holed_ef, 1) + # Both values measured at the coordinate-indexed fix and pinned here, so a + # silent change in either is caught. + expect_equal( + calculate_efficiency_factor(full, treatment), + 0.7058824, + tolerance = 1e-6 + ) + expect_equal(holed_ef, 0.7008719, tolerance = 1e-6) +}) From e97fa3ab0cd4af44451fa8c9a5237eaf8c512480 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:19:59 +0930 Subject: [PATCH 14/28] Formatting with air --- tests/testthat/test-build_design_matrix.R | 38 +++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/tests/testthat/test-build_design_matrix.R b/tests/testthat/test-build_design_matrix.R index 4946d9be..471409ee 100644 --- a/tests/testthat/test-build_design_matrix.R +++ b/tests/testthat/test-build_design_matrix.R @@ -189,7 +189,11 @@ test_that("a supplied index gives the same grid as validating in place", { # The index is the only thing hoisted out of the annealing loop, so the two # paths must be indistinguishable. for (dims in list(c(3, 8), c(8, 3), c(4, 4), c(2, 6))) { - d <- initialise_design_df(rep(LETTERS[1:4], prod(dims) / 4), dims[[1]], dims[[2]]) + d <- initialise_design_df( + rep(LETTERS[1:4], prod(dims) / 4), + dims[[1]], + dims[[2]] + ) expect_equal( build_design_matrix(d, "treatment", index = grid_index(d)), build_design_matrix(d, "treatment"), @@ -236,19 +240,30 @@ test_that("speed() scores identically whether or not the index is hoisted", { # so it is the strongest case. d <- initialise_design_df(rep(LETTERS[1:6], 4), 3, 8) args <- list( - swap = "treatment", swap_within = "1", spatial_factors = ~ row + col, - iterations = 200, seed = 42, quiet = TRUE + swap = "treatment", + swap_within = "1", + spatial_factors = ~ row + col, + iterations = 200, + seed = 42, + quiet = TRUE ) hoisted <- do.call(speed, c(list(d), args)) # Calling the objective directly takes the un-hoisted path (index = NULL). direct <- objective_function( - hoisted$design_df, "treatment", c("row", "col") + hoisted$design_df, + "treatment", + c("row", "col") ) expect_equal(direct$score, hoisted$score) - hoisted_p <- do.call(speed, c(list(d), args, list(obj_function = objective_function_piepho))) + hoisted_p <- do.call( + speed, + c(list(d), args, list(obj_function = objective_function_piepho)) + ) direct_p <- objective_function_piepho( - hoisted_p$design_df, "treatment", c("row", "col") + hoisted_p$design_df, + "treatment", + c("row", "col") ) expect_equal(direct_p$score, hoisted_p$score) }) @@ -266,9 +281,14 @@ test_that("a design whose coordinates cannot form a grid still runs when no grid expect_error(grid_index(d), "Duplicate") expect_no_error( r <- speed( - d, swap = "treatment", swap_within = "site", - spatial_factors = ~ row + col + site, iterations = 100, seed = 1, - quiet = TRUE, optimise_params = optim_params(adj_weight = 0) + d, + swap = "treatment", + swap_within = "site", + spatial_factors = ~ row + col + site, + iterations = 100, + seed = 1, + quiet = TRUE, + optimise_params = optim_params(adj_weight = 0) ) ) expect_equal(r$score, 4) From 81ca3caaa5995acd285c0fa2251c761c15f81a80 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:20:28 +0930 Subject: [PATCH 15/28] Updating plan notes --- REVIEW-NOTES-OTHER.md | 367 ++++++++++++++++-------------------------- 1 file changed, 140 insertions(+), 227 deletions(-) diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index 17870137..26208afe 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -1,10 +1,10 @@ # Review notes: grid construction and core metrics -**Scope:** `R/design_utils.R` (`build_design_matrix()`), `R/calculate_adjacency_score.R`, +**Scope:** `R/design_utils.R` (`build_design_matrix()`, `grid_index()`), `R/calculate_adjacency_score.R`, `R/metrics.R`, and `R/summary.R` where it consumes a grid. Branch **`bugfix/grid-orientation`** off `main`. -G12 and G13 touch `R/summary.R` but belong **here, not in `REVIEW-NOTES-SUMMARY.md`** (Sam, 2026-08-06): -this branch made the grid contract strict, so it owns the consequences of that strictness. +G13 touches `R/summary.R` but belongs **here, not in `REVIEW-NOTES-SUMMARY.md`** (Sam, 2026-08-06): this +branch made the grid contract strict, so it owns the consequences of that strictness. **Companion files** — one per workstream: @@ -16,83 +16,53 @@ this branch made the grid contract strict, so it owns the consequences of that s | `REVIEW-NOTES-PR91.md` | PR #91 `info-objective` | | **this file** | grid construction / core metrics | -Anything needing a branch of its own has been moved out: the A-efficiency upper bound and the missing -intercept to `REVIEW-NOTES-EFFICIENCY.md`; the S3 class collision, the `initialise_design_df()` fill -order and the now-redundant row-major sort to `KNOWN_ISSUES.md` (#2, #3, #4). +Moved out, each needing its own branch: the A-efficiency upper bound and the missing intercept +(`REVIEW-NOTES-EFFICIENCY.md`); the S3 class collision, the `initialise_design_df()` fill order and the +now-redundant row-major sort (`KNOWN_ISSUES.md` #2, #3, #4). The buffer coordinate convention — settled, +and the reason `build_design_matrix()` keeps coordinates **raw** — is `KNOWN_ISSUES.md` #1. -**Last verified:** 2026-08-06, R 4.6.1, `pkgload::load_all()`, branch at `f5d68f5`. All numbers -measured, not inferred. Resolved findings are deleted rather than annotated — see git history and the -`NEWS.md` entries for what they were. +**Last verified:** 2026-08-06, R 4.6.1, `pkgload::load_all()`. All numbers measured, not inferred. +Resolved findings and settled decisions are deleted rather than annotated — see git history and `NEWS.md`. -> ✅ **All grid-ordering work has landed.** `build_design_matrix()` is wired into -> `calculate_adjacency_score()`, `objective_function_piepho()` and `.neighbour_balance()`; -> `calculate_efficiency_factor()` indexes its own indicator matrices by coordinate. Full suite: -> **1668 pass, 0 fail, 0 warn.** +> ✅ **G1-G12 have landed** — see A1. Full suite: **1738 pass, 0 fail, 0 warn.** > -> ✅ **G10 is withdrawn, not fixed.** It said `build_design_matrix()` must **rank** its coordinates to -> satisfy D6. That was the wrong lever: ranking is nothing more than the inverse of the displacement -> `add_buffers()` applies, and inverting it downstream by inference also collapses genuine holes. D6's -> *rationale* stands; its mechanism moved to `feature/buffers`, which undoes the displacement where it -> is created. `build_design_matrix()` stays **raw** — see D6 and A5. +> 📦 **`feature/buffers` merges into this branch** (branched off `f5d68f5`), carrying the coordinate +> restoration that makes raw coordinates correct plus the `add_buffers()` deprecation. See A2 — two items +> are already done there, so read it before actioning anything. > -> 📦 **`feature/buffers` merges into this branch** (branched off `f5d68f5`). It carries the coordinate -> restoration that makes raw correct, plus the `add_buffers()` deprecation. See A2.1 — nothing here -> should be actioned without reading it, because two items in this file are already done there. -> -> 🔴 **Blocker: G13 — nothing in speed represents a design that occupies more than one grid, so MET is +> 🔴 **Blocker: G13 — nothing in speed represents a design occupying more than one grid, so MET is > broken.** `initialise_design_df(designs = )` reuses `row`/`col` per site, so every MET design has -> duplicate coordinates. `main` coped by silently discarding **30 of 80** plots; this branch errors -> instead. Neither works, and coordinate construction did not cause it — see A3. Grid metrics need a -> grouping dimension. -> -> ✅ **G12 has landed.** `summary()` now reports a grid metric as unavailable with a reason instead of -> erroring, via `.single_grid()` over `grid_index()`'s classed conditions. It does **not** make MET work — -> it stops `summary()` dying and stops `.efficiency_factor()` reporting `1.855529` — so G13 stands. See -> A1.2. -> -> ✅ **G11 has landed.** The grid-construction hot-loop cost this branch introduced is gone: coordinate -> validation is split into `grid_index()` and hoisted once per run, grid build is back to parity with the -> `matrix()` reshape it replaced, and whole `speed()` runs are 10-28% faster with bit-identical scores. -> See A1.1. +> duplicate coordinates. `main` silently discarded **30 of 80** plots; this branch errors instead. Neither +> works, and coordinate construction did not cause it. Grid metrics need a grouping dimension — see A3. > -> ⬜ **Moved out, each needing its own branch:** the A-efficiency upper bound and the missing intercept -> (`REVIEW-NOTES-EFFICIENCY.md`); the S3 class collision, the `initialise_design_df()` fill order and -> the redundant row-major sort (`KNOWN_ISSUES.md` #2, #3, #4). +> 🔶 **D7 is the one open statistical question** and it blocks the last part of G13: what a grid metric +> should report per site versus pooled. Adjacency and neighbour balance are settled (they sum exactly); +> efficiency and piepho's ED are not. See A4. --- ## A1. Landed on this branch -Kept as a one-line inventory for the PR description. The full write-ups are in git history. +One-line inventory for the PR description. Full write-ups are in git history. | Was | Now | |---|---| | **G1** four functions each assumed a data ordering, two row-major and two column-major | all four read coordinates via `build_design_matrix()` or a coordinate-indexed fill | | **G2** `objective_function_piepho()` wrote a column-major flattened grid back over the treatment column | write-back deleted; all four score components computed on the real layout, and piepho is order-invariant | -| **G3** `build_design_matrix()` didn't validate coordinates | explicit non-numeric / non-positive-integer / duplicate-coordinate errors | +| **G3** `build_design_matrix()` didn't validate coordinates | explicit missing-column / non-numeric / non-positive-integer / duplicate-coordinate errors, each its own condition class | | **G4** `.calculate_nb()` errored on sparse grids, the default path | `NA` neighbours are skipped, matching the `pair_mapping` path | | **G5** a scalar `ring_weights` errored against multi-ring `ring_dists` | recycled across every ring | -| **G6** `calculate_efficiency_factor()` couldn't compute for a buffered design (tracked as `KNOWN_ISSUES` #1b, since removed) | resolved as a side effect of G7 — coordinate indexing absorbs the offset `add_buffers()` introduces, and a genuinely holed grid computes too; verified 2026-08-06 | +| **G6** `calculate_efficiency_factor()` couldn't compute for a buffered design | resolved as a side effect of G7 — coordinate indexing absorbs the offset `add_buffers()` introduces, and a genuinely holed grid computes too; both cases now pinned in `test-grid-orientation.R` — the fix was closed with no coverage until then | | **G7** `calculate_efficiency_factor()` filled `Z` positionally, returning a different value per row ordering (0.111 vs 0.625 on a 2×6, and values `> 1`) | `Z` is indexed by each plot's own coordinates | -| **S1** `.neighbour_balance()` reported self-adjacencies that didn't exist (6 where the truth was 0) | reads coordinates; the 4×3 fixture now returns the hand-derived truth (self 0, pair min/max 5/6) | +| **S1** `.neighbour_balance()` reported self-adjacencies that didn't exist (6 where the truth was 0) | reads coordinates; the 4×3 fixture returns the hand-derived truth (self 0, pair min/max 5/6) | | **A5** lexical factor levels (`1, 10, 11, 2, …`) defeated the row-major sort, so grid metrics scored a layout that wasn't the design | coordinate construction is immune; the sort is no longer load-bearing (`KNOWN_ISSUES.md` #4) | -| **G11** coordinate validation ran on every iteration, making grid construction 16× the `matrix()` reshape it replaced | validation split into `grid_index()`, hoisted once per `speed()` run; grid build back to parity with `matrix()`, whole runs 10-28% faster — see A1.1 | -| **G12** `summary()` died outright on any design that couldn't be gridded — a MET design or non-numeric `row`/`col` labels — because `.neighbour_balance()` let `build_design_matrix()`'s error escape | `.single_grid()` gates `.neighbour_balance()`, `.efficiency_factor()` and `.replicate_spans()`, each reporting a reason; `has_grid` now means "reportable as one grid", so `layout` stops claiming an `nrow` x `ncol` that holds fewer plots than the design — see A1.2 | - -### A1.1 G11 — validation hoisted out of the annealing loop - -`build_design_matrix()` gained an optional `index =` argument; the coercion and validation half moved -into `grid_index()`, which returns the index and grid dimensions. `speed_hierarchical()` builds one per -run and threads it to the objective functions, `calculate_adjacency_score()` and -`objective_function_piepho()`. +| **G11** coordinate validation ran on every iteration, making grid construction 16× the `matrix()` reshape it replaced | validation split into `grid_index()`, hoisted once per `speed()` run and built lazily so a design that cannot be gridded still optimises if its objective never needs a grid; build back to parity — see A1.1 | +| **G12** `summary()` died outright on any design that couldn't be gridded — MET, or non-numeric `row`/`col` labels | `.single_grid()` maps `grid_index()`'s condition classes to a reason and gates `.neighbour_balance()`, `.efficiency_factor()` and `.replicate_spans()`; `has_grid` now means "reportable as one grid", so `layout` stops claiming an `nrow` × `ncol` holding fewer plots than the design | -**Built lazily, on purpose.** `grid_index()` is wrapped in `tryCatch` so a design whose coordinates -cannot form a grid — MET duplicates (G13), non-numeric labels, or no grid columns at all — still runs if -its objective never needs a grid, and still raises the same error from the same place if it does. -`index = NULL` reproduces the old behaviour exactly. Verified against a pre-fix worktree: a two-site -duplicate-coordinate design with `adj_weight = 0` runs and scores 4.000 both before and after. +### A1.1 G11 measurements -**Measured 2026-08-06**, grid build on 700 plots (28×25), 2000 reps: +Kept because they are the PR's evidence and not recoverable from the code. Grid build on 700 plots +(28×25), 2000 reps: | | µs/build | vs `matrix()` | |---|---|---| @@ -100,8 +70,8 @@ duplicate-coordinate design with `adj_weight = 0` runs and scores 4.000 both bef | `build_design_matrix()`, no index | 240 | 16.00× | | `build_design_matrix()`, index supplied | **15** | **1.00×** | -End-to-end, benchmarked against a clean worktree at `69c516d`. **Scores are bit-identical in every -case** — this is a performance change only: +End-to-end against a clean worktree at `69c516d`. **Scores are bit-identical in every case** — this is a +performance change only: | | before | after | | |---|---|---|---| @@ -110,177 +80,65 @@ case** — this is a performance change only: | `objective_function`, 120 plots, 5000 iters | 2.46 s | **2.17 s** | −12% | | `objective_function_piepho`, 120 plots, 1000 iters | 1.64 s | **1.47 s** | −10% | -Larger designs gain most, since grid construction is a bigger share of each iteration. The earlier -prediction that hoisting would land *below* `matrix()` did not hold — it lands at parity, because the -`as.character()` coercion of the treatment column stays per-iteration and is what remains of the cost. -That coercion is *not* hoistable the way validation was: the swap column is the one thing annealing -mutates, so each call re-does the level lookup and allocates a fresh length-`n` character vector. The -below-parity figure came from a micro-benchmark that pre-coerced the column outside the timing loop, which -only holds if nothing is being optimised. Parity is therefore the floor for a rebuild-per-iteration grid, -and it is accepted: the only way past it is to carry a mutable grid across iterations, which was -considered and **ruled out** (Sam, 2026-08-06). It would need a contract change to the objective-function -signature, and it is capped at under 3% of a run — the grid build is ~15 µs of a ~570 µs iteration, while -`adjacency_score_vec()` is O(n × offsets) per iteration however the grid arrives. - -`grid_index()` also gained a missing-column check, found by this work: a design with no grid columns -reached `max()` with empty vectors and produced `-Inf` dimensions plus two warnings. - -### A1.2 G12 — `summary()` reports a reason instead of dying - -Before: `summary()` on a MET design, or on one with non-numeric `row`/`col` labels, errored outright — -both optimise fine with `adj_weight = 0`, so the design object was valid but not summarisable. `main` -returned numbers for both, wrong ones via the truncation in G13, so this branch had narrowed what -`summary()` accepted. - -`.single_grid(df, rc, cc)` returns `TRUE` or a short reason, and gates `.neighbour_balance()`, -`.efficiency_factor()` and `.replicate_spans()`. Each now reports `available = FALSE` with that reason, -which the print method already handled. - -**It delegates rather than re-implementing the coordinate rules.** The obvious build was a second copy of -`grid_index()`'s four checks, which is exactly the drift risk worth avoiding — G11 had just made -`grid_index()` the single place that decides what a valid grid is. Instead `grid_index()` signals *which* -rule failed by condition class (`speed_grid_missing` / `_nonnumeric` / `_notinteger` / `_duplicate`, all -inheriting `speed_grid_error`, via `.grid_stop()`), and `.single_grid()` maps the class to a short reason. -No message-text matching, no duplicated rules, and the happy path is untouched — conditions are only -constructed on failure. - -Three things fell out that were not in the G12 write-up: - -| | | -|---|---| -| `.efficiency_factor()` needed the gate for correctness, not just to avoid an error | it does not error on duplicate coordinates — it pools the grids and returns **1.855529**, impossible for an efficiency factor. Pinned by a test that asserts the underlying `> 1` behaviour, so the reason the gate exists cannot quietly disappear | -| `.replicate_spans()` was also wrong for MET, not merely noisy | it pooled sites, making two sites' row 3 one plot apart. Gating it fixes that and removes the two leaked `NAs introduced by coercion` warnings at the same time | -| `layout` claimed an impossible shape | `has_grid` now means "reportable as **one** grid", so a MET design reports `80 plots` rather than `10 rows x 5 cols (80 plots)` — a grid holding 50. New `layout$grid_reason` carries why. `nrow`/`ncol` are `NA` unless `has_grid`, which is what the existing non-grid test already asserted | - -One test changed rather than being added: the `.efficiency_factor()` "computation fails" case used a 1×1 -grid holding three plots, which the new gate catches earlier as duplicate coordinates. Its `tryCatch` -backstop is now covered by `local_mocked_bindings()` instead, which tests the intent directly. - -Reasons are phrased as facts, not interpretations — `duplicate row/col coordinates (e.g. a multi-site -design)` rather than `spans multiple grids`, because a malformed design looks identical to a MET one. - -**Coverage added** (`test-summary.R`), where there was none — `grep site tests/testthat/test-summary.R` -previously returned nothing, which is why the suite passed while `summary()` was broken for MET: -`.single_grid()` unit cases including a genuine hole and lexical factor levels, which must *not* be -refused; a MET design through `speed()` → `summary(efficiency = TRUE)`; the `> 1` efficiency pin; -non-numeric labels asserted warning-free; and a split-plot asserting the gate does **not** withhold -metrics from a legitimate hierarchical design at either level. - -Suite after: **1728 pass, 0 fail, 0 error, 0 warn.** +Parity, not the below-parity figure first predicted: the `as.character()` coercion of the swap column stays +per-iteration and is what remains of the cost. It is not hoistable the way validation was — the swap column +is the one thing annealing mutates, so each call re-does the level lookup and allocates a fresh length-`n` +character vector. Parity is therefore the floor for a rebuild-per-iteration grid, and it is accepted: +carrying a mutable grid across iterations instead was considered and **ruled out** (Sam, 2026-08-06) — it +needs a contract change to the objective-function signature and is capped at under 3% of a run, since the +build is ~15 µs of a ~570 µs iteration while `adjacency_score_vec()` is O(n × offsets) per iteration +however the grid arrives. --- -## A2. Decisions - -### 🔷 D6. Do buffers break adjacency? — **settled: buffers never reach the metrics** (2026-08-06) - -**Answer: a buffered design must score exactly as the same design unbuffered.** Plots either side of a -buffer **are** neighbours. - -**Rationale (Sam):** when a buffered trial is *analysed*, the buffer plots are excluded and the model is -fitted on the remaining plots, which treats them as a contiguous grid — adjacent even where they are -physically separated. A design metric should describe the layout the analysis will see, not the physical -field. - -**Mechanism — decided twice, and the second answer is the one to keep.** The question was originally -framed as raw-vs-ranked coordinates inside `build_design_matrix()`, and answered "ranked". That framing -was wrong. `add_buffers()` displaces the real plots' coordinates to make room (`row + 1` for `"edge"`, -`row * 2` for `"row"`, `3 * row - 1` for `"double row"`, …) and never undoes it. Ranking is *exactly the -inverse of that displacement* — verified for all five buffer types, ranking the de-buffered coordinates -restores the original `1..n` precisely. So ranking was never a statistical position on buffers; it was -an undo, applied by inference, in the wrong place. - -Inferring it downstream also cannot tell a buffer from a real hole, so it collapses genuine physical -gaps — a road, an irregular trial edge — for no benefit. - -**So: the displacement is undone where it is created.** `add_buffers()` records what it did in -`metadata$buffer`, and `.drop_buffer_rows()` inverts it before any metric runs (`feature/buffers`, A2.1). -`build_design_matrix()` keeps **raw** coordinates. That satisfies D6 *and* preserves real gaps — -strictly better than ranking, which bought the first at the cost of the second. - -Measured on a 4×3 design with the restoration in place: `"edge"`, `"row"`, `"col"`, `"double row"` and -`"double col"`, and stacked combinations, all reproduce the unbuffered design's neighbour balance, -replicate span and efficiency exactly. - -**Longer term this stops being speed's problem at all.** `add_buffers()` is deprecated as of 0.0.10 and -moving to \pkg{biometryassist} (see `BUFFERS-HANDOFF.md` in that repo). Once it is gone, speed never creates a -displacement, so the restoration goes too and raw coordinates are simply correct with nothing to undo. - -This also settles S3 in `REVIEW-NOTES-SUMMARY.md`: `main`'s `length(unique(...))` behaviour was the -right *behaviour*; the defect was that the choice had never been stated. +## A2. What arrives when `feature/buffers` merges -G4's `NA` tolerance stays necessary regardless — a design with a genuine partial hole still produces a -sparse grid under raw coordinates. - -### 🔶 D7. What should the grid metrics report for a multi-grid (MET) design? — **open** - -Blocks the last part of G13. Adjacency and neighbour balance answer themselves — they count edges, edges -never cross a grid boundary, and summing per grid is exact (measured: 20 + 30 = 50). Two components do -not follow: - -| Component | Question | -|---|---| -| `calculate_efficiency_factor()` | An efficiency factor is a property of one experiment's information matrix; there is no meaningful sum. Options: (a) report per-site values, (b) fit one model with site effects added, (c) declare it unavailable for multi-grid designs. | -| `objective_function_piepho()`'s ED | NB sums like any edge count. ED measures evenness of a distribution, so per-grid-then-averaged and pooled are different quantities and the paper's definition assumes a single trial. | - -Note (c) is not merely the conservative option — it is currently *required* as an interim state either -way, because the alternative is continuing to return `1.855529` for a quantity bounded above by 1. - -Whatever is chosen, the same answer should apply to `summary()`'s `efficiency` entry and to -`.neighbour_balance()`, so the two never disagree about what a MET design's diagnostics mean. - -### A2.1 What arrives when `feature/buffers` merges - -Branched off `f5d68f5`, so it applies cleanly. Two items below are already done there — **do not action -them again**: +Branched off `f5d68f5`, so it applies cleanly. Two items are already done there — **do not action them +again**: | From `feature/buffers` | Effect here | |---|---| -| `metadata$buffer` transform record in `add_buffers()`, inverted by `.drop_buffer_rows()` / `.restore_buffer_coords()` | makes D6 true without touching `build_design_matrix()` | +| `metadata$buffer` transform record in `add_buffers()`, inverted by `.drop_buffer_rows()` / `.restore_buffer_coords()` | satisfies the `KNOWN_ISSUES.md` #1 convention without touching `build_design_matrix()` | | `test-summary.R` buffer test rewritten | **fixes the stale comment at [test-summary.R:304](tests/testthat/test-summary.R#L304)**, which still claims a `"row"` buffer should change the counts. Now asserts every buffer type and stacked combinations match the unbuffered design | | `add_buffers()` deprecation warning + `## Deprecations` NEWS section | buffers are leaving speed; the biometryassist repo's `BUFFERS-HANDOFF.md` specifies that side | | `.warn_if_buffers()` in `calculate_adjacency_score()`, `calculate_balance_score()`, `calculate_efficiency_factor()` | a direct metric call on a buffered frame bypasses `.drop_buffer_rows()`, so it warns rather than silently scoring the displaced layout | | `helper-buffers.R` with `add_buffers_quiet()`, and 45 rewritten test call sites | keeps the deprecation warning out of tests that are about layout | One caveat carried forward: the `metadata$buffer` record is an affine `scale`/`shift` pair, which covers -speed's buffer types but **cannot** represent biometryassist's `by =` block buffers, where gaps appear -only at group boundaries. It would need to become a per-axis `new -> old` lookup if speed ever had to -invert one of those. Under the handoff plan it never does. +speed's buffer types but **cannot** represent biometryassist's `by =` block buffers, where gaps appear only +at group boundaries. It would need to become a per-axis `new -> old` lookup if speed ever had to invert one +of those. Under the handoff plan it never does. --- -## A3. Open findings +## A3. G13 🔴 There is no representation of a design occupying more than one grid, so MET is broken -### G13 🔴 There is no representation of a design occupying more than one grid, so MET is broken - -**One root cause, four symptoms.** `build_design_matrix()` — and `matrix()` before it — models a design -as *a* grid. A multi-environment trial is several grids that share a treatment set and must never share -an edge. `initialise_multiple_designs_df()` ([design_utils.R:539](R/design_utils.R#L539)) reuses `row`/`col` -per site, so **every** MET design built the documented way has duplicate coordinates, and nothing -anywhere records which column separates the grids. +**One root cause, four symptoms.** `build_design_matrix()` — and `matrix()` before it — models a design as +*a* grid. A multi-environment trial is several grids that share a treatment set and must never share an +edge. `initialise_multiple_designs_df()` ([design_utils.R:539](R/design_utils.R#L539)) reuses `row`/`col` +per site, so **every** MET design built the documented way has duplicate coordinates, and nothing anywhere +records which column separates the grids. **Measured 2026-08-06** on `initialise_design_df(items = c(rep(1:10, 6), rep(11:20, 8)), designs = list(a = list(nrows = 10, ncols = 3), b = list(nrows = 10, ncols = 5)))` — 80 plots, 10 unique rows, 5 unique cols: -| Symptom | `main` | this branch, before G12 | now | -|---|---|---|---| -| `.neighbour_balance()` | 50-cell grid from 80 plots: **30 plots silently discarded**, one `data length differs from size of matrix` warning | errors, taking all of `summary()` with it | reported unavailable, with a reason | -| `calculate_adjacency_score()` | garbage from the same truncation | **errors** | **errors** — correct for a direct call, but there is still no way to get the right number | -| `calculate_efficiency_factor()` | pools sites into one row/col model | returns `1.855529`, silently — a value `> 1` is impossible | still `1.855529` on a direct call; withheld in `summary()` | -| sites laid side by side in one grid (`col + 3` for site b, so coordinates *are* unique) | **60** adjacencies vs **50** summing per site — 10 phantom cross-site edges | **identical, 60** | **still 60** | - -Read the last two rows carefully. This is **not** a regression this branch introduced, and it is **not -fixed by validation or by G12's gate**: duplicate coordinates don't break coordinate *indexing*, they just -quietly pool, and the side-by-side case has no duplicates at all, so no error can fire and no gate can -catch it. Both are silent wrong answers on `main` and on this branch. What the branch changed is the first -two rows, from silently wrong to loudly wrong; what G12 changed is that `summary()` survives saying so. -The duplicate-coordinate error is doing its job — it is the only reason any of this is visible — but it is -a diagnostic, not the fix. +| Symptom | `main` | this branch | +|---|---|---| +| `.neighbour_balance()` | 50-cell grid from 80 plots: **30 plots silently discarded**, one `data length differs from size of matrix` warning | reported unavailable, with a reason (G12) | +| `calculate_adjacency_score()` | garbage from the same truncation | **errors** — correct for a direct call, but there is still no way to get the right number | +| `calculate_efficiency_factor()` | pools sites into one row/col model | still `1.855529` on a direct call — a value `> 1` is impossible; withheld inside `summary()` only | +| sites laid side by side in one grid (`col + 3` for site b, so coordinates *are* unique) | **60** adjacencies vs **50** summing per site — 10 phantom cross-site edges | **still 60** | + +Read the last two rows carefully. This is **not** a regression this branch introduced and it is **not fixed +by validation or by G12's gate**: duplicate coordinates don't break coordinate *indexing*, they just quietly +pool, and the side-by-side case has no duplicates at all, so no error can fire and no gate can catch it. +The duplicate-coordinate error is doing its job — it is the only reason any of this is visible — but it is a +diagnostic, not the fix. **Adjacency and neighbour balance are summable over grids.** Measured: per-site adjacency 20 + 30 = **50**, -which is the correct whole-design figure. Both count edges, and edges never cross a grid boundary, so -summing per grid is exact rather than an approximation. That makes the fix tractable. +the correct whole-design figure. Both count edges, and edges never cross a grid boundary, so summing per +grid is exact rather than an approximation. That makes most of the fix tractable. **Implementation sketch.** @@ -294,37 +152,92 @@ summing per grid is exact rather than an approximation. That makes the fix tract 3. **A list-of-grids primitive.** `build_design_matrices(df, swap, rc, cc, by = NULL)` returning a named list, length 1 when `by` is `NULL`. `build_design_matrix()` stays exactly as it is — the single-grid primitive, still strict. Sum `adjacency_score_vec()` and the `calculate_nb()` pair tables across the - list. Note this wants a *list of indices* from `grid_index()` per grid, so it composes with G11's + list. This wants a *list of indices* from `grid_index()`, one per grid, so it composes with G11's hoisting rather than reintroducing per-iteration validation. 4. **Auto-detection is the wrong instinct.** Duplicate coordinates with no `by` should keep erroring, and - the current message already names the remedy. Inferring the grouping from a `"site"`-like column name - is how the ordering bugs in G1 happened. If it should be automatic, have + the current message already names the remedy. Inferring the grouping from a `"site"`-like column name is + how the ordering bugs in G1 happened. If it should be automatic, have `initialise_design_df(designs = )` record `design_col` as an attribute so it is *transported* rather than guessed. -5. **Efficiency is not summable** — see D7. G12 already withholds it inside `summary()`; a direct - `calculate_efficiency_factor()` call still returns `1.855529`, so the refusal needs to move into the - function itself once D7 says what the right answer is. +5. **Efficiency and ED are not summable** — see D7. `calculate_efficiency_factor()` needs to refuse a + multi-grid frame rather than return `1.855529`; G12 withholds it inside `summary()`, but a direct call + still doesn't. 6. **Then relax G12's gate.** `.single_grid()` is deliberately the *only* place `summary()` decides a design isn't griddable, so once the metrics take a grouping factor, MET designs stop reaching it and it - covers only genuinely un-griddable input. The split-plot test added with G12 is what guards against the - gate over-reaching in the meantime. + covers only genuinely un-griddable input. The split-plot test added with G12 guards against the gate + over-reaching in the meantime. -Scope check: items 1-4 are mechanical given the summability result. Item 5 and `objective_function_piepho()`'s -ED component are the parts that need a statistical answer first, and both can be gated to "unavailable" -in the meantime so MET adjacency and neighbour balance land without waiting on them. +Scope check: items 1-4 are mechanical given the summability result. Item 5 needs D7 first, and can stay +gated to "unavailable" in the meantime so MET adjacency and neighbour balance land without waiting on it. --- -## A4. Corrections that still matter +## A4. 🔶 D7. What should the grid metrics report for a multi-grid (MET) design? — **open** + +Adjacency and neighbour balance answer themselves: they count edges, edges never cross a grid boundary, and +summing per grid is exact (measured, 20 + 30 = 50). Efficiency and `objective_function_piepho()`'s ED do +not follow, and for the same reason — they are properties of an assumed *model*, not counts. + +**An efficiency factor is relative to a model, and speed's implied model is `y ~ trt + row + col`.** +`calculate_efficiency_factor()` eliminates a row-effect and column-effect nuisance space from the treatment +information matrix. A MET is not analysed that way: the residual structure is separate per site (a `dsum()` +term in `asreml()`), and row/column effects are nested within site rather than shared across sites. So the +pooled number speed currently produces corresponds to no model anyone fits. + +**Measured 2026-08-06** — two sites, 8 treatments × 3 reps per site, 4×6 grids. The reference +implementation reproduces `calculate_efficiency_factor()` to the digit wherever the design is full rank, so +the only thing varying below is the nuisance space: + +| | value | +|---|---| +| per site A / site B | 0.547 / 0.427 | +| pooled, row/col nested within site (the `dsum`-shaped model) | **0.566** | +| pooled, plain `row + col` — **what speed does now** | **0.807** | + +Two conclusions. The current pooled value is **inflated** — 0.807 against 0.566 — because pooling makes +"row 3" one factor level across both sites, borrowing strength that does not exist in the field. And +per-site-then-averaged is a different quantity again: (0.547 + 0.427)/2 = 0.487, not 0.566. There is no +aggregation shortcut. + +**Per site is the right unit, but it cannot be unconditional.** Measured on the commonest MET shape — 12 +entries, each appearing **once per site**, 4×3 grids — the per-site value is **1.833**, impossible for an +efficiency factor. With `r = 1` inside a site there are not enough residual degrees of freedom to estimate +the treatment contrasts after eliminating row and column effects, so the information matrix is singular and +the pseudo-inverse returns a meaningless number. (The pooled site-nested model for that design gives 0.105 +— low, but at least defined, because replication *across* sites is real replication.) So reporting per-site +values by default would print garbage for exactly the designs MET support exists for. + +**Recommendation, in build order.** + +1. **Refuse for multi-grid designs, with a reason.** Required as an interim state either way — the + alternative is continuing to return `1.855529` for a quantity bounded above by 1. G12 already does this + inside `summary()`; G13 item 5 moves it into `calculate_efficiency_factor()`. +2. **Then per-site values, gated on per-site estimability** — that site's own information matrix must have + rank `k - 1`, i.e. every treatment contrast estimable within the site. Labelled per site, never summed + or averaged. This is the design-actionable quantity, because the layout that can be changed is the one + within a site. A site failing the rank test reports unavailable individually rather than poisoning the + whole vector. +3. **The combined-analysis number only as an explicit opt-in.** It is computable (0.566 above) but needs + row/col nested within site *and* an assumption of equal residual variance across sites — precisely what + `dsum()` denies. Under unequal variances the combined efficiency depends on variance ratios that are + unknown at design time, so no single design-time number exists. Never the default, and never the current + plain `row + col` pooling. + +Item 2 is not misleading provided it is labelled per site and no aggregate is offered alongside it; the +misleading options are the pooled 0.807 and any average of the per-site values. + +**`objective_function_piepho()`'s ED needs the same treatment.** NB sums like any edge count, but ED +measures evenness of a distribution, so per-grid-then-averaged and pooled are different quantities and the +paper's definition assumes a single trial. Whatever is decided, the same answer must apply to `summary()`'s +`efficiency` entry and to `.neighbour_balance()`, so the two never disagree about what a MET design's +diagnostics mean. + +--- -Corrections to superseded findings have been dropped along with the findings. These bear on open items. +## A5. Corrections bearing on open items | Earlier claim | Corrected | |---|---| -| **Rank** the coordinates and you destroy real physical gaps, so validate instead | **Right, and it survived a detour.** Briefly overruled in favour of ranking; reinstated once it was clear that ranking is only the inverse of `add_buffers()`' displacement. Undo the displacement at its source and raw coordinates preserve genuine gaps at no cost. See D6. | -| D6 is a statistical question about whether buffers separate plots | **Not really.** It looked like one, but the only thing making a buffered design score differently was `add_buffers()` rewriting the real plots' coordinates. Verified: ranking the de-buffered coordinates restores the original `1..n` exactly, for all five buffer types. The statistical question is settled trivially — buffers must not change anything — and the rest was an implementation leak. | -| **G10** — `build_design_matrix()` must rank its coordinates to satisfy D6 | **Withdrawn, do not implement.** Ranking in `build_design_matrix()` would infer the undo in the wrong place and collapse real holes as collateral. `feature/buffers` records the displacement in `metadata$buffer` and inverts it in `.drop_buffer_rows()` instead; `build_design_matrix()` stays raw. G10's other three sub-items are also resolved: the `test-summary.R` comment is rewritten on that branch, the NEWS sentence has already been removed here, and `calculate_efficiency_factor()` needs no change (G6). | -| An efficiency factor `> 1` is a canary for the ordering bug (G7) | **Too narrow.** `> 1` signals rank deficiency however it arises. Measured: degenerate fixtures where treatment is confounded with row — which is what `initialise_design_df(rep(LETTERS[1:k], m), ...)` produces, see `KNOWN_ISSUES.md` #3 — return values `> 1` in **row-major** order too, on `main`. It is a canary for "something is wrong", not for ordering specifically. | -| MET only needs a gate in `summary()`; the grid code itself is fine | **Wrong, and too narrow twice over.** The gate (G12) is real but it only stops `summary()` crashing — it does not make MET work, which is the actual requirement. And two silent wrong answers survive any amount of gating: `calculate_efficiency_factor()` returns **1.855529** on a MET frame because duplicate coordinates pool rather than error, and a MET design laid side by side in one grid has *no* duplicate coordinates yet still counts **10 phantom cross-site edges** (60 vs 50). Validation cannot catch either. Grid metrics need a grouping dimension — see G13. | -| `main`'s MET behaviour was "garbage, with a warning" | **Quantified:** the `matrix()` reshape built 50 cells from 80 plots, **silently discarding 30**, with one `data length differs from size of matrix` warning. Worth stating precisely because it is the reason this branch's hard error is an improvement even though it is not the fix. | -| Hot-loop cost of `build_design_matrix()` is **2.84×** (415 → 1180 µs/build) | **Superseded by a cleaner measurement.** On the same 28×25 fixture it is **11.5×** (20 → 230 µs/build); the earlier figure bundled other work into both arms. The ratio is worse than thought and the absolute cost lower, but the actionable finding held up: 87% was loop-invariant validation, and hoisting it recovered parity. See A1.1. | +| An efficiency factor `> 1` is a canary for the ordering bug (G7) | **Too narrow.** `> 1` signals rank deficiency however it arises. Measured: degenerate fixtures where treatment is confounded with row — which is what `initialise_design_df(rep(LETTERS[1:k], m), ...)` produces, see `KNOWN_ISSUES.md` #3 — return values `> 1` in **row-major** order too, on `main`; so does a MET site with `r = 1` (D7). It is a canary for "something is wrong", not for ordering specifically. | +| MET only needs a gate in `summary()`; the grid code itself is fine | **Wrong, and too narrow twice over.** The gate (G12) only stops `summary()` crashing — it does not make MET work, which is the actual requirement. And two silent wrong answers survive any amount of gating: `calculate_efficiency_factor()` returns **1.855529** on a MET frame because duplicate coordinates pool rather than error, and a MET design laid side by side in one grid has *no* duplicate coordinates yet still counts **10 phantom cross-site edges** (60 vs 50). Validation cannot catch either. | +| `main`'s MET behaviour was "garbage, with a warning" | **Quantified:** the `matrix()` reshape built 50 cells from 80 plots, **silently discarding 30**, with one `data length differs from size of matrix` warning. Worth stating precisely because it is why this branch's hard error is an improvement even though it is not the fix. | From 2e030d0a3fbf0fabfd19b9d6a37ad07c845e435d Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:28:51 +0930 Subject: [PATCH 16/28] Updating plan --- REVIEW-NOTES-OTHER.md | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index 26208afe..ba4518c1 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -12,7 +12,7 @@ branch made the grid contract strict, so it owns the consequences of that strict |---|---| | `REVIEW-NOTES.md` | `feature/incidence` (PR #97) — `R/incidence.R` | | `REVIEW-NOTES-SUMMARY.md` | the merged `summary()` work — `R/summary.R` | -| `REVIEW-NOTES-EFFICIENCY.md` | efficiency-factor statistics — needs a new branch | +| `REVIEW-NOTES-EFFICIENCY.md` | efficiency-factor statistics — branch `feature/a-optimality` exists | | `REVIEW-NOTES-PR91.md` | PR #91 `info-objective` | | **this file** | grid construction / core metrics | @@ -24,7 +24,12 @@ and the reason `build_design_matrix()` keeps coordinates **raw** — is `KNOWN_I **Last verified:** 2026-08-06, R 4.6.1, `pkgload::load_all()`. All numbers measured, not inferred. Resolved findings and settled decisions are deleted rather than annotated — see git history and `NEWS.md`. -> ✅ **G1-G12 have landed** — see A1. Full suite: **1738 pass, 0 fail, 0 warn.** +> ✅ **The branch's original scope is closed.** The plan as committed at `b1d7adc` listed D6, D1 and +> G1-G6, plus the hot-loop cost recorded as out of scope; all are done, the last as G11. G7, S1, A5, G11 +> and G12 were found during the work. See A1. Full suite: **1738 pass, 0 fail, 0 warn.** +> +> ⬜ **Deliberately still open from the original plan:** removing the row-major sort, which A4.7 recorded +> as out of scope on purpose (`KNOWN_ISSUES.md` #4). > > 📦 **`feature/buffers` merges into this branch** (branched off `f5d68f5`), carrying the coordinate > restoration that makes raw coordinates correct plus the `add_buffers()` deprecation. See A2 — two items @@ -99,7 +104,7 @@ again**: | From `feature/buffers` | Effect here | |---|---| | `metadata$buffer` transform record in `add_buffers()`, inverted by `.drop_buffer_rows()` / `.restore_buffer_coords()` | satisfies the `KNOWN_ISSUES.md` #1 convention without touching `build_design_matrix()` | -| `test-summary.R` buffer test rewritten | **fixes the stale comment at [test-summary.R:304](tests/testthat/test-summary.R#L304)**, which still claims a `"row"` buffer should change the counts. Now asserts every buffer type and stacked combinations match the unbuffered design | +| `test-summary.R` buffer test rewritten | **fixes the stale comment at [test-summary.R:299-305](tests/testthat/test-summary.R#L299-L305)**, which still claims a `"row"` buffer should change the counts — the opposite of the `KNOWN_ISSUES.md` #1 convention. Now asserts every buffer type and stacked combinations match the unbuffered design | | `add_buffers()` deprecation warning + `## Deprecations` NEWS section | buffers are leaving speed; the biometryassist repo's `BUFFERS-HANDOFF.md` specifies that side | | `.warn_if_buffers()` in `calculate_adjacency_score()`, `calculate_balance_score()`, `calculate_efficiency_factor()` | a direct metric call on a buffered frame bypasses `.drop_buffer_rows()`, so it warns rather than silently scoring the displaced layout | | `helper-buffers.R` with `add_buffers_quiet()`, and 45 rewritten test call sites | keeps the deprecation warning out of tests that are about layout | @@ -109,13 +114,29 @@ speed's buffer types but **cannot** represent biometryassist's `by =` block buff at group boundaries. It would need to become a per-axis `new -> old` lookup if speed ever had to invert one of those. Under the handoff plan it never does. +### A2.1 Merge order: PR #97 depends on this branch + +This branch exists because the grid work was extracted out of `feature/incidence` (D1 in the original +plan). **Verified 2026-08-06** — the extraction is complete and the dependency now runs one way: + +- `feature/incidence` touches only `R/incidence.R`, docs and tests. It no longer carries any of + `R/design_utils.R`, `R/calculate_adjacency_score.R` or `R/metrics.R`, so there is nothing to conflict. +- But `incidence.R:69` calls `build_design_matrix()`, and that function **does not exist on `main`**. + +So **PR #97 cannot merge until this branch does**, and `feature/incidence` has not been rebased onto it +(`git merge-base --is-ancestor bugfix/grid-orientation feature/incidence` → false). Rebase it after this +branch lands. It calls `build_design_matrix()` without an `index`, which is correct — incidence is a +one-off diagnostic, not in the annealing loop — but it does re-implement its own `missing_cols` check, +the same duplication G12 avoided by delegating to `grid_index()`'s condition classes. Worth collapsing +when it rebases. + --- ## A3. G13 🔴 There is no representation of a design occupying more than one grid, so MET is broken **One root cause, four symptoms.** `build_design_matrix()` — and `matrix()` before it — models a design as *a* grid. A multi-environment trial is several grids that share a treatment set and must never share an -edge. `initialise_multiple_designs_df()` ([design_utils.R:539](R/design_utils.R#L539)) reuses `row`/`col` +edge. `initialise_multiple_designs_df()` ([design_utils.R:520](R/design_utils.R#L520)) reuses `row`/`col` per site, so **every** MET design built the documented way has duplicate coordinates, and nothing anywhere records which column separates the grids. @@ -146,8 +167,8 @@ grid is exact rather than an approximation. That makes most of the fix tractable Verified backwards compatible — `infer_row_col()` reads only `$dim1`/`$dim2`, and `speed()` already accepts the three-element list without complaint. That tolerance is itself a trap: a mistyped `by` is silently ignored today, so this needs validation added at the same time. -2. **Record it.** `metadata` currently holds only `row_column` / `col_column` - ([speed.R:401-406](R/speed.R#L401-L406)), which is why `summary()` cannot recover the grouping on its +2. **Record it.** `metadata` currently holds only `levels` / `row_column` / `col_column` / `per_level` + ([speed.R:418-423](R/speed.R#L418-L423)), which is why `summary()` cannot recover the grouping on its own. Add `grid_by`. 3. **A list-of-grids primitive.** `build_design_matrices(df, swap, rc, cc, by = NULL)` returning a named list, length 1 when `by` is `NULL`. `build_design_matrix()` stays exactly as it is — the single-grid From 95c48fba381d3a8df5bafe80ea8d0bf092542c17 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:46:51 +0930 Subject: [PATCH 17/28] Fixing overengineering --- R/design_utils.R | 41 +++-- R/metrics.R | 16 +- R/speed.R | 10 +- R/summary.R | 146 ++++++------------ REVIEW-NOTES-OTHER.md | 123 ++++++++++++--- man/build_design_matrix.Rd | 5 +- man/dot-efficiency_factor.Rd | 4 +- man/dot-grid_stop.Rd | 12 +- man/dot-neighbour_balance.Rd | 25 ++- man/dot-replicate_spans.Rd | 4 +- man/dot-single_grid.Rd | 34 ---- man/grid_index.Rd | 6 +- man/summary.design.Rd | 5 +- .../test-calculate_efficiency_factor.R | 5 - tests/testthat/test-grid-orientation.R | 28 ++-- tests/testthat/test-summary.R | 82 +++++----- 16 files changed, 277 insertions(+), 269 deletions(-) delete mode 100644 man/dot-single_grid.Rd diff --git a/R/design_utils.R b/R/design_utils.R index ae1c9fe2..e79ffd77 100644 --- a/R/design_utils.R +++ b/R/design_utils.R @@ -867,19 +867,20 @@ initialize_design_df <- initialise_design_df #' Signal a Coordinate Problem with a Classed Condition #' #' @description -#' The message is for someone calling a metric directly; the class lets -#' `.single_grid()` report the same problem as a short reason in a `summary()` -#' field, without matching on message text. Conditions are only constructed on -#' failure, so the hot path is unaffected. +#' Carries two phrasings of the same problem: `message`, for someone calling a +#' metric directly, and `reason`, a fragment `summary()` reports in place of a +#' metric it cannot compute. Both are defined at the throw site so they cannot +#' drift, and the class lets callers dispatch without matching on message text. #' #' @param class Condition subclass naming the specific problem. +#' @param reason Short phrase for a `summary()` field. #' @param ... Pasted together to form the message. #' #' @keywords internal -.grid_stop <- function(class, ...) { +.grid_stop <- function(class, reason, ...) { stop(structure( class = c(class, "speed_grid_error", "error", "condition"), - list(message = paste0(...), call = NULL) + list(message = paste0(...), reason = reason, call = NULL) )) } @@ -891,10 +892,8 @@ initialize_design_df <- initialise_design_df #' two-column matrix index and the grid's dimensions. #' #' Split out from [build_design_matrix()] because it is the expensive half and -#' the *invariant* half. During annealing only the treatment column changes - -#' the coordinates never do - so the index can be built once per `speed()` run -#' and reused for every iteration. Measured on a 700-plot design, validation and -#' coercion are ~87% of the cost of a grid build. +#' the *invariant* half: during annealing only the treatment column changes, so +#' the index can be built once per `speed()` run and reused every iteration. #' #' @param df A data frame with columns named by `row_column` and `col_column`. #' @param row_column Column name of the row position variable (default `"row"`). @@ -914,6 +913,7 @@ grid_index <- function(df, row_column = "row", col_column = "col") { if (length(missing_cols)) { .grid_stop( "speed_grid_missing", + "no row/column factors", "Cannot place the design on a grid: no ", paste0("`", missing_cols, "`", collapse = " or "), " column." @@ -926,6 +926,7 @@ grid_index <- function(df, row_column = "row", col_column = "col") { if (anyNA(rows) || anyNA(cols)) { .grid_stop( "speed_grid_nonnumeric", + sprintf("`%s`/`%s` labels are not numeric", row_column, col_column), "Cannot place the design on a grid: `", row_column, "` and `", @@ -940,6 +941,11 @@ grid_index <- function(df, row_column = "row", col_column = "col") { ) { .grid_stop( "speed_grid_notinteger", + sprintf( + "`%s`/`%s` are not positive whole numbers", + row_column, + col_column + ), "`", row_column, "` and `", @@ -953,6 +959,11 @@ grid_index <- function(df, row_column = "row", col_column = "col") { if (anyDuplicated(idx)) { .grid_stop( "speed_grid_duplicate", + sprintf( + "duplicate `%s`/`%s` coordinates (e.g. a multi-site design)", + row_column, + col_column + ), "Duplicate (", row_column, ", ", @@ -977,9 +988,8 @@ grid_index <- function(df, row_column = "row", col_column = "col") { #' and `col_column` coordinates, returning a character matrix of dimensions #' `max(row)` by `max(col)`. Cells with no corresponding row in `df` are `NA`. #' -#' Unlike filling via `matrix(..., byrow = )`, this reads the coordinates rather -#' than assuming an ordering, so it is correct for any row ordering of `df` and -#' for factor coordinate columns whose level order is not numeric. +#' Each plot's position comes from its own coordinates, so the row ordering of +#' `df` is irrelevant, as is the level order of factor coordinate columns. #' #' Coordinates are used as-is, never renumbered: a gap in the coordinates is a #' real gap in the field (a missing plot, or a buffer that was removed), so @@ -1010,8 +1020,9 @@ build_design_matrix <- function( if (is.null(index)) { index <- grid_index(df, row_column, col_column) } else if (!identical(index$n, nrow(df))) { - # A stale index would place treatments at the wrong coordinates silently, - # so the one cheap consistency check is worth keeping. + # Catches an index built for a different design only when the plot count + # differs; a same-length index with different coordinates is not detectable + # here, so callers still own keeping the two in step. stop( "`index` was built for ", index$n, diff --git a/R/metrics.R b/R/metrics.R index 9e554d53..512f1bf6 100644 --- a/R/metrics.R +++ b/R/metrics.R @@ -252,11 +252,8 @@ objective_function_piepho <- function(design, nb <- calculate_nb(design_matrix, pair_mapping) nb_score <- nb$var - # Coerce for calculate_balance_score()'s table(). Deliberately NOT - # as.factor(design_matrix): flattening the grid is column-major, which only - # matches `design` when the data frame happens to be in column-major order, - # and would otherwise scramble the treatments against their coordinates. - design[[swap]] <- as.factor(design[[swap]]) + # Balance and adjacency read `design` directly: the treatment column and the + # coordinates must stay aligned, so neither takes a flattened `design_matrix`. bal_score <- calculate_balance_score(design, swap, spatial_cols) adj_score <- calculate_adjacency_score( design, @@ -357,7 +354,7 @@ calculate_nb <- function(design_matrix, pair_mapping = NULL) { for (col_ in 1:n_cols) { node <- design_matrix[row_, col_] # Empty cells (a missing plot, or a removed buffer) have no pairs to - # contribute; the pair_mapping path in calculate_nb() drops them too. + # contribute, matching the pair_mapping path. if (is.na(node)) { next } @@ -718,10 +715,9 @@ calculate_efficiency_factor <- function( X <- matrix(0, nrow = n_plots, ncol = n_treatments) X[cbind(seq_len(n_plots), encoded_items)] <- 1 - # Create design matrix Z for rows and columns, indexed by each plot's actual - # coordinates. A positional fill would assume the data frame is a complete - # grid in row-major order and silently score a different layout otherwise. - # Row and col effects exclude the last row and col to avoid singularity. + # Create design matrix Z for rows and columns, indexed by each plot's own + # coordinates so the row ordering of `design_df` does not matter. Row and col + # effects exclude the last row and col to avoid singularity. Z_row <- matrix(0, nrow = n_plots, ncol = n_rows - 1) Z_col <- matrix(0, nrow = n_plots, ncol = n_cols - 1) in_row <- which(rows < n_rows) diff --git a/R/speed.R b/R/speed.R index 452f2bba..038b032a 100644 --- a/R/speed.R +++ b/R/speed.R @@ -260,12 +260,10 @@ speed_hierarchical <- function(data, optimise, quiet, seed, ...) { best_design <- current_design # Only the treatment column changes during annealing, so the validated grid - # index is invariant for the whole run and is built once here rather than on - # every iteration - it is ~87% of the cost of a grid build. Built with - # tryCatch so this stays lazy: a design whose coordinates cannot form a grid - # (duplicates from a MET, non-numeric labels) must still run if its objective - # never needs a grid, and must still raise the same error from the same place - # if it does. `NULL` restores exactly the old behaviour. + # index is invariant for the whole run and is built once here rather than every + # iteration. `NULL` on failure keeps this lazy: a design whose coordinates + # cannot form a grid still runs if its objective never needs one, and still + # errors from build_design_matrix() if it does. dots <- list(...) grid_idx <- tryCatch( grid_index( diff --git a/R/summary.R b/R/summary.R index 09ebc9ff..6b5e0665 100644 --- a/R/summary.R +++ b/R/summary.R @@ -75,7 +75,10 @@ #' - **hierarchical** - `TRUE` for a multi-level (e.g. split-plot) design. #' - **layout** - `n_plots`, `nrow`, `ncol`, `row_column`, `col_column`, #' `has_grid` (`TRUE` when the design is reportable as a single grid), and -#' `grid_reason` (why not, or `NA`). `nrow`/`ncol` are `NA` unless `has_grid`. +#' `grid_reason` (why not, or `NA`). `nrow`/`ncol` count the rows and columns +#' the design *occupies*, so a design with a gap in its coordinates (a missing +#' plot, or a removed buffer) reports fewer than the coordinates span. Both are +#' `NA` unless `has_grid`. #' - **levels** - character vector of level names (e.g. `"wp"`/`"sp"`; a single #' name for a simple design). #' - **per_level** - one element per level (named by `levels`), each a list with: @@ -137,12 +140,18 @@ summary.design <- function( want_neighbour <- is.null(neighbour) || isTRUE(neighbour) - # `has_grid` means "reportable as one grid", not merely "row/col columns - # exist". A design with duplicate coordinates spans several grids of possibly - # different shapes, so no single `nrow` x `ncol` describes it - reporting one - # would claim a layout that holds fewer plots than the design has. - grid_ok <- .single_grid(df, rc, cc) - has_grid <- isTRUE(grid_ok) + # The one coordinate validation for the whole summary: `grid` is either a + # `grid_index()` list or the reason there isn't one, and every grid metric + # below takes it rather than re-deriving it once per level. + # + # `has_grid` therefore means "reportable as one grid", not merely "row/col + # columns exist": a design with duplicate coordinates spans several grids of + # possibly different shapes, so no single `nrow` x `ncol` describes it. + grid <- tryCatch( + grid_index(df, row_column = rc, col_column = cc), + speed_grid_error = function(e) return(e$reason) + ) + has_grid <- !is.character(grid) layout <- list( n_plots = nrow(df), nrow = if (has_grid) length(unique(df[[rc]])) else NA_integer_, @@ -150,7 +159,7 @@ summary.design <- function( row_column = rc, col_column = cc, has_grid = has_grid, - grid_reason = if (has_grid) NA_character_ else grid_ok + grid_reason = if (has_grid) NA_character_ else grid ) per_level <- lapply(levels, function(lv) { @@ -198,7 +207,7 @@ summary.design <- function( # --- Evaluation metrics --- evaluation <- list( - replicate_span = .replicate_spans(df, swap, rc, cc), + replicate_span = .replicate_spans(df, swap, rc, cc, grid), connectedness = if (isFALSE(connectedness)) { list( available = FALSE, @@ -230,19 +239,19 @@ summary.design <- function( .block_spread(df, swap, block) }, efficiency = if (isTRUE(efficiency)) { - .efficiency_factor(df, swap, rc, cc) + .efficiency_factor(df, swap, rc, cc, grid) } else { list( available = FALSE, reason = "not requested (set efficiency = TRUE)" ) }, - # Each grid metric checks its own preconditions via .single_grid(), so a - # design that cannot be gridded reports a reason rather than erroring. + # A design that cannot be gridded reports `grid`'s reason rather than + # erroring. neighbour = if (!want_neighbour) { list(available = FALSE, reason = "not requested (neighbour = FALSE)") } else { - .neighbour_balance(df, swap, rc, cc) + .neighbour_balance(df, swap, rc, cc, grid) } ) @@ -685,13 +694,13 @@ print.summary.design <- function(x, ...) { #' @param df Design data frame. #' @param swap Treatment column name. #' @param rc,cc Row and column column names. +#' @param grid A [grid_index()] list, or a character reason there is no grid. #' @keywords internal -.replicate_spans <- function(df, swap, rc, cc) { +.replicate_spans <- function(df, swap, rc, cc, grid) { # Spans are distances within one grid; across grids they are meaningless # (two sites' row 3 are not one plot apart), so refuse rather than pool. - ok <- .single_grid(df, rc, cc) - if (!isTRUE(ok)) { - return(list(available = FALSE, reason = ok)) + if (is.character(grid)) { + return(list(available = FALSE, reason = grid)) } span1 <- function(x) { if (length(x) < 2) { @@ -723,64 +732,6 @@ print.summary.design <- function(x, ...) { )) } -#' Can This Design Be Placed on a Single Grid? -#' -#' @description -#' Predicate form of [grid_index()], for the diagnostics that need a grid. -#' Returns `TRUE`, or a short reason, so a summary can report one metric as -#' unavailable instead of failing outright. -#' -#' Delegates rather than repeating the coordinate rules, so the two cannot -#' drift: [grid_index()] stays the only place that decides what a valid grid is, -#' and signals which rule failed by condition class. Reasons here are phrased for -#' a summary field, where `grid_index()`'s messages - written for someone calling -#' a metric directly - would read as instructions. -#' -#' Duplicate coordinates usually mean the design occupies more than one grid: a -#' multi-environment trial reuses `row`/`col` per site. Every grid metric is -#' wrong for those, not merely uncomputable, because they pool sites that share -#' no edge. Reporting them as unavailable is the honest answer until the metrics -#' can take a grouping factor. -#' -#' @param df Design data frame. -#' @param rc,cc Row and column column names. -#' -#' @returns `TRUE`, or a length-1 character reason. -#' -#' @keywords internal -.single_grid <- function(df, rc, cc) { - return(tryCatch( - { - grid_index(df, row_column = rc, col_column = cc) - TRUE - }, - speed_grid_error = function(e) { - # Stated as a fact rather than an interpretation: duplicate coordinates - # are usually a multi-site design, but a malformed one looks the same. - return(switch( - class(e)[[1]], - speed_grid_missing = "no row/column factors", - speed_grid_nonnumeric = sprintf( - "`%s`/`%s` labels are not numeric", - rc, - cc - ), - speed_grid_notinteger = sprintf( - "`%s`/`%s` are not positive whole numbers", - rc, - cc - ), - speed_grid_duplicate = sprintf( - "duplicate `%s`/`%s` coordinates (e.g. a multi-site design)", - rc, - cc - ), - conditionMessage(e) - )) - } - )) -} - #' Detect a block-type factor for one level of a design #' #' Among *that level's* spatial factors that are not the row or column factor, @@ -967,14 +918,14 @@ print.summary.design <- function(x, ...) { #' reason rather than erroring when its assumptions are not met. #' #' @param rc,cc Row and column column names. +#' @param grid A [grid_index()] list, or a character reason there is no grid. #' @keywords internal -.efficiency_factor <- function(df, swap, rc, cc) { +.efficiency_factor <- function(df, swap, rc, cc, grid) { # Not just a guard against erroring: on duplicate coordinates # calculate_efficiency_factor() pools the grids and silently returns a value # above 1, which is impossible for an efficiency factor. - ok <- .single_grid(df, rc, cc) - if (!isTRUE(ok)) { - return(list(available = FALSE, reason = ok)) + if (is.character(grid)) { + return(list(available = FALSE, reason = grid)) } if (length(unique(df[[swap]])) < 3) { return(list(available = FALSE, reason = "requires >= 3 treatments")) @@ -1011,29 +962,30 @@ print.summary.design <- function(x, ...) { #' whereas a distinct pair that never neighbours is an imbalance. Lumping them #' together hides self-adjacency behind the same `min 0` as the harmless case. #' -#' The grid is built by [build_design_matrix()], which places each plot at its -#' own `rc`/`cc` coordinates. Reshaping the treatment column with `matrix()` -#' instead would assume the data frame's row order matches the fill order, which -#' is false for any non-square design (`speed()` sorts row-major; `matrix()` -#' fills column-major) and produced adjacency counts for a layout that wasn't -#' the design. -#' -#' Coordinates are read as-is, so a design whose plots are separated by a buffer -#' row or column (`add_buffers()` offsets and scales them) keeps that separation: -#' plots either side of a buffer are not counted as neighbours. +#' The grid comes from [build_design_matrix()], which places each plot at its own +#' `rc`/`cc` coordinates, so the counts describe the layout whatever order `df` +#' is in. Coordinates are read as-is, so plots separated by a buffer row or +#' column (`add_buffers()` offsets and scales them) keep that separation and are +#' not counted as neighbours. #' -#' Guarded by `.single_grid()`, so a design that cannot be placed on one grid is -#' reported as unavailable rather than propagating [build_design_matrix()]'s -#' error out of `summary()`. +#' A design that cannot be placed on one grid is reported as unavailable rather +#' than propagating [grid_index()]'s error out of `summary()`. #' #' @param rc,cc Row and column column names. +#' @param grid A [grid_index()] list, reused as the [build_design_matrix()] +#' index, or a character reason there is no grid. #' @keywords internal -.neighbour_balance <- function(df, swap, rc, cc) { - ok <- .single_grid(df, rc, cc) - if (!isTRUE(ok)) { - return(list(available = FALSE, reason = ok)) +.neighbour_balance <- function(df, swap, rc, cc, grid) { + if (is.character(grid)) { + return(list(available = FALSE, reason = grid)) } - dm <- build_design_matrix(df, swap, row_column = rc, col_column = cc) + dm <- build_design_matrix( + df, + swap, + row_column = rc, + col_column = cc, + index = grid + ) pair_mapping <- create_pair_mapping(df[[swap]]) nb <- calculate_nb(dm, pair_mapping) diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index ba4518c1..5a3d0b4c 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -26,7 +26,7 @@ Resolved findings and settled decisions are deleted rather than annotated — se > ✅ **The branch's original scope is closed.** The plan as committed at `b1d7adc` listed D6, D1 and > G1-G6, plus the hot-loop cost recorded as out of scope; all are done, the last as G11. G7, S1, A5, G11 -> and G12 were found during the work. See A1. Full suite: **1738 pass, 0 fail, 0 warn.** +> and G12 were found during the work. See A1. Full suite: **1739 pass, 0 fail, 0 warn.** > > ⬜ **Deliberately still open from the original plan:** removing the row-major sort, which A4.7 recorded > as out of scope on purpose (`KNOWN_ISSUES.md` #4). @@ -43,6 +43,11 @@ Resolved findings and settled decisions are deleted rather than annotated — se > 🔶 **D7 is the one open statistical question** and it blocks the last part of G13: what a grid metric > should report per site versus pooled. Adjacency and neighbour balance are settled (they sum exactly); > efficiency and piepho's ED are not. See A4. +> +> 🟠 **G14 — `summary()` reports an efficiency factor above 1 for a rank-deficient *single* grid.** Measured +> 1.61 on an ordinary unreplicated 12-entry 4×3 trial, identical on `main`. **No D7 decision needed** — a +> single grid has no pooling question — but it is fixed by the same rank gate as D7 recommendation 2, so +> build that gate once. Pre-existing, not branch-introduced. See A5. --- @@ -62,7 +67,7 @@ One-line inventory for the PR description. Full write-ups are in git history. | **S1** `.neighbour_balance()` reported self-adjacencies that didn't exist (6 where the truth was 0) | reads coordinates; the 4×3 fixture returns the hand-derived truth (self 0, pair min/max 5/6) | | **A5** lexical factor levels (`1, 10, 11, 2, …`) defeated the row-major sort, so grid metrics scored a layout that wasn't the design | coordinate construction is immune; the sort is no longer load-bearing (`KNOWN_ISSUES.md` #4) | | **G11** coordinate validation ran on every iteration, making grid construction 16× the `matrix()` reshape it replaced | validation split into `grid_index()`, hoisted once per `speed()` run and built lazily so a design that cannot be gridded still optimises if its objective never needs a grid; build back to parity — see A1.1 | -| **G12** `summary()` died outright on any design that couldn't be gridded — MET, or non-numeric `row`/`col` labels | `.single_grid()` maps `grid_index()`'s condition classes to a reason and gates `.neighbour_balance()`, `.efficiency_factor()` and `.replicate_spans()`; `has_grid` now means "reportable as one grid", so `layout` stops claiming an `nrow` × `ncol` holding fewer plots than the design | +| **G12** `summary()` died outright on any design that couldn't be gridded — MET, or non-numeric `row`/`col` labels | `summary.design()` calls `grid_index()` once and keeps either the index or the condition's `reason` as `grid`, which it passes to `.neighbour_balance()`, `.efficiency_factor()` and `.replicate_spans()`; each reports the reason instead of computing. `has_grid` now means "reportable as one grid", so `layout` no longer describes a MET as a single grid. `nrow`/`ncol` count *occupied* rows and columns (settled, Sam), so on a design with gaps they are deliberately fewer than the coordinates span — documented in `?summary.design` | ### A1.1 G11 measurements @@ -85,14 +90,11 @@ performance change only: | `objective_function`, 120 plots, 5000 iters | 2.46 s | **2.17 s** | −12% | | `objective_function_piepho`, 120 plots, 1000 iters | 1.64 s | **1.47 s** | −10% | -Parity, not the below-parity figure first predicted: the `as.character()` coercion of the swap column stays -per-iteration and is what remains of the cost. It is not hoistable the way validation was — the swap column -is the one thing annealing mutates, so each call re-does the level lookup and allocates a fresh length-`n` -character vector. Parity is therefore the floor for a rebuild-per-iteration grid, and it is accepted: -carrying a mutable grid across iterations instead was considered and **ruled out** (Sam, 2026-08-06) — it -needs a contract change to the objective-function signature and is capped at under 3% of a run, since the -build is ~15 µs of a ~570 µs iteration while `adjacency_score_vec()` is O(n × offsets) per iteration -however the grid arrives. +What remains of the cost is the per-iteration `as.character()` coercion of the swap column, which is not +hoistable the way validation was: the swap column is the one thing annealing mutates. Parity is therefore the +floor for a rebuild-per-iteration grid, and it is accepted. Carrying a mutable grid across iterations instead +was **ruled out** (Sam, 2026-08-06): it needs a contract change to the objective-function signature and is +capped at under 3% of a run, since the build is ~15 µs of a ~570 µs iteration. --- @@ -182,11 +184,12 @@ grid is exact rather than an approximation. That makes most of the fix tractable than guessed. 5. **Efficiency and ED are not summable** — see D7. `calculate_efficiency_factor()` needs to refuse a multi-grid frame rather than return `1.855529`; G12 withholds it inside `summary()`, but a direct call - still doesn't. -6. **Then relax G12's gate.** `.single_grid()` is deliberately the *only* place `summary()` decides a - design isn't griddable, so once the metrics take a grouping factor, MET designs stop reaching it and it - covers only genuinely un-griddable input. The split-plot test added with G12 guards against the gate - over-reaching in the meantime. + still doesn't. Note the gate is specifically on *duplicate coordinates*: it does **not** catch a + rank-deficient single grid, which `summary()` still reports — see A5. +6. **Then relax G12's gate.** The single `grid_index()` call in `summary.design()` is deliberately the + *only* place `summary()` decides a design isn't griddable, so once the metrics take a grouping factor, + MET designs stop reaching it and it covers only genuinely un-griddable input. The split-plot test added + with G12 guards against the gate over-reaching in the meantime. Scope check: items 1-4 are mechanical given the summability result. Item 5 needs D7 first, and can stay gated to "unavailable" in the meantime so MET adjacency and neighbour balance land without waiting on it. @@ -253,12 +256,88 @@ paper's definition assumes a single trial. Whatever is decided, the same answer `efficiency` entry and to `.neighbour_balance()`, so the two never disagree about what a MET design's diagnostics mean. ---- +**The rank gate in recommendation 2 is needed whether or not D7 is settled.** A value `> 1` signals rank +deficiency **however it arises** — a MET site with `r = 1` (above), a single grid that exhausts its residual +degrees of freedom, or one where treatment is aliased with row despite having residual df to spare +(`KNOWN_ISSUES.md` #3). The last two are single-grid designs that `summary()` reports today: see A5. So build +the gate as a rank test on *one* information matrix rather than inside the MET path, and D7 recommendation 2 +and G14 are covered by one implementation. -## A5. Corrections bearing on open items +--- -| Earlier claim | Corrected | -|---|---| -| An efficiency factor `> 1` is a canary for the ordering bug (G7) | **Too narrow.** `> 1` signals rank deficiency however it arises. Measured: degenerate fixtures where treatment is confounded with row — which is what `initialise_design_df(rep(LETTERS[1:k], m), ...)` produces, see `KNOWN_ISSUES.md` #3 — return values `> 1` in **row-major** order too, on `main`; so does a MET site with `r = 1` (D7). It is a canary for "something is wrong", not for ordering specifically. | -| MET only needs a gate in `summary()`; the grid code itself is fine | **Wrong, and too narrow twice over.** The gate (G12) only stops `summary()` crashing — it does not make MET work, which is the actual requirement. And two silent wrong answers survive any amount of gating: `calculate_efficiency_factor()` returns **1.855529** on a MET frame because duplicate coordinates pool rather than error, and a MET design laid side by side in one grid has *no* duplicate coordinates yet still counts **10 phantom cross-site edges** (60 vs 50). Validation cannot catch either. | -| `main`'s MET behaviour was "garbage, with a warning" | **Quantified:** the `matrix()` reshape built 50 cells from 80 plots, **silently discarding 30**, with one `data length differs from size of matrix` warning. Worth stating precisely because it is why this branch's hard error is an improvement even though it is not the fix. | +## A5. G14 🟠 `summary()` reports an efficiency factor above 1 for a rank-deficient single grid + +**No decision needed — this one does not ride on D7.** D7 is open because a *multi-grid* design has a real +statistical question (per site, pooled, or nested). A single grid has no such question: an A-efficiency +factor is bounded above by 1, so a value above it is wrong under any reading. What G14 shares with D7 is the +**fix**, not the decision — see the closing paragraph of A4. + +**Pre-existing, and this branch changed neither the values nor the reporting.** Measured 2026-08-06 on both +`bugfix/grid-orientation` and a clean `main` worktree. Residual df is `n − 1 − (k−1) − (r−1) − (c−1)`: + +| Design | plots | residual df | value | surfaced by | `main` | +|---|---|---|---|---|---| +| 1×6 grid, 3 treatments | 6 | −2 | **1.5** | `summary()` | **1.5** | +| 6×1 grid, 3 treatments | 6 | −2 | **1.5** | `summary()` | **1.5** | +| 4×3 grid, 12 entries unreplicated | 12 | −5 | **1.61** | `summary()` | **1.61** | +| 3×4 grid, 3 treatments confounded with row | 12 | **+4** | **1.5** | `.efficiency_factor()` | — | +| 2×3 grid, 3 treatments | 6 | 0 | 0.75 | `summary()` | — | +| 2×6 grid, 3 treatments | 12 | 3 | 0.75 | `summary()` | — | + +**There are two independent routes to a value above 1, so residual df is not a sufficient test.** Rows 1-3 +exhaust the residual degrees of freedom. Row 4 has *four* residual df and still returns 1.5, because +`rep(LETTERS[1:3], length.out = 12)` over `expand.grid(row = 1:3, col = 1:4)` puts each treatment in exactly +one row — treatment is aliased with the row effect, so eliminating the row space eliminates the treatment +contrasts with it (`KNOWN_ISSUES.md` #3). **The gate must therefore test the rank of the information matrix, +not count degrees of freedom.** The last two rows are included because they are the boundary a gate must not +reject: residual df 0 is still estimable. + +Reachability differs between the two routes. Rows 1-3 come straight out of `summary()`. Row 4 is reported by +`.efficiency_factor()` when handed such a frame, but `speed()` breaks the confounding within a single +iteration (measured: still confounded `FALSE`, `summary()` then reports 0.4891), so a design that has been +through `speed()` does not normally surface it. Route 1 is the one users hit. + +**Why G12's gate does not catch either.** `has_grid` is `TRUE` throughout: the coordinates are unique, so +`grid_index()` is satisfied and there is nothing for `.efficiency_factor()` to refuse. G12 gates on *can this +be one grid*, a coordinate property; this is a *rank* property of the model fitted on that grid. Different +question, so no amount of coordinate validation reaches it. The `< 3 treatments` guard already in +`.efficiency_factor()` is the only rank-adjacent check today, and it is far too weak. + +**Why it matters more than the MET case.** The 4×3-with-12-unreplicated-entries row is not a pathological +fixture — it is an ordinary early-generation trial, and it is the *same shape* D7 measures at 1.833 per site +inside a MET. So the impossible value is reachable from a completely routine single-site call, not only from +the MET path everyone already knows is broken. + +**Fix.** Gate `calculate_efficiency_factor()` (or `.efficiency_factor()`) on the treatment information matrix +having rank `k − 1` after eliminating the row and column space, and report unavailable with a reason when it +does not. A rank test covers both routes above; a residual-df test covers only the first. This is exactly D7 +recommendation 2's check applied to a single grid, which is why A4 now says to build it as a property of one +information matrix rather than inside the MET path. Landing it here first is the cheaper order: G14 needs no +grouping column and no D7 answer, and D7 recommendation 2 then inherits the gate instead of introducing it. + +Two details for whoever implements it. The exact spot is +[metrics.R:749](R/metrics.R#L749) — `V <- pseudo_inverse(A_RC)`, applied to the treatment information matrix +**unconditionally**, with no rank check. (Contrast [metrics.R:733-740](R/metrics.R#L733-L740), where the +nuisance space `ZtZ` *is* guarded by a `kappa()` test before choosing `pseudo_inverse()` over `solve()`.) So +the test is `rank(A_RC) == k − 1`, placed before line 749; sanity-checking the number that comes out is the +wrong shape. Second, clamping or `NA`-ing anything above 1 would also be wrong: it hides the confounded case +(row 4) behind a plausible value instead of reporting that the design cannot support the estimate. + +**Not in scope for this branch** — it is pre-existing, it is not caused by coordinate construction, and +`REVIEW-NOTES-EFFICIENCY.md` owns the efficiency statistics (branch `feature/a-optimality` exists). Recorded +here because G13 item 5 and D7 both assume the only bad efficiency value is the MET one, and that is not +true. + +### A5.1 Also found while probing — belongs in `REVIEW-NOTES-SUMMARY.md` + +`summary()` **errors outright** on a single-row or single-column design: + +``` +summary(<1x6 design>, efficiency = TRUE) +#> Error: contrasts can be applied only to factors with 2 or more levels +``` + +Thrown from `.design_connectedness()` via `model.matrix()`, because a spatial factor with one level has no +contrasts. Unrelated to the grid work — this branch does not touch `.design_connectedness()` — and it is why +the table above passes `connectedness = FALSE`. Noted so it is not lost; it needs the same treatment as G12, +i.e. report unavailable with a reason rather than propagating the error. diff --git a/man/build_design_matrix.Rd b/man/build_design_matrix.Rd index 4feb829b..4a4d8ea0 100644 --- a/man/build_design_matrix.Rd +++ b/man/build_design_matrix.Rd @@ -36,9 +36,8 @@ Places each treatment value at the grid position given by its \code{row_column} and \code{col_column} coordinates, returning a character matrix of dimensions \code{max(row)} by \code{max(col)}. Cells with no corresponding row in \code{df} are \code{NA}. -Unlike filling via \code{matrix(..., byrow = )}, this reads the coordinates rather -than assuming an ordering, so it is correct for any row ordering of \code{df} and -for factor coordinate columns whose level order is not numeric. +Each plot's position comes from its own coordinates, so the row ordering of +\code{df} is irrelevant, as is the level order of factor coordinate columns. Coordinates are used as-is, never renumbered: a gap in the coordinates is a real gap in the field (a missing plot, or a buffer that was removed), so diff --git a/man/dot-efficiency_factor.Rd b/man/dot-efficiency_factor.Rd index 5f5d8bbd..aebb671b 100644 --- a/man/dot-efficiency_factor.Rd +++ b/man/dot-efficiency_factor.Rd @@ -4,10 +4,12 @@ \alias{.efficiency_factor} \title{A-efficiency factor (opt-in wrapper)} \usage{ -.efficiency_factor(df, swap, rc, cc) +.efficiency_factor(df, swap, rc, cc, grid) } \arguments{ \item{rc, cc}{Row and column column names.} + +\item{grid}{A \code{\link[=grid_index]{grid_index()}} list, or a character reason there is no grid.} } \description{ Thin guarded wrapper over \code{\link[=calculate_efficiency_factor]{calculate_efficiency_factor()}} (a row--column model diff --git a/man/dot-grid_stop.Rd b/man/dot-grid_stop.Rd index 30171cdb..63fa6921 100644 --- a/man/dot-grid_stop.Rd +++ b/man/dot-grid_stop.Rd @@ -4,17 +4,19 @@ \alias{.grid_stop} \title{Signal a Coordinate Problem with a Classed Condition} \usage{ -.grid_stop(class, ...) +.grid_stop(class, reason, ...) } \arguments{ \item{class}{Condition subclass naming the specific problem.} +\item{reason}{Short phrase for a \code{summary()} field.} + \item{...}{Pasted together to form the message.} } \description{ -The message is for someone calling a metric directly; the class lets -\code{.single_grid()} report the same problem as a short reason in a \code{summary()} -field, without matching on message text. Conditions are only constructed on -failure, so the hot path is unaffected. +Carries two phrasings of the same problem: \code{message}, for someone calling a +metric directly, and \code{reason}, a fragment \code{summary()} reports in place of a +metric it cannot compute. Both are defined at the throw site so they cannot +drift, and the class lets callers dispatch without matching on message text. } \keyword{internal} diff --git a/man/dot-neighbour_balance.Rd b/man/dot-neighbour_balance.Rd index e79e22be..4ef83142 100644 --- a/man/dot-neighbour_balance.Rd +++ b/man/dot-neighbour_balance.Rd @@ -4,10 +4,13 @@ \alias{.neighbour_balance} \title{Neighbour-balance diagnostics} \usage{ -.neighbour_balance(df, swap, rc, cc) +.neighbour_balance(df, swap, rc, cc, grid) } \arguments{ \item{rc, cc}{Row and column column names.} + +\item{grid}{A \code{\link[=grid_index]{grid_index()}} list, reused as the \code{\link[=build_design_matrix]{build_design_matrix()}} +index, or a character reason there is no grid.} } \description{ Builds the treatment grid and counts how often each treatment pair ends up @@ -23,19 +26,13 @@ zero self-adjacency is the desirable outcome the optimiser works towards, whereas a distinct pair that never neighbours is an imbalance. Lumping them together hides self-adjacency behind the same \verb{min 0} as the harmless case. -The grid is built by \code{\link[=build_design_matrix]{build_design_matrix()}}, which places each plot at its -own \code{rc}/\code{cc} coordinates. Reshaping the treatment column with \code{matrix()} -instead would assume the data frame's row order matches the fill order, which -is false for any non-square design (\code{speed()} sorts row-major; \code{matrix()} -fills column-major) and produced adjacency counts for a layout that wasn't -the design. - -Coordinates are read as-is, so a design whose plots are separated by a buffer -row or column (\code{add_buffers()} offsets and scales them) keeps that separation: -plots either side of a buffer are not counted as neighbours. +The grid comes from \code{\link[=build_design_matrix]{build_design_matrix()}}, which places each plot at its own +\code{rc}/\code{cc} coordinates, so the counts describe the layout whatever order \code{df} +is in. Coordinates are read as-is, so plots separated by a buffer row or +column (\code{add_buffers()} offsets and scales them) keep that separation and are +not counted as neighbours. -Guarded by \code{.single_grid()}, so a design that cannot be placed on one grid is -reported as unavailable rather than propagating \code{\link[=build_design_matrix]{build_design_matrix()}}'s -error out of \code{summary()}. +A design that cannot be placed on one grid is reported as unavailable rather +than propagating \code{\link[=grid_index]{grid_index()}}'s error out of \code{summary()}. } \keyword{internal} diff --git a/man/dot-replicate_spans.Rd b/man/dot-replicate_spans.Rd index 6374a0ce..9b8f6916 100644 --- a/man/dot-replicate_spans.Rd +++ b/man/dot-replicate_spans.Rd @@ -4,7 +4,7 @@ \alias{.replicate_spans} \title{Replicate spatial spans} \usage{ -.replicate_spans(df, swap, rc, cc) +.replicate_spans(df, swap, rc, cc, grid) } \arguments{ \item{df}{Design data frame.} @@ -12,6 +12,8 @@ \item{swap}{Treatment column name.} \item{rc, cc}{Row and column column names.} + +\item{grid}{A \code{\link[=grid_index]{grid_index()}} list, or a character reason there is no grid.} } \description{ For each treatment, the minimum Manhattan separation between its replicate diff --git a/man/dot-single_grid.Rd b/man/dot-single_grid.Rd deleted file mode 100644 index 3f1f3ea3..00000000 --- a/man/dot-single_grid.Rd +++ /dev/null @@ -1,34 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/summary.R -\name{.single_grid} -\alias{.single_grid} -\title{Can This Design Be Placed on a Single Grid?} -\usage{ -.single_grid(df, rc, cc) -} -\arguments{ -\item{df}{Design data frame.} - -\item{rc, cc}{Row and column column names.} -} -\value{ -\code{TRUE}, or a length-1 character reason. -} -\description{ -Predicate form of \code{\link[=grid_index]{grid_index()}}, for the diagnostics that need a grid. -Returns \code{TRUE}, or a short reason, so a summary can report one metric as -unavailable instead of failing outright. - -Delegates rather than repeating the coordinate rules, so the two cannot -drift: \code{\link[=grid_index]{grid_index()}} stays the only place that decides what a valid grid is, -and signals which rule failed by condition class. Reasons here are phrased for -a summary field, where \code{grid_index()}'s messages - written for someone calling -a metric directly - would read as instructions. - -Duplicate coordinates usually mean the design occupies more than one grid: a -multi-environment trial reuses \code{row}/\code{col} per site. Every grid metric is -wrong for those, not merely uncomputable, because they pool sites that share -no edge. Reporting them as unavailable is the honest answer until the metrics -can take a grouping factor. -} -\keyword{internal} diff --git a/man/grid_index.Rd b/man/grid_index.Rd index 1eeac106..ab205444 100644 --- a/man/grid_index.Rd +++ b/man/grid_index.Rd @@ -25,9 +25,7 @@ everything \code{\link[=build_design_matrix]{build_design_matrix()}} needs to pl two-column matrix index and the grid's dimensions. Split out from \code{\link[=build_design_matrix]{build_design_matrix()}} because it is the expensive half and -the \emph{invariant} half. During annealing only the treatment column changes - -the coordinates never do - so the index can be built once per \code{speed()} run -and reused for every iteration. Measured on a 700-plot design, validation and -coercion are ~87\% of the cost of a grid build. +the \emph{invariant} half: during annealing only the treatment column changes, so +the index can be built once per \code{speed()} run and reused every iteration. } \keyword{internal} diff --git a/man/summary.design.Rd b/man/summary.design.Rd index 3657b0ca..42412e29 100644 --- a/man/summary.design.Rd +++ b/man/summary.design.Rd @@ -43,7 +43,10 @@ A list of class \code{"summary.design"}: \item \strong{hierarchical} - \code{TRUE} for a multi-level (e.g. split-plot) design. \item \strong{layout} - \code{n_plots}, \code{nrow}, \code{ncol}, \code{row_column}, \code{col_column}, \code{has_grid} (\code{TRUE} when the design is reportable as a single grid), and -\code{grid_reason} (why not, or \code{NA}). \code{nrow}/\code{ncol} are \code{NA} unless \code{has_grid}. +\code{grid_reason} (why not, or \code{NA}). \code{nrow}/\code{ncol} count the rows and columns +the design \emph{occupies}, so a design with a gap in its coordinates (a missing +plot, or a removed buffer) reports fewer than the coordinates span. Both are +\code{NA} unless \code{has_grid}. \item \strong{levels} - character vector of level names (e.g. \code{"wp"}/\code{"sp"}; a single name for a simple design). \item \strong{per_level} - one element per level (named by \code{levels}), each a list with: diff --git a/tests/testthat/test-calculate_efficiency_factor.R b/tests/testthat/test-calculate_efficiency_factor.R index 7be688d9..c4531650 100644 --- a/tests/testthat/test-calculate_efficiency_factor.R +++ b/tests/testthat/test-calculate_efficiency_factor.R @@ -3,11 +3,6 @@ # `items` *down columns* - `expand.grid(row, col)` varies `row` fastest - so a # row-major literal has to be transposed before it is handed over, or the design # stored at those coordinates is not the one written here. -# -# This used to be invisible: `calculate_efficiency_factor()` also filled its -# grid positionally in row-major order, so the two conventions cancelled and the -# paper's values came back from a design the package had not actually stored. -# Now that it reads the coordinates, the transpose has to be explicit. by_row <- function(items, nrows, ncols) { initialise_design_df( as.vector(matrix(items, nrow = nrows, ncol = ncols, byrow = TRUE)), diff --git a/tests/testthat/test-grid-orientation.R b/tests/testthat/test-grid-orientation.R index 76fec041..b803e811 100644 --- a/tests/testthat/test-grid-orientation.R +++ b/tests/testthat/test-grid-orientation.R @@ -1,6 +1,6 @@ -# Regression tests for the grid-orientation fix. Every expected value here is -# derived from the (row, col) coordinates by hand, never by reshaping the -# treatment column -- reshaping is the bug these tests exist to catch. +# Grid metrics must describe the layout the coordinates define, whatever order +# the data frame is in. Every expected value here is derived from the (row, col) +# coordinates by hand, never by reshaping the treatment column. # --- calculate_adjacency_score() composability ------------------------------- @@ -24,8 +24,8 @@ test_that("adjacency score is the same for either input ordering", { }) test_that("calculate_adjacency_score() composes with initialise_design_df()", { - # initialise_design_df() emits column-major data. A byrow = TRUE fill scored - # this layout as 6 when the correct answer is 0. + # initialise_design_df() emits column-major data, and no treatment neighbours + # itself in this layout, so the score is 0. # col 1 col 2 col 3 # row 1: a c b # row 2: b a c @@ -118,9 +118,8 @@ test_that("piepho components match hand-derived values on a non-square grid", { }) test_that("piepho does not overwrite the treatment column it was given", { - # The objective used to write a column-major flatten of the grid back into - # `design`, which permuted the treatments against their coordinates before - # the balance and adjacency components were computed. + # Balance and adjacency must come out identical to computing them on `design` + # directly, i.e. the treatments stay aligned with their coordinates. df <- initialise_design_df( items = rep(LETTERS[1:4], 6), nrows = 4, @@ -245,9 +244,8 @@ test_that("a genuine ring_weights length mismatch is still an error", { # --- calculate_efficiency_factor() ------------------------------------------- test_that("efficiency factor does not depend on the input row ordering", { - # It used to walk a plot counter through nested row/col loops, so it assumed - # a complete grid in row-major order and silently scored a different layout - # for anything else - including initialise_design_df()'s column-major output. + # initialise_design_df() emits column-major data, so this is the ordering a + # caller is most likely to arrive with. col_major <- initialise_design_df( items = rep(LETTERS[1:3], 4), nrows = 4, @@ -260,9 +258,8 @@ test_that("efficiency factor does not depend on the input row ordering", { calculate_efficiency_factor(col_major, treatment), calculate_efficiency_factor(row_major, treatment) ) - # Row-major was already correct, so its value must be unchanged. Column-major - # returned 1.5 - impossible for an efficiency factor, and the symptom that - # made this visible. + # Pinned so the shared value cannot drift: an efficiency factor is bounded + # above by 1, and a value over it means a rank-deficient information matrix. expect_equal(calculate_efficiency_factor(row_major, treatment), 0.9375) }) @@ -289,8 +286,7 @@ test_that("efficiency factor is unaffected by an offset coordinate origin", { # a de-buffered design arrives with coordinates that neither start at 1 nor # run consecutively. The gaps leave empty indicator columns in Z, making ZtZ # singular; the kappa() check routes to pseudo_inverse() and they contribute - # nothing. The positional fill this replaced could not get that far - it ran - # its plot counter past the end of Z and errored (subscript out of bounds). + # nothing, so the value is unchanged. base <- initialise_design_df( items = rep(LETTERS[1:3], 4), nrows = 4, diff --git a/tests/testthat/test-summary.R b/tests/testthat/test-summary.R index 744a22b1..5b8119f3 100644 --- a/tests/testthat/test-summary.R +++ b/tests/testthat/test-summary.R @@ -1,6 +1,16 @@ # Tests for summary.design / print.summary.design (Phase 3: Structure + # Optimisation + flags). Evaluation metrics are covered separately. +# The `grid` argument the evaluation helpers take: a grid_index() list, or the +# reason there is no grid. summary.design() builds it inline, once per call; +# tests calling a helper directly need the same value. +grid_or_reason <- function(df, rc = "row", cc = "col") { + return(tryCatch( + grid_index(df, row_column = rc, col_column = cc), + speed_grid_error = function(e) return(e$reason) + )) +} + simple_design <- function(iterations = 200, seed = 42) { d <- data.frame( row = rep(1:4, times = 3), @@ -119,8 +129,7 @@ test_that("score components are faithful for non-default objectives (piepho)", { quiet = TRUE ) sc <- summary(r)$per_level[[1]]$score - # Piepho exposes four additive components that sum to its score - the bug this - # fixes was the old adjacency+balance recompute not matching the piepho score. + # Piepho exposes four additive components, which must sum to its score. expect_named( sc$components, c("neighbour_balance", "even_distribution", "balance", "adjacency") @@ -643,16 +652,12 @@ test_that("efficiency is opt-in and guarded", { expect_true(is.finite(on$value)) # Guard: < 3 treatments returns NA with a reason rather than erroring. - two <- .efficiency_factor( - data.frame( - row = rep(1:2, 2), - col = rep(1:2, each = 2), - treatment = rep(c("A", "B"), 2) - ), - "treatment", - "row", - "col" + d2 <- data.frame( + row = rep(1:2, 2), + col = rep(1:2, each = 2), + treatment = rep(c("A", "B"), 2) ) + two <- .efficiency_factor(d2, "treatment", "row", "col", grid_or_reason(d2)) expect_false(two$available) }) @@ -730,10 +735,9 @@ test_that("neighbour balance separates self-adjacency from distinct-pair counts" expect_true(nb$min_pair_count >= 0) expect_true(nb$self_adjacent >= 0) - # Cross-check by walking the (row, col) coordinates directly. Deliberately - # NOT matrix(treatment, nrow, ncol): that is the same reshape the - # implementation used to perform, so an expectation built from it validated - # the code against a copy of its own mistake and passed against the bug. + # Cross-check by walking the (row, col) coordinates directly. Deliberately NOT + # matrix(treatment, nrow, ncol): reshaping is what the implementation must + # avoid, so an expectation built that way would only confirm itself. coords <- r$design_df rr <- as.numeric(as.character(coords$row)) cc <- as.numeric(as.character(coords$col)) @@ -919,13 +923,15 @@ test_that("designs without a row/column grid summarise and print without a grid" expect_match(out, "Neighbour:\\s+no row/column factors") }) -test_that(".single_grid accepts one grid and names what is wrong otherwise", { +test_that("grid_index() conditions carry the reason summary() reports", { + # summary() reports `reason` in place of a metric it cannot compute, so every + # condition class must supply one. ok <- data.frame(row = c(1, 1, 2, 2), col = c(1, 2, 1, 2), treatment = "A") - expect_true(.single_grid(ok, "row", "col")) + expect_false(is.character(grid_or_reason(ok))) # A genuine hole (missing plot) is still a single grid - coordinates stay # unique, so the metrics must not refuse it. - expect_true(.single_grid(ok[-2, ], "row", "col")) + expect_false(is.character(grid_or_reason(ok[-2, ]))) # Factor coordinates whose level order is lexical are still fine. lex <- data.frame( @@ -933,23 +939,19 @@ test_that(".single_grid accepts one grid and names what is wrong otherwise", { col = factor(c(1, 1, 1)), treatment = "A" ) - expect_true(.single_grid(lex, "row", "col")) + expect_false(is.character(grid_or_reason(lex))) - expect_equal(.single_grid(ok, "nope", "col"), "no row/column factors") + expect_equal(grid_or_reason(ok, "nope"), "no row/column factors") expect_match( - .single_grid( - data.frame(row = c("A", "B"), col = c(1, 1)), - "row", - "col" - ), + grid_or_reason(data.frame(row = c("A", "B"), col = c(1, 1))), "labels are not numeric" ) expect_match( - .single_grid(data.frame(row = c(0, 1), col = c(1, 1)), "row", "col"), + grid_or_reason(data.frame(row = c(0, 1), col = c(1, 1))), "not positive whole numbers" ) expect_match( - .single_grid(ok[c(1, 1, 2), ], "row", "col"), + grid_or_reason(ok[c(1, 1, 2), ]), "duplicate .row./.col. coordinates" ) }) @@ -1014,7 +1016,15 @@ test_that("efficiency is withheld rather than reported above 1 for MET designs", ) ) expect_gt(calculate_efficiency_factor(met, treatment), 1) - expect_false(.efficiency_factor(met, "treatment", "row", "col")$available) + gate <- .efficiency_factor( + met, + "treatment", + "row", + "col", + grid_or_reason(met) + ) + expect_false(gate$available) + expect_match(gate$reason, "duplicate") }) test_that("non-numeric row/col labels are reported, not coerced silently", { @@ -1107,11 +1117,12 @@ test_that("buffer removal drops the unused factor level from a factor swap colum test_that("evaluation helpers return a reason instead of erroring on unmet assumptions", { no_grid <- data.frame(a = 1:3, treatment = c("A", "B", "C")) - rs <- .replicate_spans(no_grid, "treatment", "row", "col") + ng <- grid_or_reason(no_grid) + rs <- .replicate_spans(no_grid, "treatment", "row", "col", ng) expect_false(rs$available) expect_equal(rs$reason, "no row/column factors") - ef <- .efficiency_factor(no_grid, "treatment", "row", "col") + ef <- .efficiency_factor(no_grid, "treatment", "row", "col", ng) expect_false(ef$available) expect_equal(ef$reason, "no row/column factors") @@ -1152,21 +1163,22 @@ test_that(".design_connectedness is trivially connected with no nuisance factors test_that(".efficiency_factor reports a reason when the computation fails", { # The wrapper must absorb an error from the underlying metric rather than - # propagate it. Mocked rather than provoked with a degenerate design: the - # 1x1 grid that used to error here is now caught earlier by .single_grid() - # (three plots at one coordinate are duplicates), which would leave this + # propagate it. Mocked rather than provoked with a degenerate design, because a + # design degenerate enough to fail the metric (e.g. a 1x1 grid, three plots + # sharing one coordinate) is rejected by grid_index() first, leaving this # backstop untested. d <- data.frame( row = rep(1:2, 3), col = rep(1:3, each = 2), treatment = rep(c("A", "B", "C"), 2) ) - expect_true(.single_grid(d, "row", "col")) + grid <- grid_or_reason(d) + expect_false(is.character(grid)) local_mocked_bindings( calculate_efficiency_factor = function(...) stop("cannot compute") ) - ef <- .efficiency_factor(d, "treatment", "row", "col") + ef <- .efficiency_factor(d, "treatment", "row", "col", grid) expect_false(ef$available) expect_equal(ef$reason, "could not be computed for this design") From 152abbaf953a9f8c8e7f6f56b1cd9170d4bbaea7 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:48:34 +0930 Subject: [PATCH 18/28] Updating decision --- REVIEW-NOTES-OTHER.md | 109 +++++++++++++++++++++++++----------------- 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index 5a3d0b4c..e1d796a4 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -40,9 +40,11 @@ Resolved findings and settled decisions are deleted rather than annotated — se > duplicate coordinates. `main` silently discarded **30 of 80** plots; this branch errors instead. Neither > works, and coordinate construction did not cause it. Grid metrics need a grouping dimension — see A3. > -> 🔶 **D7 is the one open statistical question** and it blocks the last part of G13: what a grid metric -> should report per site versus pooled. Adjacency and neighbour balance are settled (they sum exactly); -> efficiency and piepho's ED are not. See A4. +> ✅ **D7 is decided (Sam, 2026-08-07): per site, gated on per-site rank, with no combined figure.** +> Adjacency and neighbour balance sum exactly; efficiency is reported one value per site, each withheld +> with a reason if that site's contrasts aren't estimable. A combined `dsum`-shaped number needs no +> `asreml` to compute but is **not identified** — measured, the design ranking flips with the assumed +> variance ratio and the value passes 1. G13 is no longer blocked on a decision. See A4. > > 🟠 **G14 — `summary()` reports an efficiency factor above 1 for a rank-deficient *single* grid.** Measured > 1.61 on an ordinary unreplicated 12-entry 4×3 trial, identical on `main`. **No D7 decision needed** — a @@ -182,21 +184,27 @@ grid is exact rather than an approximation. That makes most of the fix tractable how the ordering bugs in G1 happened. If it should be automatic, have `initialise_design_df(designs = )` record `design_col` as an attribute so it is *transported* rather than guessed. -5. **Efficiency and ED are not summable** — see D7. `calculate_efficiency_factor()` needs to refuse a - multi-grid frame rather than return `1.855529`; G12 withholds it inside `summary()`, but a direct call - still doesn't. Note the gate is specifically on *duplicate coordinates*: it does **not** catch a - rank-deficient single grid, which `summary()` still reports — see A5. +5. **Efficiency: one value per site, each rank-gated** (D7, decided). First + `calculate_efficiency_factor()` must refuse a multi-grid frame rather than return `1.855529` — G12 + withholds it inside `summary()`, but a direct call still doesn't. Note that gate is specifically on + *duplicate coordinates*: it does **not** catch a rank-deficient single grid, which `summary()` still + reports — see A5. Build the rank gate first (G14) and the per-site path inherits it. ED is not + summable either and still needs an answer. 6. **Then relax G12's gate.** The single `grid_index()` call in `summary.design()` is deliberately the *only* place `summary()` decides a design isn't griddable, so once the metrics take a grouping factor, MET designs stop reaching it and it covers only genuinely un-griddable input. The split-plot test added with G12 guards against the gate over-reaching in the meantime. -Scope check: items 1-4 are mechanical given the summability result. Item 5 needs D7 first, and can stay -gated to "unavailable" in the meantime so MET adjacency and neighbour balance land without waiting on it. +Scope check: items 1-4 are mechanical given the summability result, and item 5 is now unblocked — D7 is +decided, so the only dependency left is building G14's rank gate first. ED (piepho) is the one piece still +without an answer, and can stay gated to "unavailable" so it does not hold the rest up. --- -## A4. 🔶 D7. What should the grid metrics report for a multi-grid (MET) design? — **open** +## A4. ✅ D7. What should the grid metrics report for a multi-grid (MET) design? — **decided 2026-08-07** + +**Per site, gated on per-site rank; no combined figure.** The reasoning and measurements are kept because +they are what justify refusing the combined number, and that refusal will be questioned again otherwise. Adjacency and neighbour balance answer themselves: they count edges, edges never cross a grid boundary, and summing per grid is exact (measured, 20 + 30 = 50). Efficiency and `objective_function_piepho()`'s ED do @@ -231,24 +239,51 @@ the pseudo-inverse returns a meaningless number. (The pooled site-nested model f — low, but at least defined, because replication *across* sites is real replication.) So reporting per-site values by default would print garbage for exactly the designs MET support exists for. -**Recommendation, in build order.** - -1. **Refuse for multi-grid designs, with a reason.** Required as an interim state either way — the - alternative is continuing to return `1.855529` for a quantity bounded above by 1. G12 already does this - inside `summary()`; G13 item 5 moves it into `calculate_efficiency_factor()`. -2. **Then per-site values, gated on per-site estimability** — that site's own information matrix must have - rank `k - 1`, i.e. every treatment contrast estimable within the site. Labelled per site, never summed - or averaged. This is the design-actionable quantity, because the layout that can be changed is the one - within a site. A site failing the rank test reports unavailable individually rather than poisoning the - whole vector. -3. **The combined-analysis number only as an explicit opt-in.** It is computable (0.566 above) but needs - row/col nested within site *and* an assumption of equal residual variance across sites — precisely what - `dsum()` denies. Under unequal variances the combined efficiency depends on variance ratios that are - unknown at design time, so no single design-time number exists. Never the default, and never the current - plain `row + col` pooling. - -Item 2 is not misleading provided it is labelled per site and no aggregate is offered alongside it; the -misleading options are the pooled 0.807 and any average of the per-site values. +**Decided (Sam, 2026-08-07): report per-site values, gated on per-site rank.** + +1. **Refuse for multi-grid designs, with a reason** — the interim state, and required either way, since + the alternative is returning `1.855529` for a quantity bounded above by 1. G12 already does this inside + `summary()`; G13 item 5 moves it into `calculate_efficiency_factor()`. +2. **Then one value per site, each gated on that site's own rank** — the site's information matrix must + have rank `k - 1`, i.e. every treatment contrast estimable within the site. A site failing the test + reports unavailable with a reason rather than poisoning the vector or being silently dropped. Labelled + per site, **never summed or averaged**: per-site-then-averaged is a different quantity from the + combined analysis (0.487 vs 0.566 above). This is also the design-actionable quantity, because the + layout that can be changed is the one within a site. +3. **No combined number, not even as an opt-in** — see below. This reverses the earlier suggestion that it + could be offered with a stated assumption. + +### Why there is no reliable combined-analysis figure — measured 2026-08-07 + +**The computation needs no `asreml`.** With the residual variances treated as known it is generalised +least squares in closed form — `C = X'WX - X'WZ (Z'WZ)⁻ Z'WX`, `W = diag(1/v_i)` — reachable with +`model.matrix()` and `pseudo_inverse()` alone. `asreml` is only needed to *estimate* variance components +from data, and at design time there is no data, so there is nothing to estimate. The obstacle is +statistical, not computational. + +Three MET designs (two sites, 8 treatments × 3 reps, 4×6 each), combined efficiency against the assumed +residual variance ratio B:A, with `v` normalised to plot-weighted mean 1: + +| design | ratio 1 | ratio 2 | ratio 4 | ratio 10 | ratio 100 | +|---|---|---|---|---|---| +| seed 1 | 0.4814 | 0.5244 | 0.6839 | 1.2102 | 9.1689 | +| seed 2 | 0.5660 | 0.6454 | 0.8901 | 1.6932 | 13.9839 | +| seed 3 | 0.5834 | 0.6535 | 0.8810 | 1.6317 | 13.1163 | + +Two independent reasons to refuse, either of which is sufficient: + +- **The ranking flips.** At ratio ≤ 2 seed 3 is the best design; from ratio 4 on, seed 2 is. So the + assumption is not a harmless caveat attached to a number — it changes which design you would choose. + Nothing at design time tells you which ratio to use. +- **The quantity is not identified under heterogeneity.** The values exceed 1 from ratio 10 — the rank + test passes, so this is not rank deficiency. An A-efficiency factor is defined *relative to an + orthogonal reference design with a single error variance*; once the variances differ there is no + canonical reference to normalise against, and a different normalisation gives a different number. The + `> 1` values are the symptom of that, not an arithmetic slip. + +Under **equal** variances (ratio 1) the quantity is well defined and exact — but that is precisely the +assumption `dsum()` exists to deny, so a MET reported that way would carry a figure computed under a model +its own analysis contradicts. Hence: per site, and no combined figure. **`objective_function_piepho()`'s ED needs the same treatment.** NB sums like any edge count, but ED measures evenness of a distribution, so per-grid-then-averaged and pooled are different quantities and the @@ -273,7 +308,9 @@ factor is bounded above by 1, so a value above it is wrong under any reading. Wh **fix**, not the decision — see the closing paragraph of A4. **Pre-existing, and this branch changed neither the values nor the reporting.** Measured 2026-08-06 on both -`bugfix/grid-orientation` and a clean `main` worktree. Residual df is `n − 1 − (k−1) − (r−1) − (c−1)`: +`bugfix/grid-orientation` and a clean `main` worktree, with `connectedness = FALSE` (S6 in +`REVIEW-NOTES-SUMMARY.md` — the single-row shapes error under the default). Residual df is +`n − 1 − (k−1) − (r−1) − (c−1)`: | Design | plots | residual df | value | surfaced by | `main` | |---|---|---|---|---|---| @@ -327,17 +364,3 @@ wrong shape. Second, clamping or `NA`-ing anything above 1 would also be wrong: `REVIEW-NOTES-EFFICIENCY.md` owns the efficiency statistics (branch `feature/a-optimality` exists). Recorded here because G13 item 5 and D7 both assume the only bad efficiency value is the MET one, and that is not true. - -### A5.1 Also found while probing — belongs in `REVIEW-NOTES-SUMMARY.md` - -`summary()` **errors outright** on a single-row or single-column design: - -``` -summary(<1x6 design>, efficiency = TRUE) -#> Error: contrasts can be applied only to factors with 2 or more levels -``` - -Thrown from `.design_connectedness()` via `model.matrix()`, because a spatial factor with one level has no -contrasts. Unrelated to the grid work — this branch does not touch `.design_connectedness()` — and it is why -the table above passes `connectedness = FALSE`. Noted so it is not lost; it needs the same treatment as G12, -i.e. report unavailable with a reason rather than propagating the error. From 00404b58670ecb9b2add4d185e655813d2987022 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:09:53 +0930 Subject: [PATCH 19/28] Fixing efficiency for METs --- R/calculate_adjacency_score.R | 57 ++++--- R/design_utils.R | 56 +++++++ R/metrics.R | 105 +++++++++++- R/speed.R | 43 ++++- R/summary.R | 133 +++++++++++---- man/calculate_adjacency_score.Rd | 16 +- man/calculate_efficiency_factor.Rd | 31 +++- man/create_speed_input.Rd | 10 +- man/grid_indices.Rd | 34 ++++ man/objective_function_piepho.Rd | 1 + man/speed.Rd | 10 +- tests/testthat/test-build_design_matrix.R | 73 ++++++++- .../test-calculate_efficiency_factor.R | 120 ++++++++------ tests/testthat/test-grid-orientation.R | 11 +- tests/testthat/test-summary.R | 151 ++++++++++++++++-- 15 files changed, 721 insertions(+), 130 deletions(-) create mode 100644 man/grid_indices.Rd diff --git a/R/calculate_adjacency_score.R b/R/calculate_adjacency_score.R index 85cbc5fa..93fcf05a 100755 --- a/R/calculate_adjacency_score.R +++ b/R/calculate_adjacency_score.R @@ -212,10 +212,16 @@ adjacency_score_vec <- function( #' to `NULL`, which keeps the strict identity match. Pass the raw matrix #' through `prep_relationship()` first; the score functions consume only #' the prepped form. -#' @param grid_index Optional pre-built index from [grid_index()], passed to -#' [build_design_matrix()] to skip coordinate validation. `speed()` supplies -#' one so the annealing loop does not revalidate every iteration; leave it -#' `NULL` for a one-off call. +#' @param by Optional column name grouping plots into separate grids (e.g. +#' `"site"` for a multi-environment trial). Each grid is scored on its own and +#' the counts summed, so no adjacency is counted between plots at different +#' sites. `NULL` (default) treats the design as a single grid, which errors if +#' two plots share a coordinate. +#' @param grid_index Optional pre-built list of indices from [grid_indices()], +#' passed to [build_design_matrix()] to skip coordinate validation. `speed()` +#' supplies one so the annealing loop does not revalidate every iteration; +#' leave it `NULL` for a one-off call. Supplying it ignores `by`, which the +#' indices already encode. #' #' @return A non-negative numeric value: the number of like-treatment edges #' in the row/column adjacency graph. @@ -258,24 +264,39 @@ calculate_adjacency_score <- function( ring_weights = 1, ring_type = c("manhattan", "chebyshev"), relationship = NULL, + by = NULL, grid_index = NULL ) { ring_type <- match.arg(ring_type) - design_matrix <- build_design_matrix( - layout_df, - swap, - row_column = row_column, - col_column = col_column, - index = grid_index - ) + if (is.null(grid_index)) { + grid_index <- grid_indices(layout_df, row_column, col_column, by = by) + } - per_cell <- adjacency_score_vec( - design_matrix, - dists = ring_dists, - weights = ring_weights, - ring_type = ring_type, - relationship = relationship + # Adjacency counts edges, and no edge crosses a grid boundary, so summing per + # grid is exact rather than an approximation. Verified on a two-site design: + # 20 + 30 = 50, against 60 when the sites are pooled into one grid - the extra + # 10 being adjacencies between plots at different sites. + totals <- vapply( + grid_index, + function(g) { + design_matrix <- build_design_matrix( + layout_df[g$rows, , drop = FALSE], + swap, + row_column = row_column, + col_column = col_column, + index = g$index + ) + per_cell <- adjacency_score_vec( + design_matrix, + dists = ring_dists, + weights = ring_weights, + ring_type = ring_type, + relationship = relationship + ) + return(sum(per_cell) / 2) + }, + numeric(1) ) - return(sum(per_cell) / 2) + return(sum(totals)) } diff --git a/R/design_utils.R b/R/design_utils.R index e79ffd77..a46fa325 100644 --- a/R/design_utils.R +++ b/R/design_utils.R @@ -981,6 +981,62 @@ grid_index <- function(df, row_column = "row", col_column = "col") { )) } +#' Split a Design into One Grid Index per Grid +#' +#' @description +#' The multi-grid counterpart of [grid_index()]. A multi-environment trial is +#' several grids that share a treatment set and never share an edge, so it +#' cannot be one matrix: sites reuse `row`/`col`, and pooling them either +#' silently overwrites plots or invents adjacencies between sites. +#' +#' With `by = NULL` this returns a one-element list, so callers have a single +#' code path whether or not the design spans grids. +#' +#' @inheritParams grid_index +#' @param by Optional column name grouping plots into grids (e.g. `"site"`). +#' `NULL` treats the design as one grid. +#' +#' @return A named list with one element per grid, each a list of `rows` (the +#' positions in `df` belonging to that grid) and `index` (that grid's +#' [grid_index()]). Named `"1"` when `by` is `NULL`. +#' +#' @keywords internal +grid_indices <- function( + df, + row_column = "row", + col_column = "col", + by = NULL +) { + if (is.null(by)) { + return(list("1" = list( + rows = seq_len(nrow(df)), + index = grid_index(df, row_column, col_column) + ))) + } + if (!by %in% names(df)) { + .grid_stop( + "speed_grid_missing_by", + sprintf("no `%s` column to group grids by", by), + "Cannot split the design into grids: no `", + by, + "` column." + ) + } + # drop = TRUE so an unused factor level does not produce an empty grid, which + # grid_index() would then reject for having no coordinates. + groups <- split(seq_len(nrow(df)), df[[by]], drop = TRUE) + out <- lapply(groups, function(rows) { + return(list( + rows = rows, + index = grid_index(df[rows, , drop = FALSE], row_column, col_column) + )) + }) + # Carried so consumers can label per-grid output without being told separately + # which column the grids came from. + attr(out, "by") <- by + return(out) +} + #' Build a Spatial Design Matrix from a Data Frame #' #' @description diff --git a/R/metrics.R b/R/metrics.R index 512f1bf6..03143d03 100644 --- a/R/metrics.R +++ b/R/metrics.R @@ -63,6 +63,7 @@ objective_function <- function(layout_df, "ring_weights", "ring_type", "relationship", + "by", "grid_index" ) )] @@ -236,14 +237,32 @@ objective_function_piepho <- function(design, pair_mapping = NULL, row_column = "row", col_column = "col", + by = NULL, grid_index = NULL, ...) { + if (is.null(grid_index)) { + grid_index <- grid_indices(design, row_column, col_column, by = by) + } + # Neighbour balance would sum across grids like any edge count, but the + # evenness-of-distribution component does not: per-grid-then-combined and + # pooled are different quantities, and Piepho's definition assumes a single + # trial. Refusing is the honest interim answer - pooling would score + # adjacencies and distances between plots at different sites. + if (length(grid_index) > 1) { + stop( + "`objective_function_piepho()` is defined for a single grid, but this ", + "design spans ", + length(grid_index), + ". Its evenness-of-distribution component has no agreed multi-grid form.", + call. = FALSE + ) + } design_matrix <- build_design_matrix( design, swap, row_column = row_column, col_column = col_column, - index = grid_index + index = grid_index[[1]]$index ) ed <- calculate_ed(design_matrix, current_score_obj$ed, swapped_items) @@ -679,15 +698,38 @@ create_pair_mapping <- function(items) { #' @param col_column Name of the column giving the column of the design (default: "col") #' #' @examples +#' # `initialise_design_df()` fills `items` down columns, so the literal below is +#' # column-major; the grid it produces is +#' # a b d c +#' # e a f b +#' # c f e d #' df_design <- initialise_design_df(c( -#' "a", "b", "d", "c", -#' "e", "a", "f", "b", -#' "c", "f", "e", "d" +#' "a", "e", "c", +#' "b", "a", "f", +#' "d", "f", "e", +#' "c", "b", "d" #' ), 3, 4) #' #' calculate_efficiency_factor(df_design, "treatment") #' -#' @return A numeric value representing the efficiency factor of the design. Higher values indicate more efficient designs. +#' # Not every design can support the estimate. Here each treatment fills one +#' # grid row, so the treatment differences cannot be separated from the row +#' # effects and there is no efficiency factor to report: +#' # a a a a +#' # b b b b +#' # c c c c +#' confounded <- initialise_design_df(rep(c("a", "b", "c"), 4), 3, 4) +#' try(calculate_efficiency_factor(confounded, "treatment")) +#' +#' @return A numeric value representing the efficiency factor of the design, +#' between 0 and 1. Higher values indicate more efficient designs. +#' +#' Errors with a `speed_efficiency_rank` condition if the design cannot support +#' the estimate - that is, if some treatment contrast is not estimable once row +#' and column effects are eliminated, whether because too few residual degrees +#' of freedom remain or because a treatment is confounded with a row or column. +#' Such a design has no efficiency factor; before this check the formula +#' returned a plausible-looking value, usually above 1. #' #' @references Piepho, H. P., Williams, E., & Michel, V. (2015). Nonresolvable Row-Column Designs with an Even #' Distribution of Treatment Replications. Journal of Agricultural, Biological, and Environmental Statistics, @@ -702,6 +744,14 @@ calculate_efficiency_factor <- function( ) { item <- as.character(substitute(item)) + # An efficiency factor is a property of one experiment's information matrix, + # and there is no meaningful way to combine several. A multi-site frame does + # not error of its own accord here - duplicate coordinates just pool the sites + # into one row/column model, which returns a value above 1 - so validate the + # coordinates explicitly rather than letting that through. Report one value + # per site instead, as `summary()` does. + grid_index(design_df, row_column, col_column) + # Design parameters encoded_items <- as.integer(as.factor(design_df[[item]])) n_treatments <- length(unique(encoded_items)) @@ -725,8 +775,16 @@ calculate_efficiency_factor <- function( Z_row[cbind(in_row, rows[in_row])] <- 1 Z_col[cbind(in_col, cols[in_col])] <- 1 - # Combine row and column design matrices - Z <- cbind(Z_row, Z_col) + # Intercept, then row and column design matrices. The intercept belongs to the + # nuisance space on its own account - the row-column model has a mean - and it + # is also what makes the estimability test below exact. Without it the mean is + # left inside the treatment term (X's rows sum to 1), so `A_RC` keeps a + # non-null direction that is neither a contrast nor orthogonal to one, and no + # rank test on it distinguishes "all contrasts estimable" from "some are not". + # Adding it does not change the reported value for a design that is estimable: + # verified against both published designs (0.834, 0.827) and against pairwise + # contrast variances taken from the full model's Moore-Penrose inverse. + Z <- cbind(1, Z_row, Z_col) # Check if Z^TZ is invertible ZtZ <- t(Z) %*% Z @@ -745,6 +803,39 @@ calculate_efficiency_factor <- function( I_n <- diag(n_plots) A_RC <- t(X) %*% (I_n - P_Z) %*% X + # With the intercept eliminated, A_RC's null space contains the all-ones + # direction, so rank n_treatments - 1 means exactly "every treatment contrast + # is estimable" and anything less means some are not. Where they are not, + # pseudo_inverse() drops the null directions and the surviving pairwise + # variances still average to something finite, so the formula returns a + # plausible-looking number - typically above 1, which is impossible - instead + # of failing. Two distinct designs reach here: one with too few residual + # degrees of freedom, and one where treatment is aliased with a row or column + # effect despite having degrees of freedom to spare. Rank catches both; + # counting degrees of freedom catches only the first. The tolerance matches + # pseudo_inverse()'s, so the gate and the inverse cannot disagree about which + # directions are null. (`qr()` is not used: its default tolerance is relative + # and reports full rank for a matrix whose eigenvalues are 2, 9e-16, 6e-16.) + if (sum(svd(A_RC)$d > 1e-10) != n_treatments - 1) { + stop(structure( + class = c( + "speed_efficiency_rank", + "speed_efficiency_error", + "error", + "condition" + ), + list( + message = paste0( + "Not all treatment contrasts are estimable after eliminating ", + "`", row_column, "` and `", col_column, "` effects, so this design ", + "cannot support an efficiency factor." + ), + reason = "treatment contrasts not estimable given row + col", + call = NULL + ) + )) + } + # Calculate Moore-Penrose inverse of A_RC, variance matrix V <- pseudo_inverse(A_RC) diff --git a/R/speed.R b/R/speed.R index 038b032a..c0054125 100644 --- a/R/speed.R +++ b/R/speed.R @@ -23,6 +23,14 @@ #' @param grid_factors A named list specifying grid factors to construct a #' matrix for calculating adjacency score, `dim1` for row and `dim2` for #' column. (default: `list(dim1 = "row", dim2 = "col")`). +#' +#' An optional third element, `by`, names a column that groups plots into +#' *separate* grids - a multi-environment trial, where each site reuses the +#' same `row`/`col` numbering. Each grid is then scored on its own and the +#' adjacency counts summed, so no adjacency is counted between plots at +#' different sites, e.g. +#' `list(dim1 = "row", dim2 = "col", by = "site")`. Without it, a design whose +#' sites share coordinates is refused rather than silently pooled. #' @param iterations Maximum number of iterations for the simulated annealing #' algorithm (default: 10000). For hierarchical designs, can be a named list #' with names matching `swap`. @@ -187,13 +195,35 @@ speed <- function(data, inferred <- infer_row_col(data, grid_factors, quiet) row_column <- inferred$row col_column <- inferred$col + # `by` groups plots into separate grids (a multi-environment trial). Validated + # here rather than left to fail later: `grid_factors` is a plain list, so a + # mistyped name would otherwise be silently ignored and every site pooled. + grid_by <- grid_factors$by + if (!is.null(grid_by)) { + if (!is.character(grid_by) || length(grid_by) != 1) { + stop( + "`grid_factors$by` must be a single column name.", + call. = FALSE + ) + } + if (!grid_by %in% names(data)) { + stop( + "`grid_factors$by` is \"", + grid_by, + "\", which is not a column in the design.", + call. = FALSE + ) + } + } # convert to factors factored <- to_factor(data) data <- factored$df if (inferred$inferred) { - # Sort the data frame to start with to ensure consistency in calculating the adjacency later + # Row order no longer affects any metric - grids are built from each plot's + # coordinates - but generate_neighbour(), random_initialise(), print() and + # autoplot() may still rely on it, so the sort stays until that is checked. data <- data[do.call(order, data[c(row_column, col_column)]), ] # Only reset row labels for base data frames; tibbles are positional and # warn on `rownames<-`, and nothing downstream reads the design's row names. @@ -229,7 +259,8 @@ speed <- function(data, design <- do.call(speed_hierarchical, c( list(data = data, optimise = optimise, quiet = quiet, seed = seed, - row_column = row_column, col_column = col_column), + row_column = row_column, col_column = col_column, + grid_by = grid_by), dots )) # Set here, not passed through do.call(): do.call would evaluate a language @@ -266,10 +297,11 @@ speed_hierarchical <- function(data, optimise, quiet, seed, ...) { # errors from build_design_matrix() if it does. dots <- list(...) grid_idx <- tryCatch( - grid_index( + grid_indices( current_design, dots$row_column %||% "row", - dots$col_column %||% "col" + dots$col_column %||% "col", + by = dots$grid_by ), error = function(e) return(NULL) ) @@ -417,6 +449,9 @@ speed_hierarchical <- function(data, optimise, quiet, seed, ...) { levels = hierarchy_levels, row_column = .dots$row_column %||% "row", col_column = .dots$col_column %||% "col", + # NULL for a single-grid design; the column separating grids otherwise, so + # summary() can recover the grouping instead of guessing it from a name. + grid_by = .dots$grid_by, per_level = per_level_meta ) diff --git a/R/summary.R b/R/summary.R index 6b5e0665..fb3db006 100644 --- a/R/summary.R +++ b/R/summary.R @@ -148,10 +148,13 @@ summary.design <- function( # columns exist": a design with duplicate coordinates spans several grids of # possibly different shapes, so no single `nrow` x `ncol` describes it. grid <- tryCatch( - grid_index(df, row_column = rc, col_column = cc), + grid_indices(df, row_column = rc, col_column = cc, by = meta$grid_by), speed_grid_error = function(e) return(e$reason) ) - has_grid <- !is.character(grid) + # A multi-environment trial occupies several grids that share a treatment set + # and never share an edge, so no single `nrow` x `ncol` describes it. + n_grids <- if (is.character(grid)) NA_integer_ else length(grid) + has_grid <- !is.character(grid) && n_grids == 1L layout <- list( n_plots = nrow(df), nrow = if (has_grid) length(unique(df[[rc]])) else NA_integer_, @@ -159,7 +162,15 @@ summary.design <- function( row_column = rc, col_column = cc, has_grid = has_grid, - grid_reason = if (has_grid) NA_character_ else grid + n_grids = n_grids, + grid_by = meta$grid_by, + grid_reason = if (has_grid) { + NA_character_ + } else if (is.character(grid)) { + grid + } else { + sprintf("%d grids, grouped by `%s`", n_grids, meta$grid_by) + } ) per_level <- lapply(levels, function(lv) { @@ -554,9 +565,30 @@ print.summary.design <- function(x, ...) { cat(lab("Repl. span:"), rs$reason, "\n", sep = "") } - # Efficiency + # Efficiency. A multi-grid design reports one value per grid and no total: + # there is no meaningful way to combine them (see `.efficiency_factor()`). ef <- e$efficiency - if (isTRUE(ef$available)) { + if (!is.null(ef$per_grid)) { + cat( + lab("Efficiency:"), + sprintf( + "per %s (A-efficiency, row-column model)\n", + if (is.null(ef$grid_by)) "grid" else paste0("`", ef$grid_by, "`") + ), + sep = "" + ) + for (nm in names(ef$per_grid)) { + one <- ef$per_grid[[nm]] + cat( + " ", + format(nm, width = max(nchar(names(ef$per_grid)))), + " ", + if (isTRUE(one$available)) fmt_num(one$value) else one$reason, + "\n", + sep = "" + ) + } + } else if (isTRUE(ef$available)) { cat( lab("Efficiency:"), fmt_num(ef$value), @@ -702,6 +734,16 @@ print.summary.design <- function(x, ...) { if (is.character(grid)) { return(list(available = FALSE, reason = grid)) } + if (length(grid) > 1L) { + return(list( + available = FALSE, + reason = sprintf( + "design spans %d grids (grouped by `%s`)", + length(grid), + attr(grid, "by") + ) + )) + } span1 <- function(x) { if (length(x) < 2) { return(NA_real_) @@ -930,22 +972,48 @@ print.summary.design <- function(x, ...) { if (length(unique(df[[swap]])) < 3) { return(list(available = FALSE, reason = "requires >= 3 treatments")) } - ef <- tryCatch( - eval(bquote(calculate_efficiency_factor( - df, - .(as.name(swap)), - row_column = rc, - col_column = cc - ))), - error = function(e) return(NULL) - ) - if (is.null(ef) || !is.finite(ef)) { - return(list( - available = FALSE, - reason = "could not be computed for this design" - )) + + one <- function(sub) { + ef <- tryCatch( + eval(bquote(calculate_efficiency_factor( + sub, + .(as.name(swap)), + row_column = rc, + col_column = cc + ))), + # A rank failure is a property of the design, so it carries its own + # reason; anything else is unexpected and gets the generic one. + speed_efficiency_error = function(e) return(e$reason), + error = function(e) return(NULL) + ) + if (is.character(ef)) { + return(list(available = FALSE, reason = ef)) + } + if (is.null(ef) || !is.finite(ef)) { + return(list( + available = FALSE, + reason = "could not be computed for this design" + )) + } + return(list(available = TRUE, value = ef)) + } + + if (length(grid) == 1L) { + return(one(df)) } - return(list(available = TRUE, value = ef)) + + # One value per grid, never summed or averaged. An efficiency factor is a + # property of a single experiment's information matrix: averaging per-grid + # values gives a different quantity from the combined analysis, and the + # combined analysis is not identified anyway - it depends on residual variance + # ratios that are unknown at design time. Each grid is gated on its own rank, + # so one unreplicated site reports its reason without withholding the others. + per_grid <- lapply(grid, function(g) return(one(df[g$rows, , drop = FALSE]))) + return(list( + available = any(vapply(per_grid, function(x) x$available, logical(1))), + per_grid = per_grid, + grid_by = attr(grid, "by") + )) } #' Neighbour-balance diagnostics @@ -979,19 +1047,24 @@ print.summary.design <- function(x, ...) { if (is.character(grid)) { return(list(available = FALSE, reason = grid)) } - dm <- build_design_matrix( - df, - swap, - row_column = rc, - col_column = cc, - index = grid - ) + # One pair mapping for the whole design, so every grid contributes to the same + # set of pairs and a pair absent from one site is still counted as zero rather + # than dropped. Counts sum across grids: an adjacency is an edge, and no edge + # crosses a grid boundary. pair_mapping <- create_pair_mapping(df[[swap]]) - nb <- calculate_nb(dm, pair_mapping) - all_pairs <- unique(pair_mapping) counts <- setNames(rep(0L, length(all_pairs)), all_pairs) - counts[names(nb$nb)] <- nb$nb + for (g in grid) { + dm <- build_design_matrix( + df[g$rows, , drop = FALSE], + swap, + row_column = rc, + col_column = cc, + index = g$index + ) + nb <- calculate_nb(dm, pair_mapping) + counts[names(nb$nb)] <- counts[names(nb$nb)] + nb$nb + } # create_pair_mapping() keys are "trt1,trt2"; a self-pair repeats the level. parts <- strsplit(names(counts), ",", fixed = TRUE) diff --git a/man/calculate_adjacency_score.Rd b/man/calculate_adjacency_score.Rd index 186a7483..4dd73db3 100644 --- a/man/calculate_adjacency_score.Rd +++ b/man/calculate_adjacency_score.Rd @@ -13,6 +13,7 @@ calculate_adjacency_score( ring_weights = 1, ring_type = c("manhattan", "chebyshev"), relationship = NULL, + by = NULL, grid_index = NULL ) } @@ -43,10 +44,17 @@ to \code{NULL}, which keeps the strict identity match. Pass the raw matrix through \code{prep_relationship()} first; the score functions consume only the prepped form.} -\item{grid_index}{Optional pre-built index from \code{\link[=grid_index]{grid_index()}}, passed to -\code{\link[=build_design_matrix]{build_design_matrix()}} to skip coordinate validation. \code{speed()} supplies -one so the annealing loop does not revalidate every iteration; leave it -\code{NULL} for a one-off call.} +\item{by}{Optional column name grouping plots into separate grids (e.g. +\code{"site"} for a multi-environment trial). Each grid is scored on its own and +the counts summed, so no adjacency is counted between plots at different +sites. \code{NULL} (default) treats the design as a single grid, which errors if +two plots share a coordinate.} + +\item{grid_index}{Optional pre-built list of indices from \code{\link[=grid_indices]{grid_indices()}}, +passed to \code{\link[=build_design_matrix]{build_design_matrix()}} to skip coordinate validation. \code{speed()} +supplies one so the annealing loop does not revalidate every iteration; +leave it \code{NULL} for a one-off call. Supplying it ignores \code{by}, which the +indices already encode.} } \value{ A non-negative numeric value: the number of like-treatment edges diff --git a/man/calculate_efficiency_factor.Rd b/man/calculate_efficiency_factor.Rd index 06beb08d..b2591d43 100644 --- a/man/calculate_efficiency_factor.Rd +++ b/man/calculate_efficiency_factor.Rd @@ -21,20 +21,43 @@ calculate_efficiency_factor( \item{col_column}{Name of the column giving the column of the design (default: "col")} } \value{ -A numeric value representing the efficiency factor of the design. Higher values indicate more efficient designs. +A numeric value representing the efficiency factor of the design, +between 0 and 1. Higher values indicate more efficient designs. + +Errors with a \code{speed_efficiency_rank} condition if the design cannot support +the estimate - that is, if some treatment contrast is not estimable once row +and column effects are eliminated, whether because too few residual degrees +of freedom remain or because a treatment is confounded with a row or column. +Such a design has no efficiency factor; before this check the formula +returned a plausible-looking value, usually above 1. } \description{ Calculates an efficiency factor of a design according to Piepho 2015. } \examples{ +# `initialise_design_df()` fills `items` down columns, so the literal below is +# column-major; the grid it produces is +# a b d c +# e a f b +# c f e d df_design <- initialise_design_df(c( - "a", "b", "d", "c", - "e", "a", "f", "b", - "c", "f", "e", "d" + "a", "e", "c", + "b", "a", "f", + "d", "f", "e", + "c", "b", "d" ), 3, 4) calculate_efficiency_factor(df_design, "treatment") +# Not every design can support the estimate. Here each treatment fills one +# grid row, so the treatment differences cannot be separated from the row +# effects and there is no efficiency factor to report: +# a a a a +# b b b b +# c c c c +confounded <- initialise_design_df(rep(c("a", "b", "c"), 4), 3, 4) +try(calculate_efficiency_factor(confounded, "treatment")) + } \references{ Piepho, H. P., Williams, E., & Michel, V. (2015). Nonresolvable Row-Column Designs with an Even diff --git a/man/create_speed_input.Rd b/man/create_speed_input.Rd index b67f0b36..ef1fcc4c 100644 --- a/man/create_speed_input.Rd +++ b/man/create_speed_input.Rd @@ -37,7 +37,15 @@ consider for balance (default: \code{~row + col}).} \item{grid_factors}{A named list specifying grid factors to construct a matrix for calculating adjacency score, \code{dim1} for row and \code{dim2} for -column. (default: \code{list(dim1 = "row", dim2 = "col")}).} +column. (default: \code{list(dim1 = "row", dim2 = "col")}). + +An optional third element, \code{by}, names a column that groups plots into +\emph{separate} grids - a multi-environment trial, where each site reuses the +same \code{row}/\code{col} numbering. Each grid is then scored on its own and the +adjacency counts summed, so no adjacency is counted between plots at +different sites, e.g. +\code{list(dim1 = "row", dim2 = "col", by = "site")}. Without it, a design whose +sites share coordinates is refused rather than silently pooled.} \item{iterations}{Maximum number of iterations for the simulated annealing algorithm (default: 10000). For hierarchical designs, can be a named list diff --git a/man/grid_indices.Rd b/man/grid_indices.Rd new file mode 100644 index 00000000..8c4ec23c --- /dev/null +++ b/man/grid_indices.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/design_utils.R +\name{grid_indices} +\alias{grid_indices} +\title{Split a Design into One Grid Index per Grid} +\usage{ +grid_indices(df, row_column = "row", col_column = "col", by = NULL) +} +\arguments{ +\item{df}{A data frame with columns named by \code{row_column} and \code{col_column}.} + +\item{row_column}{Column name of the row position variable (default \code{"row"}).} + +\item{col_column}{Column name of the column position variable +(default \code{"col"}).} + +\item{by}{Optional column name grouping plots into grids (e.g. \code{"site"}). +\code{NULL} treats the design as one grid.} +} +\value{ +A named list with one element per grid, each a list of \code{rows} (the +positions in \code{df} belonging to that grid) and \code{index} (that grid's +\code{\link[=grid_index]{grid_index()}}). Named \code{"1"} when \code{by} is \code{NULL}. +} +\description{ +The multi-grid counterpart of \code{\link[=grid_index]{grid_index()}}. A multi-environment trial is +several grids that share a treatment set and never share an edge, so it +cannot be one matrix: sites reuse \code{row}/\code{col}, and pooling them either +silently overwrites plots or invents adjacencies between sites. + +With \code{by = NULL} this returns a one-element list, so callers have a single +code path whether or not the design spans grids. +} +\keyword{internal} diff --git a/man/objective_function_piepho.Rd b/man/objective_function_piepho.Rd index 025dfdaf..05d41c2b 100644 --- a/man/objective_function_piepho.Rd +++ b/man/objective_function_piepho.Rd @@ -13,6 +13,7 @@ objective_function_piepho( pair_mapping = NULL, row_column = "row", col_column = "col", + by = NULL, grid_index = NULL, ... ) diff --git a/man/speed.Rd b/man/speed.Rd index daf356b9..d266441f 100644 --- a/man/speed.Rd +++ b/man/speed.Rd @@ -43,7 +43,15 @@ consider for balance (default: \code{~row + col}).} \item{grid_factors}{A named list specifying grid factors to construct a matrix for calculating adjacency score, \code{dim1} for row and \code{dim2} for -column. (default: \code{list(dim1 = "row", dim2 = "col")}).} +column. (default: \code{list(dim1 = "row", dim2 = "col")}). + +An optional third element, \code{by}, names a column that groups plots into +\emph{separate} grids - a multi-environment trial, where each site reuses the +same \code{row}/\code{col} numbering. Each grid is then scored on its own and the +adjacency counts summed, so no adjacency is counted between plots at +different sites, e.g. +\code{list(dim1 = "row", dim2 = "col", by = "site")}. Without it, a design whose +sites share coordinates is refused rather than silently pooled.} \item{iterations}{Maximum number of iterations for the simulated annealing algorithm (default: 10000). For hierarchical designs, can be a named list diff --git a/tests/testthat/test-build_design_matrix.R b/tests/testthat/test-build_design_matrix.R index 471409ee..fb899b53 100644 --- a/tests/testthat/test-build_design_matrix.R +++ b/tests/testthat/test-build_design_matrix.R @@ -229,11 +229,82 @@ test_that("a supplied index is checked against the design it is used with", { test_that("calculate_adjacency_score() accepts a pre-built index", { d <- initialise_design_df(rep(LETTERS[1:6], 4), 3, 8) expect_equal( - calculate_adjacency_score(d, "treatment", grid_index = grid_index(d)), + calculate_adjacency_score(d, "treatment", grid_index = grid_indices(d)), calculate_adjacency_score(d, "treatment") ) }) +test_that("calculate_adjacency_score() sums per grid and never across them", { + # Two sites sharing row/col numbering. Scoring them as one grid is impossible + # (duplicate coordinates); scoring them side by side in one grid would invent + # adjacencies between plots at different sites. + set.seed(1) + d <- rbind( + data.frame( + site = "a", + expand.grid(row = 1:4, col = 1:3), + treatment = sample(rep(LETTERS[1:6], 2)) + ), + data.frame( + site = "b", + expand.grid(row = 1:4, col = 1:3), + treatment = sample(rep(LETTERS[1:6], 2)) + ) + ) + + expect_error( + calculate_adjacency_score(d, "treatment"), + class = "speed_grid_duplicate" + ) + + per_site <- sum(vapply( + split(d, d$site), + function(s) return(calculate_adjacency_score(s, "treatment")), + numeric(1) + )) + expect_equal( + calculate_adjacency_score(d, "treatment", by = "site"), + per_site + ) + + # Laid side by side the coordinates are unique, so nothing errors - but the + # score picks up adjacencies across the join, which is exactly what `by` + # prevents. This is the case no amount of coordinate validation can catch. + side_by_side <- d + side_by_side$col[side_by_side$site == "b"] <- + side_by_side$col[side_by_side$site == "b"] + 3 + expect_gt( + calculate_adjacency_score(side_by_side, "treatment"), + calculate_adjacency_score(side_by_side, "treatment", by = "site") + ) + expect_equal( + calculate_adjacency_score(side_by_side, "treatment", by = "site"), + per_site + ) +}) + +test_that("grid_indices() returns one index per grid", { + d <- rbind( + data.frame(site = "a", expand.grid(row = 1:4, col = 1:3), treatment = "x"), + data.frame(site = "b", expand.grid(row = 1:4, col = 1:3), treatment = "y") + ) + + single <- grid_indices(d[d$site == "a", ]) + expect_length(single, 1) + expect_equal(single[[1]]$index$n, 12) + + split_grids <- grid_indices(d, by = "site") + expect_named(split_grids, c("a", "b")) + expect_equal(attr(split_grids, "by"), "site") + expect_equal(vapply(split_grids, function(g) return(g$index$n), numeric(1)), + c(a = 12, b = 12)) + # The rows recorded for each grid are the rows of that site, so a caller can + # subset the design with them. + expect_equal(split_grids$b$rows, which(d$site == "b")) + + expect_error(grid_indices(d, by = "nope"), class = "speed_grid_missing_by") +}) + test_that("speed() scores identically whether or not the index is hoisted", { # The hoist is a performance change only; guard against it becoming a # behaviour change. objective_function_piepho() builds a grid every iteration, diff --git a/tests/testthat/test-calculate_efficiency_factor.R b/tests/testthat/test-calculate_efficiency_factor.R index c4531650..d3206874 100644 --- a/tests/testthat/test-calculate_efficiency_factor.R +++ b/tests/testthat/test-calculate_efficiency_factor.R @@ -67,11 +67,16 @@ test_that("calculate_efficiency_factor provides the same results as the paper", }) test_that("calculate_efficiency_factor provides better result for an optimised design", { + # A poor but *estimable* comparator. The obvious "unoptimised" layout - each + # treatment filling one grid row - is not merely inefficient, it confounds + # treatment with row, so it has no efficiency factor at all and is refused + # (see the estimability tests below). Comparing against it would have been + # comparing against a number that does not exist. # fmt: skip df_design_initial <- by_row(c( - 1, 1, 2, 2, - 3, 3, 4, 4, - 5, 5, 6, 6 + 1, 2, 6, 3, + 4, 3, 5, 5, + 1, 4, 2, 6 ), 3, 4) # fmt: skip @@ -85,6 +90,9 @@ test_that("calculate_efficiency_factor provides better result for an optimised d abs(1 - calculate_efficiency_factor(df_design_optimised, "treatment")), abs(1 - calculate_efficiency_factor(df_design_initial, "treatment")) ) + # Both are genuine values in [0, 1], so the comparison above is meaningful. + expect_lte(calculate_efficiency_factor(df_design_optimised, "treatment"), 1) + expect_gt(calculate_efficiency_factor(df_design_initial, "treatment"), 0) }) test_that("calculate_efficiency_factor provides same result for mathematically identical designs", { @@ -120,28 +128,42 @@ test_that("calculate_efficiency_factor provides same result for mathematically i ) }) -test_that("calculate_efficiency_factor handles near-singular matrices using pseudoinverse", { - # Instead of trying to create a design that naturally triggers the pseudoinverse, - # let's test with a design that we know works and verify the function handles - # both the regular and pseudoinverse cases properly - - # Use a simple but slightly unbalanced design - df_design_test <- data.frame( +test_that("calculate_efficiency_factor refuses a design whose contrasts are not estimable", { + # C occupies row 3 entirely, so the C-vs-A contrast cannot be separated from + # the row 3 effect. Confirmed independently: fitting + # `y ~ factor(row) + factor(col) + treatment` with lm() aliases one treatment + # coefficient. There is no efficiency factor for such a design - before this + # check the formula returned a finite, plausible-looking value anyway. + df_confounded_row <- data.frame( row = c(1, 1, 2, 2, 3, 3), col = c(1, 2, 1, 2, 1, 2), treatment = c("A", "B", "A", "B", "C", "C") ) - # This should complete without error regardless of which inversion method is used - expect_no_error({ - result <- calculate_efficiency_factor(df_design_test, "treatment") - }) + expect_error( + calculate_efficiency_factor(df_confounded_row, "treatment"), + class = "speed_efficiency_rank" + ) + expect_error( + calculate_efficiency_factor(df_confounded_row, "treatment"), + "not all treatment contrasts are estimable", + ignore.case = TRUE + ) +}) - result <- calculate_efficiency_factor(df_design_test, "treatment") - expect_type(result, "double") - expect_length(result, 1) +test_that("calculate_efficiency_factor keeps designs with no residual df to spare", { + # The boundary the gate must not overshoot: zero residual degrees of freedom + # is still estimable, so these must return a value rather than be refused. + # fmt: skip + df_df0 <- by_row(c( + 1, 2, 3, + 3, 1, 2 + ), 2, 3) + + result <- calculate_efficiency_factor(df_df0, "treatment") expect_true(is.finite(result)) expect_gt(result, 0) + expect_lte(result, 1) }) test_that("calculate_efficiency_factor works with minimal design dimensions", { @@ -162,25 +184,41 @@ test_that("calculate_efficiency_factor works with minimal design dimensions", { expect_gt(result, 0) }) -test_that("calculate_efficiency_factor handles designs with high treatment-space confounding", { - # Create a design where treatments are highly confounded with a spatial dimension - # This creates a scenario more likely to need pseudoinverse without being perfectly singular +test_that("calculate_efficiency_factor refuses a single-column design with blocked treatments", { + # One plot per row means every row effect is a plot effect, so nothing is left + # to estimate a treatment difference with. Refusing is the only honest answer; + # the formula previously returned a finite value here. df_design_confounded <- data.frame( row = rep(1:8, each = 1), col = rep(1, times = 8), treatment = c("A", "A", "A", "A", "B", "B", "B", "B") # Single column, treatments in blocks ) - # This single-column design with blocked treatments should be numerically challenging - # but still solvable - expect_no_error({ - result <- calculate_efficiency_factor(df_design_confounded, "treatment") - }) + expect_error( + calculate_efficiency_factor(df_design_confounded, "treatment"), + class = "speed_efficiency_rank" + ) +}) - result <- calculate_efficiency_factor(df_design_confounded, "treatment") - expect_type(result, "double") - expect_true(is.finite(result)) - expect_gt(result, 0) +test_that("calculate_efficiency_factor refuses unreplicated and degenerate designs", { + # Every route to an impossible value, pinned together. Each was measured + # returning > 1 before the rank check existed. + unreplicated <- initialise_design_df(as.character(1:12), 4, 3) + single_row <- data.frame( + row = rep(1, 6), + col = 1:6, + treatment = rep(c("A", "B", "C"), 2) + ) + # Column-major storage, so this is one treatment per grid row - `each = 4` + # would give a diagonal pattern, which is estimable. + aliased_with_row <- initialise_design_df(rep(c("a", "b", "c"), 4), 3, 4) + + for (d in list(unreplicated, single_row, aliased_with_row)) { + expect_error( + calculate_efficiency_factor(d, "treatment"), + class = "speed_efficiency_rank" + ) + } }) test_that("calculate_efficiency_factor uses pseudoinverse for matrices with high condition numbers", { @@ -218,24 +256,12 @@ test_that("calculate_efficiency_factor uses pseudoinverse for matrices with high }) test_that("calculate_efficiency_factor honours custom row/col column names", { - df <- initialise_design_df( - c( - "a", - "b", - "d", - "c", - "e", - "a", - "f", - "b", - "c", - "f", - "e", - "d" - ), - 3, - 4 - ) + # fmt: skip + df <- by_row(c( + "a", "b", "d", "c", + "e", "a", "f", "b", + "c", "f", "e", "d" + ), 3, 4) base <- calculate_efficiency_factor(df, "treatment") # Same design, grid columns renamed - must give the same efficiency. diff --git a/tests/testthat/test-grid-orientation.R b/tests/testthat/test-grid-orientation.R index b803e811..4f565ab5 100644 --- a/tests/testthat/test-grid-orientation.R +++ b/tests/testthat/test-grid-orientation.R @@ -322,8 +322,13 @@ test_that("efficiency factor computes on a grid with a genuine hole", { # road - and is not a buffer, so it must be scored on the coordinates it has # rather than closed up. Here Z has no empty columns, so this is a separate # path from the offset case above: solve(), not pseudo_inverse(). + # The treatments are shuffled rather than laid out systematically: + # `rep(LETTERS[1:6], 4)` down a 4 x 6 grid puts each treatment in only two of + # the four rows, which confounds treatment with row and leaves the design with + # no efficiency factor to report at all. + set.seed(2) full <- initialise_design_df( - items = rep(LETTERS[1:6], 4), + items = sample(rep(LETTERS[1:6], 4)), nrows = 4, ncols = 6 ) @@ -335,8 +340,8 @@ test_that("efficiency factor computes on a grid with a genuine hole", { # silent change in either is caught. expect_equal( calculate_efficiency_factor(full, treatment), - 0.7058824, + 0.5949797, tolerance = 1e-6 ) - expect_equal(holed_ef, 0.7008719, tolerance = 1e-6) + expect_equal(holed_ef, 0.5764686, tolerance = 1e-6) }) diff --git a/tests/testthat/test-summary.R b/tests/testthat/test-summary.R index 5b8119f3..f3b9066c 100644 --- a/tests/testthat/test-summary.R +++ b/tests/testthat/test-summary.R @@ -1,12 +1,12 @@ # Tests for summary.design / print.summary.design (Phase 3: Structure + # Optimisation + flags). Evaluation metrics are covered separately. -# The `grid` argument the evaluation helpers take: a grid_index() list, or the -# reason there is no grid. summary.design() builds it inline, once per call; -# tests calling a helper directly need the same value. -grid_or_reason <- function(df, rc = "row", cc = "col") { +# The `grid` argument the evaluation helpers take: a grid_indices() list - one +# entry per grid - or the reason there is no grid at all. summary.design() builds +# it inline, once per call; tests calling a helper directly need the same value. +grid_or_reason <- function(df, rc = "row", cc = "col", by = NULL) { return(tryCatch( - grid_index(df, row_column = rc, col_column = cc), + grid_indices(df, row_column = rc, col_column = cc, by = by), speed_grid_error = function(e) return(e$reason) )) } @@ -1004,10 +1004,11 @@ test_that("multi-site (MET) designs summarise instead of erroring", { }) test_that("efficiency is withheld rather than reported above 1 for MET designs", { - # calculate_efficiency_factor() does not error on duplicate coordinates, it - # pools the grids and returns a value above 1 - impossible for an efficiency - # factor. The gate exists to stop that reaching the user, not just to stop an - # error, so pin the underlying behaviour that makes it necessary. + # An efficiency factor is a property of one experiment's information matrix, + # so a multi-site frame has no single answer. Nothing about the arithmetic + # stops it being computed - duplicate coordinates just pool the sites into one + # row/column model - so the refusal has to be explicit. Before it, this + # returned a value above 1, impossible for an efficiency factor. met <- initialise_design_df( items = rep(LETTERS[1:3], 6), designs = list( @@ -1015,7 +1016,10 @@ test_that("efficiency is withheld rather than reported above 1 for MET designs", b = list(nrows = 3, ncols = 4) ) ) - expect_gt(calculate_efficiency_factor(met, treatment), 1) + expect_error( + calculate_efficiency_factor(met, treatment), + class = "speed_grid_duplicate" + ) gate <- .efficiency_factor( met, "treatment", @@ -1027,6 +1031,133 @@ test_that("efficiency is withheld rather than reported above 1 for MET designs", expect_match(gate$reason, "duplicate") }) +test_that("a MET design reports one efficiency per site and never a pooled one", { + set.seed(1) + d <- rbind( + data.frame( + site = "a", + expand.grid(row = 1:4, col = 1:3), + treatment = sample(rep(LETTERS[1:6], 2)) + ), + data.frame( + site = "b", + expand.grid(row = 1:4, col = 1:3), + treatment = sample(rep(LETTERS[1:6], 2)) + ) + ) + r <- speed( + d, + swap = "treatment", + swap_within = "site", + spatial_factors = ~ row + col + site, + grid_factors = list(dim1 = "row", dim2 = "col", by = "site"), + iterations = 100, + seed = 42, + quiet = TRUE + ) + s <- summary(r, efficiency = TRUE) + + # The grouping is recorded, not guessed from the column name. + expect_equal(r$metadata$grid_by, "site") + expect_false(s$layout$has_grid) + expect_equal(s$layout$n_grids, 2L) + expect_match(s$layout$grid_reason, "2 grids") + # nrow/ncol stay NA: no single shape describes a design spanning two grids. + expect_true(is.na(s$layout$nrow)) + + ef <- s$per_level[[1]]$evaluation$efficiency + expect_named(ef$per_grid, c("a", "b")) + expect_equal(ef$grid_by, "site") + expect_null(ef$value) # no pooled value, ever + for (one in ef$per_grid) { + expect_true(one$available) + expect_lte(one$value, 1) + } + # Each site's value is that site scored on its own. + expect_equal( + ef$per_grid$a$value, + calculate_efficiency_factor( + r$design_df[r$design_df$site == "a", ], + treatment + ) + ) + + out <- capture_output(print(s)) + expect_match(out, "per `site`") + + # Spans pool sites if computed naively - two sites' row 3 are not one plot + # apart - so they are withheld with a reason instead. + spans <- s$per_level[[1]]$evaluation$replicate_span + expect_false(spans$available) + expect_match(spans$reason, "spans 2 grids") +}) + +test_that("a site that cannot support an efficiency factor reports only for itself", { + # Site b puts each treatment in one column, so its treatment contrasts are + # confounded with the column effects. That must not withhold site a's value. + d <- rbind( + data.frame( + site = "a", + expand.grid(row = 1:4, col = 1:3), + treatment = c("A", "B", "C", "D", "C", "D", "A", "B", "B", "A", "D", "C") + ), + data.frame( + site = "b", + expand.grid(row = 1:4, col = 1:3), + treatment = rep(c("A", "B", "C"), each = 4) + ) + ) + grid <- grid_or_reason(d, by = "site") + ef <- .efficiency_factor(d, "treatment", "row", "col", grid) + + expect_true(ef$per_grid$a$available) + expect_false(ef$per_grid$b$available) + expect_match(ef$per_grid$b$reason, "not estimable") + # `available` is TRUE overall because at least one site reported. + expect_true(ef$available) +}) + +test_that("neighbour balance sums across grids rather than pooling them", { + set.seed(3) + d <- rbind( + data.frame( + site = "a", + expand.grid(row = 1:4, col = 1:3), + treatment = sample(rep(LETTERS[1:6], 2)) + ), + data.frame( + site = "b", + expand.grid(row = 1:4, col = 1:3), + treatment = sample(rep(LETTERS[1:6], 2)) + ) + ) + both <- .neighbour_balance( + d, + "treatment", + "row", + "col", + grid_or_reason(d, by = "site") + ) + expect_true(both$available) + + # The self-adjacency count is an edge count, so it is exactly the sum of the + # per-site counts - no edge crosses a site boundary. + per_site <- vapply( + split(d, d$site), + function(s) { + return(.neighbour_balance( + s, + "treatment", + "row", + "col", + grid_or_reason(s) + )$self_adjacent) + }, + numeric(1) + ) + expect_equal(both$self_adjacent, sum(per_site)) +}) + test_that("non-numeric row/col labels are reported, not coerced silently", { d <- data.frame( row = rep(c("A", "B", "C"), each = 4), From 565bb888fc0e9dad63616de22a9a51777a498a01 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:10:26 +0930 Subject: [PATCH 20/28] Updating NEWS and plan --- NEWS.md | 14 ++++ REVIEW-NOTES-OTHER.md | 172 ++++++++++++++++++++++-------------------- 2 files changed, 106 insertions(+), 80 deletions(-) diff --git a/NEWS.md b/NEWS.md index 54f71b10..d45e6109 100644 --- a/NEWS.md +++ b/NEWS.md @@ -7,8 +7,17 @@ replicate spans and spread across blocks, neighbour balance, and opt-in efficiency). ([#73](https://github.com/biometryhub/speed/issues/73)) +- `grid_factors` gains an optional `by` element naming a column that groups plots into separate + grids, e.g. `list(dim1 = "row", dim2 = "col", by = "site")` for a multi-environment trial where + each site reuses the same `row`/`col` numbering. Each grid is scored on its own and the adjacency + and neighbour-balance counts summed, so nothing is counted between plots at different sites, and + `summary()` reports one efficiency factor per site rather than a pooled one. + ## Bug Fixes +- Multi-site designs are no longer scored as though they occupied one grid. Previously the sites were + pooled, which discarded plots whose coordinates collided and counted adjacencies between plots at + different sites; use `grid_factors$by` to name the grouping column. - Design metrics are now built from each plot's `row`/`col` coordinates rather than the order of the rows in the data frame, so they describe the actual layout. This corrects `calculate_adjacency_score()`, `calculate_efficiency_factor()`, `objective_function_piepho()` and @@ -17,6 +26,11 @@ - `summary()` no longer errors on designs that cannot be placed on a single grid, such as multi-site (MET) designs or designs with non-numeric `row`/`col` labels. The affected diagnostics report why they are unavailable instead, and are no longer computed from pooled grids. +- `calculate_efficiency_factor()` now errors, rather than returning a plausible-looking value + (usually above 1, which is impossible), for a design that cannot support the estimate - one whose + treatment contrasts are not estimable once row and column effects are eliminated. `summary()` + reports such designs as unavailable with a reason. The row-column model now includes an intercept, + which does not change the value for designs that were already valid. - `calculate_nb()` no longer errors on designs with missing plots when `pair_mapping` is not supplied. - `calculate_adjacency_score()` now recycles a single `ring_weights` value across every entry of diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index e1d796a4..586d94cd 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -24,9 +24,10 @@ and the reason `build_design_matrix()` keeps coordinates **raw** — is `KNOWN_I **Last verified:** 2026-08-06, R 4.6.1, `pkgload::load_all()`. All numbers measured, not inferred. Resolved findings and settled decisions are deleted rather than annotated — see git history and `NEWS.md`. -> ✅ **The branch's original scope is closed.** The plan as committed at `b1d7adc` listed D6, D1 and -> G1-G6, plus the hot-loop cost recorded as out of scope; all are done, the last as G11. G7, S1, A5, G11 -> and G12 were found during the work. See A1. Full suite: **1739 pass, 0 fail, 0 warn.** +> ✅ **The branch's original scope is closed, and the findings it turned up with it.** The plan as +> committed at `b1d7adc` listed D6, D1 and G1-G6, plus the hot-loop cost recorded as out of scope; all are +> done, the last as G11. G7, S1, A5, G11, G12, G13 and G14 were found during the work and are also done. +> See A1. Full suite: **1774 pass, 0 fail, 0 warn.** > > ⬜ **Deliberately still open from the original plan:** removing the row-major sort, which A4.7 recorded > as out of scope on purpose (`KNOWN_ISSUES.md` #4). @@ -35,10 +36,9 @@ Resolved findings and settled decisions are deleted rather than annotated — se > restoration that makes raw coordinates correct plus the `add_buffers()` deprecation. See A2 — two items > are already done there, so read it before actioning anything. > -> 🔴 **Blocker: G13 — nothing in speed represents a design occupying more than one grid, so MET is -> broken.** `initialise_design_df(designs = )` reuses `row`/`col` per site, so every MET design has -> duplicate coordinates. `main` silently discarded **30 of 80** plots; this branch errors instead. Neither -> works, and coordinate construction did not cause it. Grid metrics need a grouping dimension — see A3. +> ✅ **G13 has landed.** `grid_factors` gains an optional `by`, so a design can occupy several grids. +> Adjacency and neighbour balance are summed per grid, efficiency is reported per grid, and nothing is +> counted between plots at different sites. See A3. > > ✅ **D7 is decided (Sam, 2026-08-07): per site, gated on per-site rank, with no combined figure.** > Adjacency and neighbour balance sum exactly; efficiency is reported one value per site, each withheld @@ -46,10 +46,12 @@ Resolved findings and settled decisions are deleted rather than annotated — se > `asreml` to compute but is **not identified** — measured, the design ranking flips with the assumed > variance ratio and the value passes 1. G13 is no longer blocked on a decision. See A4. > -> 🟠 **G14 — `summary()` reports an efficiency factor above 1 for a rank-deficient *single* grid.** Measured -> 1.61 on an ordinary unreplicated 12-entry 4×3 trial, identical on `main`. **No D7 decision needed** — a -> single grid has no pooling question — but it is fixed by the same rank gate as D7 recommendation 2, so -> build that gate once. Pre-existing, not branch-introduced. See A5. +> ✅ **G14 has landed.** `calculate_efficiency_factor()` refuses a design whose treatment contrasts are +> not estimable instead of returning an impossible value, and the row-column model now carries an +> intercept, which is what makes the rank test exact. See A5. +> +> ⬜ **Still open:** `objective_function_piepho()`'s ED component has no agreed multi-grid form, so it +> refuses a multi-grid design rather than pooling one. See the end of A4. --- @@ -69,6 +71,8 @@ One-line inventory for the PR description. Full write-ups are in git history. | **S1** `.neighbour_balance()` reported self-adjacencies that didn't exist (6 where the truth was 0) | reads coordinates; the 4×3 fixture returns the hand-derived truth (self 0, pair min/max 5/6) | | **A5** lexical factor levels (`1, 10, 11, 2, …`) defeated the row-major sort, so grid metrics scored a layout that wasn't the design | coordinate construction is immune; the sort is no longer load-bearing (`KNOWN_ISSUES.md` #4) | | **G11** coordinate validation ran on every iteration, making grid construction 16× the `matrix()` reshape it replaced | validation split into `grid_index()`, hoisted once per `speed()` run and built lazily so a design that cannot be gridded still optimises if its objective never needs a grid; build back to parity — see A1.1 | +| **G13** nothing represented a design occupying more than one grid, so every MET design was scored as one pooled grid — discarding plots whose coordinates collided, or inventing adjacencies between sites | `grid_factors` gains an optional `by`; `grid_indices()` returns one validated index per grid; adjacency and neighbour balance sum per grid, efficiency is reported per grid, and `metadata$grid_by` records the grouping — see A3 | +| **G14** `calculate_efficiency_factor()` returned a plausible-looking value, usually above 1, for a design whose treatment contrasts are not estimable | refuses with a `speed_efficiency_rank` condition, which `summary()` reports as a reason; the row-column model gained an intercept, which is what makes the rank test exact and changes no existing value — see A5 | | **G12** `summary()` died outright on any design that couldn't be gridded — MET, or non-numeric `row`/`col` labels | `summary.design()` calls `grid_index()` once and keeps either the index or the condition's `reason` as `grid`, which it passes to `.neighbour_balance()`, `.efficiency_factor()` and `.replicate_spans()`; each reports the reason instead of computing. `has_grid` now means "reportable as one grid", so `layout` no longer describes a MET as a single grid. `nrow`/`ncol` count *occupied* rows and columns (settled, Sam), so on a design with gaps they are deliberately fewer than the coordinates span — documented in `?summary.design` | ### A1.1 G11 measurements @@ -136,7 +140,20 @@ when it rebases. --- -## A3. G13 🔴 There is no representation of a design occupying more than one grid, so MET is broken +## A3. G13 ✅ A design can now occupy more than one grid + +**Landed 2026-08-07.** `grid_factors` gained an optional `by` naming the column that separates grids; +`grid_indices()` returns one validated index per grid (a one-element list when `by` is `NULL`, so callers +have a single code path); `calculate_adjacency_score()` and `.neighbour_balance()` sum per grid; +`summary()` reports one efficiency per grid; `metadata$grid_by` records the grouping so `summary()` never +has to guess it. A mistyped `by` is rejected rather than silently ignored. `objective_function_piepho()` +refuses a multi-grid design, because its ED component has no agreed multi-grid form. + +Measured on two 4×3 sites sharing coordinates: `by = "site"` scores **3**, exactly the sum of the +per-site scores, where pooling them side by side scores higher on account of adjacencies across the join. +The record below is what the fix had to account for. + +### The problem it fixed **One root cause, four symptoms.** `build_design_matrix()` — and `matrix()` before it — models a design as *a* grid. A multi-environment trial is several grids that share a treatment set and must never share an @@ -148,56 +165,40 @@ records which column separates the grids. list(a = list(nrows = 10, ncols = 3), b = list(nrows = 10, ncols = 5)))` — 80 plots, 10 unique rows, 5 unique cols: -| Symptom | `main` | this branch | +| Symptom | `main` | now | |---|---|---| -| `.neighbour_balance()` | 50-cell grid from 80 plots: **30 plots silently discarded**, one `data length differs from size of matrix` warning | reported unavailable, with a reason (G12) | -| `calculate_adjacency_score()` | garbage from the same truncation | **errors** — correct for a direct call, but there is still no way to get the right number | -| `calculate_efficiency_factor()` | pools sites into one row/col model | still `1.855529` on a direct call — a value `> 1` is impossible; withheld inside `summary()` only | -| sites laid side by side in one grid (`col + 3` for site b, so coordinates *are* unique) | **60** adjacencies vs **50** summing per site — 10 phantom cross-site edges | **still 60** | +| `.neighbour_balance()` | 50-cell grid from 80 plots: **30 plots silently discarded**, one `data length differs from size of matrix` warning | summed per grid | +| `calculate_adjacency_score()` | garbage from the same truncation | summed per grid with `by`; still errors without it, which is correct | +| `calculate_efficiency_factor()` | pools sites into one row/col model, returning `1.855529` — a value `> 1` is impossible | refuses a multi-grid frame; `summary()` reports one value per site | +| sites laid side by side in one grid (`col + 3` for site b, so coordinates *are* unique) | **60** adjacencies vs **50** summing per site — 10 phantom cross-site edges | `by` gives **50**; without it, still 60 | -Read the last two rows carefully. This is **not** a regression this branch introduced and it is **not fixed -by validation or by G12's gate**: duplicate coordinates don't break coordinate *indexing*, they just quietly -pool, and the side-by-side case has no duplicates at all, so no error can fire and no gate can catch it. -The duplicate-coordinate error is doing its job — it is the only reason any of this is visible — but it is a -diagnostic, not the fix. +The last row is the one that mattered most: it has no duplicate coordinates, so **no error can fire and no +gate can catch it** — it is a silently wrong number on `main` and would have stayed one under any amount of +coordinate validation. Only carrying the grouping fixes it, which is why the duplicate-coordinate error was +a diagnostic rather than the fix. It is pinned by a test that lays two sites side by side and asserts the +pooled score exceeds the per-grid one. **Adjacency and neighbour balance are summable over grids.** Measured: per-site adjacency 20 + 30 = **50**, the correct whole-design figure. Both count edges, and edges never cross a grid boundary, so summing per grid is exact rather than an approximation. That makes most of the fix tractable. -**Implementation sketch.** - -1. **Carry the grouping column.** Extend `grid_factors` to `list(dim1 = "row", dim2 = "col", by = "site")`. - Verified backwards compatible — `infer_row_col()` reads only `$dim1`/`$dim2`, and `speed()` already - accepts the three-element list without complaint. That tolerance is itself a trap: a mistyped `by` is - silently ignored today, so this needs validation added at the same time. -2. **Record it.** `metadata` currently holds only `levels` / `row_column` / `col_column` / `per_level` - ([speed.R:418-423](R/speed.R#L418-L423)), which is why `summary()` cannot recover the grouping on its - own. Add `grid_by`. -3. **A list-of-grids primitive.** `build_design_matrices(df, swap, rc, cc, by = NULL)` returning a named - list, length 1 when `by` is `NULL`. `build_design_matrix()` stays exactly as it is — the single-grid - primitive, still strict. Sum `adjacency_score_vec()` and the `calculate_nb()` pair tables across the - list. This wants a *list of indices* from `grid_index()`, one per grid, so it composes with G11's - hoisting rather than reintroducing per-iteration validation. -4. **Auto-detection is the wrong instinct.** Duplicate coordinates with no `by` should keep erroring, and - the current message already names the remedy. Inferring the grouping from a `"site"`-like column name is - how the ordering bugs in G1 happened. If it should be automatic, have - `initialise_design_df(designs = )` record `design_col` as an attribute so it is *transported* rather - than guessed. -5. **Efficiency: one value per site, each rank-gated** (D7, decided). First - `calculate_efficiency_factor()` must refuse a multi-grid frame rather than return `1.855529` — G12 - withholds it inside `summary()`, but a direct call still doesn't. Note that gate is specifically on - *duplicate coordinates*: it does **not** catch a rank-deficient single grid, which `summary()` still - reports — see A5. Build the rank gate first (G14) and the per-site path inherits it. ED is not - summable either and still needs an answer. -6. **Then relax G12's gate.** The single `grid_index()` call in `summary.design()` is deliberately the - *only* place `summary()` decides a design isn't griddable, so once the metrics take a grouping factor, - MET designs stop reaching it and it covers only genuinely un-griddable input. The split-plot test added - with G12 guards against the gate over-reaching in the meantime. - -Scope check: items 1-4 are mechanical given the summability result, and item 5 is now unblocked — D7 is -decided, so the only dependency left is building G14's rank gate first. ED (piepho) is the one piece still -without an answer, and can stay gated to "unavailable" so it does not hold the rest up. +### Decisions taken while building it + +- **Auto-detection was rejected.** Duplicate coordinates with no `by` still error, and the message names + the remedy. Inferring the grouping from a `"site"`-like column name is how the ordering bugs in G1 + happened. If it should ever be automatic, have `initialise_design_df(designs = )` record `design_col` + as an attribute so the grouping is *transported* rather than guessed. +- **`by` is validated, because `grid_factors` is a plain list.** A mistyped element would otherwise be + silently dropped and every site pooled — the failure mode is a wrong number, not an error. +- **`build_design_matrix()` was left untouched**, still the strict single-grid primitive. + `grid_indices()` sits above it and returns one index per grid, so the G11 hoisting composes: the + annealing loop still validates once per run, not once per iteration. +- **`.replicate_spans()` stays withheld for multi-grid designs.** Spans are distances within one grid; + two sites' row 3 are not one plot apart, and there is no combined span to report. +- **`objective_function_piepho()` refuses rather than pools.** Its NB component would sum like any edge + count, but ED measures evenness of a distribution: per-grid-then-combined and pooled are different + quantities, and the paper's definition assumes a single trial. This is the one piece of G13 still + awaiting a statistical answer. --- @@ -300,12 +301,32 @@ and G14 are covered by one implementation. --- -## A5. G14 🟠 `summary()` reports an efficiency factor above 1 for a rank-deficient single grid - -**No decision needed — this one does not ride on D7.** D7 is open because a *multi-grid* design has a real -statistical question (per site, pooled, or nested). A single grid has no such question: an A-efficiency -factor is bounded above by 1, so a value above it is wrong under any reading. What G14 shares with D7 is the -**fix**, not the decision — see the closing paragraph of A4. +## A5. G14 ✅ An efficiency factor above 1 for a rank-deficient single grid + +**Landed 2026-08-07.** `calculate_efficiency_factor()` now errors with a `speed_efficiency_rank` condition +when the treatment contrasts are not estimable, and `summary()` turns that into a reason. Two findings from +building it are worth keeping, because both contradict what this section originally proposed. + +**The row-column model now includes an intercept (Sam's suggestion), and that is what makes the test +exact.** The proposed gate was `rank(A_RC) == k - 1` on the existing matrix. That is wrong without an +intercept: `X`'s rows sum to 1, so the treatment mean is estimable, a sound design gives rank `k`, and an +equality test rejects valid designs. Testing the contrast space instead is not a fix either — for a PSD +matrix, "no contrast lies in the null space" is weaker than "every contrast is estimable", so it passes +designs whose contrasts are not estimable. Putting the intercept in the nuisance space makes the null space +exactly the all-ones direction, at which point `rank(A_RC) == k - 1` means precisely what it should. +**Verified not to change any value**: both published designs still return 0.834 and 0.827, matching the +paper and matching pairwise contrast variances taken from the full model's Moore-Penrose inverse. This also +closes E2 in `REVIEW-NOTES-EFFICIENCY.md`. + +**`qr()$rank` is unusable here** — its default tolerance is relative, and it reports rank 3 for a matrix +whose eigenvalues are `2, 8.7e-16, 6.3e-16`. The gate uses `svd()` with the same absolute tolerance as +`pseudo_inverse()`, so the gate and the inverse cannot disagree about which directions are null. + +**Confirmed against base R.** Every design below was cross-checked by fitting +`y ~ factor(row) + factor(col) + treatment` with `lm()` and asking whether it aliases a treatment +coefficient. `lm()` agrees with the gate on all of them — including the four that existing tests asserted +should return a number. Note the term order matters: `lm()` pivots in formula order, so putting `treatment` +first lets it absorb the confounding and alias the row terms instead. **Pre-existing, and this branch changed neither the values nor the reporting.** Measured 2026-08-06 on both `bugfix/grid-orientation` and a clean `main` worktree, with `connectedness = FALSE` (S6 in @@ -345,22 +366,13 @@ fixture — it is an ordinary early-generation trial, and it is the *same shape* inside a MET. So the impossible value is reachable from a completely routine single-site call, not only from the MET path everyone already knows is broken. -**Fix.** Gate `calculate_efficiency_factor()` (or `.efficiency_factor()`) on the treatment information matrix -having rank `k − 1` after eliminating the row and column space, and report unavailable with a reason when it -does not. A rank test covers both routes above; a residual-df test covers only the first. This is exactly D7 -recommendation 2's check applied to a single grid, which is why A4 now says to build it as a property of one -information matrix rather than inside the MET path. Landing it here first is the cheaper order: G14 needs no -grouping column and no D7 answer, and D7 recommendation 2 then inherits the gate instead of introducing it. - -Two details for whoever implements it. The exact spot is -[metrics.R:749](R/metrics.R#L749) — `V <- pseudo_inverse(A_RC)`, applied to the treatment information matrix -**unconditionally**, with no rank check. (Contrast [metrics.R:733-740](R/metrics.R#L733-L740), where the -nuisance space `ZtZ` *is* guarded by a `kappa()` test before choosing `pseudo_inverse()` over `solve()`.) So -the test is `rank(A_RC) == k − 1`, placed before line 749; sanity-checking the number that comes out is the -wrong shape. Second, clamping or `NA`-ing anything above 1 would also be wrong: it hides the confounded case -(row 4) behind a plausible value instead of reporting that the design cannot support the estimate. - -**Not in scope for this branch** — it is pre-existing, it is not caused by coordinate construction, and -`REVIEW-NOTES-EFFICIENCY.md` owns the efficiency statistics (branch `feature/a-optimality` exists). Recorded -here because G13 item 5 and D7 both assume the only bad efficiency value is the MET one, and that is not -true. +**Why clamping would have been wrong.** Capping or `NA`-ing anything above 1 hides the confounded case +(row 4) behind a plausible value instead of reporting that the design cannot support the estimate. The +refusal is the point, not the bound. + +**Fallout in the existing tests, all of it real.** Four tests asserted a finite, positive value for designs +`lm()` also calls non-estimable, and one `@examples` block used such a design — it would have failed +`R CMD check` once the gate existed. Each was replaced with an estimable fixture, keeping the test's +original intent, plus new tests pinning the refusals and the `residual df 0` boundary that must **not** be +rejected. The comparator in "provides better result for an optimised design" is the clearest case: it was +comparing against a number that does not exist. From 56f17db2c2eb2f15fca31892e7897a58eb99d9de Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:23:04 +0930 Subject: [PATCH 21/28] Updating ED for METs --- R/metrics.R | 111 +++++++++++----- man/objective_function_piepho.Rd | 9 ++ tests/testthat/test-objective_functions.R | 151 +++++++++++++++++++--- 3 files changed, 227 insertions(+), 44 deletions(-) diff --git a/R/metrics.R b/R/metrics.R index 03143d03..75855ad8 100644 --- a/R/metrics.R +++ b/R/metrics.R @@ -202,6 +202,14 @@ calculate_balance_score <- function(layout_df, swap, spatial_cols) { #' @inheritParams calculate_ed #' @param design A data frame representing the spatial information of the design #' @param current_score_obj A named list containing the current score +#' @param by,grid_index Optional grouping of plots into separate grids; see +#' [calculate_adjacency_score()]. Neighbour balance counts edges, so it sums +#' across grids. Evenness of distribution measures how far apart a treatment's +#' replicates are, and there is no distance between plots at different sites, +#' so it is scored **per grid** and the scores summed - reported both in total +#' and per grid, and identical to optimising each grid on its own, since a swap +#' moves plots within one grid. A grid with no treatment replicated inside it +#' has no evenness to measure and contributes `0`. #' #' @examples #' design_df <- initialise_design_df( @@ -240,35 +248,67 @@ objective_function_piepho <- function(design, by = NULL, grid_index = NULL, ...) { + # `by`/`grid_index` are documented on calculate_adjacency_score(); evenness is + # scored per grid and the scores summed. See the loop below. if (is.null(grid_index)) { grid_index <- grid_indices(design, row_column, col_column, by = by) } - # Neighbour balance would sum across grids like any edge count, but the - # evenness-of-distribution component does not: per-grid-then-combined and - # pooled are different quantities, and Piepho's definition assumes a single - # trial. Refusing is the honest interim answer - pooling would score - # adjacencies and distances between plots at different sites. - if (length(grid_index) > 1) { - stop( - "`objective_function_piepho()` is defined for a single grid, but this ", - "design spans ", - length(grid_index), - ". Its evenness-of-distribution component has no agreed multi-grid form.", - call. = FALSE + + # Every grid is scored on its own. A spanning tree measures distance between + # plots, and there is no distance between plots at different sites: pooling + # them either treats two sites' (1, 1) as the same point or invents a distance + # across the join. + ed <- list() + ed_scores <- setNames(numeric(length(grid_index)), names(grid_index)) + nb_counts <- list() + + for (nm in names(grid_index)) { + g <- grid_index[[nm]] + design_matrix <- build_design_matrix( + design[g$rows, , drop = FALSE], + swap, + row_column = row_column, + col_column = col_column, + index = g$index ) + ed[[nm]] <- calculate_ed( + design_matrix, + current_score_obj$ed[[nm]], + swapped_items + ) + # A grid with nothing replicated inside it has no spanning tree to measure, + # so evenness of distribution does not apply there: it contributes 0 rather + # than 1/0, which would make the whole score `Inf` and leave the optimiser + # with nothing to compare. Replication is fixed for the run, so a grid is + # either in or out of this component for the whole run. + ed_scores[[nm]] <- if (length(ed[[nm]]) == 0L) { + 0 + } else { + 1 / + sum(vapply(ed[[nm]], function(ed_rep) return(ed_rep$min_mst), numeric(1))) + } + nb_counts[[nm]] <- unlist(calculate_nb(design_matrix, pair_mapping)$nb) } - design_matrix <- build_design_matrix( - design, - swap, - row_column = row_column, - col_column = col_column, - index = grid_index[[1]]$index - ) - ed <- calculate_ed(design_matrix, current_score_obj$ed, swapped_items) - # sum(1/) or 1/sum - ed_score <- 1 / sum(vapply(ed, function(ed_rep) ed_rep$min_mst, numeric(1))) - nb <- calculate_nb(design_matrix, pair_mapping) + # The per-grid scores are summed rather than pooled into one reciprocal. Both + # give the same optimisation - a swap moves plots within one grid, so it + # changes only that grid's term - but summing keeps ED scaling with adjacency, + # which also sums per grid, instead of shrinking as sites are added. + ed_score <- sum(ed_scores) + + # Neighbour balance counts edges and no edge crosses a grid boundary, so the + # counts sum across grids before the variance is taken. + all_pairs <- unique(unlist(lapply(nb_counts, names))) + totals <- setNames(numeric(length(all_pairs)), all_pairs) + for (counts in nb_counts) { + totals[names(counts)] <- totals[names(counts)] + counts + } + nb <- list( + nb = totals, + max_nb = max(totals), + max_pairs = names(totals)[totals == max(totals)], + var = stats::var(totals) + ) nb_score <- nb$var # Balance and adjacency read `design` directly: the treatment column and the @@ -282,18 +322,31 @@ objective_function_piepho <- function(design, grid_index = grid_index ) + # Per-grid evenness is reported alongside the total, never instead of it: the + # values are not comparable *between* grids - a 3-replicate spanning tree is + # longer than a 2-replicate one whatever the spread - so they are shown side + # by side rather than ranked or averaged. + components <- c( + neighbour_balance = nb_score, + even_distribution = ed_score, + balance = bal_score, + adjacency = adj_score + ) + if (length(grid_index) > 1) { + components <- c( + components, + setNames(ed_scores, paste0("even_distribution_", names(ed_scores))) + ) + } + return(list( score = round(nb_score + ed_score + bal_score + adj_score, 10), ed = ed, + ed_per_grid = ed_scores, bal = bal_score, adj = adj_score, nb = nb, - components = c( - neighbour_balance = nb_score, - even_distribution = ed_score, - balance = bal_score, - adjacency = adj_score - ) + components = components )) } diff --git a/man/objective_function_piepho.Rd b/man/objective_function_piepho.Rd index 05d41c2b..8bc7add9 100644 --- a/man/objective_function_piepho.Rd +++ b/man/objective_function_piepho.Rd @@ -35,6 +35,15 @@ objective_function_piepho( \item{col_column}{Name of column representing the column of the design (default: "col")} +\item{by, grid_index}{Optional grouping of plots into separate grids; see +\code{\link[=calculate_adjacency_score]{calculate_adjacency_score()}}. Neighbour balance counts edges, so it sums +across grids. Evenness of distribution measures how far apart a treatment's +replicates are, and there is no distance between plots at different sites, +so it is scored \strong{per grid} and the scores summed - reported both in total +and per grid, and identical to optimising each grid on its own, since a swap +moves plots within one grid. A grid with no treatment replicated inside it +has no evenness to measure and contributes \code{0}.} + \item{...}{Extra parameters passed from \link{speed}} } \value{ diff --git a/tests/testthat/test-objective_functions.R b/tests/testthat/test-objective_functions.R index c8077599..c0c86753 100644 --- a/tests/testthat/test-objective_functions.R +++ b/tests/testthat/test-objective_functions.R @@ -276,7 +276,7 @@ test_that("objective_function_piepho works with basic design", { ) expect_type(result, "list") - expect_named(result, c("score", "ed", "bal", "adj", "nb", "components")) + expect_named(result, c("score", "ed", "ed_per_grid", "bal", "adj", "nb", "components")) expect_type(result$score, "double") expect_length(result$score, 1) expect_type(result$ed, "list") @@ -335,10 +335,12 @@ test_that("objective_function_piepho handles different treatment replication pat expect_type(result_3_reps, "list") expect_type(result_4_reps, "list") - # Check that ed structure reflects different replication patterns - expect_true("2" %in% names(result_2_reps$ed)) - expect_true("3" %in% names(result_3_reps$ed)) - expect_true("4" %in% names(result_4_reps$ed)) + # `ed` is keyed by grid, then by replication class within that grid. A design + # with no `by` is a single grid named "1". + expect_named(result_2_reps$ed, "1") + expect_true("2" %in% names(result_2_reps$ed[["1"]])) + expect_true("3" %in% names(result_3_reps$ed[["1"]])) + expect_true("4" %in% names(result_4_reps$ed[["1"]])) # Scores should be different for different replication patterns expect_false(identical(result_2_reps$score, result_3_reps$score)) @@ -376,7 +378,7 @@ test_that("objective_function_piepho handles incremental calculation with curren expect_type(incremental_result, "list") expect_named( incremental_result, - c("score", "ed", "bal", "adj", "nb", "components") + c("score", "ed", "ed_per_grid", "bal", "adj", "nb", "components") ) # Test that incremental calculation works differently from full calculation @@ -405,7 +407,7 @@ test_that("objective_function_piepho works without pair_mapping", { result <- objective_function_piepho(design_df, "treatment", c("row", "col")) expect_type(result, "list") - expect_named(result, c("score", "ed", "bal", "adj", "nb", "components")) + expect_named(result, c("score", "ed", "ed_per_grid", "bal", "adj", "nb", "components")) }) test_that("objective_function_piepho handles different spatial column configurations", { @@ -484,7 +486,7 @@ test_that("objective_function_piepho uses custom row and column names", { col_column = "Column" ) expect_type(result, "list") - expect_named(result, c("score", "ed", "bal", "adj", "nb", "components")) + expect_named(result, c("score", "ed", "ed_per_grid", "bal", "adj", "nb", "components")) }) test_that("objective_function_piepho score is properly rounded to 10 decimal places", { @@ -534,7 +536,7 @@ test_that("objective_function_piepho handles designs with missing values", { pair_mapping = pair_mapping ) expect_type(result, "list") - expect_named(result, c("score", "ed", "bal", "adj", "nb", "components")) + expect_named(result, c("score", "ed", "ed_per_grid", "bal", "adj", "nb", "components")) }) test_that("objective_function_piepho errors on single treatment design", { @@ -575,13 +577,132 @@ test_that("objective_function_piepho individual components are reasonable", { expect_true(is.finite(result$nb$var)) expect_gte(result$nb$var, 0) # Variance should be non-negative - # Check ed component structure + # Check ed component structure: one entry per grid, each keyed by replication + # class. expect_type(result$ed, "list") - for (ed_rep in result$ed) { - expect_named(ed_rep, c("msts", "min_mst", "min_items")) - expect_true(is.finite(ed_rep$min_mst)) - expect_gte(ed_rep$min_mst, 0) # MST should be non-negative + for (grid_ed in result$ed) { + for (ed_rep in grid_ed) { + expect_named(ed_rep, c("msts", "min_mst", "min_items")) + expect_true(is.finite(ed_rep$min_mst)) + expect_gte(ed_rep$min_mst, 0) # MST should be non-negative + } } + + # One evenness score per grid, summing to the reported component. + expect_length(result$ed_per_grid, length(result$ed)) + expect_equal( + sum(result$ed_per_grid), + result$components[["even_distribution"]] + ) +}) + +test_that("objective_function_piepho scores evenness per grid and sums them", { + # A p-rep multi-environment trial: partial replication *within* each site, + # overlapping but unequal subsets across them. + set.seed(7) + d <- rbind( + data.frame( + site = "a", + expand.grid(row = 1:4, col = 1:3), + treatment = sample(c( + rep(c("A", "B", "C", "D"), 2), + "E", "F", "G", "H" + )) + ), + data.frame( + site = "b", + expand.grid(row = 1:4, col = 1:3), + treatment = sample(c(rep(c("E", "F", "G"), 3), "A", "B", "I")) + ) + ) + + res <- objective_function_piepho( + d, + "treatment", + c("row", "col", "site"), + by = "site" + ) + + expect_named(res$ed_per_grid, c("a", "b")) + expect_equal( + sum(res$ed_per_grid), + res$components[["even_distribution"]] + ) + # Reported per grid as well as in total, so a site can be acted on. + expect_equal( + res$components[["even_distribution_a"]], + res$ed_per_grid[["a"]] + ) + + # Each grid's evenness is exactly what that grid scores on its own: a + # spanning tree measures distance between plots, and there is none between + # plots at different sites. + alone <- objective_function_piepho( + d[d$site == "a", ], + "treatment", + c("row", "col") + ) + expect_equal( + res$ed_per_grid[["a"]], + alone$components[["even_distribution"]] + ) + + # A swap inside one grid cannot change another grid's evenness, which is why + # summing the per-grid scores optimises each grid independently. + moved <- d + i <- which(moved$site == "a")[c(1, 5)] + moved$treatment[i] <- moved$treatment[rev(i)] + after <- objective_function_piepho( + moved, + "treatment", + c("row", "col", "site"), + by = "site" + ) + expect_equal(after$ed_per_grid[["b"]], res$ed_per_grid[["b"]]) +}) + +test_that("a grid with nothing replicated inside it contributes no evenness", { + # Evenness measures how far apart a treatment's replicates are, so a grid + # with no replicated treatment has nothing to measure. It must contribute 0 + # rather than 1/0, which would make the whole score Inf and leave the + # optimiser with nothing to compare. + set.seed(7) + d <- rbind( + data.frame( + site = "a", + expand.grid(row = 1:4, col = 1:3), + treatment = sample(c( + rep(c("A", "B", "C", "D"), 2), + "E", "F", "G", "H" + )) + ), + data.frame( + site = "b", + expand.grid(row = 1:4, col = 1:3), + treatment = LETTERS[1:12] # every entry once at this site + ) + ) + + res <- objective_function_piepho( + d, + "treatment", + c("row", "col", "site"), + by = "site" + ) + expect_equal(res$ed_per_grid[["b"]], 0) + expect_gt(res$ed_per_grid[["a"]], 0) + expect_true(is.finite(res$score)) + + # The same rule covers a single grid with no replication at all, which + # previously scored Inf. + unreplicated <- initialise_design_df(as.character(1:12), 4, 3) + single <- objective_function_piepho( + unreplicated, + "treatment", + c("row", "col") + ) + expect_equal(single$components[["even_distribution"]], 0) + expect_true(is.finite(single$score)) }) test_that("objective_function_piepho handles extra parameters via ...", { @@ -606,7 +727,7 @@ test_that("objective_function_piepho handles extra parameters via ...", { }) expect_type(result, "list") - expect_named(result, c("score", "ed", "bal", "adj", "nb", "components")) + expect_named(result, c("score", "ed", "ed_per_grid", "bal", "adj", "nb", "components")) }) test_that("objective_function_factorial works", { From aacf6fc9f366b265966519d7ab4d9c3a4113d51f Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:23:16 +0930 Subject: [PATCH 22/28] Updating NEWS and plan --- NEWS.md | 4 ++++ REVIEW-NOTES-OTHER.md | 46 ++++++++++++++++++++++++++++++++----------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/NEWS.md b/NEWS.md index d45e6109..6d1de200 100644 --- a/NEWS.md +++ b/NEWS.md @@ -18,6 +18,10 @@ - Multi-site designs are no longer scored as though they occupied one grid. Previously the sites were pooled, which discarded plots whose coordinates collided and counted adjacencies between plots at different sites; use `grid_factors$by` to name the grouping column. +- `objective_function_piepho()` now scores evenness of distribution per grid and sums the scores, + reporting each grid's separately, instead of measuring distances between plots at different sites. + A grid with no treatment replicated within it contributes `0`; previously such a design scored + `Inf`, which also affected single-site designs with no replication at all. - Design metrics are now built from each plot's `row`/`col` coordinates rather than the order of the rows in the data frame, so they describe the actual layout. This corrects `calculate_adjacency_score()`, `calculate_efficiency_factor()`, `objective_function_piepho()` and diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index 586d94cd..cbfe2a04 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -50,8 +50,9 @@ Resolved findings and settled decisions are deleted rather than annotated — se > not estimable instead of returning an impossible value, and the row-column model now carries an > intercept, which is what makes the rank test exact. See A5. > -> ⬜ **Still open:** `objective_function_piepho()`'s ED component has no agreed multi-grid form, so it -> refuses a multi-grid design rather than pooling one. See the end of A4. +> ✅ **piepho's ED is settled too** (Sam, 2026-08-07): scored **per grid and summed**, each grid's value +> reported alongside the total. A grid with nothing replicated inside it contributes `0` — which also +> fixes an `Inf` that single-grid unreplicated designs scored on `main`. See the end of A4. --- @@ -195,10 +196,8 @@ grid is exact rather than an approximation. That makes most of the fix tractable annealing loop still validates once per run, not once per iteration. - **`.replicate_spans()` stays withheld for multi-grid designs.** Spans are distances within one grid; two sites' row 3 are not one plot apart, and there is no combined span to report. -- **`objective_function_piepho()` refuses rather than pools.** Its NB component would sum like any edge - count, but ED measures evenness of a distribution: per-grid-then-combined and pooled are different - quantities, and the paper's definition assumes a single trial. This is the one piece of G13 still - awaiting a statistical answer. +- **`objective_function_piepho()` scores ED per grid and sums.** Its NB component sums like any edge + count; ED is a within-grid measure and is reported per grid as well as in total. See the end of A4. --- @@ -286,11 +285,36 @@ Under **equal** variances (ratio 1) the quantity is well defined and exact — b assumption `dsum()` exists to deny, so a MET reported that way would carry a figure computed under a model its own analysis contradicts. Hence: per site, and no combined figure. -**`objective_function_piepho()`'s ED needs the same treatment.** NB sums like any edge count, but ED -measures evenness of a distribution, so per-grid-then-averaged and pooled are different quantities and the -paper's definition assumes a single trial. Whatever is decided, the same answer must apply to `summary()`'s -`efficiency` entry and to `.neighbour_balance()`, so the two never disagree about what a MET design's -diagnostics mean. +### ED: scored per grid and summed — decided 2026-08-07 + +NB sums like any edge count. ED does not: it measures how far apart a treatment's replicates are, and +there is no distance between plots at different sites. Pooling either treats two sites' `(1, 1)` as the +same point or invents a distance across the join — measured on a p-rep MET, **five of nine treatments** +had pooled replication exceeding their within-site maximum, so their spanning trees were built largely +from non-distances. + +**Each grid is scored on its own and the scores are summed**, with each grid's value reported alongside +the total. Three things settle the form: + +- **Summing is not a compromise.** A swap moves plots within one grid, so it changes only that grid's + term; minimising the sum is identical, move for move, to optimising each grid independently. +- **Per-grid scores must not be compared with each other.** Measured on a p-rep MET: site a scores 1.00 + with replication class `{2}`, site b scores 0.50 with class `{3}` — a 3-replicate spanning tree is + longer than a 2-replicate one whatever the spread. So "min across grids" and "mean of per-grid scores" + would both systematically flag whichever site had lower replication. They are reported side by side, + never ranked or averaged, exactly like per-site efficiency. +- **Summing the per-grid scores beats one pooled reciprocal.** Both optimise identically, but adjacency + sums counts across grids, so it grows with site count; under a pooled `1 / sum(all MSTs)` ED *shrinks* + as sites are added (the two sites above: 1.00 and 0.50 individually, 0.33 pooled) and quietly loses + weight against adjacency and balance. Summing gives 1.50 and tracks the other components. + +**A grid with nothing replicated inside it contributes `0`**, not `1/0`. This also fixes a pre-existing +single-grid defect: `objective_function_piepho()` returned **`Inf`** for a fully unreplicated design — an +ordinary early-generation trial — leaving every candidate scoring `Inf` and the optimiser with nothing to +compare. + +The same answer applies to `summary()`'s `efficiency` entry and `.neighbour_balance()`, so the three never +disagree about what a MET design's diagnostics mean. **The rank gate in recommendation 2 is needed whether or not D7 is settled.** A value `> 1` signals rank deficiency **however it arises** — a MET site with `r = 1` (above), a single grid that exhausts its residual From b259db4b3cc4cba1b260964290b026fd0bc31727 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:35:31 +0930 Subject: [PATCH 23/28] Updating finished plan --- REVIEW-NOTES-OTHER.md | 132 +++++++++++++++++------------------------- 1 file changed, 53 insertions(+), 79 deletions(-) diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md index cbfe2a04..b1261092 100644 --- a/REVIEW-NOTES-OTHER.md +++ b/REVIEW-NOTES-OTHER.md @@ -3,9 +3,6 @@ **Scope:** `R/design_utils.R` (`build_design_matrix()`, `grid_index()`), `R/calculate_adjacency_score.R`, `R/metrics.R`, and `R/summary.R` where it consumes a grid. Branch **`bugfix/grid-orientation`** off `main`. -G13 touches `R/summary.R` but belongs **here, not in `REVIEW-NOTES-SUMMARY.md`** (Sam, 2026-08-06): this -branch made the grid contract strict, so it owns the consequences of that strictness. - **Companion files** — one per workstream: | File | Workstream | @@ -16,43 +13,34 @@ branch made the grid contract strict, so it owns the consequences of that strict | `REVIEW-NOTES-PR91.md` | PR #91 `info-objective` | | **this file** | grid construction / core metrics | -Moved out, each needing its own branch: the A-efficiency upper bound and the missing intercept -(`REVIEW-NOTES-EFFICIENCY.md`); the S3 class collision, the `initialise_design_df()` fill order and the -now-redundant row-major sort (`KNOWN_ISSUES.md` #2, #3, #4). The buffer coordinate convention — settled, -and the reason `build_design_matrix()` keeps coordinates **raw** — is `KNOWN_ISSUES.md` #1. +Moved out: the A-efficiency upper bound (`REVIEW-NOTES-EFFICIENCY.md` E1, branch `feature/a-optimality`); +the S3 class collision, the `initialise_design_df()` fill order, the now-redundant row-major sort and the +internal-naming convention (`KNOWN_ISSUES.md` #2-#5). The buffer coordinate convention — settled, and the +reason `build_design_matrix()` keeps coordinates **raw** — is `KNOWN_ISSUES.md` #1. -**Last verified:** 2026-08-06, R 4.6.1, `pkgload::load_all()`. All numbers measured, not inferred. +**Last verified:** 2026-08-07, R 4.6.1, `pkgload::load_all()`. All numbers measured, not inferred. Resolved findings and settled decisions are deleted rather than annotated — see git history and `NEWS.md`. -> ✅ **The branch's original scope is closed, and the findings it turned up with it.** The plan as -> committed at `b1d7adc` listed D6, D1 and G1-G6, plus the hot-loop cost recorded as out of scope; all are -> done, the last as G11. G7, S1, A5, G11, G12, G13 and G14 were found during the work and are also done. -> See A1. Full suite: **1774 pass, 0 fail, 0 warn.** +> ✅ **Nothing is left to implement on this branch.** The plan committed at `b1d7adc` listed D6, D1 and +> G1-G6, plus the hot-loop cost recorded as out of scope; all are done, the last as G11. G7, S1, A5, G11, +> G12, G13 and G14 were found during the work and are done too, along with D7 and piepho's ED. See A1. +> Full suite: **1787 pass, 0 fail, 0 warn.** > -> ⬜ **Deliberately still open from the original plan:** removing the row-major sort, which A4.7 recorded -> as out of scope on purpose (`KNOWN_ISSUES.md` #4). +> **Two things still gate the PR, neither of them code here:** > > 📦 **`feature/buffers` merges into this branch** (branched off `f5d68f5`), carrying the coordinate > restoration that makes raw coordinates correct plus the `add_buffers()` deprecation. See A2 — two items > are already done there, so read it before actioning anything. > -> ✅ **G13 has landed.** `grid_factors` gains an optional `by`, so a design can occupy several grids. -> Adjacency and neighbour balance are summed per grid, efficiency is reported per grid, and nothing is -> counted between plots at different sites. See A3. -> -> ✅ **D7 is decided (Sam, 2026-08-07): per site, gated on per-site rank, with no combined figure.** -> Adjacency and neighbour balance sum exactly; efficiency is reported one value per site, each withheld -> with a reason if that site's contrasts aren't estimable. A combined `dsum`-shaped number needs no -> `asreml` to compute but is **not identified** — measured, the design ranking flips with the assumed -> variance ratio and the value passes 1. G13 is no longer blocked on a decision. See A4. +> 🔗 **PR #97 must merge after this branch**, then be rebased onto it. See A2.1. > -> ✅ **G14 has landed.** `calculate_efficiency_factor()` refuses a design whose treatment contrasts are -> not estimable instead of returning an impossible value, and the row-column model now carries an -> intercept, which is what makes the rank test exact. See A5. +> ⬜ **Deliberately left undone:** removing the row-major sort, which the original plan recorded as out of +> scope on purpose (`KNOWN_ISSUES.md` #4). > -> ✅ **piepho's ED is settled too** (Sam, 2026-08-07): scored **per grid and summed**, each grid's value -> reported alongside the total. A grid with nothing replicated inside it contributes `0` — which also -> fixes an `Inf` that single-grid unreplicated designs scored on `main`. See the end of A4. +> **Closed elsewhere as a side effect of this work:** E2 in `REVIEW-NOTES-EFFICIENCY.md` (the intercept +> the rank gate required) and S5 in `REVIEW-NOTES-SUMMARY.md` (a disconnected design no longer reports a +> healthy-looking efficiency). S6(1) — `summary()` erroring on a single-row design — is untouched and +> stays open there. --- @@ -148,7 +136,7 @@ when it rebases. have a single code path); `calculate_adjacency_score()` and `.neighbour_balance()` sum per grid; `summary()` reports one efficiency per grid; `metadata$grid_by` records the grouping so `summary()` never has to guess it. A mistyped `by` is rejected rather than silently ignored. `objective_function_piepho()` -refuses a multi-grid design, because its ED component has no agreed multi-grid form. +sums neighbour balance across grids and scores evenness per grid. Measured on two 4×3 sites sharing coordinates: `by = "site"` scores **3**, exactly the sum of the per-site scores, where pooling them side by side scores higher on account of adjacencies across the join. @@ -241,10 +229,10 @@ values by default would print garbage for exactly the designs MET support exists **Decided (Sam, 2026-08-07): report per-site values, gated on per-site rank.** -1. **Refuse for multi-grid designs, with a reason** — the interim state, and required either way, since - the alternative is returning `1.855529` for a quantity bounded above by 1. G12 already does this inside - `summary()`; G13 item 5 moves it into `calculate_efficiency_factor()`. -2. **Then one value per site, each gated on that site's own rank** — the site's information matrix must +1. **Refuse for multi-grid designs, with a reason** — `calculate_efficiency_factor()` validates the + coordinates, so a multi-site frame is refused rather than returning `1.855529` for a quantity bounded + above by 1. +2. **One value per site, each gated on that site's own rank** — the site's information matrix must have rank `k - 1`, i.e. every treatment contrast estimable within the site. A site failing the test reports unavailable with a reason rather than poisoning the vector or being silently dropped. Labelled per site, **never summed or averaged**: per-site-then-averaged is a different quantity from the @@ -316,12 +304,10 @@ compare. The same answer applies to `summary()`'s `efficiency` entry and `.neighbour_balance()`, so the three never disagree about what a MET design's diagnostics mean. -**The rank gate in recommendation 2 is needed whether or not D7 is settled.** A value `> 1` signals rank -deficiency **however it arises** — a MET site with `r = 1` (above), a single grid that exhausts its residual -degrees of freedom, or one where treatment is aliased with row despite having residual df to spare -(`KNOWN_ISSUES.md` #3). The last two are single-grid designs that `summary()` reports today: see A5. So build -the gate as a rank test on *one* information matrix rather than inside the MET path, and D7 recommendation 2 -and G14 are covered by one implementation. +**One rank gate serves both.** A value `> 1` signals rank deficiency **however it arises** — a MET site with +`r = 1`, a single grid that exhausts its residual degrees of freedom, or one where treatment is aliased with +row despite having residual df to spare (`KNOWN_ISSUES.md` #3). Built as a rank test on *one* information +matrix rather than inside the MET path, so the per-site path inherits it (G14, A5). --- @@ -352,47 +338,35 @@ coefficient. `lm()` agrees with the gate on all of them — including the four t should return a number. Note the term order matters: `lm()` pivots in formula order, so putting `treatment` first lets it absorb the confounding and alias the row terms instead. -**Pre-existing, and this branch changed neither the values nor the reporting.** Measured 2026-08-06 on both -`bugfix/grid-orientation` and a clean `main` worktree, with `connectedness = FALSE` (S6 in -`REVIEW-NOTES-SUMMARY.md` — the single-row shapes error under the default). Residual df is +**What it was returning, on `main` as well as here.** Measured 2026-08-06, with `connectedness = FALSE` +(S6 in `REVIEW-NOTES-SUMMARY.md` — the single-row shapes error under the default). Residual df is `n − 1 − (k−1) − (r−1) − (c−1)`: -| Design | plots | residual df | value | surfaced by | `main` | -|---|---|---|---|---|---| -| 1×6 grid, 3 treatments | 6 | −2 | **1.5** | `summary()` | **1.5** | -| 6×1 grid, 3 treatments | 6 | −2 | **1.5** | `summary()` | **1.5** | -| 4×3 grid, 12 entries unreplicated | 12 | −5 | **1.61** | `summary()` | **1.61** | -| 3×4 grid, 3 treatments confounded with row | 12 | **+4** | **1.5** | `.efficiency_factor()` | — | -| 2×3 grid, 3 treatments | 6 | 0 | 0.75 | `summary()` | — | -| 2×6 grid, 3 treatments | 12 | 3 | 0.75 | `summary()` | — | - -**There are two independent routes to a value above 1, so residual df is not a sufficient test.** Rows 1-3 -exhaust the residual degrees of freedom. Row 4 has *four* residual df and still returns 1.5, because -`rep(LETTERS[1:3], length.out = 12)` over `expand.grid(row = 1:3, col = 1:4)` puts each treatment in exactly -one row — treatment is aliased with the row effect, so eliminating the row space eliminates the treatment -contrasts with it (`KNOWN_ISSUES.md` #3). **The gate must therefore test the rank of the information matrix, -not count degrees of freedom.** The last two rows are included because they are the boundary a gate must not -reject: residual df 0 is still estimable. - -Reachability differs between the two routes. Rows 1-3 come straight out of `summary()`. Row 4 is reported by -`.efficiency_factor()` when handed such a frame, but `speed()` breaks the confounding within a single -iteration (measured: still confounded `FALSE`, `summary()` then reports 0.4891), so a design that has been -through `speed()` does not normally surface it. Route 1 is the one users hit. - -**Why G12's gate does not catch either.** `has_grid` is `TRUE` throughout: the coordinates are unique, so -`grid_index()` is satisfied and there is nothing for `.efficiency_factor()` to refuse. G12 gates on *can this -be one grid*, a coordinate property; this is a *rank* property of the model fitted on that grid. Different -question, so no amount of coordinate validation reaches it. The `< 3 treatments` guard already in -`.efficiency_factor()` is the only rank-adjacent check today, and it is far too weak. - -**Why it matters more than the MET case.** The 4×3-with-12-unreplicated-entries row is not a pathological -fixture — it is an ordinary early-generation trial, and it is the *same shape* D7 measures at 1.833 per site -inside a MET. So the impossible value is reachable from a completely routine single-site call, not only from -the MET path everyone already knows is broken. - -**Why clamping would have been wrong.** Capping or `NA`-ing anything above 1 hides the confounded case -(row 4) behind a plausible value instead of reporting that the design cannot support the estimate. The -refusal is the point, not the bound. +| Design | plots | residual df | was | now | +|---|---|---|---|---| +| 1×6 grid, 3 treatments | 6 | −2 | **1.5** | refused | +| 6×1 grid, 3 treatments | 6 | −2 | **1.5** | refused | +| 4×3 grid, 12 entries unreplicated | 12 | −5 | **1.61** | refused | +| 3×4 grid, 3 treatments confounded with row | 12 | **+4** | **1.5** | refused | +| 2×3 grid, 3 treatments | 6 | 0 | 0.75 | 0.75 | +| 2×6 grid, 3 treatments | 12 | 3 | 0.75 | 0.75 | + +**Two independent routes reach a value above 1, so residual df is not a sufficient test.** Rows 1-3 exhaust +the residual degrees of freedom. Row 4 has *four* residual df and still returned 1.5, because each treatment +occupies exactly one grid row — treatment is aliased with the row effect, so eliminating the row space +eliminates the treatment contrasts with it (`KNOWN_ISSUES.md` #3). The last two rows are the boundary the +gate must **not** reject: residual df 0 is still estimable, and both still return 0.75. + +**Coordinate validation could never have caught it.** `has_grid` is `TRUE` throughout — the coordinates are +unique — so G12's gate has nothing to refuse. That gate asks *can this be one grid*, a coordinate property; +this is a *rank* property of the model fitted on that grid. + +**It is reachable from a routine single-site call.** The 4×3-with-12-unreplicated-entries row is an ordinary +early-generation trial, and the same shape D7 measures at 1.833 per site inside a MET — so this was never +only a MET problem. + +**Clamping would have been wrong.** Capping or `NA`-ing anything above 1 hides the confounded case (row 4) +behind a plausible value instead of reporting that the design cannot support the estimate. **Fallout in the existing tests, all of it real.** Four tests asserted a finite, positive value for designs `lm()` also calls non-estimable, and one `@examples` block used such a design — it would have failed From 61decd1d657e52f1a5bdaa234c3a8c6c3324254c Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:35:56 +0930 Subject: [PATCH 24/28] Removing plan --- REVIEW-NOTES-OTHER.md | 376 ------------------------------------------ 1 file changed, 376 deletions(-) delete mode 100644 REVIEW-NOTES-OTHER.md diff --git a/REVIEW-NOTES-OTHER.md b/REVIEW-NOTES-OTHER.md deleted file mode 100644 index b1261092..00000000 --- a/REVIEW-NOTES-OTHER.md +++ /dev/null @@ -1,376 +0,0 @@ -# Review notes: grid construction and core metrics - -**Scope:** `R/design_utils.R` (`build_design_matrix()`, `grid_index()`), `R/calculate_adjacency_score.R`, -`R/metrics.R`, and `R/summary.R` where it consumes a grid. Branch **`bugfix/grid-orientation`** off `main`. - -**Companion files** — one per workstream: - -| File | Workstream | -|---|---| -| `REVIEW-NOTES.md` | `feature/incidence` (PR #97) — `R/incidence.R` | -| `REVIEW-NOTES-SUMMARY.md` | the merged `summary()` work — `R/summary.R` | -| `REVIEW-NOTES-EFFICIENCY.md` | efficiency-factor statistics — branch `feature/a-optimality` exists | -| `REVIEW-NOTES-PR91.md` | PR #91 `info-objective` | -| **this file** | grid construction / core metrics | - -Moved out: the A-efficiency upper bound (`REVIEW-NOTES-EFFICIENCY.md` E1, branch `feature/a-optimality`); -the S3 class collision, the `initialise_design_df()` fill order, the now-redundant row-major sort and the -internal-naming convention (`KNOWN_ISSUES.md` #2-#5). The buffer coordinate convention — settled, and the -reason `build_design_matrix()` keeps coordinates **raw** — is `KNOWN_ISSUES.md` #1. - -**Last verified:** 2026-08-07, R 4.6.1, `pkgload::load_all()`. All numbers measured, not inferred. -Resolved findings and settled decisions are deleted rather than annotated — see git history and `NEWS.md`. - -> ✅ **Nothing is left to implement on this branch.** The plan committed at `b1d7adc` listed D6, D1 and -> G1-G6, plus the hot-loop cost recorded as out of scope; all are done, the last as G11. G7, S1, A5, G11, -> G12, G13 and G14 were found during the work and are done too, along with D7 and piepho's ED. See A1. -> Full suite: **1787 pass, 0 fail, 0 warn.** -> -> **Two things still gate the PR, neither of them code here:** -> -> 📦 **`feature/buffers` merges into this branch** (branched off `f5d68f5`), carrying the coordinate -> restoration that makes raw coordinates correct plus the `add_buffers()` deprecation. See A2 — two items -> are already done there, so read it before actioning anything. -> -> 🔗 **PR #97 must merge after this branch**, then be rebased onto it. See A2.1. -> -> ⬜ **Deliberately left undone:** removing the row-major sort, which the original plan recorded as out of -> scope on purpose (`KNOWN_ISSUES.md` #4). -> -> **Closed elsewhere as a side effect of this work:** E2 in `REVIEW-NOTES-EFFICIENCY.md` (the intercept -> the rank gate required) and S5 in `REVIEW-NOTES-SUMMARY.md` (a disconnected design no longer reports a -> healthy-looking efficiency). S6(1) — `summary()` erroring on a single-row design — is untouched and -> stays open there. - ---- - -## A1. Landed on this branch - -One-line inventory for the PR description. Full write-ups are in git history. - -| Was | Now | -|---|---| -| **G1** four functions each assumed a data ordering, two row-major and two column-major | all four read coordinates via `build_design_matrix()` or a coordinate-indexed fill | -| **G2** `objective_function_piepho()` wrote a column-major flattened grid back over the treatment column | write-back deleted; all four score components computed on the real layout, and piepho is order-invariant | -| **G3** `build_design_matrix()` didn't validate coordinates | explicit missing-column / non-numeric / non-positive-integer / duplicate-coordinate errors, each its own condition class | -| **G4** `.calculate_nb()` errored on sparse grids, the default path | `NA` neighbours are skipped, matching the `pair_mapping` path | -| **G5** a scalar `ring_weights` errored against multi-ring `ring_dists` | recycled across every ring | -| **G6** `calculate_efficiency_factor()` couldn't compute for a buffered design | resolved as a side effect of G7 — coordinate indexing absorbs the offset `add_buffers()` introduces, and a genuinely holed grid computes too; both cases now pinned in `test-grid-orientation.R` — the fix was closed with no coverage until then | -| **G7** `calculate_efficiency_factor()` filled `Z` positionally, returning a different value per row ordering (0.111 vs 0.625 on a 2×6, and values `> 1`) | `Z` is indexed by each plot's own coordinates | -| **S1** `.neighbour_balance()` reported self-adjacencies that didn't exist (6 where the truth was 0) | reads coordinates; the 4×3 fixture returns the hand-derived truth (self 0, pair min/max 5/6) | -| **A5** lexical factor levels (`1, 10, 11, 2, …`) defeated the row-major sort, so grid metrics scored a layout that wasn't the design | coordinate construction is immune; the sort is no longer load-bearing (`KNOWN_ISSUES.md` #4) | -| **G11** coordinate validation ran on every iteration, making grid construction 16× the `matrix()` reshape it replaced | validation split into `grid_index()`, hoisted once per `speed()` run and built lazily so a design that cannot be gridded still optimises if its objective never needs a grid; build back to parity — see A1.1 | -| **G13** nothing represented a design occupying more than one grid, so every MET design was scored as one pooled grid — discarding plots whose coordinates collided, or inventing adjacencies between sites | `grid_factors` gains an optional `by`; `grid_indices()` returns one validated index per grid; adjacency and neighbour balance sum per grid, efficiency is reported per grid, and `metadata$grid_by` records the grouping — see A3 | -| **G14** `calculate_efficiency_factor()` returned a plausible-looking value, usually above 1, for a design whose treatment contrasts are not estimable | refuses with a `speed_efficiency_rank` condition, which `summary()` reports as a reason; the row-column model gained an intercept, which is what makes the rank test exact and changes no existing value — see A5 | -| **G12** `summary()` died outright on any design that couldn't be gridded — MET, or non-numeric `row`/`col` labels | `summary.design()` calls `grid_index()` once and keeps either the index or the condition's `reason` as `grid`, which it passes to `.neighbour_balance()`, `.efficiency_factor()` and `.replicate_spans()`; each reports the reason instead of computing. `has_grid` now means "reportable as one grid", so `layout` no longer describes a MET as a single grid. `nrow`/`ncol` count *occupied* rows and columns (settled, Sam), so on a design with gaps they are deliberately fewer than the coordinates span — documented in `?summary.design` | - -### A1.1 G11 measurements - -Kept because they are the PR's evidence and not recoverable from the code. Grid build on 700 plots -(28×25), 2000 reps: - -| | µs/build | vs `matrix()` | -|---|---|---| -| `matrix()` — what `main` did | 15 | 1.00× | -| `build_design_matrix()`, no index | 240 | 16.00× | -| `build_design_matrix()`, index supplied | **15** | **1.00×** | - -End-to-end against a clean worktree at `69c516d`. **Scores are bit-identical in every case** — this is a -performance change only: - -| | before | after | | -|---|---|---|---| -| `objective_function`, 700 plots, 2000 iters | 1.58 s | **1.14 s** | −28% | -| `objective_function`, 700 plots, 5000 iters | 3.89 s | **2.86 s** | −26% | -| `objective_function`, 120 plots, 5000 iters | 2.46 s | **2.17 s** | −12% | -| `objective_function_piepho`, 120 plots, 1000 iters | 1.64 s | **1.47 s** | −10% | - -What remains of the cost is the per-iteration `as.character()` coercion of the swap column, which is not -hoistable the way validation was: the swap column is the one thing annealing mutates. Parity is therefore the -floor for a rebuild-per-iteration grid, and it is accepted. Carrying a mutable grid across iterations instead -was **ruled out** (Sam, 2026-08-06): it needs a contract change to the objective-function signature and is -capped at under 3% of a run, since the build is ~15 µs of a ~570 µs iteration. - ---- - -## A2. What arrives when `feature/buffers` merges - -Branched off `f5d68f5`, so it applies cleanly. Two items are already done there — **do not action them -again**: - -| From `feature/buffers` | Effect here | -|---|---| -| `metadata$buffer` transform record in `add_buffers()`, inverted by `.drop_buffer_rows()` / `.restore_buffer_coords()` | satisfies the `KNOWN_ISSUES.md` #1 convention without touching `build_design_matrix()` | -| `test-summary.R` buffer test rewritten | **fixes the stale comment at [test-summary.R:299-305](tests/testthat/test-summary.R#L299-L305)**, which still claims a `"row"` buffer should change the counts — the opposite of the `KNOWN_ISSUES.md` #1 convention. Now asserts every buffer type and stacked combinations match the unbuffered design | -| `add_buffers()` deprecation warning + `## Deprecations` NEWS section | buffers are leaving speed; the biometryassist repo's `BUFFERS-HANDOFF.md` specifies that side | -| `.warn_if_buffers()` in `calculate_adjacency_score()`, `calculate_balance_score()`, `calculate_efficiency_factor()` | a direct metric call on a buffered frame bypasses `.drop_buffer_rows()`, so it warns rather than silently scoring the displaced layout | -| `helper-buffers.R` with `add_buffers_quiet()`, and 45 rewritten test call sites | keeps the deprecation warning out of tests that are about layout | - -One caveat carried forward: the `metadata$buffer` record is an affine `scale`/`shift` pair, which covers -speed's buffer types but **cannot** represent biometryassist's `by =` block buffers, where gaps appear only -at group boundaries. It would need to become a per-axis `new -> old` lookup if speed ever had to invert one -of those. Under the handoff plan it never does. - -### A2.1 Merge order: PR #97 depends on this branch - -This branch exists because the grid work was extracted out of `feature/incidence` (D1 in the original -plan). **Verified 2026-08-06** — the extraction is complete and the dependency now runs one way: - -- `feature/incidence` touches only `R/incidence.R`, docs and tests. It no longer carries any of - `R/design_utils.R`, `R/calculate_adjacency_score.R` or `R/metrics.R`, so there is nothing to conflict. -- But `incidence.R:69` calls `build_design_matrix()`, and that function **does not exist on `main`**. - -So **PR #97 cannot merge until this branch does**, and `feature/incidence` has not been rebased onto it -(`git merge-base --is-ancestor bugfix/grid-orientation feature/incidence` → false). Rebase it after this -branch lands. It calls `build_design_matrix()` without an `index`, which is correct — incidence is a -one-off diagnostic, not in the annealing loop — but it does re-implement its own `missing_cols` check, -the same duplication G12 avoided by delegating to `grid_index()`'s condition classes. Worth collapsing -when it rebases. - ---- - -## A3. G13 ✅ A design can now occupy more than one grid - -**Landed 2026-08-07.** `grid_factors` gained an optional `by` naming the column that separates grids; -`grid_indices()` returns one validated index per grid (a one-element list when `by` is `NULL`, so callers -have a single code path); `calculate_adjacency_score()` and `.neighbour_balance()` sum per grid; -`summary()` reports one efficiency per grid; `metadata$grid_by` records the grouping so `summary()` never -has to guess it. A mistyped `by` is rejected rather than silently ignored. `objective_function_piepho()` -sums neighbour balance across grids and scores evenness per grid. - -Measured on two 4×3 sites sharing coordinates: `by = "site"` scores **3**, exactly the sum of the -per-site scores, where pooling them side by side scores higher on account of adjacencies across the join. -The record below is what the fix had to account for. - -### The problem it fixed - -**One root cause, four symptoms.** `build_design_matrix()` — and `matrix()` before it — models a design as -*a* grid. A multi-environment trial is several grids that share a treatment set and must never share an -edge. `initialise_multiple_designs_df()` ([design_utils.R:520](R/design_utils.R#L520)) reuses `row`/`col` -per site, so **every** MET design built the documented way has duplicate coordinates, and nothing anywhere -records which column separates the grids. - -**Measured 2026-08-06** on `initialise_design_df(items = c(rep(1:10, 6), rep(11:20, 8)), designs = -list(a = list(nrows = 10, ncols = 3), b = list(nrows = 10, ncols = 5)))` — 80 plots, 10 unique rows, -5 unique cols: - -| Symptom | `main` | now | -|---|---|---| -| `.neighbour_balance()` | 50-cell grid from 80 plots: **30 plots silently discarded**, one `data length differs from size of matrix` warning | summed per grid | -| `calculate_adjacency_score()` | garbage from the same truncation | summed per grid with `by`; still errors without it, which is correct | -| `calculate_efficiency_factor()` | pools sites into one row/col model, returning `1.855529` — a value `> 1` is impossible | refuses a multi-grid frame; `summary()` reports one value per site | -| sites laid side by side in one grid (`col + 3` for site b, so coordinates *are* unique) | **60** adjacencies vs **50** summing per site — 10 phantom cross-site edges | `by` gives **50**; without it, still 60 | - -The last row is the one that mattered most: it has no duplicate coordinates, so **no error can fire and no -gate can catch it** — it is a silently wrong number on `main` and would have stayed one under any amount of -coordinate validation. Only carrying the grouping fixes it, which is why the duplicate-coordinate error was -a diagnostic rather than the fix. It is pinned by a test that lays two sites side by side and asserts the -pooled score exceeds the per-grid one. - -**Adjacency and neighbour balance are summable over grids.** Measured: per-site adjacency 20 + 30 = **50**, -the correct whole-design figure. Both count edges, and edges never cross a grid boundary, so summing per -grid is exact rather than an approximation. That makes most of the fix tractable. - -### Decisions taken while building it - -- **Auto-detection was rejected.** Duplicate coordinates with no `by` still error, and the message names - the remedy. Inferring the grouping from a `"site"`-like column name is how the ordering bugs in G1 - happened. If it should ever be automatic, have `initialise_design_df(designs = )` record `design_col` - as an attribute so the grouping is *transported* rather than guessed. -- **`by` is validated, because `grid_factors` is a plain list.** A mistyped element would otherwise be - silently dropped and every site pooled — the failure mode is a wrong number, not an error. -- **`build_design_matrix()` was left untouched**, still the strict single-grid primitive. - `grid_indices()` sits above it and returns one index per grid, so the G11 hoisting composes: the - annealing loop still validates once per run, not once per iteration. -- **`.replicate_spans()` stays withheld for multi-grid designs.** Spans are distances within one grid; - two sites' row 3 are not one plot apart, and there is no combined span to report. -- **`objective_function_piepho()` scores ED per grid and sums.** Its NB component sums like any edge - count; ED is a within-grid measure and is reported per grid as well as in total. See the end of A4. - ---- - -## A4. ✅ D7. What should the grid metrics report for a multi-grid (MET) design? — **decided 2026-08-07** - -**Per site, gated on per-site rank; no combined figure.** The reasoning and measurements are kept because -they are what justify refusing the combined number, and that refusal will be questioned again otherwise. - -Adjacency and neighbour balance answer themselves: they count edges, edges never cross a grid boundary, and -summing per grid is exact (measured, 20 + 30 = 50). Efficiency and `objective_function_piepho()`'s ED do -not follow, and for the same reason — they are properties of an assumed *model*, not counts. - -**An efficiency factor is relative to a model, and speed's implied model is `y ~ trt + row + col`.** -`calculate_efficiency_factor()` eliminates a row-effect and column-effect nuisance space from the treatment -information matrix. A MET is not analysed that way: the residual structure is separate per site (a `dsum()` -term in `asreml()`), and row/column effects are nested within site rather than shared across sites. So the -pooled number speed currently produces corresponds to no model anyone fits. - -**Measured 2026-08-06** — two sites, 8 treatments × 3 reps per site, 4×6 grids. The reference -implementation reproduces `calculate_efficiency_factor()` to the digit wherever the design is full rank, so -the only thing varying below is the nuisance space: - -| | value | -|---|---| -| per site A / site B | 0.547 / 0.427 | -| pooled, row/col nested within site (the `dsum`-shaped model) | **0.566** | -| pooled, plain `row + col` — **what speed does now** | **0.807** | - -Two conclusions. The current pooled value is **inflated** — 0.807 against 0.566 — because pooling makes -"row 3" one factor level across both sites, borrowing strength that does not exist in the field. And -per-site-then-averaged is a different quantity again: (0.547 + 0.427)/2 = 0.487, not 0.566. There is no -aggregation shortcut. - -**Per site is the right unit, but it cannot be unconditional.** Measured on the commonest MET shape — 12 -entries, each appearing **once per site**, 4×3 grids — the per-site value is **1.833**, impossible for an -efficiency factor. With `r = 1` inside a site there are not enough residual degrees of freedom to estimate -the treatment contrasts after eliminating row and column effects, so the information matrix is singular and -the pseudo-inverse returns a meaningless number. (The pooled site-nested model for that design gives 0.105 -— low, but at least defined, because replication *across* sites is real replication.) So reporting per-site -values by default would print garbage for exactly the designs MET support exists for. - -**Decided (Sam, 2026-08-07): report per-site values, gated on per-site rank.** - -1. **Refuse for multi-grid designs, with a reason** — `calculate_efficiency_factor()` validates the - coordinates, so a multi-site frame is refused rather than returning `1.855529` for a quantity bounded - above by 1. -2. **One value per site, each gated on that site's own rank** — the site's information matrix must - have rank `k - 1`, i.e. every treatment contrast estimable within the site. A site failing the test - reports unavailable with a reason rather than poisoning the vector or being silently dropped. Labelled - per site, **never summed or averaged**: per-site-then-averaged is a different quantity from the - combined analysis (0.487 vs 0.566 above). This is also the design-actionable quantity, because the - layout that can be changed is the one within a site. -3. **No combined number, not even as an opt-in** — see below. This reverses the earlier suggestion that it - could be offered with a stated assumption. - -### Why there is no reliable combined-analysis figure — measured 2026-08-07 - -**The computation needs no `asreml`.** With the residual variances treated as known it is generalised -least squares in closed form — `C = X'WX - X'WZ (Z'WZ)⁻ Z'WX`, `W = diag(1/v_i)` — reachable with -`model.matrix()` and `pseudo_inverse()` alone. `asreml` is only needed to *estimate* variance components -from data, and at design time there is no data, so there is nothing to estimate. The obstacle is -statistical, not computational. - -Three MET designs (two sites, 8 treatments × 3 reps, 4×6 each), combined efficiency against the assumed -residual variance ratio B:A, with `v` normalised to plot-weighted mean 1: - -| design | ratio 1 | ratio 2 | ratio 4 | ratio 10 | ratio 100 | -|---|---|---|---|---|---| -| seed 1 | 0.4814 | 0.5244 | 0.6839 | 1.2102 | 9.1689 | -| seed 2 | 0.5660 | 0.6454 | 0.8901 | 1.6932 | 13.9839 | -| seed 3 | 0.5834 | 0.6535 | 0.8810 | 1.6317 | 13.1163 | - -Two independent reasons to refuse, either of which is sufficient: - -- **The ranking flips.** At ratio ≤ 2 seed 3 is the best design; from ratio 4 on, seed 2 is. So the - assumption is not a harmless caveat attached to a number — it changes which design you would choose. - Nothing at design time tells you which ratio to use. -- **The quantity is not identified under heterogeneity.** The values exceed 1 from ratio 10 — the rank - test passes, so this is not rank deficiency. An A-efficiency factor is defined *relative to an - orthogonal reference design with a single error variance*; once the variances differ there is no - canonical reference to normalise against, and a different normalisation gives a different number. The - `> 1` values are the symptom of that, not an arithmetic slip. - -Under **equal** variances (ratio 1) the quantity is well defined and exact — but that is precisely the -assumption `dsum()` exists to deny, so a MET reported that way would carry a figure computed under a model -its own analysis contradicts. Hence: per site, and no combined figure. - -### ED: scored per grid and summed — decided 2026-08-07 - -NB sums like any edge count. ED does not: it measures how far apart a treatment's replicates are, and -there is no distance between plots at different sites. Pooling either treats two sites' `(1, 1)` as the -same point or invents a distance across the join — measured on a p-rep MET, **five of nine treatments** -had pooled replication exceeding their within-site maximum, so their spanning trees were built largely -from non-distances. - -**Each grid is scored on its own and the scores are summed**, with each grid's value reported alongside -the total. Three things settle the form: - -- **Summing is not a compromise.** A swap moves plots within one grid, so it changes only that grid's - term; minimising the sum is identical, move for move, to optimising each grid independently. -- **Per-grid scores must not be compared with each other.** Measured on a p-rep MET: site a scores 1.00 - with replication class `{2}`, site b scores 0.50 with class `{3}` — a 3-replicate spanning tree is - longer than a 2-replicate one whatever the spread. So "min across grids" and "mean of per-grid scores" - would both systematically flag whichever site had lower replication. They are reported side by side, - never ranked or averaged, exactly like per-site efficiency. -- **Summing the per-grid scores beats one pooled reciprocal.** Both optimise identically, but adjacency - sums counts across grids, so it grows with site count; under a pooled `1 / sum(all MSTs)` ED *shrinks* - as sites are added (the two sites above: 1.00 and 0.50 individually, 0.33 pooled) and quietly loses - weight against adjacency and balance. Summing gives 1.50 and tracks the other components. - -**A grid with nothing replicated inside it contributes `0`**, not `1/0`. This also fixes a pre-existing -single-grid defect: `objective_function_piepho()` returned **`Inf`** for a fully unreplicated design — an -ordinary early-generation trial — leaving every candidate scoring `Inf` and the optimiser with nothing to -compare. - -The same answer applies to `summary()`'s `efficiency` entry and `.neighbour_balance()`, so the three never -disagree about what a MET design's diagnostics mean. - -**One rank gate serves both.** A value `> 1` signals rank deficiency **however it arises** — a MET site with -`r = 1`, a single grid that exhausts its residual degrees of freedom, or one where treatment is aliased with -row despite having residual df to spare (`KNOWN_ISSUES.md` #3). Built as a rank test on *one* information -matrix rather than inside the MET path, so the per-site path inherits it (G14, A5). - ---- - -## A5. G14 ✅ An efficiency factor above 1 for a rank-deficient single grid - -**Landed 2026-08-07.** `calculate_efficiency_factor()` now errors with a `speed_efficiency_rank` condition -when the treatment contrasts are not estimable, and `summary()` turns that into a reason. Two findings from -building it are worth keeping, because both contradict what this section originally proposed. - -**The row-column model now includes an intercept (Sam's suggestion), and that is what makes the test -exact.** The proposed gate was `rank(A_RC) == k - 1` on the existing matrix. That is wrong without an -intercept: `X`'s rows sum to 1, so the treatment mean is estimable, a sound design gives rank `k`, and an -equality test rejects valid designs. Testing the contrast space instead is not a fix either — for a PSD -matrix, "no contrast lies in the null space" is weaker than "every contrast is estimable", so it passes -designs whose contrasts are not estimable. Putting the intercept in the nuisance space makes the null space -exactly the all-ones direction, at which point `rank(A_RC) == k - 1` means precisely what it should. -**Verified not to change any value**: both published designs still return 0.834 and 0.827, matching the -paper and matching pairwise contrast variances taken from the full model's Moore-Penrose inverse. This also -closes E2 in `REVIEW-NOTES-EFFICIENCY.md`. - -**`qr()$rank` is unusable here** — its default tolerance is relative, and it reports rank 3 for a matrix -whose eigenvalues are `2, 8.7e-16, 6.3e-16`. The gate uses `svd()` with the same absolute tolerance as -`pseudo_inverse()`, so the gate and the inverse cannot disagree about which directions are null. - -**Confirmed against base R.** Every design below was cross-checked by fitting -`y ~ factor(row) + factor(col) + treatment` with `lm()` and asking whether it aliases a treatment -coefficient. `lm()` agrees with the gate on all of them — including the four that existing tests asserted -should return a number. Note the term order matters: `lm()` pivots in formula order, so putting `treatment` -first lets it absorb the confounding and alias the row terms instead. - -**What it was returning, on `main` as well as here.** Measured 2026-08-06, with `connectedness = FALSE` -(S6 in `REVIEW-NOTES-SUMMARY.md` — the single-row shapes error under the default). Residual df is -`n − 1 − (k−1) − (r−1) − (c−1)`: - -| Design | plots | residual df | was | now | -|---|---|---|---|---| -| 1×6 grid, 3 treatments | 6 | −2 | **1.5** | refused | -| 6×1 grid, 3 treatments | 6 | −2 | **1.5** | refused | -| 4×3 grid, 12 entries unreplicated | 12 | −5 | **1.61** | refused | -| 3×4 grid, 3 treatments confounded with row | 12 | **+4** | **1.5** | refused | -| 2×3 grid, 3 treatments | 6 | 0 | 0.75 | 0.75 | -| 2×6 grid, 3 treatments | 12 | 3 | 0.75 | 0.75 | - -**Two independent routes reach a value above 1, so residual df is not a sufficient test.** Rows 1-3 exhaust -the residual degrees of freedom. Row 4 has *four* residual df and still returned 1.5, because each treatment -occupies exactly one grid row — treatment is aliased with the row effect, so eliminating the row space -eliminates the treatment contrasts with it (`KNOWN_ISSUES.md` #3). The last two rows are the boundary the -gate must **not** reject: residual df 0 is still estimable, and both still return 0.75. - -**Coordinate validation could never have caught it.** `has_grid` is `TRUE` throughout — the coordinates are -unique — so G12's gate has nothing to refuse. That gate asks *can this be one grid*, a coordinate property; -this is a *rank* property of the model fitted on that grid. - -**It is reachable from a routine single-site call.** The 4×3-with-12-unreplicated-entries row is an ordinary -early-generation trial, and the same shape D7 measures at 1.833 per site inside a MET — so this was never -only a MET problem. - -**Clamping would have been wrong.** Capping or `NA`-ing anything above 1 hides the confounded case (row 4) -behind a plausible value instead of reporting that the design cannot support the estimate. - -**Fallout in the existing tests, all of it real.** Four tests asserted a finite, positive value for designs -`lm()` also calls non-estimable, and one `@examples` block used such a design — it would have failed -`R CMD check` once the gate existed. Each was replaced with an estimable fixture, keeping the test's -original intent, plus new tests pinning the refusals and the `residual df 0` boundary that must **not** be -rejected. The comparator in "provides better result for an optimised design" is the clearest case: it was -comparing against a number that does not exist. From 4f96a28f4feffe6a2dc0739803dbd979af0747ea Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:59:21 +0930 Subject: [PATCH 25/28] Updating MET vignette with new setup --- vignettes/met.qmd | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/vignettes/met.qmd b/vignettes/met.qmd index 1b60ab36..6a5df19e 100644 --- a/vignettes/met.qmd +++ b/vignettes/met.qmd @@ -99,6 +99,10 @@ plot_layout(met_design, "block") For MET designs, we use lists of named arguments to specify the hierarchical structure. The `optimise` parameter defines what to optimise and constraints at each level. +Each site numbers its rows and columns from 1, so several plots share the same `row`/`col` pair. A MET is not +one grid but several, which share a treatment set and never share an edge. Tell `speed()` which column +separates them with the `by` element of `grid_factors`: + ```{r met-example} optimise <- list( connectivity = list(spatial_factors = ~site), @@ -110,6 +114,7 @@ met_result <- speed( swap = "treatment", early_stop_iterations = 5000, optimise = optimise, + grid_factors = list(dim1 = "row", dim2 = "col", by = "site"), optimise_params = optim_params(random_initialisation = TRUE, adj_weight = 0), seed = 112 ) @@ -117,6 +122,10 @@ met_result <- speed( met_result ``` +Each site is then scored on its own and the results combined, so no two plots at different sites are ever +treated as neighbours. Without `by`, a design whose sites reuse coordinates is refused rather than silently +pooled - the grid-based diagnostics would otherwise describe a layout that does not exist. + ## Output of the Optimisation The output shows optimisation results for the design. The score and iterations are combined for the entire @@ -203,6 +212,7 @@ met_result <- speed( early_stop_iterations = 10000, iterations = 50000, optimise = optimise, + grid_factors = list(dim1 = "row", dim2 = "col", by = "site"), optimise_params = optim_params(random_initialisation = 10, adj_weight = 0), seed = 112 ) @@ -330,6 +340,7 @@ met_result <- speed( early_stop_iterations = 8000, iterations = 50000, optimise = optimise, + grid_factors = list(dim1 = "row", dim2 = "col", by = "site"), optimise_params = optim_params(random_initialisation = 30, adj_weight = 0), seed = 112 ) From 3d0d9f0cdc4a1147eea235cf56fd0400683caa76 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:13:20 +0930 Subject: [PATCH 26/28] Restructuring NEWS and updating Claude instructions --- CLAUDE.md | 2 ++ NEWS.md | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 70177764..0a82a3c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,8 @@ Per `CONTRIBUTING.md`, do **not** restyle code that is unrelated to your PR. User-facing changes should add a bullet to the top of `NEWS.md`. NEWS entries should be kept concise, with just a short 1-2 sentence summary of the changes, not paragraphs of explanation. If a change is related to a GitHub issue, it can be referenced just by number in parentheses e.g. (#1), but do not reference GitHub issues unless it's certain that they are related. +Within a release, sections must appear in the order **Major Changes**, **Minor Changes**, **Bug Fixes** - regardless of how many entries each holds. Omit a section entirely if it is empty. + ## Architecture ### Entry point and control flow diff --git a/NEWS.md b/NEWS.md index eeb48885..ca4b5c0c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -9,6 +9,11 @@ several grids, e.g. `list(dim1 = "row", dim2 = "col", by = "site")` for a multi-environment trial. Each grid is scored on its own. +## Minor Changes + +- Designs whose `row`/`col` columns are not numeric, or where two plots share a coordinate, now fail + with a message naming the problem. + ## Bug Fixes - Design metrics are now built from each plot's `row`/`col` coordinates rather than the order of the @@ -29,11 +34,6 @@ - `swap_all = TRUE` no longer changes the replication of a design when an earlier level has unbalanced a swap group mid-search. Only treatments with matching replication are exchanged. -## Minor Changes - -- Designs whose `row`/`col` columns are not numeric, or where two plots share a coordinate, now fail - with a message naming the problem. - # speed 0.0.9 ## Major Changes From 980b3baf045efe762296a591b1c7e3da418d08f2 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:19:25 +0930 Subject: [PATCH 27/28] Reducing verbosity of comments --- R/calculate_adjacency_score.R | 4 +- R/design_utils.R | 11 ++--- R/metrics.R | 82 +++++++++++--------------------- R/speed.R | 42 +++++----------- R/summary.R | 28 +++++------ R/verify_utils.R | 23 +++++++++ man/objective_function_piepho.Rd | 11 ++--- man/verify.Rd | 18 +++++++ tests/testthat/test-speed.R | 29 +++++++++++ 9 files changed, 130 insertions(+), 118 deletions(-) diff --git a/R/calculate_adjacency_score.R b/R/calculate_adjacency_score.R index 93fcf05a..1bdb7c44 100755 --- a/R/calculate_adjacency_score.R +++ b/R/calculate_adjacency_score.R @@ -274,9 +274,7 @@ calculate_adjacency_score <- function( } # Adjacency counts edges, and no edge crosses a grid boundary, so summing per - # grid is exact rather than an approximation. Verified on a two-site design: - # 20 + 30 = 50, against 60 when the sites are pooled into one grid - the extra - # 10 being adjacencies between plots at different sites. + # grid is exact rather than an approximation. totals <- vapply( grid_index, function(g) { diff --git a/R/design_utils.R b/R/design_utils.R index 66576473..4130bb20 100644 --- a/R/design_utils.R +++ b/R/design_utils.R @@ -932,9 +932,9 @@ initialize_design_df <- initialise_design_df #' #' @keywords internal grid_index <- function(df, row_column = "row", col_column = "col") { - # Checked before coercion: absent columns would otherwise reach max() as - # empty vectors and yield -Inf dimensions with a warning, rather than saying - # what is wrong. A design with no grid at all reaches here from speed(). + # Checked before coercion: absent columns would otherwise reach max() as empty + # vectors and yield -Inf dimensions with a warning, rather than saying what is + # wrong. missing_cols <- setdiff(c(row_column, col_column), names(df)) if (length(missing_cols)) { .grid_stop( @@ -1102,9 +1102,8 @@ build_design_matrix <- function( if (is.null(index)) { index <- grid_index(df, row_column, col_column) } else if (!identical(index$n, nrow(df))) { - # Catches an index built for a different design only when the plot count - # differs; a same-length index with different coordinates is not detectable - # here, so callers still own keeping the two in step. + # Only catches a mismatched index when the plot count differs; callers still + # own keeping index and design in step. stop( "`index` was built for ", index$n, diff --git a/R/metrics.R b/R/metrics.R index 75855ad8..03da17fa 100644 --- a/R/metrics.R +++ b/R/metrics.R @@ -203,13 +203,10 @@ calculate_balance_score <- function(layout_df, swap, spatial_cols) { #' @param design A data frame representing the spatial information of the design #' @param current_score_obj A named list containing the current score #' @param by,grid_index Optional grouping of plots into separate grids; see -#' [calculate_adjacency_score()]. Neighbour balance counts edges, so it sums -#' across grids. Evenness of distribution measures how far apart a treatment's -#' replicates are, and there is no distance between plots at different sites, -#' so it is scored **per grid** and the scores summed - reported both in total -#' and per grid, and identical to optimising each grid on its own, since a swap -#' moves plots within one grid. A grid with no treatment replicated inside it -#' has no evenness to measure and contributes `0`. +#' [calculate_adjacency_score()]. Neighbour balance sums across grids, while +#' evenness of distribution is scored **per grid** and the scores summed, +#' reported both in total and per grid. A grid with no treatment replicated +#' inside it contributes `0`. #' #' @examples #' design_df <- initialise_design_df( @@ -248,16 +245,13 @@ objective_function_piepho <- function(design, by = NULL, grid_index = NULL, ...) { - # `by`/`grid_index` are documented on calculate_adjacency_score(); evenness is - # scored per grid and the scores summed. See the loop below. + # `by`/`grid_index` are documented on calculate_adjacency_score() if (is.null(grid_index)) { grid_index <- grid_indices(design, row_column, col_column, by = by) } - # Every grid is scored on its own. A spanning tree measures distance between - # plots, and there is no distance between plots at different sites: pooling - # them either treats two sites' (1, 1) as the same point or invents a distance - # across the join. + # Each grid is scored on its own: there is no distance between plots at + # different sites, so a pooled spanning tree would be meaningless. ed <- list() ed_scores <- setNames(numeric(length(grid_index)), names(grid_index)) nb_counts <- list() @@ -276,11 +270,8 @@ objective_function_piepho <- function(design, current_score_obj$ed[[nm]], swapped_items ) - # A grid with nothing replicated inside it has no spanning tree to measure, - # so evenness of distribution does not apply there: it contributes 0 rather - # than 1/0, which would make the whole score `Inf` and leave the optimiser - # with nothing to compare. Replication is fixed for the run, so a grid is - # either in or out of this component for the whole run. + # A grid with nothing replicated has no spanning tree, so it contributes 0 + # rather than 1/0, which would make the whole score `Inf`. ed_scores[[nm]] <- if (length(ed[[nm]]) == 0L) { 0 } else { @@ -290,10 +281,8 @@ objective_function_piepho <- function(design, nb_counts[[nm]] <- unlist(calculate_nb(design_matrix, pair_mapping)$nb) } - # The per-grid scores are summed rather than pooled into one reciprocal. Both - # give the same optimisation - a swap moves plots within one grid, so it - # changes only that grid's term - but summing keeps ED scaling with adjacency, - # which also sums per grid, instead of shrinking as sites are added. + # Summed rather than pooled into one reciprocal, so ED scales with adjacency - + # which also sums per grid - instead of shrinking as sites are added. ed_score <- sum(ed_scores) # Neighbour balance counts edges and no edge crosses a grid boundary, so the @@ -322,10 +311,8 @@ objective_function_piepho <- function(design, grid_index = grid_index ) - # Per-grid evenness is reported alongside the total, never instead of it: the - # values are not comparable *between* grids - a 3-replicate spanning tree is - # longer than a 2-replicate one whatever the spread - so they are shown side - # by side rather than ranked or averaged. + # Reported alongside the total, not instead of it: per-grid values are not + # comparable between grids, so they are never ranked or averaged. components <- c( neighbour_balance = nb_score, even_distribution = ed_score, @@ -797,12 +784,9 @@ calculate_efficiency_factor <- function( ) { item <- as.character(substitute(item)) - # An efficiency factor is a property of one experiment's information matrix, - # and there is no meaningful way to combine several. A multi-site frame does - # not error of its own accord here - duplicate coordinates just pool the sites - # into one row/column model, which returns a value above 1 - so validate the - # coordinates explicitly rather than letting that through. Report one value - # per site instead, as `summary()` does. + # An efficiency factor is a property of one experiment, and several cannot be + # combined. Validated explicitly because pooled sites otherwise return a value + # above 1 rather than erroring; `summary()` reports one value per site. grid_index(design_df, row_column, col_column) # Design parameters @@ -828,15 +812,11 @@ calculate_efficiency_factor <- function( Z_row[cbind(in_row, rows[in_row])] <- 1 Z_col[cbind(in_col, cols[in_col])] <- 1 - # Intercept, then row and column design matrices. The intercept belongs to the - # nuisance space on its own account - the row-column model has a mean - and it - # is also what makes the estimability test below exact. Without it the mean is - # left inside the treatment term (X's rows sum to 1), so `A_RC` keeps a - # non-null direction that is neither a contrast nor orthogonal to one, and no - # rank test on it distinguishes "all contrasts estimable" from "some are not". - # Adding it does not change the reported value for a design that is estimable: - # verified against both published designs (0.834, 0.827) and against pairwise - # contrast variances taken from the full model's Moore-Penrose inverse. + # Intercept, then row and column design matrices. The row-column model has a + # mean, and including it is what makes the estimability test below exact: + # without it the mean stays inside the treatment term (X's rows sum to 1) and + # no rank test on `A_RC` can separate estimable contrasts from inestimable + # ones. Reported values for estimable designs are unchanged. Z <- cbind(1, Z_row, Z_col) # Check if Z^TZ is invertible @@ -856,19 +836,13 @@ calculate_efficiency_factor <- function( I_n <- diag(n_plots) A_RC <- t(X) %*% (I_n - P_Z) %*% X - # With the intercept eliminated, A_RC's null space contains the all-ones - # direction, so rank n_treatments - 1 means exactly "every treatment contrast - # is estimable" and anything less means some are not. Where they are not, - # pseudo_inverse() drops the null directions and the surviving pairwise - # variances still average to something finite, so the formula returns a - # plausible-looking number - typically above 1, which is impossible - instead - # of failing. Two distinct designs reach here: one with too few residual - # degrees of freedom, and one where treatment is aliased with a row or column - # effect despite having degrees of freedom to spare. Rank catches both; - # counting degrees of freedom catches only the first. The tolerance matches - # pseudo_inverse()'s, so the gate and the inverse cannot disagree about which - # directions are null. (`qr()` is not used: its default tolerance is relative - # and reports full rank for a matrix whose eigenvalues are 2, 9e-16, 6e-16.) + # Rank n_treatments - 1 means every treatment contrast is estimable. Without + # this gate pseudo_inverse() drops the null directions and returns a + # plausible-looking value above 1 instead of failing. Rank catches both + # aliasing and too few residual degrees of freedom; counting degrees of + # freedom catches only the latter. The tolerance matches pseudo_inverse()'s so + # the two cannot disagree, and `qr()` is unusable here - its relative default + # reports full rank for eigenvalues 2, 9e-16, 6e-16. if (sum(svd(A_RC)$d > 1e-10) != n_treatments - 1) { stop(structure( class = c( diff --git a/R/speed.R b/R/speed.R index c0054125..16ac2139 100644 --- a/R/speed.R +++ b/R/speed.R @@ -191,39 +191,22 @@ speed <- function(data, } } + # `by` groups plots into separate grids (a multi-environment trial) + .verify_grid_by(data, grid_factors) + grid_by <- grid_factors$by + # Infer row and column columns inferred <- infer_row_col(data, grid_factors, quiet) row_column <- inferred$row col_column <- inferred$col - # `by` groups plots into separate grids (a multi-environment trial). Validated - # here rather than left to fail later: `grid_factors` is a plain list, so a - # mistyped name would otherwise be silently ignored and every site pooled. - grid_by <- grid_factors$by - if (!is.null(grid_by)) { - if (!is.character(grid_by) || length(grid_by) != 1) { - stop( - "`grid_factors$by` must be a single column name.", - call. = FALSE - ) - } - if (!grid_by %in% names(data)) { - stop( - "`grid_factors$by` is \"", - grid_by, - "\", which is not a column in the design.", - call. = FALSE - ) - } - } # convert to factors factored <- to_factor(data) data <- factored$df if (inferred$inferred) { - # Row order no longer affects any metric - grids are built from each plot's - # coordinates - but generate_neighbour(), random_initialise(), print() and - # autoplot() may still rely on it, so the sort stays until that is checked. + # Metrics are built from each plot's coordinates now, but neighbour + # generation and plotting may still rely on row order, so the sort stays. data <- data[do.call(order, data[c(row_column, col_column)]), ] # Only reset row labels for base data frames; tibbles are positional and # warn on `rownames<-`, and nothing downstream reads the design's row names. @@ -290,11 +273,9 @@ speed_hierarchical <- function(data, optimise, quiet, seed, ...) { current_design <- layout_df best_design <- current_design - # Only the treatment column changes during annealing, so the validated grid - # index is invariant for the whole run and is built once here rather than every - # iteration. `NULL` on failure keeps this lazy: a design whose coordinates - # cannot form a grid still runs if its objective never needs one, and still - # errors from build_design_matrix() if it does. + # Only the treatment column moves during annealing, so build the index once. + # `NULL` on failure defers to build_design_matrix(), so a design that cannot + # form a grid still runs if its objective never needs one. dots <- list(...) grid_idx <- tryCatch( grid_indices( @@ -303,7 +284,7 @@ speed_hierarchical <- function(data, optimise, quiet, seed, ...) { dots$col_column %||% "col", by = dots$grid_by ), - error = function(e) return(NULL) + speed_grid_error = function(e) return(NULL) ) # Sequential optimisation for each hierarchy level @@ -449,8 +430,7 @@ speed_hierarchical <- function(data, optimise, quiet, seed, ...) { levels = hierarchy_levels, row_column = .dots$row_column %||% "row", col_column = .dots$col_column %||% "col", - # NULL for a single-grid design; the column separating grids otherwise, so - # summary() can recover the grouping instead of guessing it from a name. + # NULL for a single-grid design, so summary() need not guess the grouping grid_by = .dots$grid_by, per_level = per_level_meta ) diff --git a/R/summary.R b/R/summary.R index fb3db006..212b33de 100644 --- a/R/summary.R +++ b/R/summary.R @@ -141,12 +141,10 @@ summary.design <- function( want_neighbour <- is.null(neighbour) || isTRUE(neighbour) # The one coordinate validation for the whole summary: `grid` is either a - # `grid_index()` list or the reason there isn't one, and every grid metric - # below takes it rather than re-deriving it once per level. + # `grid_index()` list or the reason there isn't one. # - # `has_grid` therefore means "reportable as one grid", not merely "row/col - # columns exist": a design with duplicate coordinates spans several grids of - # possibly different shapes, so no single `nrow` x `ncol` describes it. + # `has_grid` means "reportable as one grid", not merely "row/col columns + # exist": duplicate coordinates span several grids of differing shapes. grid <- tryCatch( grid_indices(df, row_column = rc, col_column = cc, by = meta$grid_by), speed_grid_error = function(e) return(e$reason) @@ -964,8 +962,7 @@ print.summary.design <- function(x, ...) { #' @keywords internal .efficiency_factor <- function(df, swap, rc, cc, grid) { # Not just a guard against erroring: on duplicate coordinates - # calculate_efficiency_factor() pools the grids and silently returns a value - # above 1, which is impossible for an efficiency factor. + # calculate_efficiency_factor() pools the grids and returns an impossible >1. if (is.character(grid)) { return(list(available = FALSE, reason = grid)) } @@ -1002,12 +999,10 @@ print.summary.design <- function(x, ...) { return(one(df)) } - # One value per grid, never summed or averaged. An efficiency factor is a - # property of a single experiment's information matrix: averaging per-grid - # values gives a different quantity from the combined analysis, and the - # combined analysis is not identified anyway - it depends on residual variance - # ratios that are unknown at design time. Each grid is gated on its own rank, - # so one unreplicated site reports its reason without withholding the others. + # One value per grid, never summed or averaged: averaging gives a different + # quantity from the combined analysis, which is not identified at design time + # anyway. Each grid is gated on its own rank, so one unreplicated site reports + # its reason without withholding the others. per_grid <- lapply(grid, function(g) return(one(df[g$rows, , drop = FALSE]))) return(list( available = any(vapply(per_grid, function(x) x$available, logical(1))), @@ -1047,10 +1042,9 @@ print.summary.design <- function(x, ...) { if (is.character(grid)) { return(list(available = FALSE, reason = grid)) } - # One pair mapping for the whole design, so every grid contributes to the same - # set of pairs and a pair absent from one site is still counted as zero rather - # than dropped. Counts sum across grids: an adjacency is an edge, and no edge - # crosses a grid boundary. + # One pair mapping for the whole design, so a pair absent from one site counts + # as zero rather than being dropped. Counts sum across grids: no edge crosses + # a grid boundary. pair_mapping <- create_pair_mapping(df[[swap]]) all_pairs <- unique(pair_mapping) counts <- setNames(rep(0L, length(all_pairs)), all_pairs) diff --git a/R/verify_utils.R b/R/verify_utils.R index 7dc4518e..4befa148 100644 --- a/R/verify_utils.R +++ b/R/verify_utils.R @@ -196,6 +196,29 @@ } } +#' Verify the `by` element of `grid_factors` +#' +#' @description +#' `grid_factors` is a plain list, so a mistyped `by` would be ignored and every +#' grid silently pooled. Checked before any optimisation happens. +#' +#' @inheritParams speed +#' +#' @rdname verify +#' +#' @keywords internal +.verify_grid_by <- function(data, grid_factors) { + grid_by <- grid_factors$by + if (!is.null(grid_by)) { + if (!is.character(grid_by) || length(grid_by) != 1) { + data_type_error("grid_factors$by", "a single column name") + } + verify_column_exists(grid_by, data, "grid grouping column") + } + + return(invisible(NULL)) +} + # Other functions for verifying diff --git a/man/objective_function_piepho.Rd b/man/objective_function_piepho.Rd index 8bc7add9..288ac3f6 100644 --- a/man/objective_function_piepho.Rd +++ b/man/objective_function_piepho.Rd @@ -36,13 +36,10 @@ objective_function_piepho( \item{col_column}{Name of column representing the column of the design (default: "col")} \item{by, grid_index}{Optional grouping of plots into separate grids; see -\code{\link[=calculate_adjacency_score]{calculate_adjacency_score()}}. Neighbour balance counts edges, so it sums -across grids. Evenness of distribution measures how far apart a treatment's -replicates are, and there is no distance between plots at different sites, -so it is scored \strong{per grid} and the scores summed - reported both in total -and per grid, and identical to optimising each grid on its own, since a swap -moves plots within one grid. A grid with no treatment replicated inside it -has no evenness to measure and contributes \code{0}.} +\code{\link[=calculate_adjacency_score]{calculate_adjacency_score()}}. Neighbour balance sums across grids, while +evenness of distribution is scored \strong{per grid} and the scores summed, +reported both in total and per grid. A grid with no treatment replicated +inside it contributes \code{0}.} \item{...}{Extra parameters passed from \link{speed}} } diff --git a/man/verify.Rd b/man/verify.Rd index ac0175cf..5a0e9d61 100644 --- a/man/verify.Rd +++ b/man/verify.Rd @@ -5,6 +5,7 @@ \alias{.verify_hierarchical_inputs} \alias{.verify_optim_params} \alias{.verify_swap_all_replication} +\alias{.verify_grid_by} \title{Verify Inputs for \code{speed}} \usage{ .verify_speed_inputs( @@ -42,6 +43,8 @@ ) .verify_swap_all_replication(data, optimise, dummy_group = NULL) + +.verify_grid_by(data, grid_factors) } \arguments{ \item{data}{A data frame containing the experimental design with spatial @@ -85,6 +88,18 @@ see more in example.} \item{dummy_group}{Name of the internal placeholder column used for a level with no \code{swap_within} boundary, so it can be described as the whole design.} + +\item{grid_factors}{A named list specifying grid factors to construct a +matrix for calculating adjacency score, \code{dim1} for row and \code{dim2} for +column. (default: \code{list(dim1 = "row", dim2 = "col")}). + +An optional third element, \code{by}, names a column that groups plots into +\emph{separate} grids - a multi-environment trial, where each site reuses the +same \code{row}/\code{col} numbering. Each grid is then scored on its own and the +adjacency counts summed, so no adjacency is counted between plots at +different sites, e.g. +\code{list(dim1 = "row", dim2 = "col", by = "site")}. Without it, a design whose +sites share coordinates is refused rather than silently pooled.} } \description{ Verify inputs for the \code{speed} function. @@ -99,5 +114,8 @@ any optimisation happens rather than silently altering replication. Called on the resolved \code{optimise} list, so it covers simple, legacy hierarchical and \verb{optimise = } calls alike, including levels that set \code{swap_all} individually. + +\code{grid_factors} is a plain list, so a mistyped \code{by} would be ignored and every +grid silently pooled. Checked before any optimisation happens. } \keyword{internal} diff --git a/tests/testthat/test-speed.R b/tests/testthat/test-speed.R index 4a3a79ad..a0cb564c 100644 --- a/tests/testthat/test-speed.R +++ b/tests/testthat/test-speed.R @@ -1417,6 +1417,35 @@ test_that("speed runs with grid_factors", { expect_equal(result$score, 1) }) +test_that("`grid_factors$by` is checked before optimising", { + test_data <- data.frame( + lane = rep(1:5, times = 4), + position = rep(1:4, each = 5), + treatment = rep(LETTERS[1:4], 5) + ) + + speed_by <- function(by) { + return(speed( + data = test_data, + swap = "treatment", + spatial_factors = ~ lane + position, + grid_factors = list(dim1 = "lane", dim2 = "position", by = by), + iterations = 10, + seed = 42, + quiet = TRUE + )) + } + + # A mistyped column would otherwise be ignored and every grid pooled + expect_error(speed_by("site"), "not found in", fixed = TRUE) + expect_error( + speed_by(c("site", "block")), + "must be a single column name", + fixed = TRUE + ) + expect_error(speed_by(1), "must be a single column name", fixed = TRUE) +}) + test_that("speed handles MET", { # 5 sites, 100 treatments, 7 total reps # 5x28x5 From a6275092472203c4e88309241aeda3af245191d1 Mon Sep 17 00:00:00 2001 From: Sam Rogers <7007561+rogerssam@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:35:05 +0930 Subject: [PATCH 28/28] Adding additional tests --- tests/testthat/test-build_design_matrix.R | 63 ++++++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/tests/testthat/test-build_design_matrix.R b/tests/testthat/test-build_design_matrix.R index fb899b53..b73fbced 100644 --- a/tests/testthat/test-build_design_matrix.R +++ b/tests/testthat/test-build_design_matrix.R @@ -305,6 +305,56 @@ test_that("grid_indices() returns one index per grid", { expect_error(grid_indices(d, by = "nope"), class = "speed_grid_missing_by") }) +test_that("grid_index() reports why a design cannot be gridded", { + ok <- expand.grid(row = 1:2, col = 1:2) + + nonnumeric <- ok + nonnumeric$row <- c("a", "b", "c", "d") + expect_error(grid_index(nonnumeric), class = "speed_grid_nonnumeric") + + # Coordinates index a matrix directly, so both zero and fractional fail + below_one <- ok + below_one$row <- c(0L, 1L, 2L, 3L) + expect_error(grid_index(below_one), class = "speed_grid_notinteger") + + fractional <- ok + fractional$col <- c(1.5, 2.5, 3.5, 4.5) + expect_error(grid_index(fractional), class = "speed_grid_notinteger") +}) + +test_that("every grid failure carries a `speed_grid_error` and a reason", { + # speed_hierarchical() and summary() both dispatch on the parent class rather + # than the specific one, so each condition must carry it and a `reason`. + ok <- expand.grid(row = 1:2, col = 1:2) + + nonnumeric <- ok + nonnumeric$row <- c("a", "b", "c", "d") + fractional <- ok + fractional$col <- c(1.5, 2.5, 3.5, 4.5) + duplicated_coords <- rbind(ok, ok) + + cases <- list( + missing = data.frame(lane = 1:4), + nonnumeric = nonnumeric, + notinteger = fractional, + duplicate = duplicated_coords + ) + + for (nm in names(cases)) { + err <- tryCatch(grid_index(cases[[nm]]), speed_grid_error = function(e) { + return(e) + }) + expect_s3_class(err, "speed_grid_error") + expect_type(err$reason, "character") + expect_gt(nchar(err$reason), 0) + } + + expect_error( + grid_indices(ok, by = "nope"), + class = "speed_grid_error" + ) +}) + test_that("speed() scores identically whether or not the index is hoisted", { # The hoist is a performance change only; guard against it becoming a # behaviour change. objective_function_piepho() builds a grid every iteration, @@ -370,6 +420,15 @@ test_that("grid_index() names a missing coordinate column", { # the absent columns reach max() as empty vectors and give -Inf dimensions. d <- data.frame(a = 1:4, b = 1:4, treatment = LETTERS[1:4]) - expect_error(grid_index(d), "no `row` or `col` column") - expect_error(grid_index(d, "row", "b"), "no `row` column") + expect_error( + grid_index(d), + "no `row` or `col` column", + class = "speed_grid_missing" + ) + # Only one of the pair missing is still a missing-column failure + expect_error( + grid_index(d, "row", "b"), + "no `row` column", + class = "speed_grid_missing" + ) })