tests/testthat/test-plot-xlim-grid.R

# PAD-001 regression: prediction grid must be restricted to user-supplied
# xlim for continuous-treatment plots, so the visible curve actually ends at
# (lo, hi) and the .pad_xlim(mult=0.04) coord_cartesian pad becomes visible
# whitespace. See test-spec.md §1 (B1-B6), §2-§3.
#
# Pipeline isolation: this file does NOT depend on rendering, quarto, or
# snapshots. It builds interflex objects on bundled toy datasets, calls
# plot.interflex(..., show.all = TRUE), and inspects ggplot_build() output.

skip_if_not_installed("ggplot2")

library(interflex)
library(ggplot2)

# --- Helpers (mirror those in test-plot-limits.R) ----------------------------

load_toy <- function(name) {
    e <- new.env()
    ok <- tryCatch({ data(list = name, envir = e); TRUE },
                   error = function(e) FALSE,
                   warning = function(w) FALSE)
    if (!ok || !exists(name, envir = e)) {
        rdata <- system.file("data", "interflex.RData", package = "interflex")
        if (!nzchar(rdata)) rdata <- file.path("..", "..", "data", "interflex.RData")
        if (file.exists(rdata)) load(rdata, envir = e)
    }
    if (!exists(name, envir = e)) skip(paste("toy dataset", name, "not available"))
    get(name, envir = e)
}

get_inner_ggplots <- function(out, ...) {
    # plot.interflex is not exported from the namespace, so resolve it via :::
    # which works whether the package is installed or loaded via devtools.
    plot_fn <- getFromNamespace("plot.interflex", "interflex")
    pg <- plot_fn(out, show.all = TRUE, ...)
    if (inherits(pg, "ggplot")) return(list(pg))
    stopifnot(is.list(pg), length(pg) >= 1L)
    for (g in pg) stopifnot(inherits(g, "ggplot"))
    pg
}

coord_limits <- function(p) {
    co <- p$coordinates
    stopifnot(inherits(co, "CoordCartesian"))
    list(x = co$limits$x, y = co$limits$y)
}

# Extract per-layer x range for GeomLine / GeomPath / GeomRibbon layers
# from a built ggplot. Returns a list of named lists.
layer_x_range <- function(g) {
    b <- ggplot2::ggplot_build(g)
    out <- list()
    for (i in seq_along(g$layers)) {
        l <- g$layers[[i]]
        df <- b$data[[i]]
        if (is.null(df)) next
        geom_class <- class(l$geom)[1]
        if (!geom_class %in% c("GeomLine", "GeomPath", "GeomRibbon")) next
        xs <- c()
        if ("x" %in% names(df))    xs <- c(xs, df$x)
        if ("xmin" %in% names(df)) xs <- c(xs, df$xmin)
        if ("xmax" %in% names(df)) xs <- c(xs, df$xmax)
        xs <- xs[is.finite(xs)]
        if (length(xs) == 0) next
        out[[length(out) + 1L]] <- list(
            i = i, geom = geom_class,
            min = min(xs), max = max(xs)
        )
    }
    out
}

# --- Per-estimator scenarios -------------------------------------------------
#
# Toy dataset choice: use s2 (continuous D, 200 rows, X in [-0.4, 6.2]). Pick
# xlim = c(0, 5) so both endpoints are strictly interior to the X range and a
# pre-fix run (which spans the full data range) would FAIL B2 on the high side
# (data x extends to ~6.2 > 5) AND B1 (data x reaches ~-0.4 < 0).

CONT_DS    <- "s2"
LO         <- 0
HI         <- 5
SPAN       <- HI - LO                  # 5
EPS        <- 1e-9 * max(1, SPAN)      # ~5e-9
PAD        <- 0.04 * SPAN              # 0.20
EXP_COORD  <- c(LO - PAD, HI + PAD)    # c(-0.20, 5.20)

