---
title: "4. Boundary Modes"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{4. Boundary Modes}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  fig.width = 7,
  fig.height = 4
)
```

Every wavelet transform has to decide what happens at the edges of the signal, where the filter window extends past the data. `rLifting` exposes five boundary modes — `symmetric`, `periodic`, `zero`, `local_linear`, and `one_sided` — and the choice has a small effect on offline denoising but a substantial one in causal and stream modes. This vignette walks through the semantics of each mode, the empirical impact measured on the regular-grid benchmark (`data/benchmark_rlifting.rda`), and the constraints on combining boundary modes with irregular grids.

```{r setup}
library(rLifting)

if (!requireNamespace("ggplot2", quietly = TRUE)) {
  knitr::opts_chunk$set(eval = FALSE)
  message("'ggplot2' is required to render plots. Vignette code will not run.")
} else {
  library(ggplot2)
}

data("benchmark_rlifting", package = "rLifting")
set.seed(20260522)
```

## 1. Why boundary handling matters

Inside each predict and update step at decomposition level $j$, the engine convolves the polyphase subband with a short filter. For positions near the edges, that convolution asks for samples at indices below 0 or above $n - 1$. The boundary mode tells the engine what to return for those out-of-bounds reads. Two factors amplify the impact:

- Filter length. A wavelet with $k$ predict coefficients contaminates up to $k - 1$ samples near each edge per level. Long filters (CDF 9/7) have wider boundary zones than short ones (Haar).
- Decomposition depth. Boundary effects propagate inward with each level. At level $j$, the contaminated zone spans roughly $(k - 1) \cdot 2^{j}$ samples from each edge of the original signal.

In offline mode there are two real boundaries (start and end of the signal). In causal and stream modes the sliding window has two virtual boundaries at every step: nothing exists before the oldest sample in the buffer, and nothing exists after the newest. Whatever you choose to put there ends up convolved with real data once per filtered sample, so the choice matters more in the windowed modes than in the offline pass.

The C++ implementation of the five modes — four point-wise (modes 1–4) and one filter-renormalising (mode 5) — lives in `inst/notes/04-boundary-and-threshold.md`.

## 2. The five modes — semantics

Each mode answers the same question — "what does $x[i]$ equal for $i < 0$ or $i \geq n$?" — in a different way:

- `symmetric` (default): half-sample mirror reflection (`WPER`/`HSF` in the wavelet literature; the default in PyWavelets and MATLAB `dwt`). Boundary samples are duplicated: $x[-1] = x[0]$, $x[-2] = x[1]$, $x[n] = x[n-1]$, $x[n+1] = x[n-2]$, …. This contrasts with whole-sample reflection (used in libraries under names like `reflect`), where $x[-1] = x[1]$ and the boundary sample is *not* repeated. In causal mode, half-sample means the most recent observation is mirrored into the virtual future — see §6 for the empirical consequences with short-tap filters.
- `periodic`: wrap-around. $x[-1] = x[n-1]$, $x[n] = x[0]$. Exact for genuinely periodic signals.
- `zero`: pad with zero. Simplest and assumption-free, at the cost of a step discontinuity at the edges.
- `local_linear`: OLS extrapolation. Fit a line through the `ll_k` nearest boundary samples (default 4) and extrapolate. Preserves trends.
- `one_sided`: change the filter, not the signal. Drop out-of-bounds taps and renormalise the remaining coefficients by their sum. The output near the edge is computed only from real samples that exist.

The following figure shows what each extension looks like on a small synthetic signal:

```{r extensions-schematic, echo=FALSE, fig.height=4.5, fig.cap="Figure 1: The five boundary modes applied to a synthetic signal. The original 10 samples are shown in black; the virtual extensions (4 samples on each side) are shown in colour. symmetric mirrors the signal; periodic wraps; zero pads with zero; local_linear extrapolates a line fit through ll_k = 4 boundary samples; one_sided does not extend the signal — instead it renormalises the filter at boundary positions (shown here as 'no extension' for illustration)."}
x = c(1.0, 0.8, 0.6, 0.5, 0.7, 0.9, 1.1, 1.4, 1.5, 1.3)
n = length(x)
idx_orig = 1:n

