---
title: "Advanced weasel: Selection Constraints and Diagnostics"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Advanced weasel: Selection Constraints and Diagnostics}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

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

```{r setup}
library(weasel)
long_data <- generate_weasel_dummy_data(
  n_ids = 400,
  n_times = 12,
  seed = 7
)
```

## Gap semantics

Two kinds of absence are treated differently throughout the package:

* **Endpoints**: whether a respondent is observed at the first and last
  wave of the window. Controlled by `require_endpoints` in scenarios
  and reported as `has_lower` / `has_upper` in the id metrics.
* **Interior gaps**: runs of missing waves strictly between a
  respondent's first and last observed wave within the window. A
  respondent observed at waves 2 to 7 of a 1-to-8 window has no
  interior gap; late entry and early exit are not gaps.

This separation keeps the constraints orthogonal: you can demand
anchored endpoints while tolerating interior gaps, or the reverse.

## Custom scenarios

`weasel_plan()` accepts a custom scenario table. It must contain the
columns `scenario`, `require_endpoints`, `max_missing`, `n_gap_max`,
and `max_gap_len`; the table is validated and a missing column raises
an immediate error rather than silently selecting nobody.

```{r}
my_scenarios <- data.frame(
  scenario          = c("complete_case", "one_gap_ok", "very_lenient"),
  require_endpoints = c(TRUE, TRUE, FALSE),
  max_missing       = c(0, 2, 4),
  n_gap_max         = c(0, 1, 4),
  max_gap_len       = c(0, 2, 4)
)

plan_custom <- weasel_plan(
  long_data,
  id = "id",
  wave = "time",
  span = "core",
  core_len = 8,
  scenarios = my_scenarios
)

weasel_print_table(
  weasel_compare_scenarios(plan_custom),
  title = "Custom scenarios", digits = 3
)
```

## Scoring and weights

The composite score is built only from observed subset quality. With
default weights it is:

```
score = 2.0 * mean_prop_present      (coverage)
      + 1.2 * endpoint_rate          (endpoints)
      + 0.8 * n_ids / max(n_ids)     (relative sample size)
      - 0.6 * worst_missing / L      (worst retained respondent)
      - 0.4 * (mean_n_gap + mean_max_gap) / L   (gap structure)
```

Scenarios that retain no respondents receive `NA` and are never
recommended; exact ties go to the larger sample, then to input-table
order, and `tie_tolerance` flags near-ties without changing the
recommendation. You can reweight any term:

```{r}
size_first <- weasel_compare_scenarios(
  plan_custom,
  weights = c(size = 3, coverage = 1)
)
size_first$scenario[size_first$recommended]
```

## Non-consecutive wave schedules

Both pipelines evaluate presence over a wave grid. The default,
`grid = "consecutive"`, treats every integer between the span bounds as
a scheduled wave. That is wrong for biennial panels or panels whose
waves are recorded as years: every skipped year would count as a wave
missed by everyone.

```{r}
biennial <- generate_weasel_dummy_data(
  n_ids = 200,
  waves = seq(2008, 2022, by = 2),
  seed = 11
)
sort(unique(biennial$time))
```

With the consecutive default, weasel now warns (a
`weasel_empty_waves` warning) and every scenario collapses:

```{r}
p_wrong <- weasel_plan(biennial, "id", "time", span = "full")
weasel_compare_scenarios(p_wrong)$n_ids
```

`grid = "observed"` evaluates presence on the schedule that actually
occurs in the data. Interior gaps then mean skipped *scheduled* waves,
and the span length `L` counts observed waves:

```{r}
p_biennial <- weasel_plan(biennial, "id", "time",
                          span = "full", grid = "observed")
p_biennial
```

`span = "core"` combines with `grid = "observed"` as you would expect:
the window is a block of `core_len` consecutive *scheduled* waves.
The same `grid` argument exists in `set_weasel_scope()` for the
exploratory pipeline.

## Sensitivity of the sample size to the tolerances

How strongly does the retained sample react to the tolerance settings?
`weasel_sensitivity()` sweeps every combination of the supplied values
against the per-respondent metrics already stored in the plan, so it is
cheap even for large panels:

```{r}
sens <- weasel_sensitivity(
  plan_custom,
  require_endpoints = c(TRUE, FALSE),
  max_missing = 0:2,
  n_gap_max = 0:1,
  max_gap_len = 0:2
)
weasel_print_table(sens, title = "Tolerance sweep", n = 10)
```