assert_xlim_grid <- function(g, label) {
    layers <- layer_x_range(g)
    expect_true(length(layers) >= 1L)
    for (lr in layers) {
        ctx <- paste(label, "layer", lr$i, lr$geom)
        # B1: layer x stays inside [lo, hi]
        expect_gte(lr$min, LO - EPS, label = paste(ctx, "min"))
        expect_lte(lr$max, HI + EPS, label = paste(ctx, "max"))
        # B2: layer x reaches the supplied endpoints
        expect_lte(lr$min, LO + EPS, label = paste(ctx, "min reaches lo"))
        expect_gte(lr$max, HI - EPS, label = paste(ctx, "max reaches hi"))
    }
    # B3: coord pad is exactly 4% of span on each side
    lims <- coord_limits(g)
    expect_false(is.null(lims$x))
    expect_equal(lims$x[1], EXP_COORD[1], tolerance = 1e-9)
    expect_equal(lims$x[2], EXP_COORD[2], tolerance = 1e-9)
}

assert_default_xlim_null <- function(g, label) {
    lims <- coord_limits(g)
    expect_null(lims$x, label = paste(label, "default coord$xlim"))
}

# --- Linear ------------------------------------------------------------------

test_that("PAD-001 linear: layer x grid is restricted to user xlim", {
  skip_on_cran()
    s <- load_toy(CONT_DS)
    out <- interflex(estimator = "linear", data = s,
                     Y = "Y", D = "D", X = "X",
                     treat.type = "continuous", na.rm = TRUE,
                     xlim = c(LO, HI))
    inner <- get_inner_ggplots(out)
    expect_true(length(inner) >= 1L)
    for (g in inner) assert_xlim_grid(g, "linear")
})

test_that("PAD-001 linear: default (no xlim) leaves coord$xlim NULL", {
  skip_on_cran()
    s <- load_toy(CONT_DS)
    out <- interflex(estimator = "linear", data = s,
                     Y = "Y", D = "D", X = "X",
                     treat.type = "continuous", na.rm = TRUE)
    inner <- get_inner_ggplots(out)
    for (g in inner) assert_default_xlim_null(g, "linear default")
})

# --- Kernel ------------------------------------------------------------------

test_that("PAD-001 kernel: layer x grid is restricted to user xlim", {
  skip_on_cran()
    s <- load_toy(CONT_DS)
    out <- tryCatch(
        interflex(estimator = "kernel", data = s,
                  Y = "Y", D = "D", X = "X",
                  treat.type = "continuous", na.rm = TRUE,
                  nboots = 2, vartype = "delta",
                  xlim = c(LO, HI)),
        error = function(e) { skip(paste("kernel build failed:", conditionMessage(e))) })
    inner <- get_inner_ggplots(out)
    expect_true(length(inner) >= 1L)
    for (g in inner) assert_xlim_grid(g, "kernel")
})

test_that("PAD-001 kernel: default (no xlim) leaves coord$xlim NULL", {
  skip_on_cran()
    s <- load_toy(CONT_DS)
    out <- tryCatch(
        interflex(estimator = "kernel", data = s,
                  Y = "Y", D = "D", X = "X",
                  treat.type = "continuous", na.rm = TRUE,
                  nboots = 2, vartype = "delta"),
        error = function(e) { skip(paste("kernel build failed:", conditionMessage(e))) })
    inner <- get_inner_ggplots(out)
    for (g in inner) assert_default_xlim_null(g, "kernel default")
})

# --- Binning -----------------------------------------------------------------

test_that("PAD-001 binning: layer x grid is restricted to user xlim", {
  skip_on_cran()
    s <- load_toy(CONT_DS)
    out <- tryCatch(
        interflex(estimator = "binning", data = s,
                  Y = "Y", D = "D", X = "X",
                  treat.type = "continuous", na.rm = TRUE,
                  nbins = 3, nboots = 2,
                  xlim = c(LO, HI)),
        error = function(e) { skip(paste("binning build failed:", conditionMessage(e))) })
    inner <- get_inner_ggplots(out)
    expect_true(length(inner) >= 1L)
    for (g in inner) assert_xlim_grid(g, "binning")
})