# Build virtual extensions to display
make_ext = function(mode) {
  left = numeric(4); right = numeric(4)
  if (mode == "symmetric") {
    left = rev(x[1:4]); right = rev(x[(n-3):n])
  } else if (mode == "periodic") {
    left = x[(n-3):n]; right = x[1:4]
  } else if (mode == "zero") {
    left = rep(0, 4); right = rep(0, 4)
  } else if (mode == "local_linear") {
    bL = (sum((0:3) * x[1:4]) - mean(0:3) * sum(x[1:4])) /
         sum(((0:3) - mean(0:3))^2)
    aL = mean(x[1:4]) - bL * mean(0:3)
    left = aL + bL * (-4:-1)
    bR = (sum((0:3) * x[(n-3):n]) - mean(0:3) * sum(x[(n-3):n])) /
         sum(((0:3) - mean(0:3))^2)
    aR = mean(x[(n-3):n]) - bR * mean(0:3)
    right = aR + bR * (4:7)
  } else if (mode == "one_sided") {
    left = rep(NA, 4); right = rep(NA, 4)
  }
  data.frame(
    index = c((-3):0, idx_orig, (n + 1):(n + 4)),
    value = c(left, x, right),
    region = c(rep("ext", 4), rep("orig", n), rep("ext", 4))
  )
}

modes = c("symmetric", "periodic", "zero", "local_linear", "one_sided")
df_ext = do.call(rbind, lapply(modes, function(m) {
  d = make_ext(m); d$mode = m; d
}))
df_ext$mode = factor(df_ext$mode, levels = modes)

ggplot(df_ext, aes(x = index, y = value)) +
  geom_line(
    data = subset(df_ext, region == "orig"),
    colour = "black", linewidth = 0.8
  ) +
  geom_point(
    data = subset(df_ext, region == "orig"),
    colour = "black", size = 1.5
  ) +
  geom_line(
    data = subset(df_ext, region == "ext"),
    aes(colour = mode), linewidth = 0.8, na.rm = TRUE
  ) +
  geom_point(
    data = subset(df_ext, region == "ext"),
    aes(colour = mode), size = 1.5, na.rm = TRUE
  ) +
  geom_vline(
    xintercept = c(0.5, n + 0.5),
    linetype = "dashed", colour = "grey50"
  ) +
  facet_wrap(~ mode, ncol = 2) +
  theme_minimal() +
  theme(legend.position = "none") +
  labs(
    title = "Boundary extensions on a small synthetic signal",
    subtitle = "Black: original 10 samples. Coloured: virtual extension (4 on each side).",
    x = "Sample index", y = "Amplitude"
  )
```

## 3. The `ll_k` parameter for `local_linear`

`local_linear` is the only boundary mode with a numeric tunable: `ll_k` controls how many boundary samples are used in the OLS line fit. Defaults to 4; clamped to `n` if larger; minimum value is 2.

- `ll_k = 2`: slope determined by a single pair of points — most sensitive to noise on the boundary samples.
- `ll_k = 4` (default): four-point fit, robust to a single noisy outlier.
- `ll_k = 8` or larger: very smooth slope estimate, but uses samples that may not represent the local trend.

The benchmark in this package fixes `ll_k = 4`, so the recommendations on `local_linear` below reflect that setting; the general behaviour (preserves trends, can over-extrapolate on curvature) holds for other reasonable values.

## 4. Empirical impact in offline mode

In offline mode the signal has two real boundaries. For long signals and shallow decomposition the affected zone is small, and the choice of boundary mode is a second-order effect. The benchmark confirms this — across the cdf53/universal/semisoft slice with default α/β, the boundary spread per signal is at most a few percent:

```{r offline-boundary-spread}
sub_off = subset(
  benchmark_rlifting,
  Mode == "offline" & Wavelet == "cdf53" & ThresholdMethod == "universal" &
    !grepl("tuned", Method) & Shrinkage == "semisoft"
)
agg_off = aggregate(MSE_median ~ Signal + Boundary, data = sub_off, FUN = mean)
wide_off = reshape(
  agg_off, idvar = "Signal", timevar = "Boundary",
  direction = "wide"
)

names(wide_off) = sub("MSE_median\\.", "", names(wide_off))
modes_cols = c("symmetric", "periodic", "zero", "local_linear", "one_sided")
wide_off$ratio_max_min = round(
  apply(wide_off[, modes_cols], 1, max) /
    apply(wide_off[, modes_cols], 1, min), 3
)
wide_off[, c("Signal", modes_cols, "ratio_max_min")]
```

Max-over-min ratios sit between 1.01 and 1.09 — within the noise of any single denoising call. The default `symmetric` is almost indistinguishable from `local_linear` and `zero` in offline mode; `periodic` is the only one with a visible penalty on the non-periodic signals (still under 10%).

The practical takeaway is that for offline denoising the choice barely matters. Pick `symmetric` (default) unless you have a specific reason to use a different one (a genuinely periodic signal, a finite-duration pulse that ends in zero, or a strong trend at the boundary).

## 5. Empirical impact in causal and stream mode

In causal and stream modes every output sample requires a fresh decision about the right boundary of the sliding window — and the right boundary never has real data, because the future does not exist. The boundary choice now affects every filtered output, not just the first and last few samples. The picture is also more sensitive to the wavelet choice than in offline mode, so we split the analysis: first the recommended causal-mode default (`haar`, per `vignette("v01-introduction")` §3), then a note on what changes for longer-filter wavelets.

### 5.1 With `haar` (the causal-mode default)

```{r causal-boundary-haar}
sub_haar = subset(
  benchmark_rlifting,
  Mode == "causal" & Wavelet == "haar" & ThresholdMethod == "universal" &
    !grepl("tuned", Method) & Shrinkage == "semisoft"
)
agg_haar = aggregate(
  MSE_settled_median ~ Signal + Boundary, 
  data = sub_haar, FUN = mean
)

