---
title: "Hybrids and aggregates"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Hybrids and aggregates}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  eval = FALSE
)
```

Most names resolve to a single binomial. Two kinds do not, and both need
handling in matching and again in trait resolution. A **hybrid** names a cross
between two taxa, sometimes as a nothospecies (`Mentha ×piperita`) and sometimes
as a formula that spells out both parents (`Salix alba × Salix fragilis`). An
**aggregate** names a group of closely related microspecies under one label
(`Rubus fruticosus agg.`, `Galium mollugo s.l.`), sitting one step above the
binomial. taxify treats the two with a parallel fallback: where a hybrid formula
has no record of its own, it resolves through its two parents; where an aggregate
has no record of its own, it falls back to the nominal binomial. This vignette
covers hybrids first, then aggregates.

## Hybrid names in taxonomy

Botanical nomenclature uses a dedicated marker for hybrids: the
multiplication sign (×, U+00D7). This marker appears in
three distinct positions, each signalling a different kind of hybrid.

A **nothogenus** places the marker before the genus name, signalling an
intergeneric hybrid (a cross between species in two different genera).
Leyland cypress is a well-known example:

> ×Cupressocyparis leylandii

A **nothospecies** places the marker before the specific epithet, with the
genus the same on both sides of the cross:

> Mentha ×piperita

Peppermint (*Mentha ×piperita*, a cross of *M. aquatica* and *M.
spicata*) is the classic case. The third form, a **hybrid formula**, names
both parent species explicitly, joined by the multiplication sign:

> Salix alba × Salix fragilis

In real-world data, the multiplication sign is frequently replaced by a
lowercase or uppercase "x". Herbarium databases, spreadsheet exports, and
OCR outputs rarely preserve the Unicode character. taxify accepts all three
forms (`×`, `x`, `X`) and normalizes them internally. The detection
logic distinguishes a standalone "x" used as a hybrid marker from an "x"
that is part of a word (e.g., the genus *Saxifraga*) by requiring
whitespace boundaries around the letter.

```{r}
library(taxify)
```

## How taxify detects hybrids

Detection happens early in the pipeline, during name cleaning and before
any backbone matching. When `taxify()` receives an input vector, each name
passes through `clean_names()`, which calls the internal `detect_hybrid()`
function. The function tokenizes the name, looks for the hybrid marker in
specific positions, and classifies the result as nothogenus, nothospecies,
formula, or non-hybrid.

The output of `taxify()` includes two hybrid columns. `is_hybrid` (logical)
records whether a hybrid marker was found in the original input; it is always
present regardless of whether the name matched a backbone record.
`hybrid_type` records the finer classification: `"nothogenus"`,
`"nothospecies"`, `"formula"`, or `NA` for a non-hybrid. The parent binomials
of a formula (and their resolved accepted names) are added on demand by
`add_hybrid_info()`, covered below.

After detection, the hybrid marker is handled by type. For a nothospecies
like "Mentha ×piperita", the cleaned form becomes "Mentha piperita", and
taxify also tries the sign-retained form "Mentha × piperita" (some backbones
store the × in the canonical name). For a nothogenus like
"×Cupressocyparis leylandii", taxify tries the sign-retained
"× Cupressocyparis leylandii" and the no-space "×Cupressocyparis leylandii"
alongside the stripped binomial, so a backbone that keeps the sign still
matches.

A **hybrid formula** is not a single taxon name, so it is handled
differently. taxify first tries the full parent-expanded formula
("Salix alba × Salix fragilis") against the backbone: some backbones store a
cross as a name or synonym of the resulting nothospecies (here *Salix ×
rubens*), and when they do, the formula resolves normally.

If the formula itself is not stored as a backbone synonym, taxify parses the
parents. When both parents resolve, `matched_name` contains the input-parent
formula and `accepted_name` contains the accepted-parent formula (these differ
only when a parent is a synonym, mirroring an ordinary synonym row). If either
parent fails to resolve, both remain `NA`, but the row is still flagged
`is_hybrid = TRUE` with `match_type = "hybrid_formula"`. This is the cleanest
behavior possible without a full parentage crosswalk. The parents are also
recoverable through `add_hybrid_info()`, and downstream trait joins fall back
to them automatically (see below).

The same-genus formula shorthand is recognized: `"Salix alba × fragilis"`,
where the second parent is written as a bare epithet, parses to parents
"Salix alba" and "Salix fragilis".

## Worked example: matching a mixed species list

Consider a list that includes ordinary species, a nothospecies, a
nothogenus, and a hybrid formula. We pass them all to `taxify()` in a
single call.

```{r}
names <- c(
  "Quercus robur",
  "Mentha x piperita",
  "x Cupressocyparis leylandii",
  "Salix alba x Salix fragilis",
  "Platanus x hispanica"
)