test_that("PAD-001 binning: default (no xlim) leaves coord$xlim NULL", {
  skip_on_cran()
    s <- load_toy(CONT_DS)
    out <- tryCatch(
        interflex(estimator = "binning", data = s,
                  Y = "Y", D = "D", X = "X",
                  treat.type = "continuous", na.rm = TRUE,
                  nbins = 3, nboots = 2),
        error = function(e) { skip(paste("binning build failed:", conditionMessage(e))) })
    inner <- get_inner_ggplots(out)
    for (g in inner) assert_default_xlim_null(g, "binning default")
})

# --- Lasso (PO-lasso, continuous PLR) ----------------------------------------

test_that("PAD-001 lasso: layer x grid is restricted to user xlim", {
  skip_on_cran()
    skip_if_not_installed("glmnet")
    s <- load_toy(CONT_DS)
    out <- suppressWarnings(suppressMessages(
        interflex(estimator = "lasso", data = s,
                  Y = "Y", D = "D", X = "X",
                  treat.type = "continuous", na.rm = TRUE,
                  nboots = 2,
                  xlim = c(LO, HI))))
    inner <- get_inner_ggplots(out)
    expect_true(length(inner) >= 1L)
    for (g in inner) assert_xlim_grid(g, "lasso")
})

test_that("PAD-001 lasso: default (no xlim) leaves coord$xlim NULL", {
  skip_on_cran()
    skip_if_not_installed("glmnet")
    s <- load_toy(CONT_DS)
    out <- suppressWarnings(suppressMessages(
        interflex(estimator = "lasso", data = s,
                  Y = "Y", D = "D", X = "X",
                  treat.type = "continuous", na.rm = TRUE,
                  nboots = 2)))
    inner <- get_inner_ggplots(out)
    for (g in inner) assert_default_xlim_null(g, "lasso default")
})

# --- DML ---------------------------------------------------------------------

test_that("PAD-001 DML: layer x grid is restricted to user xlim", {
  skip_on_cran()
    skip_if_not_installed("DoubleML")
    skip_if_not_installed("mlr3")
    s <- load_toy(CONT_DS)
    out <- suppressWarnings(suppressMessages(
        interflex(estimator = "dml", data = s,
                  Y = "Y", D = "D", X = "X",
                  treat.type = "continuous", na.rm = TRUE,
                  model.y = "linear", model.t = "linear",
                  xlim = c(LO, HI))))
    inner <- get_inner_ggplots(out)
    expect_true(length(inner) >= 1L)
    for (g in inner) assert_xlim_grid(g, "DML")
})

# --- §2.6 .pad_xlim default mult is still 0.04 -------------------------------

test_that(".pad_xlim default mult is 0.04 (PAD-001 invariant)", {
  skip_on_cran()
    expect_equal(interflex:::.pad_xlim(c(0, 10)), c(-0.4, 10.4))
})

# --- §2.5 Discrete-treatment plots are unaffected (B6) -----------------------

test_that("PAD-001 discrete-treatment plot with explicit xlim still pads", {
  skip_on_cran()
    # s1: D in {0,1}, X in [-0.4, 6.2]. Pick interior xlim so the test
    # exercises an explicit user xlim on a discrete plot.
    s1 <- load_toy("s1")
    user_xlim <- c(0, 5)
    span <- diff(user_xlim)
    out <- interflex(estimator = "linear", data = s1,
                     Y = "Y", D = "D", X = "X", na.rm = TRUE,
                     xlim = user_xlim)
    inner <- get_inner_ggplots(out)
    for (g in inner) {
        lims <- coord_limits(g)
        expect_false(is.null(lims$x))
        expect_equal(lims$x[1], user_xlim[1] - 0.04 * span, tolerance = 1e-9)
        expect_equal(lims$x[2], user_xlim[2] + 0.04 * span, tolerance = 1e-9)
    }
})

