---
title: "Extending sondage with Custom Methods"
output: rmarkdown::html_vignette
bibliography: references.bib
vignette: >
  %\VignetteIndexEntry{Extending sondage with Custom Methods}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>"
)
```


## Overview

`sondage` ships with 16 built-in sampling methods, but researchers and agencies often need algorithms that are not included. The registration API lets you plug a custom unequal probability or balanced sampling method into the existing dispatchers and generics without modifying the package source.

After registering a method, it participates in the same dispatcher and sample-object API as built-in methods. Joint-probability and covariance queries are available when the registration supplies a `joint_fn`.

## The registration API

A single call to `register_method()` adds a new method:

```r
library(sondage)
register_method(
  name,                     # unique method name (character)
  type = "wor",             # "wor", "wr", or "balanced"
  sample_fn,                # function(pik/hits, n, ...) -> integer indices
  joint_fn = NULL,          # function(pik/hits, sample_idx, ...) -> matrix
  fixed_size = TRUE,        # required for with-replacement methods
  variance_family = NULL,   # declared variance-estimation family (optional)
  supports_prn = NULL,      # WOR/WR only: permanent random-number support?
  supports_aux = NULL,      # balanced only: balancing-variable support?
  supports_strata = NULL,   # balanced only: stratification support?
  supports_spread = NULL,   # balanced only: spatial-spreading support?
  probabilities = "unknown" # exact, approximate, or unknown (the default)
)
```

The callback contracts are:

- A WOR method uses **`sample_fn(pik, n = NULL, prn = NULL, ...)`**, where `pik` contains inclusion probabilities. It returns distinct selected unit indices (1-based).
- A WR method uses **`sample_fn(hits, n = NULL, prn = NULL, ...)`**, where `hits` contains expected hit counts. It returns `n` selected unit indices, with possible repeats.
- A balanced method uses **`sample_fn(pik, n = NULL, aux = NULL, ...)`**, where `aux` is the auxiliary balancing matrix passed to `balanced_wor()` (or `NULL`).
- The optional joint callback follows the same naming distinction: **`joint_fn(pik, sample_idx = NULL, ...)`** returns joint inclusion probabilities for WOR and balanced methods, while **`joint_fn(hits, sample_idx = NULL, ...)`** returns joint expected hits for WR methods. It returns an $N \times N$ matrix when `sample_idx` is `NULL`, or the corresponding submatrix otherwise.

The dispatcher validates that sample indices are finite integers in range and obey the replacement and sample-size rules. It also checks that joint results are finite numeric matrices with the expected dimensions and symmetry; statistical properties remain the method author's responsibility.

Capability flags describe what the method can do, and the dispatchers enforce them. `NULL` means unspecified: it resolves to `FALSE`, except that `supports_aux` resolves to `TRUE` for a balanced method. WOR and WR methods may explicitly set only `supports_prn`; balanced methods may explicitly set only `supports_aux`, `supports_strata`, and `supports_spread`. Supplying a capability for another method type is an error, even when its value is `FALSE`.

A `"wor"` or `"wr"` method that sets `supports_prn = TRUE` receives a validated `prn` vector for sample coordination. Otherwise supplying `prn` is an error. A `"balanced"` method only receives a `strata` argument when registered with `supports_strata = TRUE`, and a `spread` argument (spatial coordinates) when registered with `supports_spread = TRUE`. Spread-only methods that never look at balancing variables should declare `supports_aux = FALSE`. In every case, passing a design input to a method without the matching capability is an error, because silently dropping `prn`, `aux`, `strata`, or `spread` would change the requested design. The simplest balanced registration therefore needs nothing beyond `aux`. The cube method's `bounds` interface is currently built-in only, registered balanced methods cannot opt into `bounds`.

`variance_family` declares how design-based variance should be estimated for samples drawn with the method: `"srs"`, `"pps_brewer"`, `"poisson"`, `"wr"`, or `"unsupported"`. `sondage` does not use it itself, but packages that export samples for variance estimation do. Without a declaration they must infer a treatment from `type` and `fixed_size`, and for a random-size WOR method no safe inference exists because a Poisson-type method (independent selections) and a correlated random-size scheme need different estimators. The declaration is an assertion the author is responsible for and `?register_method` lists the constraints between `variance_family`, `type`, and `fixed_size`, and Example 1 shows how to check a declaration by simulation. When in doubt, declare `"unsupported"` rather than guess.

`probabilities` places the method in the first-order probability taxonomy. Use `"exact"` when the true first-order inclusion probabilities equal the `pik` passed to a WOR callback, or the expected hits equal the `hits` passed to a WR callback, as they do for Sampford and the cube method. Use `"approximate"` when the method honors its input to a documented approximation, as Pareto and sequential Poisson order sampling do. Use `"unknown"`, the default, when the input only steers the selection. Successive sampling with `sample.int(prob = pik, replace = FALSE)` belongs to this last tier because its true inclusion probabilities differ from `pik`. The same draw with replacement does honor expected hits, so a multinomial-style `"wr"` method declares `"exact"`.

The tier matters to packages that use `1/pik` as design weights. For an `"unknown"` method, those weights are systematically biased rather than merely noisy. A downstream package may therefore read the tier through `method_spec()` and refuse the method for weighted estimation instead of asserting probabilities the design never had. The default is deliberately strict. If you have not established which tier your method is in, its selection probabilities are unknown. Sampling through sondage itself is unaffected because the declaration describes the method and never disables it. As with `variance_family`, the method author is responsible for the declaration. The simulation below shows how to check it.

Helper functions `registered_methods()`, `is_registered_method()`, and `unregister_method()` manage the registry.

## Example 1: Randomized pivotal sampling

The pivotal method of @deville1998 repeatedly combines two fractional
inclusion probabilities and resolves at least one of them to zero or one.
Randomizing the pair at each step gives a compact example of an exact
fixed-size $\pi$ps sampler that is not already a built-in `sondage` method.

```{r pivotal-fn}
random_pivotal_sample <- function(pik, n = NULL, prn = NULL, ...) {
  tol <- 1e-06
  active <- which(pik > tol & pik < 1 - tol)

  while (length(active) >= 2L) {
    ij <- sample(active, 2L)
    i <- ij[1L]
    j <- ij[2L]
    total <- pik[i] + pik[j]

    if (total < 1) {
      if (runif(1) < pik[i] / total)
        pik[c(i, j)] <- c(total, 0)
      else pik[c(i, j)] <- c(0, total)
    } else {
      if (runif(1) < (1 - pik[j]) / (2 - total)) {
        pik[c(i, j)] <- c(1, total - 1)
      } else {
        pik[c(i, j)] <- c(total - 1, 1)
      }
    }
    active <- which(pik > tol & pik < 1 - tol)
  }
  sort(which(pik > 0.5))
}
```

### Joint probabilities via `he_jip()`

Closed-form joint probabilities are not generally available for randomized
pivotal sampling. To demonstrate the optional `joint_fn` hook, we attach the
high-entropy approximation of @brewer2003. This makes the standard variance
generics available, but it remains an approximation whose suitability should
be checked for the intended population. For designs closer to conditional
Poisson sampling, `hajek_jip()` is another exported approximation.

```{r pivotal-register}
library(sondage)