result <- taxify(names, backbone = "wfo")
result[, c("input_name", "accepted_name", "is_hybrid", "hybrid_type", "match_type")]
```

The expected output looks roughly like this:

| input_name                   | accepted_name               | is_hybrid | hybrid_type  | match_type     |
|:-----------------------------|:----------------------------|:----------|:-------------|:---------------|
| Quercus robur                | Quercus robur               | FALSE     | NA           | exact          |
| Mentha x piperita            | Mentha × piperita           | TRUE      | nothospecies | exact          |
| x Cupressocyparis leylandii  | Hesperotropsis leylandii    | TRUE      | nothogenus   | exact          |
| Salix alba x Salix fragilis  | Salix alba × Salix fragilis | TRUE      | formula      | hybrid_formula |
| Platanus x hispanica         | Platanus × hispanica        | TRUE      | nothospecies | exact          |

Several things are visible here. The two nothospecies (Mentha, Platanus)
matched because WFO stores these as accepted names with the × character in
the canonical name. The nothogenus ×Cupressocyparis matched too: taxify tries
the sign-retained forms, and WFO resolves the cross to its accepted genus
(*Hesperotropsis*). The hybrid formula resolves through its two parents: WFO
carries no single record for this cross, so `match_type` is `hybrid_formula`.
Because both parents resolve against WFO, the cross is named by them --
`accepted_name` is "Salix alba × Salix fragilis" (the accepted-parent cross)
and `matched_name` the input-parent cross. When a parent cannot be matched,
both stay `NA`.

The `is_hybrid` column is TRUE for all four hybrid inputs and `hybrid_type`
names the kind, regardless of whether the name matched. Both record a property
of the input, not of the match result.

## Extracting hybrid details with add_hybrid_info()

`hybrid_type` is already in the `taxify()` output. `add_hybrid_info()` goes
further for **hybrid formulas**: it parses the `input_name` column for the two
parents and resolves each against the same backbone(s) used for the result. It
adds four columns:

- `hybrid_parent_1`: the first parent binomial (for formulas) or NA

- `hybrid_parent_2`: the second parent binomial (for formulas, with
  abbreviated or omitted genera expanded) or NA
- `hybrid_parent_1_accepted`, `hybrid_parent_2_accepted`: the accepted name
  each parent resolves to, or NA if it did not match

For nothogenus and nothospecies names, the parent columns are NA because the
input names only the hybrid itself, not its parents. The parent species of
Mentha ×piperita (Mentha aquatica and Mentha spicata) are not encoded in the
name string. Only hybrid formulas carry both parent names explicitly.

```{r}
result |> add_hybrid_info()
```

The new columns for our five-name example:

| input_name                   | hybrid_type   | hybrid_parent_1 | hybrid_parent_1_accepted | hybrid_parent_2 |
|:-----------------------------|:--------------|:----------------|:-------------------------|:----------------|
| Quercus robur                | NA            | NA              | NA                       | NA              |
| Mentha x piperita            | nothospecies  | NA              | NA                       | NA              |
| x Cupressocyparis leylandii  | nothogenus    | NA              | NA                       | NA              |
| Salix alba x Salix fragilis  | formula       | Salix alba      | Salix alba               | Salix fragilis  |
| Platanus x hispanica         | nothospecies  | NA              | NA                       | NA              |

## Worked example: parsing hybrid formulas

Hybrid formulas appear in botanical and horticultural datasets more often
than one might expect. Field botanists record them when the parentage of a
specimen is known or suspected. The formulas vary in notation: some spell
out both genera in full, others abbreviate the second genus.

```{r}
formulas <- c(
  "Salix alba x Salix fragilis",
  "Quercus pyrenaica x Q. petraea",
  "Populus nigra x Populus deltoides",
  "Rosa canina x R. gallica"
)