wide_haar = reshape(
  agg_haar, idvar = "Signal", 
  timevar = "Boundary", direction = "wide"
)

names(wide_haar) = sub("MSE_settled_median\\.", "", names(wide_haar))
wide_haar$ratio_max_min = round(
  apply(wide_haar[, modes_cols], 1, max) /
    apply(wide_haar[, modes_cols], 1, min), 3
)
wide_haar[, c("Signal", modes_cols, "ratio_max_min")]
```

```{r causal-boundary-haar-plot, echo=FALSE, fig.height=4, fig.cap="Figure 2: Causal-mode MSE by boundary across the four DJ signals (haar wavelet, universal threshold, semisoft shrinkage, ll_k = 4). With a single-tap predict filter, one_sided and zero both refuse to invent data at the right boundary and dominate every signal; symmetric pays a 10–50% penalty by mirroring the most recent sample inward."}
df_long_haar = do.call(
  rbind, 
  lapply(
    modes_cols, 
    function(b) {
      data.frame(
        Signal = wide_haar$Signal, Boundary = b,
        MSE = wide_haar[[b]]
      )
    }
  )
)

df_long_haar$Boundary = factor(df_long_haar$Boundary, levels = modes_cols)

ggplot(df_long_haar, aes(x = Boundary, y = MSE, fill = Boundary)) +
  geom_col() +
  facet_wrap(~ Signal, scales = "free_y", ncol = 4) +
  scale_fill_brewer(palette = "Set2") +
  theme_minimal() +
  theme(
    legend.position = "none",
    axis.text.x = element_text(angle = 45, hjust = 1)
  ) +
  labs(
    title = "Causal-mode MSE by boundary (haar, universal/semisoft)",
    x = NULL, y = "MSE (settled)"
  )
```

With `haar` the pattern is clean across all four signals: `zero` and `one_sided` lead, tied to three decimal places, while `symmetric` pays 10–50% more MSE and `local_linear` pays substantially more (30–96%). The reason is structural — `haar`'s predict filter has a single coefficient, so the only thing the boundary mode controls is whether the engine ignores the missing tap (`one_sided` renormalises it away; `zero` sets it to zero) or mirrors the boundary sample into the virtual position (`symmetric`). The right boundary always sits at the most recent observation; reflecting a fresh transition inward to fill the virtual future is the worst possible choice, because it places a mirror copy of the transition exactly where the filter is most sensitive.

`local_linear` and `periodic` consistently underperform: `local_linear` extrapolates a slope from the last few samples, and that slope is unreliable at exactly the moment the signal turns; `periodic` substitutes the oldest sample in the window for the virtual future, which has no physical meaning unless the signal really is periodic with period $W$.

### 5.2 With longer filters (CDF 5/3 and beyond)

Longer-filter wavelets in causal mode have a more delicate relationship with `one_sided`. Below, the same slice with `cdf53`:

```{r causal-boundary-cdf53}
sub_cdf = subset(
  benchmark_rlifting,
  Mode == "causal" & Wavelet == "cdf53" & ThresholdMethod == "universal" &
    !grepl("tuned", Method) & Shrinkage == "semisoft"
)
agg_cdf = aggregate(
  MSE_settled_median ~ Signal + Boundary, 
  data = sub_cdf, FUN = mean
)

wide_cdf = reshape(
  agg_cdf, idvar = "Signal", 
  timevar = "Boundary", direction = "wide"
)