register_method(
  "random_pivotal",
  type            = "wor",
  sample_fn       = random_pivotal_sample,
  joint_fn        = he_jip,
  fixed_size      = TRUE,
  variance_family = "pps_brewer",
  probabilities   = "exact"
)
```

The method is now available through the standard dispatcher, and its declared
joint approximation flows through `joint_inclusion_prob()` and
`sampling_cov()`:

```{r pivotal-use}
pik <- inclusion_prob(c(2, 3, 4, 5, 6, 7, 8, 9), n = 4)
s <- unequal_prob_wor(pik, method = "random_pivotal")
s

pikl <- joint_inclusion_prob(s)
round(pikl, 4)
round(sampling_cov(s, weighted = TRUE), 4)
joint_inclusion_prob(s, sampled_only = TRUE)
```

### Verifying the registration

Simulation verifies the exact first-order contract and shows how closely the
chosen joint approximation follows this particular randomized pairing rule.

```{r pivotal-verify}
sim <- unequal_prob_wor(pik, method = "random_pivotal", nrep = 5000)
freq <- tabulate(sim$sample, nbins = length(pik)) / 5000
cbind(target = pik, empirical = freq)

N <- length(pik)
co_occur <- matrix(0, N, N)
for (j in seq_len(5000)) {
  selected <- sim$sample[, j]
  co_occur[selected, selected] <- co_occur[selected, selected] + 1
}
empirical_jip <- co_occur / 5000
he_pikl <- he_jip(pik)