# --- §3 Edge cases -----------------------------------------------------------

# --- §2.8 PASS 2 — Path 2 (plot-time xlim) ----------------------------------
#
# Path 2 fits interflex WITHOUT xlim, then calls plot(out, xlim = c(lo, hi)).
# B7: B1 + B3 must hold on g2. B2 is relaxed to "within one grid step".
# B8: idempotence — Path1+Path2 with same xlim yields identical layer x range
#     to Path1-only; narrower plot-time xlim shrinks further.
# B9: default plot(out) (no xlim anywhere) yields NULL coord x and full range.
# B10: discrete unchanged under plot-time xlim.

plot_fn_pi <- function() getFromNamespace("plot.interflex", "interflex")

# Relaxed B2 tolerance: max gap is data_range / (neval - 1), default neval=50
relaxed_gap <- function(out_obj, span_xlim) {
    # Try to find the moderator data range on the fitted object.
    xvec <- NULL
    if (!is.null(out_obj$data) && !is.null(out_obj$X) &&
        is.character(out_obj$X) && out_obj$X %in% names(out_obj$data)) {
        xvec <- out_obj$data[[out_obj$X]]
    }
    if (is.null(xvec) && !is.null(out_obj$Xvec)) xvec <- out_obj$Xvec
    if (is.null(xvec)) return(span_xlim)   # permissive fallback
    xvec <- xvec[is.finite(xvec)]
    if (length(xvec) < 2L) return(span_xlim)
    diff(range(xvec)) / (50 - 1)
}

assert_xlim_grid_path2 <- function(g, label, out_obj) {
    layers <- layer_x_range(g)
    expect_true(length(layers) >= 1L)
    gap <- relaxed_gap(out_obj, SPAN)
    for (lr in layers) {
        ctx <- paste("Path2", label, "layer", lr$i, lr$geom)
        # B1: stays inside [lo, hi]
        expect_gte(lr$min, LO - EPS, label = paste(ctx, "min<lo"))
        expect_lte(lr$max, HI + EPS, label = paste(ctx, "max>hi"))
        # B2 relaxed: reaches within one grid step
        expect_lte(lr$min, LO + gap + EPS,
                   label = paste(ctx, "min did not reach near lo"))
        expect_gte(lr$max, HI - gap - EPS,
                   label = paste(ctx, "max did not reach near hi"))
    }
    # B3: coord pad still exactly 4% of span
    lims <- coord_limits(g)
    expect_false(is.null(lims$x))
    expect_equal(lims$x[1], EXP_COORD[1], tolerance = 1e-9)
    expect_equal(lims$x[2], EXP_COORD[2], tolerance = 1e-9)
}

# B7 — Path 2 per-estimator -------------------------------------------------

test_that("PAD-001 B7 linear Path 2: plot-time xlim restricts layer grid", {
  skip_on_cran()
    s <- load_toy(CONT_DS)
    out <- interflex(estimator = "linear", data = s,
                     Y = "Y", D = "D", X = "X",
                     treat.type = "continuous", na.rm = TRUE)
    pf <- plot_fn_pi()
    g2 <- pf(out, show.all = TRUE, xlim = c(LO, HI))
    inner <- if (inherits(g2, "ggplot")) list(g2) else g2
    expect_true(length(inner) >= 1L)
    for (g in inner) assert_xlim_grid_path2(g, "linear", out)
})

test_that("PAD-001 B7 kernel Path 2: plot-time xlim restricts layer grid", {
  skip_on_cran()
    s <- load_toy(CONT_DS)
    out <- tryCatch(
        interflex(estimator = "kernel", data = s,
                  Y = "Y", D = "D", X = "X",
                  treat.type = "continuous", na.rm = TRUE,
                  nboots = 2, vartype = "delta"),
        error = function(e) { skip(paste("kernel build failed:", conditionMessage(e))) })
    pf <- plot_fn_pi()
    g2 <- pf(out, show.all = TRUE, xlim = c(LO, HI))
    inner <- if (inherits(g2, "ggplot")) list(g2) else g2
    expect_true(length(inner) >= 1L)
    for (g in inner) assert_xlim_grid_path2(g, "kernel", out)
})