formula_result <- taxify(formulas, backbone = "wfo")
formula_result <- formula_result |> add_hybrid_info()

formula_result[, c("input_name", "hybrid_type",
                    "hybrid_parent_1", "hybrid_parent_2")]
```

| input_name                         | hybrid_type | hybrid_parent_1    | hybrid_parent_2     |
|:-----------------------------------|:------------|:-------------------|:--------------------|
| Salix alba x Salix fragilis        | formula     | Salix alba         | Salix fragilis      |
| Quercus pyrenaica x Q. petraea     | formula     | Quercus pyrenaica  | Quercus petraea     |
| Populus nigra x Populus deltoides   | formula     | Populus nigra      | Populus deltoides    |
| Rosa canina x R. gallica           | formula     | Rosa canina        | Rosa gallica         |

The genus abbreviation "Q." in the second example was expanded to
"Quercus" automatically. taxify infers the full genus from the first
parent in the formula. The same expansion happened for "R." to "Rosa" in
the fourth row. This expansion is purely textual: the first token of the
first parent is used as the genus for the second parent whenever the
second parent's genus field matches the pattern of a single capital letter
followed by a period.

## What matches and what does not

The three hybrid types have different matching profiles against backbone
databases.

**Nothospecies** are the best-supported form. WFO and COL both store many
nothospecies as accepted names, with the × character as part of the
canonical name. Mentha ×piperita, Platanus ×hispanica, and
Narcissus ×medioluteus are examples that appear in both backbones.
taxify's matching logic handles the marker correctly: it tries the
stripped form ("Mentha piperita") and the form with the ×
reinserted ("Mentha × piperita"). At least one of these typically matches.

**Nothogenera** are matched through the same sign-aware logic, so coverage
follows the backbone. taxify tries "× Cupressocyparis leylandii",
"×Cupressocyparis leylandii", and the stripped "Cupressocyparis leylandii",
so a backbone that stores the sign form (as WFO and COL do) resolves the
cross. When a nothogenus is genuinely absent, the row has
`match_type = "none"` and `accepted_name = NA`, with `is_hybrid` still TRUE.

**Hybrid formulas** are tried against the backbone as a whole first (some
backbones store the cross as a synonym of the nothospecies). When that
misses, the row is marked `match_type = "hybrid_formula"` with `NA` match
columns -- never collapsed to one parent. To work with the parents, use
`add_hybrid_info()`, which resolves both against the same backbone:

```{r}
info <- result |> add_hybrid_info()
formula_rows <- info[!is.na(info$hybrid_type) & info$hybrid_type == "formula", ]
formula_rows[, c("input_name", "hybrid_parent_1", "hybrid_parent_1_accepted",
                 "hybrid_parent_2", "hybrid_parent_2_accepted")]
```

## Traits for hybrids: the parent fallback

Trait enrichment is hybrid-aware. When you attach traits to a result that
contains a hybrid formula, each trait is resolved with a ladder:

1. **the hybrid directly** -- if the cross resolved to a nothospecies and that
   name carries the trait, it is used as-is;
2. **the average of both parents** -- otherwise the two parents are resolved
   and their trait values combined: numeric traits are averaged, and a
   categorical trait is taken as the shared value, or reported as `"A x B"`
   (with a warning) when the parents disagree;
3. **the single available parent** -- if only one parent carries the trait,
   its value is used.

This happens automatically inside every `add_<source>()` door and inside
`add_trait()`; no extra call is needed.

```{r}
# A hybrid formula with no direct trait record inherits from its parents
taxify("Salix alba x Salix fragilis", backbone = "wfo") |>
  add_trait("plant_height")