A steep jump in `n_ids` between adjacent rows tells you exactly which
constraint is binding; reviewers tend to ask for this table.

## Selectivity: who gets excluded?

Selecting on participation completeness can bias a sample if retention
relates to the covariates you study. `weasel_selectivity()` compares
retained respondents with those observed in the span but excluded,
using each respondent's value at their first observed wave (or their
within-span mean with `at = "mean"`), and reports standardized mean
differences:

First observed waves can differ between respondents, so this need not
compare a common baseline. A missing item at the first observed wave
remains missing, even if a later wave has a value. Within-span means
can also average different sets of waves. For covariates that change
over time, differing observation times can contribute to the comparison.

```{r}
sel <- weasel_selectivity(plan_custom, "one_gap_ok")
weasel_print_table(sel, title = "Retained vs excluded", digits = 3)
```

Absolute SMD values around 0.1 are a descriptive balance heuristic,
rather than a universal cutoff (see
[Austin, 2009](https://doi.org/10.1002/sim.3697)). Participation in this
example is generated without using the covariates, but finite-sample
differences can still exceed that value, as `var2` and `var1` do here.
These comparisons describe observed covariate means; small SMDs do not
establish similar distributions, ignorable attrition or unbiased estimates.

## Plans without attached data

By default the plan object carries the original data, which is
convenient but heavy for large panels (for example when saving plans
with `saveRDS()`). Use `keep_data = FALSE` and pass the data back in
where needed:

```{r}
p_light <- weasel_plan(long_data, "id", "time",
                       span = "full", keep_data = FALSE)
nrow(weasel_apply(p_light, "lenient", data = long_data))
```

`weasel_sensitivity()` and `weasel_justify_subset()` keep working on
such plans because they only need the stored metrics.

New plans store a structural fingerprint with `encoding_version = 2`:
aggregate counts plus an order-invariant digest of the deduplicated
(id, wave) assignments across the full supplied panel, including waves
outside the selected window. The encoding preserves supported ID values
exactly, including distinct numeric values and strings containing separator
characters, and does not depend on numeric display options. Covariate
values are excluded. The guard checks explicitly supplied data; reuse of
attached data assumes the original attached object.

A structural mismatch, including reassigned participation with unchanged
aggregate counts, produces one classed warning (`weasel_data_mismatch`).
The result still uses the stored scenario IDs: the guard does not replan,
change the saved plan or ensure that changed data satisfy the original
scenario. Review the warning before using the result:

```{r}
d_other <- long_data[-(1:10), ]
msg <- tryCatch(
  weasel_apply(p_light, "lenient", data = d_other),
  warning = function(w) conditionMessage(w)
)
substr(msg, 1, 78)
```

Saved plans are checked using their recorded encoding version. Fingerprints
without a version retain the historical v1 encoding, including its loss of
some numeric ID distinctions, ambiguous separator framing and dependence on
numeric display options. Older count-only fingerprints compare their stored fields;
plans without a fingerprint have no structural guard. No saved fingerprint is
silently upgraded. Rebuild a plan from its verified original data to create a
new fingerprint. An unsupported or malformed encoding version raises
`weasel_error_fingerprint_version` rather than falling back to v1.

The fingerprint is an accidental-drift diagnostic, not authentication of
covariate values or protection against deliberate tampering.

## Span selection in detail

`span = "core"` slides a window of `core_len` consecutive grid waves
over the full observed range and scores each window by mean per-wave
respondent coverage; with `grid = "consecutive"`, waves with no
observations contribute zero, so the chosen window is always a
contiguous block of `core_len` integer waves. Use `span = "full"` to
keep the entire range:

```{r}
plan_full <- weasel_plan(long_data, "id", "time", span = "full")
c(lower = plan_full$lower, upper = plan_full$upper)
```

## Explicit analysis windows

To supply your own analysis window, pass explicit bounds. The plan
records `span_reason = "explicit"`, and `weasel_justify_subset()`
describes the bounds as user-supplied without inferring when or why
they were chosen:

```{r}
plan_fixed <- weasel_plan(long_data, "id", "time", lower = 3, upper = 10)
plan_fixed$span_reason
```

For automatic core windows, the selection is inspectable: every
candidate window and its coverage is stored, the chosen one is
flagged, and exact coverage ties raise a classed warning
(`weasel_tied_windows`) instead of being resolved silently:

```{r}
weasel_print_table(plan_custom$span_candidates, digits = 0)
```

## Auditing the recommendation

`recommended` marks the highest composite score under the declared
weights, nothing more. The active weights and the per-scenario score
decomposition travel with the comparison table, `near_tie` flags
scenarios the score cannot meaningfully separate (tune with
`tie_tolerance`), and because the size term is normalised within the
supplied scenario set, scores are comparison-relative rather than
absolute properties of a scenario. Existing scores move only when the
largest retained sample in the set changes, which can reverse a ranking
between two otherwise unchanged scenarios; a zero `size` weight removes
the effect:

```{r}
cmp_audit <- weasel_compare_scenarios(plan_custom)
attr(cmp_audit, "weights")
weasel_print_table(attr(cmp_audit, "score_components"), digits = 3)
cmp_audit[c("scenario", "score", "recommended", "near_tie")]
```

## The planning population

Respondents enter the planning population when they are observed at
least once inside the span; every retention proportion is relative to
that population, not to the full panel. Plans store and print the
counts, and the justification text states the denominator explicitly:

```{r}
plan_custom$population
```

## Auditing per-respondent metrics

The plan object exposes the per-respondent table that the scenarios
filter on, which is useful for custom diagnostics:

```{r}
m <- plan_custom$id_metrics
weasel_print_table(head(m, 8), title = "Per-respondent metrics")

# distribution of interior gap counts
table(m$n_gap)
```

## Scope pipeline constraints

The exploratory pipeline accepts the same structural vocabulary as the
scenario tables. `min_present` sets the minimum number of observed
waves a respondent needs to be kept (default 1, so exploration shows
everyone); `max_missing`, `max_gap_len`, `n_gap_max`, and
`require_endpoints` apply the same constraints a plan scenario would:

```{r}
set_weasel_scope(
  long_data,
  id = "id", wave = "time",
  lower = 2, upper = 11,
  min_present = 6,   # at least 6 observed waves within the span
  max_gap_len = 1,   # no interior gap longer than 1 wave
  n_gap_max = 2      # at most 2 interior gaps
)
evaluate_weasel_scope()
weasel_reshape_to_wide()
weasel_summarize_waves()
weasel_print_table(weasel_filter_wave_summary(),
                   title = "Constrained patterns", n = 8)
weasel_clear_scope()
```

## Justification styles

`weasel_justify_subset()` writes the methods paragraph in three
registers:

```{r}
cat(weasel_justify_subset(plan_custom, "one_gap_ok", style = "concise"),
    "\n")
```

The in-text citation (package author and year) is derived
automatically from the package's own citation metadata, so the
zero-argument call is already fully citable; supply `author`/`year`
only to override the derived reference:

```{r}
cat(weasel_justify_subset(plan_custom, "one_gap_ok"), "\n\n")
cat(weasel_justify_subset(plan_custom, "one_gap_ok", style = "methods",
                          author = "van den Bosch", year = "2026"),
    "\n")
```

The `extended` style adds a fuller design rationale:

```{r}
cat(weasel_justify_subset(plan_custom, "one_gap_ok", style = "extended"),
    "\n")
```

## Quiet mode and reproducibility

All status messages respect a single option:

```{r}
options(weasel.verbose = FALSE)
p_quiet <- weasel_plan(long_data, "id", "time", span = "full")
options(weasel.verbose = TRUE)
```

The data generator runs synthetic draws in a separate R process, while
console colours rotate using package-private state without random draws.
Neither writes to the caller's global workspace. The caller's random-number
stream, RNG configuration and cached Box-Muller normal deviate remain
untouched. Explicit seeds reproduce the same panels as before. Without a
seed, each generation process draws and reports its own seed; repeated calls
can produce different panels. Each call incurs R process startup time, and
temporary files are removed on exit, including errors.

You can also use `suppressMessages()` for the logo, scope report and full
demonstration. Quiet mode suppresses explicit table display too, while
retaining its formatted return value. For example, generation leaves your
analysis stream unchanged:

```{r}
set.seed(99)
before <- rnorm(2)

set.seed(99)
tmp <- generate_weasel_dummy_data(n_ids = 20, n_times = 5, seed = 1)
after <- rnorm(2)

identical(before, after)
```
