Metadata-Version: 2.4
Name: sparpartner
Version: 3.0.0
Summary: Deterministic, benchmark-driven stratified sampler.
Author: Henry
Author-email: Henry <osas2henry@gmail.com>
License: All Rights Reserved
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pandas>=1.3
Dynamic: author
Dynamic: license-file
Dynamic: requires-python

# sparpartner

A profile-matching engine for tabular data. You define a benchmark
profile (the thing you actually care about matching, however you want
to define it), `sparpartner` ranks every row in your data by how
closely it resembles that profile, and `slicer` gives you a
principled way to decide what "resembles" even means, instead of you
guessing weights by feel.

Train/test splitting is one thing this is good for. It's not the only
one. See [Use cases](#use-cases) below for others, like finding the
customers, matches, or candidates that most resemble an ideal profile
you define.

## Why this exists

Ranking rows against a benchmark, instead of scoring them in
isolation, turns out to answer a few different questions depending on
what you feed it as the benchmark:

- **"Does my model generalize, or did it just memorize a
  neighborhood?"** A random train/test split assumes the test set
  should look statistically like the train set. That's the wrong
  question if what you actually want to know is whether the model
  generalizes past one specific profile. Set the benchmark to the
  toughest, most representative case, and the rows most like it become
  your test set, the harder and more honest check.
- **"Which of my customers/rows most resemble my ideal profile?"**
  Same ranking machinery, different benchmark. Set the benchmark to
  an ideal customer profile instead of a "hardest test case" profile,
  and the exact same ranking now tells you who to prioritize, not who
  to hold out. See [Use cases](#use-cases) for a full walkthrough.

Either way, `sparpartner` answers it by ranking every row in your
data by how closely it resembles a benchmark ("bench_marks") you
define, then sorting closest-to-benchmark first. You then slice the
sorted frame yourself, or let `sparring_n` and the anchor trim do it
for you (see [Anchor trim](#anchor-trim-anchors_mean--anchors_condition)
below):

- **Lookalikes as test, the rest as train**: the rows most like the
  benchmark are always at the top of the sorted result, so `head()`
  gives you the test set and `tail()` (or everything past your cut)
  gives you the train set. This is the harder, more honest check: it
  tells you whether the model actually learned something general, or
  only performs well near cases it's already seen a lot of.
- **Just the lookalikes, nothing else**: pass `sparring_n` and skip
  the manual slice entirely. `sample()` hands you back only the top
  `sparring_n` rows, already ranked, plus a report on exactly that
  set (see the sparring report section below).
- **Only the lookalikes that hold up as a group, not just individually**:
  pass `anchors_mean` alongside `sparring_n` (or on its own) when you
  want the returned set to satisfy a running condition, not just a
  per-row cutoff. See [Anchor trim](#anchor-trim-anchors_mean--anchors_condition).

`sparpartner` only produces the ranking (and, optionally, the
top-N slice, and, optionally, the anchor-trimmed slice on top of
that). What you do with that ranking, a train/test cut, a
shortlist of best-fit rows, or something else entirely, is on your
side (see [Use cases](#use-cases) and the usage examples below).

## Where the idea comes from

A fighter in camp doesn't spar with whoever's free in the gym, they
specifically look for a sparring partner who moves, reaches, and
hits like the opponent they're about to face. Training against a
random partner tells you nothing about how you'll actually do;
training against someone who resembles the real threat does.
`sparpartner` applies that same logic to a model: instead of a
random holdout, it finds the rows that resemble the toughest, most
relevant "opponent" profile and holds those back as the real test,
so what's left to train on is everything *unlike* that opponent,
and the test genuinely checks whether the model can handle the
match it's actually walking into.

## Use cases

`sample()` and `slicer()` don't know or care what your benchmark
represents, that's entirely up to what you put in `bench_marks`. A
few different framings, same two functions.

### 1. Train/test split (the hardest, most honest holdout)

Set the benchmark to the toughest, most representative case you can
define. The rows most like it become your test set, everything else
trains the model. This is the use case covered in depth throughout
the rest of this README, see [Sample usage](#sample-usage) below for
the full walkthrough.

### 2. Profile matching (finding your best-fit rows)

Same ranking, different intent. Instead of asking "which rows should
I hold out as a hard test," ask "which rows most resemble the profile
I actually want more of." Point `bench_marks` at an ideal customer,
an ideal candidate, an ideal match, whatever "ideal" means for your
data, and the ranking now tells you who to prioritize.

```python
customers = df   # your customer table

ideal_customer = {
    "country": "Nigeria",
    "age": 32,
    "income": 450000,
    "last_active_date": "2026-08-20",
    "channel": "referral",
}

weights = slicer(
    source="country",
    recency="last_active_date",
    causatives=["income", "age", "channel"],
    decay_causatives=True,
    decay=0.5,
)
# {'country': 100, 'last_active_date': 50.0,
#  'income': 25.0, 'age': 12.5, 'channel': 6.25}

best_customers, report = sample(
    customers,
    bench_marks=ideal_customer,
    custom_weights=weights,
    best_first=True,
)
```

`best_customers` comes back sorted so the rows most like
`ideal_customer` are first, exactly the same mechanics as the
train/test case, just pointed at a different kind of benchmark. The
`slicer` hierarchy is what makes this more than "customers are good
because they have high income": it's saying start with the profile
characteristic you actually care about most (`country`, the
`source`), then progressively contextualize it with time
(`last_active_date`), then explanatory factors (`income`, `age`,
`channel`), in that deliberate order, instead of eyeballing weights.

`bystanders` fits naturally here too, a feature can be observed in
`sparring_report` without being allowed to influence who gets
selected:

```
SOURCE
country
   |
WHEN
last_active_date
   |
WHY / WHAT
income
age
channel
   |
BYSTANDERS (reported, not weighted)
customer_id
region
```

```python
weights = slicer(
    source="country",
    recency="last_active_date",
    causatives=["income", "age", "channel"],
    decay_causatives=True,
    bystanders=["customer_id", "region"],
)
```

`region` and `customer_id` now show up in `sparring_report` as
`spar_region` / `spar_customer_id`, so you can see how close matches
tend to be on those dimensions too, without either one moving who
actually gets ranked as a best-fit customer.

### 3. Anything else that reduces to "rank rows by resemblance to X"

Candidate screening against an ideal-hire profile, lead scoring
against your best-converting customer, match-finding against a
target opponent profile, the underlying operation is always the same
"rank by resemblance to a benchmark," only the benchmark and what you
do with the ranking changes.

## How the scoring works

You give it:

- `df`: your data. Any column names are fine, including ones
  starting with `spar`; `sample()` never touches or overwrites
  your own columns (see [Validation](#validation)).
- `bench_marks`: a dict of `{column_name: benchmark_value}`, one
  entry per signal you care about
- `custom_weights`: a **dict** of `{column_name: weight}`, saying
  which columns matter and how much. Insertion order is preserved
  and drives both the `show_progress` readout order and, combined
  with descending weight, the tie-break cascade order (see below).

For each weighted column, `sparpartner` auto-detects the column's
type and scores every row's distance to the benchmark on a 0-1
scale (1.0 = exact match, 0.0 = as far as possible):

| Detected type | How distance is measured |
|---|---|
| **numeric** | `abs(value - bench)`, capped by the column's own max observed distance from bench |
| **date** | both sides converted to "age in days" relative to the benchmark date, capped by the column's own max observed age |
| **string** | exact match = 1, anything else = 0 |

Date detection is automatic. A column is only treated as a date if
its values look date-shaped (contain a separator like `-`, `/`, `.`
or a recognizable month name) **and** parse successfully at least
98% of the time. Bare numeric-looking strings (e.g. `"12345"`) never
even reach the date-parsing attempt, and object-dtype columns of
digit strings fall through to the exact-match string path instead of
being mistaken for numbers. Only a real numeric dtype gets the
numeric path.

### String scoring, in detail

Strings never need manual 1/0 encoding before you hand them to
`sample()`, the string path does that for you automatically. Any
column that isn't numeric, isn't datetime, and doesn't parse as a
date gets scored by exact match against its benchmark:

```python
import pandas as pd
from sparpartner import sample

df = pd.DataFrame({
    "id": [1, 2, 3, 4, 5],
    "country": ["US", "US", "CA", "US", "MX"],
})

bench_marks = {"country": "US"}
custom_weights = {"country": 1}

result, report = sample(
    df,
    bench_marks=bench_marks,
    custom_weights=custom_weights,
    best_first=True,
)

print(report)
# {'spar_country': 60.0, 'spar_min_score': 0.0, 'spar_max_score': 100.0, 'spar_mean_score': 60.0}
```

Under the hood, each row's `country` value is compared to `"US"`
with a plain equality check:

| `country` value | matches bench `"US"`? | per-row score |
|---|---|---|
| `"US"` | yes | `1.0` |
| `"US"` | yes | `1.0` |
| `"CA"` | no | `0.0` |
| `"US"` | yes | `1.0` |
| `"MX"` | no | `0.0` |

That gives 3 exact matches out of 5 rows, an average of `0.6`, which
is exactly the `spar_country: 60.0` you see in the report (scores in
`sparring_report` are always shown on a 0-100 scale, not 0-1). Rows
with a matching `country` sort ahead of rows that don't, same as any
other signal.

This is exact-match only, not fuzzy or partial-similarity matching.
`"Manchester United"` vs `"Man United"` scores `0.0`, the same as
`"Manchester United"` vs `"Real Madrid"`, there's no partial credit
for near-matches the way numeric or date columns get graduated
distance-based scoring.

Each column's 0-1 score is multiplied by its weight and summed into
one raw score per row. That raw sum is divided by the total weight
to get each row's overall match score, **but only if the total
weight is > 0**. In that normal case the match score always lands in
the 0-1 range, however many signals or weights you used. If the
weights sum to `<= 0`, normalization is skipped entirely and the raw,
unnormalized weighted sum is used instead (not guaranteed to fall in
0-1).

All of this (the per-signal 0-1 scores and the per-row overall
match score) is working state `sample()` uses internally to sort,
tie-break, and slice. **None of it is added as columns to the `df`
you get back.** The df you receive always contains only your
original columns, reordered (and sliced, if `sparring_n` and/or
`anchors_mean` are set). Score info comes back separately, as
aggregates in `sparring_report`, see
[The sparring report](#the-sparring-report).

The result is always sorted by match score descending, the row
closest to the benchmark is always first, this is also exactly what
`best_first=True` means, see
[Row order (`best_first`)](#row-order-best_first) below.

### Tie-breaking

Rows that land on the exact same match score aren't left to random or
arbitrary order. Ties are broken by the per-signal score of the
**highest-weight** signal first (higher wins), then the next-highest,
cascading down the weight-sorted signal list until the tie resolves.
Signals that share the same weight are compared in the order they
appear in `custom_weights` (dict insertion order). Only if every
signal is exhausted and rows are still tied does it fall back to
pandas' stable sort (original row order).

## Row order (`best_first`)

By default (`best_first=True`), the returned `df` is sorted with the
closest match to the benchmark first. Pass `best_first=False` and, as
the very last step before returning, `sample()` flips that same set
of rows so the worst-of-selection is first and the best-of-selection
is last.

This only changes **presentation order**. It never changes which
rows get selected (e.g. via `sparring_n` or `anchors_mean`), never
touches scoring or tie-breaking, and `sparring_report` is identical
either way, since it's an order-independent aggregate.

It exists to replace a manual post-hoc flip like:

```python
result = result.sort_index(ascending=False).reset_index(drop=True)
```

which is fragile against how `sample()`'s own indexing/reset works.
Use `best_first=False` instead when you want the worst-of-selection
row first:

```python
result, report = sample(
    df,
    bench_marks=bench_marks,
    custom_weights=custom_weights,
    best_first=False,
)
```

## The sparring report

`sample()` doesn't just return the ranked frame, it returns a
`(df, sparring_report)` tuple. `sparring_report` is a flat dict
summarizing match quality, with one `spar_<name>` key per signal
plus `spar_min_score` / `spar_max_score` / `spar_mean_score`, e.g.:

```python
{
    "spar_age": 55.0, "spar_income": 57.5,   # one key per signal, avg score
    "spar_min_score": 0.0,
    "spar_max_score": 91.93,
    "spar_mean_score": 56.45,
}
```

Every value is on a 0-100 scale (100 = perfect match to benchmark)
and rounded to 2 decimal places. This is the *only* place score
information comes back to you: individual per-row scores are never
returned, only these aggregates.

A signal with `weight=0` is excluded from ranking/sorting entirely
(it can never move the match score or break a tie), but it still
gets its own `spar_<name>` entry in `sparring_report`, so you can
track how close rows are on a signal without letting that signal
influence which rows are considered "closest".

By default (`sparring_n=None`) both the returned `df` and the report
cover **all** rows, unless `anchors_mean` is also set, in which case
"all rows" means "all rows that survive the anchor trim," see
[Anchor trim](#anchor-trim-anchors_mean--anchors_condition) below.
Pass an int and `sample()` slices the sorted result down to just the
top `sparring_n` rows (the ones closest to the benchmark) **before**
anything else happens (including the anchor trim, if `anchors_mean`
is also set). That slice is what you get back as `df` (further
trimmed by anchors, if applicable), and it's also exactly what
`sparring_report` and the score distribution are computed on.
There's no separate "full set" kept around once `sparring_n` is set;
if you need the rest of the rows too (e.g. to build the train set),
take them from your original `df` yourself, or call `sample()` again
with `sparring_n=None`.

### Progress readout (`show_progress`)

When `show_progress=True`, the sparring report section of the
printed readout marks each signal's average score, and the min /
max / mean of the score distribution, with a traffic-light emoji:

- 🔴 avg score in the bottom third (0-33.3)
- 🟡 avg score in the middle third (33.3-66.7)
- 🟢 avg score in the top third (66.7-100)

```
    age                  avg score= 64.33  🟡
    signup_date          avg score= 38.00  🟡
    country              avg score= 50.00  🟡

  SCORE DISTRIBUTION
  ------------------
    spar_min_score       =   6.67  🔴
    spar_max_score       =  82.00  🟢
    spar_mean_score      =  50.78  🟡
```

This is purely a print-time visual, it doesn't change anything about
`sparring_report`'s actual values.

If `anchors_mean` is set, `show_progress=True` also prints a small
`ANCHOR TRIM` block before this, showing the anchors and condition
used, the pool size going in, where (if anywhere) the walk stopped,
and how many rows were kept.

## Anchor trim (`anchors_mean` / `anchors_condition`)

`sparring_n` alone cuts by a fixed count, "give me the top 300
regardless of how they hold up as a group." `anchors_mean` is a
different kind of cut: "keep taking the best-matching rows in order,
but stop the moment the group, taken together, no longer meets a
condition I care about." It's a running, group-level check, not a
per-row one.

### What it does

After `sparring_n` slicing (or over the full sorted result, if
`sparring_n=None`), `sample()` walks the remaining rows in order,
best match first. At each row it tentatively adds that row to an
accepted set, recomputes a running statistic for every anchor over
that accepted set (including the row just added), and checks whether
the accepted set still satisfies `anchors_condition`. The first row
that would break the condition is **not added**, and the walk stops
right there, everything accepted before it is the final result. If
every row passes, the whole pool is kept. If the very first row
already fails, the result is an empty frame, there's no floor
forcing at least one row through.

Because the walk always proceeds in order and stops for good on the
first failure, `sparring_n` combined with `anchors_mean` behaves as
an upper bound on how many rows the anchors get to consider, not a
guarantee of how many rows come out. You can pass `sparring_n=3000,
anchors_mean=...` and get anywhere from 0 to 3000 rows back,
depending on where (if anywhere) the condition first breaks.

### `anchors_mean`

A dict of `{key: target}`. Each `key` is resolved one of two ways:

- **A literal column name in `df`** (e.g. `"age"`): the running
  **mean** of that raw column, on its own native scale. The `target`
  you give is compared on that same scale (`"age": 30` means "don't
  let the running average age drop below 30").
- **A reserved alias**, matching the same 0-100 scale
  `sparring_report` already uses:
  - `"mean_score"` / `"spar_mean_score"`: running mean of the overall
    match score.
  - `"min_score"` / `"spar_min_score"`: running min of the overall
    match score.
  - `"max_score"` / `"spar_max_score"`: running max of the overall
    match score.
  - `"spar_<signal_name>"` (e.g. `"spar_age"`): running mean of that
    individual signal's own per-row score, not the raw column, the
    signal must already be a key in `custom_weights`.

Any key that isn't a real `df` column and doesn't match one of these
aliases raises a `ValueError` during validation, before any scoring
runs, the same way an unmatched `custom_weights` key does.

### `anchors_condition`

Either `"AND"` or `"OR"` (case-insensitive), always required whenever
`anchors_mean` is set:

- **`"AND"`**: the accepted set is considered failing only when
  **every** anchor is currently failing. As long as at least one
  anchor still holds up, the row is let through.
- **`"OR"`**: the accepted set is considered failing when **any**
  anchor is currently failing. All anchors have to hold up
  simultaneously for the row to be let through.

This generalizes to any number of anchors, one is fine, so is five.

### Example

```python
anchors_mean = {
    "age": 30,                  # running average age must stay >= 30
    "spar_mean_score": 50,      # running average match score (0-100) must stay >= 50
}
anchors_condition = "AND"       # stop only once BOTH are failing at once

result, report = sample(
    df,
    bench_marks=bench_marks,
    custom_weights=custom_weights,
    best_first=True,
    sparring_n=300,              # consider at most the top 300 matches
    anchors_mean=anchors_mean,
    anchors_condition=anchors_condition,
)
```

`report` here reflects whatever survived the trim, not the original
300-row pool, `sparring_report` and the score distribution are
always computed on the final, post-trim set.

### Requirements and edge cases

- **Requires `drop_nan=True`.** Running means/mins/maxes over a pool
  that still has NaNs in it can't be trusted, so passing
  `anchors_mean` together with `drop_nan=False` raises a `ValueError`
  immediately, before scoring starts.
- **No floor.** If the first row in the pool already fails the
  condition, the trim returns zero rows. This is intentional, the
  rule is the rule, there's no "keep at least one row" fallback.
- **`sparring_n=None` still works.** With no `sparring_n` set, the
  anchor trim runs over the entire sorted `df`, there's no ceiling on
  how large the pool it walks through can be.
- **Fast at scale.** Each anchor's running mean/min/max is computed
  once, up front, in a single pass over the pool, rather than being
  recalculated from scratch at every row. This matters once
  `sparring_n` (or the full pool) gets into the thousands, the trim
  stays fast instead of slowing down quadratically with pool size.

## Generating `custom_weights` with `slicer`

Hand-picking numbers for `custom_weights` (`{"country": 2, "signup_date": 1,
"income": 1}`) works fine for a handful of signals, but it gets
arbitrary fast: why 2 and not 3? why does `income` get the same
weight as `signup_date`? `slicer` exists to replace that guesswork
with a principled cascade, so the weights you hand to `sample()` come
from a deliberate hierarchy of "how much does this signal matter"
instead of numbers picked by feel.

### The philosophy (TS-DC: source / sub-primary / secondary)

`slicer` treats your signals as belonging to tiers, not a flat list:

- **source** (the anchor): the primary feature (or set of features)
  the whole weighting is built around, e.g. `country`. "Source"
  covers the anchor broadly, who, what, or where the weighting
  originates from. Always present, always takes the entire pool to
  start, and it's the only tier that never decays.
- **temporal** (the sub-primary, the WHEN): recency and/or
  seasonality, e.g. `signup_date`. Optional, and it eats into the
  pool the source started with.
- **causatives** (the secondary, the WHY/WHAT): explanatory features
  that context the anchor further, e.g. `income`, `channel`.
  Optional, and they eat into whatever pool is left after temporal.

Each tier (other than source) takes a bite out of the *remaining*
pool at a fixed `decay` rate, rather than the tiers splitting one
fixed pot up front. That's the cascade: `source_pool = 100` always
(source never decays), `temporal_pool = source_pool * decay` only if
temporal is used, `causative_pool = last_pool * decay` only if
causatives are used, decaying from whichever pool was last actually
assigned (so if temporal is skipped, causatives decay straight from
`source_pool`, not from a temporal_pool that never existed).

The result is a flat `{feature_name: weight}` dict, on the same
0-100-ish scale `sample()` expects for `custom_weights`, ready to
pass straight through.

### Usage

```python
from sparpartner import slicer

weights = slicer(
    source="country",
    recency="last_active_date",
    season="signup_month",
    causatives=["income", "channel"],
    decay_causatives=True,   # rank-based split: income > channel
    decay=0.5,
)
# {'country': 100, 'last_active_date': 33.33, 'signup_month': 16.67,
#  'income': 33.33, 'channel': 16.67}

result, sparring_report = sample(
    df,
    bench_marks=bench_marks,
    custom_weights=weights,
    best_first=True,
)
```

A few things worth knowing about how the tiers behave:

- **`source` accepts either a single feature name or a list of
  them.** With a single name (a plain `str`, or a one-item list),
  that name simply takes the whole 100-point pool, same as before.
  With a list of 2 or more names, `pool_source` must be set
  explicitly, there's no default: `pool_source=False` gives every
  name in the list the full 100 independently (no sharing at all,
  each anchor stands on its own), `pool_source=True` treats the 100
  as one shared pool, split evenly across every name in the list.
  There's no rank-based option for `source`, order never matters
  here, and `source` itself never decays either way, only how the
  100 gets distributed changes.
- **`temporal` has two mutually exclusive modes.** Either pass a
  single `temporal="<feature>"` (it takes the whole temporal pool),
  or pass `recency=` and `season=` together (never `temporal` with
  either of them, and never just one of `recency`/`season`). When
  both are given, `recency` is always rank 0 (the larger share) and
  `season` is always rank 1 (the smaller share), split by the same
  `decay` rate as everything else, not a separately tunable
  percentage.
- **`causatives` accepts either a single feature name or a list of
  any length.** A plain `str` is treated the same as a one-item list.
  There's no cap on how many you can pass, `causative_pool` is a
  fixed quota (`last_pool * decay`) no matter how many names split
  it, more causatives just means each one gets a thinner slice of
  that same quota. How multiple causatives split depends on
  `decay_causatives`, which has no default and must be set explicitly
  whenever `causatives` resolves to 2 or more items:
  - `False` splits evenly, order doesn't matter.
  - `True` splits by rank, first item gets the most, decaying all the
    way down the list at the same `decay` rate as everything else.
  - a positive **int `N`** is a hybrid: only the first `N` causatives
    (by list order) get rank-decayed against each other, the way
    `True` would rank them, then whatever's left of `causative_pool`
    after that is split evenly across the rest. Handy when you want
    the top few causatives to dominate but don't want a long
    geometric tail thinning out every remaining one. If `N` is
    greater than or equal to how many causatives you passed, this
    behaves identically to `True`, there's no tail left to flatten.

  With exactly one causative, `decay_causatives` is ignored entirely,
  there's no rank to decay across a single item, it gets the whole
  causative pool either way.
- **`decay` is the single dial** controlling every split in the
  cascade: the temporal/causative pool sizes, the recency/season
  split, the source split (if `pool_source=True`), and the causatives
  split whenever `decay_causatives` is `True` or an int. As `decay`
  approaches 1, splits flatten toward even; as it approaches 0, the
  earlier-ranked item dominates. `source` is the one exception, it
  never decays regardless of `decay`.
- **`bystanders` are along for the ride only.** Names passed here
  show up in the returned dict at weight `0`, taking no part in any
  split or decay math. This is the same shape `sample()` already
  accepts, a `custom_weights` entry with `weight=0`, which per
  `sample()`'s own contract still generates a `spar_<name>` entry in
  `sparring_report`, so you can pass a `slicer` bystander straight
  into `sample()` to track how close rows are on it, without letting
  it influence which rows are ranked closest.
- **Every feature name must be unique across all groups** (`source`,
  `temporal`/`recency`/`season`, `causatives`, `bystanders`); reusing
  a name across two groups raises a `ValueError`, and comma-joined
  strings passed instead of a real list (`source`/`causatives`/
  `bystanders`) are rejected for the same reason `sample()` rejects
  them.

### Usage examples

`slicer` can be called a lot of different ways depending on how many
tiers you actually need. A single `source` on its own is a valid
call, every other tier is optional and only shows up in the result
if you pass it.

**1. Simplest call, source only**

```python
weights = slicer(source="country")
# {'country': 100}
```

**2. `source` + `temporal` as one combined feature**

```python
weights = slicer(source="country", temporal="signup_recency_blend")
# {'country': 100, 'signup_recency_blend': 50.0}   # decay=0.5 default
```

**3. `source` + `recency`/`season` split instead of one combined `temporal`**

```python
weights = slicer(
    source="country",
    recency="days_since_signup",
    season="signup_month",
)
# {'country': 100,
#  'days_since_signup': 33.33,
#  'signup_month': 16.67}
```

**4. Full cascade: `source` -> `temporal` -> `causatives`, rank-decayed**

```python
weights = slicer(
    source="country",
    recency="days_since_signup",
    season="signup_month",
    causatives=["income", "channel", "referral_source"],
    decay_causatives=True,
)
# {'country': 100,
#  'days_since_signup': 33.33, 'signup_month': 16.67,
#  'income': 14.29, 'channel': 7.14, 'referral_source': 3.57}
```

**5. Same cascade, `causatives` split evenly instead of by rank**

```python
weights = slicer(
    source="country",
    temporal="signup_recency_blend",
    causatives=["income", "channel"],
    decay_causatives=False,
)
# {'country': 100, 'signup_recency_blend': 50.0,
#  'income': 12.5, 'channel': 12.5}
```

**5b. Hybrid split, decay the top 2 causatives, flatten the rest**

```python
weights = slicer(
    source="country",
    temporal="recency_blend",
    causatives=["income", "channel", "referral_source", "device_type", "region"],
    decay_causatives=2,
)
# causative_pool here = 25 (100 * 0.5 * 0.5)
# {'country': 100, 'recency_blend': 50.0,
#  'income': 12.9, 'channel': 6.45,
#  'referral_source': 1.88, 'device_type': 1.88, 'region': 1.88}
# income/channel are rank-decayed against each other same as True
# would rank them; whatever's left of the 25-pool after that (~5.65)
# is split evenly across the remaining 3 causatives instead of
# continuing to decay them into a long, thinning tail
```

**6. A single `causatives` feature, passed as a plain `str`**

```python
weights = slicer(source="country", causatives="income")
# {'country': 100, 'income': 50.0}
# decay_causatives isn't required here, there's only one item to rank
```

**7. Multi-source, `pool_source=False`, each source keeps the full pool**

```python
weights = slicer(source=["home_team", "away_team"], pool_source=False)
# {'home_team': 100, 'away_team': 100}
```

**8. Multi-source, `pool_source=True`, the pool is shared evenly**

```python
weights = slicer(source=["home_team", "away_team"], pool_source=True)
# {'home_team': 50.0, 'away_team': 50.0}
```

**9. Multi-source (3 names), pooled, plus the full temporal + causative cascade**

```python
weights = slicer(
    source=["home_team", "away_team", "referee"],
    pool_source=True,
    recency="days_since_last_match",
    season="season_stage",
    causatives=["xg_diff", "possession_pct"],
    decay_causatives=True,
)
# {'home_team': 33.33, 'away_team': 33.33, 'referee': 33.33,
#  'days_since_last_match': 33.33, 'season_stage': 16.67,
#  'xg_diff': 16.67, 'possession_pct': 8.33}
```

**10. With `bystanders`, reported at weight 0, no effect on the cascade**

```python
weights = slicer(
    source="country",
    causatives="income",
    bystanders=["signup_channel", "referral_code"],
)
# {'country': 100, 'income': 50.0,
#  'signup_channel': 0, 'referral_code': 0}
```

**11. Custom `decay` rate, steeper vs flatter cascade**

```python
weights_steep = slicer(source="country", temporal="recency_blend", decay=0.3)
# {'country': 100, 'recency_blend': 30.0}

weights_flat = slicer(source="country", temporal="recency_blend", decay=0.8)
# {'country': 100, 'recency_blend': 80.0}
```

**A note on `pool_source`, when it's required and when it's ignored**

`pool_source` only matters once `source` is a list of 2 or more
names, that's the only situation where it must be set explicitly
(`True` or `False`, no default). If `source` is a plain `str`, or a
list with just one name in it, `pool_source` is ignored entirely,
that single name always takes the full pool regardless of what (or
whether) `pool_source` is set:

```python
# pool_source omitted, source is a single str, no error
slicer(source="country")
# {'country': 100}

# pool_source omitted, source is a one-item list, still no error
slicer(source=["country"])
# {'country': 100}

# pool_source omitted, source has 2+ names, this raises
slicer(source=["home_team", "away_team"])
# ValueError: pool_source must be explicitly set to True or False when
# source is a list of more than one feature name. there is no default

# pool_source now provided, works fine
slicer(source=["home_team", "away_team"], pool_source=True)
# {'home_team': 50.0, 'away_team': 50.0}
```

## Usage

### Sample usage

`df` is the only argument you can pass positionally. Every other
argument, including `bench_marks`, `custom_weights`, and
`best_first`, must be passed by keyword (see
[Keyword-only arguments](#keyword-only-arguments) below).

```python
import pandas as pd
from sparpartner import sample

df = pd.DataFrame({
    "id": [1, 2, 3, 4, 5],
    "age": [25, 30, 47, 52, 33],
    "signup_date": ["2023-01-15", "2023-03-02", "2022-11-20", "2023-01-10", "2023-06-01"],
    "country": ["US", "US", "CA", "US", "MX"],
})

bench_marks = {
    "age": 30,
    "signup_date": "2023-01-01",
    "country": "US",
}

custom_weights = {
    "age": 2,
    "signup_date": 1,
    "country": 1,
}

result, sparring_report = sample(
    df,
    bench_marks=bench_marks,
    custom_weights=custom_weights,
    best_first=True,      # required, no default, True = best match first; False = worst-of-selection first
    sparring_n=None,      # None = every row scored, sorted, and returned
    drop_nan=True,
    show_progress=True,   # prints the full scoring breakdown
)

print(result)
print(sparring_report)
```

`age=30`, `signup_date="2023-01-01"`, and `country="US"` closely
match row `id=1` (age 25, close date, US), so that row lands at or
near the top of the sorted output (or the bottom, if
`best_first=False`). The `country` column here is the string
exact-match path in action: rows `1`, `2`, and `4` score `1.0`
against bench `"US"`, rows `3` and `5` (`"CA"`, `"MX"`) score `0.0`,
see [String scoring, in detail](#string-scoring-in-detail) above for
the full walkthrough.

```python
# --- post-sample: turn the ranking into an actual train/test split ---
# Use best_first=False: worst-of-selection first, best match (closest to
# benchmark) last. That puts the lookalike rows in one contiguous block
# at the tail, so the split is just a slice off the end, no need to
# track which end is which.
ranked, _ = sample(
    df,
    bench_marks=bench_marks,
    custom_weights=custom_weights,
    best_first=False,
)

# Slice however large you want the test set to be, e.g. the top 30%:
cut = int(len(ranked) * 0.3)
test = ranked.iloc[-cut:]    # lookalikes, the harder, honest test set
train = ranked.iloc[:-cut]   # everything unlike the benchmark
```

### Parameters

| Name | Type | Default | What it does |
|---|---|---|---|
| `df` | DataFrame | required, positional | Must contain a column for every name (key) in `custom_weights`. No restriction on your own column names, `spar`-prefixed columns are fine |
| `bench_marks` | dict | `None`, but required (raises if left `None`) | `{column_name: benchmark_value}`. Keyword-only |
| `custom_weights` | dict of `{name: weight}` | `None`, but required (raises if left `None`) | Which columns to score and how much each contributes. Keyword-only |
| `best_first` | bool | `None`, but required (raises if left `None`) | Applied last, after everything else (including `sparring_n` slicing and the anchor trim). `True` = best match first. `False` = flips that same set of rows to worst-of-selection first, best last. Never changes which rows are selected; see [Row order](#row-order-best_first). Keyword-only |
| `sparring_n` | int or `None` | `None` | `None` = every row scored, sorted, and returned (or, if `anchors_mean` is set, every row that survives the trim). An int slices the sorted result down to the top `sparring_n` rows **before** anything else, including the anchor trim. That slice (further trimmed by anchors, if applicable) is what's returned as `df`, and what the report/distribution are computed on. Keyword-only |
| `drop_nan` | bool | `True` | If `True`, drops any row with a NaN in a per-signal working score **or** in the overall match score, after printing (if `show_progress`) a sanity check of what was dropped and why. Must be `True` if `anchors_mean` is set, see [Anchor trim](#anchor-trim-anchors_mean--anchors_condition). Keyword-only |
| `anchors_mean` | dict or `None` | `None` | `{key: target}`, keys are either a real `df` column name (compared as a running mean on its native scale) or a reserved alias (`mean_score`/`min_score`/`max_score`/`spar_<signal>`, compared as a running mean/min/max on the 0-100 score scale). Applied after `sparring_n` slicing. See [Anchor trim](#anchor-trim-anchors_mean--anchors_condition). Keyword-only |
| `anchors_condition` | `"AND"` or `"OR"` (case-insensitive) | `None`, required if `anchors_mean` is set | `"AND"` stops only once every anchor is failing at once. `"OR"` stops the moment any single anchor is failing. See [Anchor trim](#anchor-trim-anchors_mean--anchors_condition). Keyword-only |
| `show_progress` | bool | `False` | Prints a full readout, in run order: header (including a `signals used` count), per-column type/cap/sample scores, drop_nan check (if `drop_nan=True`), normalize check, sort-apply readout, sparring_n slice readout (if `sparring_n` is set), anchor trim readout (if `anchors_mean` is set), top N ranked rows with weighted contributions (`N` in the header always matches the number of rows actually shown), the sparring report itself with 🔴/🟡/🟢 markers next to each score flagging any signal contributing zero separation, and finally a best_first flip readout (only printed if `best_first=False`). Keyword-only |

### Returns

`sample()` returns a `(df, sparring_report)` tuple, not just a
DataFrame. The `df` always contains only your original columns
(reordered/sliced), no score columns are ever attached to it. See
[The sparring report](#the-sparring-report) for how score
information comes back to you instead.

### Validation

Input validation runs upfront, before any scoring starts, in three
passes: general parameter checks, `custom_weights` checks, then
`anchors_mean`/`anchors_condition` checks (only if `anchors_mean` is
set).

Raises `TypeError` if:
- `df` isn't a pandas DataFrame
- `bench_marks` isn't a dict
- `custom_weights` isn't a dict
- `drop_nan`, `show_progress`, or `best_first` isn't a bool
- `sparring_n` isn't an int or `None` (bools are rejected too)
- `anchors_mean` isn't a dict (when it isn't `None`)

Raises `ValueError` if:
- `bench_marks` is left as `None` (its default)
- `custom_weights` is left as `None` (its default)
- `best_first` is left as `None` (its default)
- `df` has no rows
- `custom_weights` is an empty dict
- `sparring_n` isn't a positive integer
- a `custom_weights` key isn't a string, or doesn't match a column
  in `df`
- a `custom_weights` key has no matching entry in `bench_marks`
- a `custom_weights` key is literally named `"score"`. `sample()`
  keeps its own overall-total working score internally, and a
  signal named `"score"` would generate the exact same internal name,
  corrupting that total instead of just shadowing a per-signal value.
  Rename that column in `df` (and its entries in
  `bench_marks`/`custom_weights`) before calling `sample()`. Names
  that merely *contain* "score", like `test_score` or `score_pct`,
  are unaffected, only an exact match on `"score"` collides
- a weight isn't numeric (bools are rejected too, a `bool` is
  technically an `int` in Python but was never meant as a weight)
- a weight is `NaN`, `inf`, or `-inf`
- `anchors_mean` is an empty dict
- `anchors_mean` is set while `drop_nan=False`
- `anchors_condition` is left as `None` while `anchors_mean` is set,
  or is a string other than `"AND"`/`"OR"` (case-insensitive)
- an `anchors_mean` key isn't a string
- an `anchors_mean` key doesn't match a real `df` column and doesn't
  match a reserved alias (`mean_score`/`min_score`/`max_score`/
  `spar_<signal>`, where `<signal>` must be a key already present in
  `custom_weights`)
- an `anchors_mean` key resolves to a real `df` column that isn't
  numeric
- an `anchors_mean` target isn't a finite real number

**Note on duplicate signal names:** since `custom_weights` is now a
dict, keys are inherently unique, so a repeated column name can no
longer be passed in the first place, Python itself resolves a
repeated key in a dict literal (keeping only the last value) before
`sample()` ever sees it. There's nothing left for validation to
catch here.

## A couple of things worth knowing

- **Your own column names are unrestricted**: `sample()` computes
  its working scores under internally-generated names that can't
  collide with anything you'd realistically name a column, and those
  working columns are always dropped before the df is returned. You
  can freely have your own columns named `spar_score`, `spar_age`,
  or anything else, `sample()` won't touch, rename, or overwrite
  them.
- **Object-dtype numeric strings**: a column of strings like
  `"100"`, `"200"` (object dtype, no separator) is scored as an
  exact-match string column, *not* auto-converted to numeric. Only
  genuine numeric dtypes (`int`, `float`) get the numeric distance
  path.
- **Keyword-only arguments**: `df` is the only argument `sample()`
  accepts positionally. Every other argument, `bench_marks`,
  `custom_weights`, `best_first`, `sparring_n`, `drop_nan`,
  `anchors_mean`, `anchors_condition`, and `show_progress`, must be
  passed by name. This is enforced by Python itself: a positional
  call like `sample(df, weights, benchmarks)` fails immediately with
  a `TypeError`, before any of `sample()`'s own code runs. It exists
  specifically to rule out accidentally swapping `bench_marks` and
  `custom_weights`, which are both dicts and can't be told apart by
  type alone. `bench_marks`, `custom_weights`, and `best_first`
  additionally default to `None` but are not actually optional,
  leaving any of them out (or passing `None` explicitly) raises a
  `ValueError` naming exactly which one is missing.