test_that("PAD-001 B7 binning Path 2: plot-time xlim restricts layer grid", {
  skip_on_cran()
    s <- load_toy(CONT_DS)
    out <- tryCatch(
        interflex(estimator = "binning", data = s,
                  Y = "Y", D = "D", X = "X",
                  treat.type = "continuous", na.rm = TRUE,
                  nbins = 3, nboots = 2),
        error = function(e) { skip(paste("binning build failed:", conditionMessage(e))) })
    pf <- plot_fn_pi()
    g2 <- pf(out, show.all = TRUE, xlim = c(LO, HI))
    inner <- if (inherits(g2, "ggplot")) list(g2) else g2
    expect_true(length(inner) >= 1L)
    for (g in inner) assert_xlim_grid_path2(g, "binning", out)
})

test_that("PAD-001 B7 lasso Path 2: plot-time xlim restricts layer grid", {
  skip_on_cran()
    skip_if_not_installed("glmnet")
    s <- load_toy(CONT_DS)
    out <- tryCatch(
        interflex(estimator = "lasso", data = s,
                  Y = "Y", D = "D", X = "X",
                  treat.type = "continuous", na.rm = TRUE),
        error = function(e) { skip(paste("lasso build failed:", conditionMessage(e))) })
    pf <- plot_fn_pi()
    g2 <- tryCatch(pf(out, show.all = TRUE, xlim = c(LO, HI)),
                   error = function(e) { skip(paste("lasso plot failed:", conditionMessage(e))) })
    inner <- if (inherits(g2, "ggplot")) list(g2) else g2
    expect_true(length(inner) >= 1L)
    for (g in inner) assert_xlim_grid_path2(g, "lasso", out)
})

test_that("PAD-001 B7 DML Path 2: plot-time xlim restricts layer grid", {
  skip_on_cran()
    skip_if_not_installed("DoubleML")
    skip_if_not_installed("mlr3")
    s <- load_toy(CONT_DS)
    out <- tryCatch(
        interflex(estimator = "DML", data = s,
                  Y = "Y", D = "D", X = "X",
                  treat.type = "continuous", na.rm = TRUE),
        error = function(e) { skip(paste("DML build failed:", conditionMessage(e))) })
    pf <- plot_fn_pi()
    g2 <- tryCatch(pf(out, show.all = TRUE, xlim = c(LO, HI)),
                   error = function(e) { skip(paste("DML plot failed:", conditionMessage(e))) })
    inner <- if (inherits(g2, "ggplot")) list(g2) else g2
    expect_true(length(inner) >= 1L)
    for (g in inner) assert_xlim_grid_path2(g, "DML", out)
})

# B8 — Idempotence: Path1 and Path1+Path2(same) yield identical layer x ------

test_that("PAD-001 B8 linear: Path 1 + Path 2 idempotence and narrowing", {
  skip_on_cran()
    s <- load_toy(CONT_DS)
    out1 <- interflex(estimator = "linear", data = s,
                      Y = "Y", D = "D", X = "X",
                      treat.type = "continuous", na.rm = TRUE,
                      xlim = c(LO, HI))
    pf <- plot_fn_pi()
    g_a <- pf(out1, show.all = TRUE)
    g_b <- pf(out1, show.all = TRUE, xlim = c(LO, HI))
    g_c <- pf(out1, show.all = TRUE, xlim = c(-0.5, 0.5))
    inner_a <- if (inherits(g_a, "ggplot")) list(g_a) else g_a
    inner_b <- if (inherits(g_b, "ggplot")) list(g_b) else g_b
    inner_c <- if (inherits(g_c, "ggplot")) list(g_c) else g_c
    expect_equal(length(inner_a), length(inner_b))
    # g_a and g_b must be bit-identical on layer x ranges
    for (i in seq_along(inner_a)) {
        la <- layer_x_range(inner_a[[i]])
        lb <- layer_x_range(inner_b[[i]])
        expect_equal(length(la), length(lb))
        for (j in seq_along(la)) {
            expect_equal(la[[j]]$min, lb[[j]]$min, tolerance = 1e-12)
            expect_equal(la[[j]]$max, lb[[j]]$max, tolerance = 1e-12)
        }
    }
    # g_c narrows further to [-0.5, 0.5]
    for (g in inner_c) {
        layers <- layer_x_range(g)
        expect_true(length(layers) >= 1L)
        for (lr in layers) {
            expect_gte(lr$min, -0.5 - 1e-9)
            expect_lte(lr$max,  0.5 + 1e-9)
        }
        lims <- coord_limits(g)
        expect_equal(lims$x[1], -0.5 - 0.04, tolerance = 1e-9)
        expect_equal(lims$x[2],  0.5 + 0.04, tolerance = 1e-9)
    }
})

