---
title: "Joint effects and interactions between management actions"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Joint effects and interactions between management actions}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
library(multiscape)
```

Supply the **total effect of executing a combination** in the same call as its
individual effects. The package preserves those totals and calculates signed
interaction corrections, rather than attributing portions to individual actions.

```{r inputs}
base <- create_problem(
  data.frame(id = 10L, cost = 0), data.frame(id = 1L, name = "habitat"),
  data.frame(pu = 10L, feature = 1L, amount = 100)
) |>
  add_actions(data.frame(id = c("restore", "control")), cost = 1) |>
  add_action_sets(data.frame(set = c("restore_control", "restore_control"),
                             action = c("restore", "control"))) |>
  add_constraint_action_cardinality(2, "max")
effects <- data.frame(action = c("restore", "control", "restore_control"),
                      feature = "habitat", effect = c(30, 20, 70))
problem <- add_effects(base, effects)
problem$data$effect_terms[, c("action", "total_effect", "effect")]
```

The pair correction is `70 - 30 - 20 = 20`. Selecting both yields 70, while
restoration alone yields 30. Registration and effects do not force selection;
cardinality controls capacity and logical relations control permitted groups.

The modern `action` field accepts atomic IDs or registered set IDs. `effect`,
`outcome`, and `relative_change` keep their meanings, sharing the same reference:

```{r equivalent-inputs}
outcomes <- data.frame(action = effects$action, feature = "habitat", outcome = c(130, 120, 170))
changes <- data.frame(action = effects$action, feature = "habitat", relative_change = c(.3, .2, .7))
outcome_problem <- add_effects(base, outcomes)
relative_problem <- add_effects(base, changes)
stopifnot(isTRUE(all.equal(problem$data$effect_terms, outcome_problem$data$effect_terms)),
          isTRUE(all.equal(problem$data$effect_terms, relative_problem$data$effect_terms)))
```

Modern raster lists can likewise include names of registered sets, with the same
layer order, aggregation, reference, and interpretation rules as individual
rasters. Legacy component filtering is rejected for sets. With no `pu`, a joint
row expands only over units where every member has an available pair. Explicit
joint rows with unavailable members are rejected. Later locks or incompatible
constraints can make a supplied combination unreachable without removing its
original observation.

## Preserve inputs and record assumptions

```{r provenance}
problem$data$effects_input
problem$data$joint_effects[, c("action", "effect", "action_outcome")]
problem$data$effects_meta[c("missing_interactions", "missing_individual_effects")]
```

`effects_input` preserves the original table; `effects_original` preserves all
canonical totals; `joint_effects` contains supplied set totals; `effect_terms`
contains signed corrections. `dist_effects` retains individual rows for
compatibility. Different set names cannot provide duplicate membership totals
for one PU/feature. Zero corrections remain preserved as input observations.

Unspecified individual effects and interactions are **assumed zero**. This is
an explicit model assumption, not evidence that an unobserved interaction is
absent. For three actions, subtract individual and supplied pair corrections:

$$\delta_{ABC}=E(ABC)-E(A)-E(B)-E(C)-\delta_{AB}-\delta_{AC}-\delta_{BC}.$$

The rule extends to larger sets. No unprovided subsets are generated automatically.

## Exact activation and model size

For set $S$, compilation adds a continuous auxiliary satisfying:

$$0\le y_{i,S}\le1,\quad y_{i,S}\le x_{i,a}\ (a\in S),\quad
y_{i,S}\ge\sum_{a\in S}x_{i,a}-|S|+1.$$

Binary member decisions determine it exactly, even with negative coefficients
or economic objectives. One PU/membership variable is shared across features.
Only reachable, non-zero corrections need auxiliaries. Availability, known
cardinality limits, and exclusions can eliminate combinations. Inferred negative
final feature amounts are excluded through linear outcome constraints.

Ten actions have 1,013 subsets of size two or greater, but only non-zero,
reachable corrections contribute variables. Sparse interactions limit growth;
solution time still depends on the full model and the number of MO runs.

## Solve with all three MO methods

The benefit objective maximizes signed change, including all individual
contributions and the pair correction. Each method selects both
actions, with benefit 70 and action cost 2.

```{r methods}
mo_base <- problem |>
  add_constraint_action_cardinality(1, "min") |>
  add_objective_min_cost(alias = "cost", include_pu_cost = FALSE) |>
  add_objective_max_effect(alias = "benefit") |>
  set_solver_cbc(gap_limit = 0, verbose = FALSE)