pairs <- data.frame(
  i = c(1, 2, 3, 5),
  j = c(8, 7, 6, 8)
)
pairs$HE <- round(he_pikl[cbind(pairs$i, pairs$j)], 4)
pairs$empirical <- round(empirical_jip[cbind(pairs$i, pairs$j)], 4)
pairs
```

The first-order frequencies should agree within Monte Carlo error. Differences
in the pairwise columns measure approximation error, not failure of the pivotal
sampler. If that error is unacceptable, omit `joint_fn` and declare
`variance_family = "unsupported"`, or supply a design-specific joint or
variance estimator.

```{r pivotal-cleanup, include = FALSE}
unregister_method("random_pivotal")
```


## Example 2: Wrapping an external package (sampling::UPtille)

When an algorithm is already implemented in another package, the
wrapper is minimal. Here we wrap `UPtille` and `UPtillepi2` from the
**sampling** package [@tille2006].

```{r tille, eval = requireNamespace("sampling", quietly = TRUE)}
tille_sample <- function(pik, n = NULL, prn = NULL, ...) {
  which(as.logical(sampling::UPtille(pik)))
}

tille_joint <- function(pik, sample_idx = NULL, ...) {
  pikl <- sampling::UPtillepi2(pik)
  if (!is.null(sample_idx)) {
    pikl <- pikl[sample_idx, sample_idx, drop = FALSE]
  }
  pikl
}

register_method(
  "tille",
  type            = "wor",
  sample_fn       = tille_sample,
  joint_fn        = tille_joint,
  fixed_size      = TRUE,
  variance_family = "pps_brewer",
  probabilities   = "exact"
)

pik <- inclusion_prob(c(2, 3, 4, 5, 6, 7, 8, 9), n = 4)
s <- unequal_prob_wor(pik, method = "tille")
s

# Exact joint inclusion probabilities from UPtillepi2
round(joint_inclusion_prob(s), 4)

# Full variance estimation chain
round(sampling_cov(s), 4)
```

```{r tille-cleanup, include = FALSE, eval = requireNamespace("sampling", quietly = TRUE)}
unregister_method("tille")
```

## Example 3: A custom with-replacement method

With-replacement callbacks receive expected hits rather than inclusion
probabilities. The sample callback returns `n` indices and may repeat units;
the joint callback returns $E(N_iN_j)$, not joint inclusion probabilities.
This example reproduces a multinomial PPS design under a custom name:

```{r custom-wr}
custom_multinomial_sample <- function(hits, n = NULL, prn = NULL, ...) {
  sample.int(length(hits), size = n, replace = TRUE, prob = hits)
}

custom_multinomial_joint <- function(hits, sample_idx = NULL, ...) {
  n <- round(sum(hits))
  keep <- if (is.null(sample_idx)) seq_along(hits) else sample_idx
  h <- hits[keep]
  factor <- (n - 1) / n
  joint <- factor * outer(h, h)
  diag(joint) <- h + factor * h^2
  joint
}

register_method(
  "custom_multinomial",
  type            = "wr",
  sample_fn       = custom_multinomial_sample,
  joint_fn        = custom_multinomial_joint,
  variance_family = "wr",
  probabilities   = "exact"
)

hits <- expected_hits(c(2, 3, 5, 10), n = 4)
s_wr <- unequal_prob_wr(hits, method = "custom_multinomial")
s_wr
joint_expected_hits(s_wr)
joint_expected_hits(s_wr, sampled_only = TRUE)
```

```{r custom-wr-cleanup, include = FALSE}
unregister_method("custom_multinomial")
```


## Example 4: A custom balanced method

Balanced methods register with `type = "balanced"` and dispatch
through `balanced_wor()`. The minimal contract only involves `aux`,
so wrapping an aux-only algorithm is a one-liner. Here we wrap the
cube implementation from the **sampling** package, whose landing
phase uses linear programming and can therefore give different
samples than the built-in `"cube"` method.

Note two conventions. The wrapper receives `aux` exactly as the
caller supplied it (validated, but without the sample-size
constraint prepended), so we `cbind(pik, aux)` ourselves because
`samplecube()` expects the size constraint as a balancing column.
And since the cube method produces a high-entropy design, we can
pass `he_jip` as the `joint_fn`, which is exactly what the built-in
method uses.

```{r cube-lp, eval = requireNamespace("sampling", quietly = TRUE)}
cube_lp_sample <- function(pik, n = NULL, aux = NULL, ...) {
  X <- cbind(pik, aux)
  which(sampling::samplecube(X, pik, comment = FALSE) == 1)
}