names(wide_cdf) = sub("MSE_settled_median\\.", "", names(wide_cdf))
wide_cdf$best = modes_cols[apply(wide_cdf[, modes_cols], 1, which.min)]
wide_cdf[, c("Signal", modes_cols, "best")]
```

With `cdf53` the dominance shifts: `symmetric` wins on `blocks` and `doppler`, `zero` on `bumps`, `local_linear` on `heavisine`. `one_sided` is no longer an universal best — on `blocks` it is 1.7× worse than `symmetric`, and `periodic`/`zero` are 5–6× worse than `symmetric` on the same signal. The mechanism: `cdf53` has a two-coefficient predict step. When one of those two taps is out-of-window, `one_sided` drops it and renormalises the remaining coefficient (a single 0.5 becomes 1.0). That doubling amplifies whatever noise sits at the boundary sample and propagates a larger error than a benign mirror reflection would. For longer filters (`db2`, `cdf97`) the same effect is even more pronounced; `one_sided` can blow up by an order of magnitude or more on certain (wavelet, signal) combinations.

The practical reading: `one_sided` is the right default in causal mode only when paired with a short filter (`haar`). For longer-filter causal denoising — when the signal is smooth and you have specific reasons to use `cdf53` or `cdf97` — start with `symmetric` and try `zero` or `local_linear` only if symmetric shows visible boundary artefacts.

Stream mode follows the same pattern as causal — the underlying `WaveletEngine` is the same.

## 6. `one_sided` constraints

`one_sided` is the only mode that modifies the filter rather than extending the signal. That has two practical consequences:

1. Incompatibility with irregular grids. When the user passes `t` and selects `one_sided`, the C++ engine takes the `one_sided` branch and calls `onesided_conv` directly, skipping the Lagrange interpolation that the irregular path would otherwise apply. The package raises a warning at the R level when `one_sided` is combined with `t` or `irregular = TRUE`. Use `symmetric` or `local_linear` if you need both irregular grids and edge-aware boundaries.
2. Slight computational overhead at the boundary. The fast path inside `onesided_conv` is the same as a regular convolution for any window position whose filter taps are all in-bounds — only the boundary positions pay a small renormalisation cost. On a long signal the per-call overhead is negligible.

`one_sided` does not break causality. By construction it uses only samples that exist inside the window, so the leakage-free guarantee of `denoise_signal_causal` and `new_wavelet_stream` is preserved — the same counterfactual leakage check shown in `vignette("v03-causal-stream")` §7 passes for `one_sided` exactly as it does for `symmetric`.

## 7. Decision guide

### 7.1 Bench-grounded recommendations

The causal/stream rows are conditional on the wavelet — the `one_sided` and `zero` advantages observed in §5.1 hold only for `haar`. For wavelets with longer predict filters, the picture inverts: `one_sided` can degrade MSE by an order of magnitude or more (§5.2), and `zero` becomes signal-dependent.

| Situation | Recommendation | Evidence |
|:----------|:---------------|:---------|
| Offline mode, any signal, any wavelet | `symmetric` (default) is fine | Boundary choice changes MSE by at most ~9% across the four DJ signals (§4 table) |
| Causal/stream with `haar` (the causal default) | `one_sided` or `zero` | Both lead on all four DJ signals (§5.1 table); 10–33% lower MSE than `symmetric` |
| Causal/stream with longer filters (`db2`, `cdf53`, `dd4`, `cdf97`) | `symmetric` is the safe default | `one_sided` is catastrophic on most (signal × wavelet) cells; `zero` is signal-dependent (§5.2) |

### 7.2 Heuristics from the literature

| Situation | Recommendation | Source |
|:----------|:---------------|:-------|
| Genuinely periodic signal (rotating machinery, seasonal data with known period) | `periodic` | Matches the actual signal structure; the only mode that is exact at the boundary in that case |
| Finite-duration pulse known to be zero outside the observation window | `zero` | Reflects the physical model exactly |
| Signal with a strong linear trend at the boundary | `local_linear` (offline only) | Captures the trend; `ll_k = 4` is a robust default. Bench does not sweep `ll_k`. |

For the broader empirical picture across modes, wavelets, and the full thresholding grid, see `vignette("v07-benchmarks")`. The C++ implementation rationale for the four mandatory code paths — and why `one_sided` is the only one that needs special handling — is in `inst/notes/04-boundary-and-threshold.md` Part A.

Irregular grids. The recommendations above apply to uniform sampling. On non-uniform grids the picture changes: `local_linear` becomes the offline default (it preserves the local trend that the Lagrange-corrected predict step expects), and `symmetric`'s offline advantage disappears on trending signals because it injects a phantom flattening at the boundary. For causal and stream mode, `one_sided` skips the Lagrange path (§6 above) and should be replaced by `zero` when using haar (the dominant causal wavelet). The full irregular-grid boundary recommendations are in `vignette("v05-irregular-grids")` §5–§6.

## References

Sweldens, W. (1996). The lifting scheme: A custom-design construction of biorthogonal wavelets. *Applied and Computational Harmonic Analysis*, **3**(2), 186–200.

Cohen, A., Daubechies, I., & Feauveau, J.-C. (1992). Biorthogonal bases of compactly supported wavelets. *Communications on Pure and Applied Mathematics*, **45**(5), 485–560.

Daubechies, I., Guskov, I., Schröder, P., & Sweldens, W. (1999). Wavelets on irregular point sets. *Philosophical Transactions of the Royal Society A*, **357**(1760), 2397–2413.