methods <- list(
  weighted = set_method_weighted_sum(mo_base, aliases = c("cost", "benefit"),
    normalize_weights = FALSE, runs = set_runs_manual(data.frame(weight_cost = 1, weight_benefit = 1))),
  epsilon = set_method_epsilon_constraint(mo_base, primary = "cost", aliases = c("cost", "benefit"),
    runs = set_runs_manual(data.frame(eps_benefit = 69))),
  augmecon = set_method_augmecon(mo_base, primary = "cost", aliases = c("cost", "benefit"),
    runs = set_runs_manual(data.frame(eps_benefit = 69)))
)
```

```{r solve-mo, eval=requireNamespace("rcbc", quietly = TRUE)}
solutions <- lapply(methods, solve)
lapply(solutions, get_objectives)
for (solution in solutions) {
  objective <- get_objectives(solution)
  feature <- get_features(solution)
  stopifnot(objective$cost == 2, objective$benefit == 70,
            feature$selected_baseline == 100, feature$selected_amount_after == 170)
}
```

## Negative interactions, net benefit, and final loss

```{r negative-interaction}
negative_input <- effects
negative_input$effect[negative_input$action == "restore_control"] <- 40
negative <- add_effects(base, negative_input)
negative$data$effect_terms[, c("action", "total_effect", "effect")]
economic <- negative |>
  add_profit(c(restore = 10, control = 9)) |>
  add_objective_max_profit(alias = "profit") |>
  set_solver_cbc(gap_limit = 0, verbose = FALSE)
```

```{r solve-negative, eval=requireNamespace("rcbc", quietly = TRUE)}
solution <- solve(economic)
get_features(solution)
stopifnot(get_features(solution)$selected_net == 40,
          get_features(solution)$selected_benefit == 40,
          get_features(solution)$selected_loss == 0,
          get_features(solution)$selected_amount_after == 140)
```

The correction is -10, but the final change is +40. The summary reports benefit
40 and loss zero, counting the selected reference once. It does not classify
the negative correction as an ecological loss by itself.

`add_objective_max_effect()` now optimizes that signed net change. Earlier
versions counted positive effects only; models with negative effects can change
their solutions. No separate net-benefit function is needed.

```{r signed-objectives}
benefit_problem <- negative |> add_objective_max_effect(alias = "benefit")
loss_problem <- negative |>
  add_constraint_action_cardinality(2, "equal") |>
  add_objective_min_loss(alias = "loss")
```

```{r solve-signed-objectives, eval=requireNamespace("rcbc", quietly = TRUE)}
benefit_solution <- solve(set_solver_cbc(benefit_problem, gap_limit = 0, verbose = FALSE))
loss_solution <- solve(set_solver_cbc(loss_problem, gap_limit = 0, verbose = FALSE))
stopifnot(get_objectives(benefit_solution)$benefit == 40,
          get_objectives(loss_solution)$loss == 0)
```

For signed change $\Delta_{if}$ after combining scoped actions, the two criteria
are $\max \sum_{i,f}\Delta_{if}$ and
$\min \sum_{i,f}\max(-\Delta_{if},0)$. Gains can offset deterioration in the
benefit objective. In the loss objective, gains in one unit or feature cannot
cancel losses in another. Both retain independent action and feature scopes.
An interaction belongs to an action scope only if every member belongs to it.

Loss is linearized exactly using finite bounds derived from the signed
coefficients. Mixed-sign PU/feature expressions require one continuous loss
variable and one binary sign variable; sign-compatible expressions need no
such auxiliaries. The constraints make the loss exact even if it is a secondary
objective or has zero weight. All required scoped expressions are prepared in
the common model used by weighted-sum, epsilon-constraint, and AUGMECON.

```{r deteriorating-joint-outcome}
deteriorating_input <- effects
deteriorating_input$effect[deteriorating_input$action == "restore_control"] <- -10
deteriorating <- add_effects(base, deteriorating_input) |>
  add_constraint_action_cardinality(2, "equal")
```

```{r solve-deterioration, eval=requireNamespace("rcbc", quietly = TRUE)}
net_solution <- deteriorating |> add_objective_max_effect(alias = "benefit") |>
  set_solver_cbc(gap_limit = 0, verbose = FALSE) |> solve()
loss_solution <- deteriorating |> add_objective_min_loss(alias = "loss") |>
  set_solver_cbc(gap_limit = 0, verbose = FALSE) |> solve()
stopifnot(get_objectives(net_solution)$benefit == -10,
          get_objectives(loss_solution)$loss == 10,
          get_features(loss_solution)$selected_amount_after == 90)
```

The pair correction is now -60; the objective counts the final change of -10,
and the final loss is 10. It does not count the correction's magnitude as loss.

## Targets on joint outcomes

Targets require the reference plus the joint change. With reference 100 and
joint change +40, selecting both actions contributes 140, rather than adding
the two individual post-action amounts (130 + 120). Action-scoped targets count
the reference only when at least one scoped action is selected in the unit.

```{r joint-target}
target_problem <- negative |>
  add_constraint_targets_absolute(140) |>
  add_objective_min_cost(alias = "cost")
```

```{r solve-joint-target, eval=requireNamespace("rcbc", quietly = TRUE)}
target_solution <- solve(set_solver_cbc(target_problem, gap_limit = 0, verbose = FALSE))
get_targets(target_solution)
stopifnot(get_objectives(target_solution)$cost == 2,
          get_features(target_solution)$selected_amount_after == 140,
          get_targets(target_solution)$achieved == 140)
```