register_method(
  "cube_lp",
  type            = "balanced",
  sample_fn       = cube_lp_sample,
  joint_fn        = he_jip,
  variance_family = "pps_brewer",
  probabilities   = "exact"
)

pik <- inclusion_prob(c(2, 3, 4, 5, 6, 7, 8, 9), n = 4)
x <- matrix(c(10, 20, 15, 25, 30, 35, 40, 45))
s <- balanced_wor(pik, aux = x, method = "cube_lp")
s

# Balancing check: HT estimate of the aux total vs the true total
colSums(x[s$sample, , drop = FALSE] / pik[s$sample]) - colSums(x)
```

To support stratification as well, register with
`supports_strata = TRUE` and add a `strata` argument to the
sampler. The dispatcher passes `strata` as dense integer labels
`1:H` (only when the caller supplies them), and demotes
`fixed_size` with a warning when per-stratum `sum(pik)` is not
close to an integer, mirroring the built-in method.

```{r cube-lp-strata, eval = requireNamespace("sampling", quietly = TRUE)}
cube_lp_stratified <- function(pik, n = NULL, aux = NULL, strata = NULL, ...) {
  if (is.null(strata)) {
    return(cube_lp_sample(pik, n = n, aux = aux))
  }
  X <- if (is.null(aux)) matrix(pik, ncol = 1) else cbind(pik, aux)
  which(sampling::balancedstratification(X, strata, pik, comment = FALSE) == 1)
}

register_method(
  "cube_lp_str",
  type            = "balanced",
  sample_fn       = cube_lp_stratified,
  joint_fn        = he_jip,
  variance_family = "pps_brewer",
  supports_strata = TRUE,
  probabilities   = "exact"
)

pik <- rep(0.5, 8)
strata <- rep(1:2, each = 4)
s <- balanced_wor(pik, aux = matrix(as.double(1:8)), strata = strata,
                  method = "cube_lp_str")

# Within-stratum sample sizes are preserved
tabulate(strata[s$sample], nbins = 2)
```

```{r cube-lp-cleanup, include = FALSE, eval = requireNamespace("sampling", quietly = TRUE)}
unregister_method("cube_lp")
unregister_method("cube_lp_str")
```

## Example 5: A spatial method with `spread`

Spatially balanced (well-spread) designs select units that are far
apart in space, which improves precision whenever the study variable
is spatially structured. Registered balanced methods opt into
spatial spreading with `supports_spread = TRUE`, and then receive
the coordinate matrix that the caller passes to
`balanced_wor(spread = )`.

`sondage` ships LPM2 and SCPS as the built-in spread-only methods
`balanced_wor(pik, spread = , method = "lpm2")` and
`balanced_wor(pik, spread = , method = "scps")`. Here we register
its sibling LPM1 [@grafstrom2012], which differs only in the pair
rule: a random undecided unit competes with its nearest undecided
neighbour *only when the two are mutual nearest neighbours*,
giving slightly better spread at extra cost. The in-house SCPS core uses
the maximal-weight rule of @grafstrom2012scps with a weighted quickselect
distance cutoff. Methods not shipped in `sondage`, such as local cube, can
still be supplied through the same registration contract.

```{r lpm1-fn}
lpm1_sample <- function(pik, n = NULL, aux = NULL, spread = NULL, ...) {
  d <- as.matrix(dist(spread))
  diag(d) <- Inf
  p <- pik
  eps <- 1e-9
  repeat {
    u <- which(p > eps & p < 1 - eps)
    if (length(u) == 0L) {
      break
    }
    if (length(u) == 1L) {
      p[u] <- as.numeric(runif(1) < p[u])
      break
    }
    i <- u[sample.int(length(u), 1L)]
    v <- u[u != i]
    j <- v[which.min(d[i, v])]
    w <- u[u != j]
    if (w[which.min(d[j, w])] != i) {
      next # not mutual nearest neighbours: redraw i
    }
    s <- p[i] + p[j]
    if (s > 1) {
      if (runif(1) < (1 - p[j]) / (2 - s)) {
        p[i] <- 1
        p[j] <- s - 1
      } else {
        p[j] <- 1
        p[i] <- s - 1
      }
    } else {
      if (runif(1) < p[j] / s) {
        p[j] <- s
        p[i] <- 0
      } else {
        p[i] <- s
        p[j] <- 0
      }
    }
  }
  which(p > 1 - eps)
}