```

If the parents have first been materialized with `add_hybrid_info()`, the
trait doors additionally expose each parent's own value as
`<trait>_parent1` / `<trait>_parent2`, so the components of the average are
visible alongside the combined value.

## The multiplication sign and its substitutes

The Unicode multiplication sign (U+00D7) is the correct character for
hybrid notation under the International Code of Nomenclature. In practice,
data arrive with three common representations:

1. The Unicode character itself: `×` (common in well-curated databases)

2. A lowercase `x` surrounded by spaces (common in spreadsheets and field
   data)
3. An uppercase `X` surrounded by spaces (less common, but occurs in older
   databases and OCR output)

taxify normalizes all three forms internally. The `detect_hybrid()`
function replaces every occurrence of U+00D7 with a space-padded "x" and
then works with a uniform token stream, so the downstream logic only needs
to handle one representation. The space-boundary requirement prevents
false positives: "Saxifraga" does not trigger hybrid detection because the
"x" sits within a word rather than standing alone between tokens.

A subtlety arises with mojibake. When UTF-8 text containing the ×
character is read with a Latin-1 or Windows-1252 encoding, the two-byte
sequence can be misinterpreted as "\u00c3\u0097" or "\u00c3\u2014". The
name cleaning pipeline detects and repairs both of these common
misreadings before hybrid detection runs, so names corrupted by encoding
errors are still handled correctly.

## Practical notes

**Which backbones have the most hybrids.** WFO has the broadest coverage
of plant nothospecies and nothogenera, reflecting its focus on the world
flora. COL includes hybrids across all kingdoms but coverage is uneven.
GBIF aggregates data from many sources and includes hybrid names where the
contributing checklists provide them. ITIS, NCBI, and OTT have minimal
hybrid coverage.

**Hybrid detection is input-side only.** taxify detects hybrids in the
names that you supply. It does not scan the backbone for hybrid records.
If a backbone stores "Mentha × piperita" as an accepted name, taxify
will match your input against it, but the backbone record's own hybrid
status is not exposed as a separate field. The `is_hybrid` column reflects
your input, not the backbone.

**Formulas with infraspecific ranks.** The parser expects binomials (genus
plus epithet) on both sides of the × marker. Formulas that include
subspecies or variety ranks (e.g., "Salix alba var. vitellina × Salix
fragilis") will still be detected as formulas, but the parent extraction
may include the rank and infraspecific epithet as part of the parent name.
This is generally the desired behavior, since the full trinomial identifies
the parent more precisely than the binomial alone.

**Authorship in hybrid names.** Hybrid names sometimes carry authorship
strings (e.g., "Mentha ×piperita L."). The name cleaning pipeline
strips authorship before matching, so the presence of an author string
does not interfere with hybrid detection or matching.

```{r}
# Authorship is stripped; hybrid detection still works
taxify("Mentha x piperita L.", backbone = "wfo")
```

**Adding hybrid info resolves the parents.** `add_hybrid_info()` parses the
parents from the `input_name` column and resolves each against the backbone(s)
used for the result, so it makes one backbone lookup for the set of unique
parents (memoized within a session). The parse itself is pure string work; the
parent resolution reuses the same matching engine as `taxify()`.

## Aggregate names in taxonomy

An aggregate groups several closely related microspecies under one name.
Apomictic complexes are the usual reason: *Rubus fruticosus*, *Taraxacum
officinale*, and *Hieracium* each cover hundreds of near-indistinguishable
segregates, and field data routinely records the aggregate rather than commit to
a microspecies. Two markers signal one:

> Rubus fruticosus agg.

*agg.* (aggregate) is the explicit form. The other is *s.l.* (*sensu lato*, "in
the broad sense"), which widens a name to include taxa a narrow reading would
split off:

> Galium mollugo s.l.

Both mean the same thing for matching: the name refers to the group, not to a
single binomial. taxify folds the spelling variants a source might use --
`agg.`, `aggr.`, `agg`, `-agg`, `coll. sp.`, `sensu lato` -- to a canonical
`agg.` or `s.l.` marker, recorded in the `qualifier` column. The opposite marker,
*s.str.* (*sensu stricto*, "in the narrow sense"), is a qualifier but not an
aggregate: it points at the core binomial, so it is recorded in `qualifier` and
matched as an ordinary name.

## How taxify matches aggregates

A backbone may or may not carry a dedicated taxon for the aggregate. Only the
aggregate-bearing backbones -- Euro+Med and WoRMS -- store `"<binomial> aggr."`
as a concept of its own; the others record the binomial but not the group above
it. The `aggregates` argument sets what happens in each case.

`aggregates = "preserve"` (the default) keeps the aggregate as its own concept.
It matches the backbone's aggregate taxon where one exists, and otherwise falls
back to the nominal binomial, setting `aggregate_fallback = TRUE` so the
aggregate-to-species collapse is visible rather than silent. `aggregate_fallback`
is `FALSE` when the dedicated aggregate taxon was found, `TRUE` when it fell back,
and `NA` for non-aggregate names.

`aggregates = "collapse"` strips the marker up front and matches the binomial the
way any name is matched. The qualifier is still recorded, but there is no
fallback to flag, so `aggregate_fallback` stays `NA`.

## Worked example: an aggregate species list

Matched against WFO, which carries no aggregate taxa, each aggregate falls back
to its binomial and is flagged.

```{r}
agg_names <- c(
  "Rubus fruticosus agg.",
  "Taraxacum officinale agg.",
  "Galium mollugo s.l.",
  "Quercus robur"
)