# B9 — default plot(out) with no xlim anywhere -------------------------------

test_that("PAD-001 B9 linear: default plot(out) leaves coord$xlim NULL and full range", {
  skip_on_cran()
    s <- load_toy(CONT_DS)
    out <- interflex(estimator = "linear", data = s,
                     Y = "Y", D = "D", X = "X",
                     treat.type = "continuous", na.rm = TRUE)
    pf <- plot_fn_pi()
    g <- pf(out, show.all = TRUE)
    inner <- if (inherits(g, "ggplot")) list(g) else g
    for (gi in inner) {
        lims <- coord_limits(gi)
        expect_null(lims$x)
        # Layer x should span ~full data range, not a restricted window.
        layers <- layer_x_range(gi)
        if (length(layers) >= 1L) {
            data_rng <- range(s$X, na.rm = TRUE)
            for (lr in layers) {
                # Allow a tiny slack — full-data grid
                expect_lte(lr$min, data_rng[1] + 1e-6)
                expect_gte(lr$max, data_rng[2] - 1e-6)
            }
        }
    }
})

# B10 — Discrete unchanged under plot-time xlim ------------------------------

test_that("PAD-001 B10 discrete: plot-time xlim still pads, no layer filter", {
  skip_on_cran()
    s1 <- load_toy("s1")
    out <- interflex(estimator = "linear", data = s1,
                     Y = "Y", D = "D", X = "X", na.rm = TRUE)
    pf <- plot_fn_pi()
    g <- pf(out, show.all = TRUE, xlim = c(0, 5))
    inner <- if (inherits(g, "ggplot")) list(g) else g
    for (gi in inner) {
        lims <- coord_limits(gi)
        expect_false(is.null(lims$x))
        expect_equal(lims$x[1], 0 - 0.04 * 5, tolerance = 1e-9)
        expect_equal(lims$x[2], 5 + 0.04 * 5, tolerance = 1e-9)
    }
})

test_that("PAD-001 narrow xlim entirely inside data: linear", {
  skip_on_cran()
    s <- load_toy(CONT_DS)
    narrow_lo <- 1
    narrow_hi <- 2
    out <- interflex(estimator = "linear", data = s,
                     Y = "Y", D = "D", X = "X",
                     treat.type = "continuous", na.rm = TRUE,
                     xlim = c(narrow_lo, narrow_hi))
    inner <- get_inner_ggplots(out)
    span <- narrow_hi - narrow_lo
    eps <- 1e-9 * max(1, span)
    for (g in inner) {
        layers <- layer_x_range(g)
        expect_true(length(layers) >= 1L)
        for (lr in layers) {
            expect_gte(lr$min, narrow_lo - eps)
            expect_lte(lr$max, narrow_hi + eps)
            expect_lte(lr$min, narrow_lo + eps)
            expect_gte(lr$max, narrow_hi - eps)
        }
    }
})

Try the interflex package in your browser

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

interflex documentation built on April 14, 2026, 5:10 p.m.