register_method(
  "lpm1",
  type             = "balanced",
  sample_fn        = lpm1_sample,
  variance_family  = "unsupported",
  supports_aux     = FALSE,
  supports_spread  = TRUE,
  probabilities    = "exact"
)
```

Since the local pivotal method and SCPS spread but never exactly balance
on auxiliary totals, a custom method in this family is registered with
`supports_aux = FALSE`.
A caller who passes `aux` then gets an immediate error instead of a
sample that silently ignored the requested balancing constraints.

```{r lpm1-run}
set.seed(25)
N <- 200
coords <- cbind(runif(N), runif(N))
pik <- rep(0.15, N)

s <- balanced_wor(pik, spread = coords, method = "lpm1")
s

# Spread diagnostic: mean distance to the nearest sampled neighbour
# (larger is better spread)
nn_dist <- function(idx) {
  d <- as.matrix(dist(coords[idx, ]))
  diag(d) <- Inf
  mean(apply(d, 1, min))
}
s2 <- balanced_wor(pik, spread = coords, method = "lpm2")
s3 <- balanced_wor(pik, spread = coords, method = "scps")
c(
  lpm1 = nn_dist(s$sample),
  lpm2 = nn_dist(s2$sample),
  scps = nn_dist(s3$sample),
  srs = nn_dist(sample.int(N, s$n))
)
```

A note on variance estimation. Well-spread designs have no
tractable joint inclusion probabilities (the high-entropy
approximation does not apply, since spreading deliberately drives
nearby joint probabilities toward zero), so `joint_fn` is usually
left as `NULL` and `joint_inclusion_prob()` / `sampling_cov()`
will error for these methods. In practice, variance for spatially
balanced samples is estimated with local-neighbourhood estimators
such as the local mean estimator of @grafstrom2014, which `sondage`
does not implement yet.

```{r lpm1-cleanup, include = FALSE}
unregister_method("lpm1")
```

## Writing a `joint_fn`

A `joint_fn` is optional but enables `joint_inclusion_prob()`,
`joint_expected_hits()`, and `sampling_cov()`. Five common
strategies:

1. **Exact formula.** When the design has a known closed form
   (e.g., `UPtillepi2` above).

2. **`he_jip()`.** The high-entropy approximation
   [@brewer2003]. Best for maximum-entropy and high-entropy
   designs (Sampford, Tillé, and most $\pi$ps procedures). Uses
   optimised C code internally. Pass it directly:
   `joint_fn = he_jip`.

3. **`hajek_jip()`.** The Hajek [-@hajek1964] approximation
   based on conditional Poisson (rejective) sampling theory.
   Simpler formula and slightly cheaper, but generally a bit
   less accurate than `he_jip()`. Best when the design is
   obtained by conditioning independent Poisson trials on the
   sample size. Pass it directly: `joint_fn = hajek_jip`.

4. **Other approximations**. Other methods exist in the literature and can be
   implemented as custom `joint_fn` functions. @tille1996 reviews
   several alternatives, including approximations based on
   Poisson, rejective, and successive sampling theory, each with
   different accuracy--computation trade-offs. Any function that
   accepts `(pik, sample_idx = NULL, ...)` and returns a symmetric
   matrix is a valid `joint_fn`.

5. **Monte Carlo estimation.** Resample $B$ times and estimate
   $\hat{\pi}_{ij} = B^{-1} \sum_{b=1}^B I(i \in S_b)\, I(j \in S_b)$.
   Slow but universal:

```{r mc-joint, eval = FALSE}
mc_joint <- function(pik, sample_idx = NULL, ..., B = 5000) {
  N <- length(pik)
  n <- as.integer(round(sum(pik)))
  co <- matrix(0, N, N)
  for (b in seq_len(B)) {
    s <- my_sampler(pik, n = n)
    co[s, s] <- co[s, s] + 1
  }
  pikl <- co / B
  diag(pikl) <- tabulate(unlist(
    replicate(B, my_sampler(pik, n = n), simplify = FALSE)
  ), nbins = N) / B
  if (!is.null(sample_idx)) {
    pikl <- pikl[sample_idx, sample_idx, drop = FALSE]
  }
  pikl
}
```

The `sample_idx` argument enables the `sampled_only = TRUE` path
in `joint_inclusion_prob()`. When `sample_idx` is non-NULL, you
may either (a) compute the full matrix and subset as above, or
(b) skip rows/columns not in `sample_idx` for efficiency.

## Session persistence

The registry lives in the package namespace and resets when
`sondage` is reloaded. To make a registration persistent across
sessions, place the `register_method()` call in your `.Rprofile` or
in a project-level setup script.

## References