res <- taxify(agg_names, backbone = "wfo")
res[, c("input_name", "accepted_name", "qualifier",
        "aggregate_fallback", "match_type")]
```

| input_name                | accepted_name        | qualifier | aggregate_fallback | match_type |
|:--------------------------|:---------------------|:----------|:-------------------|:-----------|
| Rubus fruticosus agg.     | Rubus fruticosus     | agg.      | TRUE               | exact      |
| Taraxacum officinale agg. | Taraxacum officinale | agg.      | TRUE               | exact      |
| Galium mollugo s.l.       | Galium mollugo       | s.l.      | TRUE               | exact      |
| Quercus robur             | Quercus robur        | NA        | NA                 | exact      |

The three aggregates resolve to their binomials with `aggregate_fallback = TRUE`;
the plain species carries `NA` in both qualifier columns. Against Euro+Med, where
*Rubus fruticosus* aggr. is a stored taxon, the same query would resolve to the
aggregate itself with `aggregate_fallback = FALSE`.

## Traits for aggregates: the binomial fallback

Trait enrichment is aggregate-aware, the same way it is hybrid-aware. The join
resolves an aggregate along the taxonomic hierarchy rather than expecting an
exact string hit:

- a **species** query takes its own value first, and inherits its aggregate's
  value where the source records the trait only at the aggregate level;
- an **aggregate** query takes the aggregate-level value first, and where the
  source carries none it falls back to the nominal binomial's value as a
  pragmatic stand-in -- the species' own measurement standing in for the group,
  not an aggregate-level figure.

This happens automatically inside every `add_<source>()` door and inside
`add_trait()`.

```{r}
# Rubus fruticosus agg. takes the aggregate's trait where the source records
# one, otherwise the nominal Rubus fruticosus value.
taxify("Rubus fruticosus agg.") |>
  add_trait("plant_height")
```

The binomial fallback is on by default. Turn it off, per call or globally, to
keep an aggregate without aggregate-level data as `NA`:

```{r}
taxify("Rubus fruticosus agg.") |>
  add_trait("plant_height", aggregate_trait_fallback = FALSE)

options(taxify.aggregate_trait_fallback = FALSE)
```

With `options(taxify.trait_provenance = TRUE)`, each enrichment adds a
`<enrichment>_basis` column recording where each value came from: `"primary"` for
a same-level hit, `"aggregate"` for a species inheriting its aggregate's value,
and `"binomial"` for an aggregate standing in on its binomial. The
[enrichments vignette](https://gillescolling.com/taxify/articles/enrichments.html)
covers the enrichment join and the provenance columns in general.
