diff --git a/README.md b/README.md
index 7126766..3d72548 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,6 @@
# Sentinel
+**Version 2.0** — see [V2_FEATURES.md](V2_FEATURES.md) for what changed and how to migrate.
## Overview
@@ -11,9 +12,26 @@ Roblox Sentinel, part of the Roblox Safety Toolkit, is a Python library designed
By prioritizing recall over precision, Sentinel serves as a high-recall candidate generator for more thorough investigation. This approach is particularly effective for applications where rare patterns are critical to identify. Rather than treating each message in isolation, Sentinel analyzes patterns across messages to identify concerning behavior.
-## What’s New: Aggregation options and Explainability
+## What's new in 2.0
-Sentinel now includes multiple aggregation strategies and built‑in explainability to help you tune for your use case and understand why a score was assigned.
+Version 1.0 gave you an index and a score. Getting a *good* score still meant
+hand-building the index, guessing at hyperparameters, and re-encoding your corpus every
+time you wanted to try something different. 2.0 closes that loop:
+
+| | |
+|---|---|
+| [`from_texts()`](#creating-a-new-index) | Build an index in one call, instead of an eight-step recipe where two steps fail silently if skipped |
+| [`subsample()`](#resizing-an-index) | Resize an index without re-encoding — a 3x3 grid in 9 ms rather than 12.5 s |
+| [`sentinel.simulation`](#simulation-based-tuning) | Measure which aggregator and hyperparameters suit your data |
+| [Index sweeps](#simulation-based-tuning) | Grid search over index size and negative ratio, encoding the observations only once |
+| [Persisted corpus](#storage-options) | Explanations name the matched sentence after a reload, not a row number |
+| [Seeded loading](#quick-start) | A saved index behaves identically on every load |
+
+Full detail and migration notes: [V2_FEATURES.md](V2_FEATURES.md).
+
+### Aggregation options and explainability
+
+Sentinel includes multiple aggregation strategies and built‑in explainability to help you tune for your use case and understand why a score was assigned.
- Aggregators (in `sentinel.score_formulae`):
- `skewness(scores, min_size_of_scores=10)`: default, pattern‑oriented and robust to message count
@@ -260,6 +278,23 @@ saved_config = index.save(
)
```
+## Resizing an index
+
+Encoding a sentence produces the same numbers regardless of which index it ends up in,
+so a small index is just a large one with rows removed. `subsample()` copies the rows you
+want rather than re-running the model, which is what makes sweeping index size
+affordable — on the shipped example index, building a 3x3 grid of configurations takes
+about 9 ms against roughly 12.5 s to re-encode the same 16,100 rows.
+
+```python
+smaller = index.subsample(n_positive=1000, neg_to_pos_ratio=5.0, seed=42)
+```
+
+It returns a new index and never modifies the original, which matters when you loop over
+sizes: if each call shrank the receiver, the second run would start from the first run's
+leftovers and every later result would be quietly wrong. Corpus texts follow their own
+embedding rows, so explanations stay truthful.
+
## Testing for optimal Thresholds and data ratio's
Usage of the 'examples\Example_Threshold_Script.py' script will allow for quick threshold checks for a variety of ratios, by default these are 10:1, 5:1 and 1:1 ratios. This has predefined example chat logs, and should, show optimal settings for the dataset being used based on an average score and average detection count.
@@ -317,12 +352,44 @@ pd.DataFrame(compare_aggregators(scored))
pd.DataFrame(run_grid_search(index, groups, top_k_values=[3, 5, 10], min_score_values=[0.0, 0.1, 0.25]))
```
-Each result row reports three families of evaluation metrics, so you can tune for whatever matters to your use case:
+Two different things are called "metrics" here, and keeping them apart helps:
+
+- The **summarize metric** (the aggregator) is *how* a group's many per-observation scores become one number. This is what you sweep.
+- The **evaluation metric** is *how good* that separation turned out to be. You do not pick one — every row reports all three families below, so you can tune for whatever matters to your use case.
- Ranking: `roc_auc`, `recall_at_n`, `precision_at_n`, `rank_ratio` (do known positives rank at the top?)
- Threshold / classification: `precision`, `recall`, `f1`, `false_positive_rate` at a chosen (or automatically best‑F1) cutoff
- Separation / distribution: `mean_separation`, `cohens_d`, `ks_statistic` (threshold‑free)
+### Sweeping the index itself
+
+The grid search can also vary the index, not just `top_k` and the threshold. Index size is
+usually the highest-leverage knob, and `subsample()` makes it cheap to explore:
+
+```python
+pd.DataFrame(run_grid_search(
+ index, groups,
+ n_positive_values=[1000, 5000, 10000], # index size
+ neg_to_pos_ratios=[0.2, 1.0, 5.0], # index balance
+ top_k_values=[3, 5, 10],
+ index_seed=42, # reproducible index configurations
+))
+```
+
+The arguments are ordered to match the loop nesting, which is itself ordered by cost:
+resizing the index is cheap, re-scoring is not, and thresholds and aggregators are free on
+the cached scores.
+
+**Observations are encoded once for the whole sweep.** An observation's embedding depends
+on the encoder, never on the index it is scored against, so re-encoding on every pass was
+recomputing identical numbers. On a 2x2x3 sweep over 320 observations this took a measured
+run from **2.99 s to 0.52 s, a 5.7x saving**, with byte-identical results. Pass
+`cache_observation_embeddings=False` if memory is tight.
+
+Rows report both the requested and actual index sizes, because a request is clipped when
+the index holds fewer examples than you asked for — without `index_n_positive_actual` two
+rows can look like a genuine size sweep while describing nearly the same index.
+
See [examples/sentinel_against_hate.ipynb](examples/sentinel_against_hate.ipynb) for a worked example comparing aggregators on hate‑speech data.
## How It Works
diff --git a/V2_FEATURES.md b/V2_FEATURES.md
new file mode 100644
index 0000000..0c0bf35
--- /dev/null
+++ b/V2_FEATURES.md
@@ -0,0 +1,293 @@
+# What's new in Sentinel 2.0
+
+Sentinel 1.0 gave you an index and a score. Getting a *good* score still meant
+hand-building the index, guessing at hyperparameters, and re-encoding your corpus every
+time you wanted to try something different.
+
+Version 2.0 is about closing that loop: build an index in one call, resize it without
+re-encoding, measure which settings actually work, and see which text drove a score.
+
+The main changes:
+
+| | |
+|---|---|
+| [One-line index building](#build-an-index-in-one-call) | `from_texts()` replaces an eight-step recipe with two silent failure modes |
+| [Resizing without re-encoding](#resize-an-index-without-re-encoding) | `subsample()` makes index size an affordable thing to explore |
+| [Six summarize metrics, not two](#more-ways-to-summarize-and-ways-to-measure-which-one-works) | Plus explainability, and the evaluation metrics to choose between them |
+| [A tuning harness](#tune-with-the-simulation-harness) | Measure configurations on your own labelled data, numpy-only |
+| [Index sweep axes](#sweep-index-size-and-ratio) | Grid search over index size and balance, encoding the observations once |
+| [Explanations that survive a reload](#explanations-survive-a-reload) | The corpus is persisted, so explanations name text rather than row numbers |
+| [Reproducible loading](#reproducible-loading) | A saved index behaves identically on every load |
+| [Run it in a container](#run-it-in-a-container) | Dockerfiles for GPU and CPU-only use |
+
+Migration notes are at the end: [Migrating from 1.0](#migrating-from-10).
+
+## Build an index in one call
+
+`SentinelLocalIndex.from_texts()` replaces an eight-step recipe, two steps of which fail
+*silently* when you skip them: omit `normalize_embeddings=True` and the similarity maths
+quietly returns wrong numbers, and omit the corpus and you lose explanations. Neither
+raises. Neither warns.
+
+```python
+from sentinel import SentinelLocalIndex
+
+index = SentinelLocalIndex.from_texts(
+ positive_texts=["...examples of the rare class..."],
+ negative_texts=["...examples of ordinary content..."],
+ neg_to_pos_ratio=5.0,
+ seed=42,
+)
+```
+
+Surplus negatives are dropped *before* encoding rather than after. Encoding is per-text
+and is the only expensive step, so at a 1:1 ratio against 1,000 positives, passing
+100,000 negatives no longer means paying to encode 99,000 rows that never reach the index.
+
+## Resize an index without re-encoding
+
+Encoding a sentence produces the same numbers regardless of which index it ends up in, so
+a small index is just a large one with rows removed. `subsample()` copies the rows you
+want instead of re-running the model:
+
+```python
+smaller = index.subsample(n_positive=1000, neg_to_pos_ratio=5.0, seed=42)
+```
+
+It returns a new index and never mutates the original, which matters when you loop over
+sizes: if each call shrank the receiver, run two would start from run one's leftovers and
+every later result would be quietly wrong.
+
+Each side draws from its own seeded generator. Sharing one would couple them, so a
+configuration that kept every positive (and therefore drew nothing) would select
+different negatives from one that subsampled - two cells meant to differ along one axis
+would differ along two.
+
+## Tune with the simulation harness
+
+`sentinel.simulation` answers "which settings work best on *my* data?". It is numpy-only,
+with no Ray, S3 or experiment trackers, and it separates two things that are easy to
+confuse:
+
+- The **summarize metric** (the aggregator) is how a group's many per-observation scores
+ become one number. This is what you sweep.
+- The **evaluation metric** is how good the resulting separation is. You do not choose
+ one; every row reports three families, so you can tune for whatever matters.
+
+Why the summarize metric matters so much: picture a hateful podcast episode with 97 dull
+segments and 3 nasty ones, against a normal episode of 100 unremarkable segments.
+
+| Episode | Segment scores | Average | Maximum |
+|---|---|---|---|
+| Hateful | 97 x 0.05, 3 x 0.90 | 0.0755 | 0.90 |
+| Normal | 100 x 0.06 | 0.06 | 0.08 |
+
+Averaging drowns the signal; the maximum finds it. But the maximum is fragile - one odd
+segment flags an innocent episode - which is why the default is `skewness`, which looks
+for a *pattern* of spikes and does not care how long the episode is.
+
+```python
+from sentinel.simulation import LabeledGroup, score_groups, compare_aggregators
+import pandas as pd
+
+groups = [
+ LabeledGroup(name="source_a", label=1, observations=[...]),
+ LabeledGroup(name="source_b", label=0, observations=[...]),
+]
+
+scored = score_groups(index, groups, top_k=5) # expensive, once
+pd.DataFrame(compare_aggregators(scored)) # cheap, all six aggregators
+```
+
+## More ways to summarize, and ways to measure which one works
+
+1.0 gave you two summarize metrics: `mean_of_positives` and `skewness`. That is not much of
+a choice, and no way at all to know whether either suited your data.
+
+2.0 brings the count to six and adds the means to decide between them.
+
+**Four new summarize metrics** in `sentinel.score_formulae`, alongside the original two:
+
+| Metric | Shape it looks for |
+|---|---|
+| `skewness` (default) | A few spikes in an otherwise flat set of scores, regardless of count |
+| `mean_of_positives` | Overall level among scores that cleared the floor |
+| `top_k_mean` | The strongest handful of signals |
+| `percentile_score` | A high percentile, robust to single outliers |
+| `softmax_weighted_mean` | Smoothly emphasises higher scores |
+| `max_score` | The single strongest signal |
+
+**Explainability on every result.** `RareClassAffinityResult` now carries
+`aggregation_name`, `aggregation_stats` and per-text `explanations` - which neighbours were
+closest, their similarities, and the contrastive terms behind the score. You can see *why* a
+source scored as it did, not just that it did.
+
+**Evaluation metrics.** Three families, reported on every result row, because "good" depends
+on what you are building:
+
+| Family | Metrics | Answers |
+|---|---|---|
+| Ranking | `roc_auc`, `recall_at_n`, `precision_at_n`, `rank_ratio` | Do the known positives come out on top? Right for a review queue. |
+| Threshold | `precision`, `recall`, `f1`, `false_positive_rate` | Of the sources I flagged, how many were right? Right for automatic action. |
+| Separation | `mean_separation`, `cohens_d`, `ks_statistic` | How far apart are the two populations, independent of any cutoff? Right for monitoring drift. |
+
+You do not pick a family. All three are computed, so you read whichever matches your use
+case - and you can see when they disagree, which they do: an aggregator can rank well while
+having a mediocre best F1.
+
+**A way to compare them side by side.** `DEFAULT_AGGREGATORS` is a name-to-function map of
+all six, so `compare_aggregators` can evaluate every one of them on a single set of scores,
+and `evaluate_groups` can measure any one of them on its own:
+
+```python
+from sentinel.simulation import DEFAULT_AGGREGATORS, compare_aggregators, evaluate_groups
+
+# All six, ranked however you like.
+pd.DataFrame(compare_aggregators(scored)).sort_values("roc_auc", ascending=False)
+
+# Or one at a time, with all three families reported.
+evaluate_groups(scored, DEFAULT_AGGREGATORS["skewness"], aggregator_name="skewness")
+```
+
+This is what turns six options into a decision you can defend with evidence rather than a
+default you inherited.
+
+## Sweep index size and ratio
+
+`run_grid_search` can now sweep the index itself, not just `top_k` and the threshold:
+
+```python
+pd.DataFrame(run_grid_search(
+ index, groups,
+ n_positive_values=[1000, 5000, 10000],
+ neg_to_pos_ratios=[0.2, 1.0, 5.0],
+ top_k_values=[3, 5, 10],
+ index_seed=42,
+))
+```
+
+The arguments are ordered to match the loop nesting, which is itself ordered by cost:
+
+```
+for n_positive in n_positive_values: # cheap: subsample()
+ for ratio in neg_to_pos_ratios: # cheap: subsample()
+ for top_k in top_k_values: # re-scores
+ for min_score in min_score_values: # cheap
+ for aggregator in aggregators: # cheap
+```
+
+**Observations are encoded once for the whole sweep.** An observation's embedding depends
+on the encoder, never on the index it is scored against, and a subsampled index shares its
+parent's model - so re-encoding per pass was recomputing identical numbers. On a 2x2x3
+sweep over 320 observations this took a real run from **2.99s to 0.52s, a 5.7x saving**,
+producing byte-identical rows. The saving grows with the grid, because the one encoding
+pass is amortised over more scoring passes.
+
+Set `cache_observation_embeddings=False` if memory is tight; the cache holds one embedding
+per observation.
+
+Rows report both what you asked for and what you got, because a request is clipped when
+the index is smaller than requested:
+
+```
+index_n_positive=10 -> index_n_positive_actual=10, index_n_negative_actual=20
+index_n_positive=999 -> index_n_positive_actual=15, index_n_negative_actual=30
+```
+
+Without the actual counts, those two rows could look like a genuine size sweep when they
+describe nearly the same index.
+
+## Explanations survive a reload
+
+In 1.0, `save()` wrote only embeddings and config. After any reload the corpus was gone,
+so explanations reported the row number of a match instead of the matched sentence.
+
+2.0 writes a `corpus.json` beside the embeddings. It is written **unconditionally**, with
+nulls when there is no corpus, so it can never describe rows from an earlier save to the
+same path. Saving without a corpus therefore clears any corpus already there - which is
+deliberate, because keeping it is only correct if the new embeddings are the same rows in
+the same order, and nothing enforces that.
+
+Alignment is defended at three points: a mismatched corpus is refused at save time,
+discarded with a warning at load time, and carried through row-for-row whenever rows are
+dropped. Degrading to row numbers is recoverable; naming the wrong sentence is not.
+
+Indices saved before this file existed simply lack it and continue to load normally.
+
+## Reproducible loading
+
+`load()` downsamples negatives to the requested ratio, and that choice is random. In 1.0
+it was unseeded, so a saved index behaved like a slightly different model on every load -
+on the shipped example index, the same text scored 0.028636 on one load and 0.011523 on
+the next.
+
+```python
+index = SentinelLocalIndex.load(path="...", seed=42)
+```
+
+Seeding uses a private generator, so your own randomness is untouched.
+
+## Run it in a container
+
+2.0 ships Dockerfiles, so you can try Sentinel without resolving a Python and PyTorch
+environment first. There are two: the default is GPU-capable, and `Dockerfile.cpu` is a
+smaller CPU-only build for when you are only scoring.
+
+```bash
+docker build -t sentinel . # GPU-capable
+docker build -f Dockerfile.cpu -t sentinel:cpu . # smaller, CPU only
+```
+
+See the README for running the demo and mounting your own scripts.
+
+## Migrating from 1.0
+
+Most code needs no change. The breaking changes are deliberate and narrow.
+
+**Python 3.10 is now the minimum.** 1.0 declared support for 3.9, but never tested it - CI has
+run 3.10 to 3.12 only. That claim had also become impossible to honour: current `torch`
+requires 3.10 or newer, so a 3.9 install had to silently resolve to an older, untested torch.
+Raising the floor is a breaking change, which is why it belongs in a major release. Dropping
+3.9 also lets three backport packages go - `importlib-metadata`, `importlib-resources` and
+`zipp` - whose functionality is in the standard library from 3.10.
+
+**Arguments after the first are now keyword-only** on `calculate_rare_class_affinity`,
+`from_texts` and `load`. This is what lets a new argument sit in a logical place instead
+of being appended to an eleven-parameter list forever.
+
+```python
+# 1.0 - still works, but only because the first argument is positional
+index.calculate_rare_class_affinity(texts)
+
+# 1.0 positional style - no longer valid
+index.calculate_rare_class_affinity(texts, 10)
+
+# 2.0
+index.calculate_rare_class_affinity(texts, top_k=10)
+```
+
+Every call site in this repository already used keywords, and the documentation always
+taught that style, so in practice this is unlikely to affect you.
+
+**Grid-search index columns are prefixed.** `evaluate_groups` returns an `n_positive`
+meaning the number of positive *groups* in your evaluation set. The index size briefly
+shared that name and silently overwrote it. If you read grid-search output, rename:
+
+| Old | New |
+|---|---|
+| `n_positive` (index size) | `index_n_positive` |
+| `neg_to_pos_ratio` | `index_neg_to_pos_ratio` |
+| `n_positive_actual` | `index_n_positive_actual` |
+| `n_negative_actual` | `index_n_negative_actual` |
+
+`n_positive` now unambiguously means the positive-group count, as `evaluate_groups`
+always documented. Writing a column twice raises instead of overwriting.
+
+**Rows are still plain dicts**, so `pd.DataFrame(rows)` keeps working.
+
+## Known limitations
+
+- `top_k` still forces a re-score. Caching the neighbour search would make it as cheap as
+ the index axes, but that is a deeper change.
+- `RareClassAffinityResult.observation_scores` is keyed by observation text, so duplicate
+ observations within one group collapse into a single entry.
diff --git a/examples/sentinel_against_hate.ipynb b/examples/sentinel_against_hate.ipynb
index 5936edc..f12a6e6 100644
--- a/examples/sentinel_against_hate.ipynb
+++ b/examples/sentinel_against_hate.ipynb
@@ -2817,6 +2817,1448 @@
"\n",
"This approach demonstrates why robust hate speech detection requires attention not just to average content characteristics, but to distribution patterns that might reveal occasional but significant harmful content within otherwise unremarkable material."
]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Appendix: What's new in Sentinel 2.0\n",
+ "\n",
+ "One part of 2.0 is already demonstrated above, on real data: **Tuning aggregation\n",
+ "strategies with a simulation** uses the `sentinel.simulation` harness, which 2.0\n",
+ "introduced. Treat that section as the worked example - it compares aggregators on\n",
+ "thousands of real podcast segments, which is the comparison that actually tells you\n",
+ "something.\n",
+ "\n",
+ "This appendix covers the rest, and fills in the parts of the harness that section does\n",
+ "not reach (`evaluate_groups`, `DEFAULT_AGGREGATORS`, and the new index sweep axes).\n",
+ "\n",
+ "It is deliberately **self-contained** - it builds its own small index from a handful of\n",
+ "example sentences and does not depend on the data-loading cells above, so you can run it\n",
+ "on its own in a few seconds. The flip side is that its fixture is too easy to separate\n",
+ "aggregators meaningfully; for that, read the section above. The timings here are\n",
+ "illustrations of shape rather than benchmarks, and the advantages grow with real corpora\n",
+ "where encoding dominates everything else.\n",
+ "\n",
+ "Full write-up and migration notes: [V2_FEATURES.md](../V2_FEATURES.md)."
+ ],
+ "id": "d2659d67"
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-03T23:56:17.820400Z",
+ "iopub.status.busy": "2026-08-03T23:56:17.820313Z",
+ "iopub.status.idle": "2026-08-03T23:56:23.361049Z",
+ "shell.execute_reply": "2026-08-03T23:56:23.359713Z"
+ }
+ },
+ "source": [
+ "import tempfile\n",
+ "import time\n",
+ "from pathlib import Path\n",
+ "\n",
+ "import pandas as pd\n",
+ "\n",
+ "import sentinel\n",
+ "from sentinel import SentinelLocalIndex\n",
+ "from sentinel.simulation import (\n",
+ " LabeledGroup,\n",
+ " compare_aggregators,\n",
+ " encode_observations,\n",
+ " evaluate_groups,\n",
+ " run_grid_search,\n",
+ " score_groups,\n",
+ ")\n",
+ "\n",
+ "print(f\"Sentinel version: {sentinel.__version__}\")"
+ ],
+ "execution_count": 22,
+ "outputs": [
+ {
+ "output_type": "stream",
+ "text": [
+ "Sentinel version: 2.0.0\n"
+ ],
+ "name": "stdout"
+ }
+ ],
+ "id": "52d0094c"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 1. `from_texts()` - build an index in one call\n",
+ "\n",
+ "The **Build The Indexes** section above does this by hand: load the model, keep the\n",
+ "scaling function, encode both sides, remember `normalize_embeddings=True`, keep the\n",
+ "corpus, then construct the index.\n",
+ "\n",
+ "Two of those steps fail *silently* when skipped. Without normalisation the similarity\n",
+ "maths quietly returns wrong numbers, and without the corpus you lose explanations.\n",
+ "Neither raises, and neither warns - you simply get worse results and never find out.\n",
+ "`from_texts()` does the whole recipe, inside the library, where it is tested."
+ ],
+ "id": "65f4f3a1"
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-03T23:56:23.364588Z",
+ "iopub.status.busy": "2026-08-03T23:56:23.364218Z",
+ "iopub.status.idle": "2026-08-03T23:56:23.974273Z",
+ "shell.execute_reply": "2026-08-03T23:56:23.973053Z"
+ }
+ },
+ "source": [
+ "# A few examples of the rare class we want to catch, and of ordinary conversation.\n",
+ "HATEFUL = [\n",
+ " \"they are vermin and need to be removed from our country\",\n",
+ " \"those people are subhuman and do not deserve any rights\",\n",
+ " \"we must cleanse the nation of their filthy kind\",\n",
+ " \"they are a disease infecting our society and must be purged\",\n",
+ " \"their race has always been inferior and violent by nature\",\n",
+ " \"we should drive every last one of them out by force\",\n",
+ " \"the world would be better off without their bloodline\",\n",
+ " \"they breed like rats and are ruining everything we built\",\n",
+ "]\n",
+ "ORDINARY = [\n",
+ " \"the weather has been lovely this week\",\n",
+ " \"i really enjoyed that documentary last night\",\n",
+ " \"we should meet for coffee sometime soon\",\n",
+ " \"the train was delayed again this morning\",\n",
+ " \"she gave a thoughtful talk about urban planning\",\n",
+ " \"my neighbour is teaching me to bake bread\",\n",
+ " \"the new library opens on the corner next month\",\n",
+ " \"he spent the weekend repairing his bicycle\",\n",
+ " \"they are renovating the community centre this spring\",\n",
+ " \"the match ended in a draw after extra time\",\n",
+ " \"i finally finished the book you lent me\",\n",
+ " \"the bakery on the high street changed hands\",\n",
+ " \"we planted tomatoes along the back fence\",\n",
+ " \"the bus route is being extended next year\",\n",
+ " \"there is a food market every second saturday\",\n",
+ " \"my sister is moving house in the autumn\",\n",
+ "]\n",
+ "\n",
+ "# Half of each list goes into the index; the rest is held out for evaluation below, so we\n",
+ "# never score an example against itself.\n",
+ "index_positives, held_out_hateful = HATEFUL[:6], HATEFUL[6:]\n",
+ "index_negatives, held_out_ordinary = ORDINARY[:6], ORDINARY[6:]\n",
+ "\n",
+ "demo_index = SentinelLocalIndex.from_texts(\n",
+ " positive_texts=index_positives,\n",
+ " negative_texts=index_negatives,\n",
+ " model_name=\"sentence-transformers/all-MiniLM-L6-v2\",\n",
+ ")\n",
+ "\n",
+ "print(f\"positives: {tuple(demo_index.positive_embeddings.shape)}\")\n",
+ "print(f\"negatives: {tuple(demo_index.negative_embeddings.shape)}\")\n",
+ "print(f\"corpus kept automatically: {demo_index.positive_corpus is not None}\")\n",
+ "print(f\"normalisation applied: {demo_index.encoding_kwargs['normalize_embeddings']}\")"
+ ],
+ "execution_count": 23,
+ "outputs": [
+ {
+ "output_type": "stream",
+ "text": [
+ "positives: (6, 384)\n",
+ "negatives: (6, 384)\n",
+ "corpus kept automatically: True\n",
+ "normalisation applied: True\n"
+ ],
+ "name": "stdout"
+ }
+ ],
+ "id": "978aefee"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 2. The corpus survives a save/load round trip\n",
+ "\n",
+ "In 1.0, `save()` wrote only the embeddings and the config. After any reload the corpus\n",
+ "was gone, so explanations reported the *row number* of a match rather than the sentence\n",
+ "that caused it - useful to a machine, useless to a reviewer.\n",
+ "\n",
+ "2.0 writes a `corpus.json` beside the embeddings, so an explanation can name the text."
+ ],
+ "id": "6ea59f1a"
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-03T23:56:23.976751Z",
+ "iopub.status.busy": "2026-08-03T23:56:23.976640Z",
+ "iopub.status.idle": "2026-08-03T23:56:24.494507Z",
+ "shell.execute_reply": "2026-08-03T23:56:24.493255Z"
+ }
+ },
+ "source": [
+ "with tempfile.TemporaryDirectory() as tmp:\n",
+ " demo_index.save(\n",
+ " path=tmp, encoder_model_name_or_path=\"sentence-transformers/all-MiniLM-L6-v2\"\n",
+ " )\n",
+ " print(\"files written:\", sorted(p.name for p in Path(tmp).iterdir()))\n",
+ "\n",
+ " # A seed makes the load reproducible, which is also new in 2.0: without it the\n",
+ " # negative downsampling differed on every load and the same text scored differently.\n",
+ " reloaded = SentinelLocalIndex.load(\n",
+ " path=tmp, negative_to_positive_ratio=None, seed=42\n",
+ " )\n",
+ "\n",
+ "result = reloaded.calculate_rare_class_affinity(\n",
+ " [\"those people are a plague on this country\"]\n",
+ ")\n",
+ "explanation = next(iter(result.explanations.values()))\n",
+ "\n",
+ "print(\"\\nWhy that text scored as it did - the nearest examples, named rather than numbered:\")\n",
+ "for neighbor in explanation[\"neighbors\"][:3]:\n",
+ " print(f\" {neighbor['sign']} {neighbor['scaled_score']:.3f} {neighbor['neighbor']}\")"
+ ],
+ "execution_count": 24,
+ "outputs": [
+ {
+ "output_type": "stream",
+ "text": [
+ "files written: ['corpus.json', 'embeddings.safetensors', 'sentinel_local_index_config.json']\n"
+ ],
+ "name": "stdout"
+ },
+ {
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "Why that text scored as it did - the nearest examples, named rather than numbered:\n",
+ " + 0.540 they are a disease infecting our society and must be purged\n",
+ " + 0.424 those people are subhuman and do not deserve any rights\n",
+ " + 0.397 we must cleanse the nation of their filthy kind\n"
+ ],
+ "name": "stdout"
+ }
+ ],
+ "id": "b4222b16"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "`load_corpus` reads those texts back on their own, without loading the embeddings -\n",
+ "handy for checking what a saved index actually contains before you trust it. It returns\n",
+ "`(None, None)` for an index saved before 2.0, which is how the shipped example index\n",
+ "behaves and why its explanations would show row numbers."
+ ],
+ "id": "629213a8"
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-03T23:56:24.497182Z",
+ "iopub.status.busy": "2026-08-03T23:56:24.497042Z",
+ "iopub.status.idle": "2026-08-03T23:56:24.505049Z",
+ "shell.execute_reply": "2026-08-03T23:56:24.504047Z"
+ }
+ },
+ "source": [
+ "from sentinel.io import load_corpus\n",
+ "\n",
+ "with tempfile.TemporaryDirectory() as tmp:\n",
+ " demo_index.save(\n",
+ " path=tmp, encoder_model_name_or_path=\"sentence-transformers/all-MiniLM-L6-v2\"\n",
+ " )\n",
+ " saved_positive, saved_negative = load_corpus(path=tmp)\n",
+ "\n",
+ "print(f\"positives stored: {len(saved_positive)} negatives stored: {len(saved_negative)}\")\n",
+ "print(f\"first positive: {saved_positive[0]}\")\n",
+ "\n",
+ "# The index shipped with this repository predates corpus support.\n",
+ "print(f\"\\nshipped example index: {load_corpus(path='hate_speech_model')}\")"
+ ],
+ "execution_count": 25,
+ "outputs": [
+ {
+ "output_type": "stream",
+ "text": [
+ "positives stored: 6 negatives stored: 6\n",
+ "first positive: they are vermin and need to be removed from our country\n",
+ "\n",
+ "shipped example index: (None, None)\n"
+ ],
+ "name": "stdout"
+ }
+ ],
+ "id": "41c1dfe0"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 3. `subsample()` - resize an index without re-encoding\n",
+ "\n",
+ "Encoding a sentence produces the same numbers regardless of which index it ends up in,\n",
+ "so a small index is just a large one with rows removed. Before 2.0, trying a smaller\n",
+ "index meant encoding the whole corpus from scratch.\n",
+ "\n",
+ "The example index shipped with this repository is a realistic size, so it shows the\n",
+ "point better than our six-sentence one."
+ ],
+ "id": "5eec83da"
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-03T23:56:24.507918Z",
+ "iopub.status.busy": "2026-08-03T23:56:24.507789Z",
+ "iopub.status.idle": "2026-08-03T23:56:25.101191Z",
+ "shell.execute_reply": "2026-08-03T23:56:25.099711Z"
+ }
+ },
+ "source": [
+ "shipped = SentinelLocalIndex.load(\n",
+ " path=\"hate_speech_model\", negative_to_positive_ratio=None, seed=42\n",
+ ")\n",
+ "\n",
+ "start = time.perf_counter()\n",
+ "smaller = shipped.subsample(n_positive=500, neg_to_pos_ratio=5.0, seed=42)\n",
+ "elapsed_ms = (time.perf_counter() - start) * 1000\n",
+ "\n",
+ "print(f\"original: {shipped.positive_embeddings.shape[0]:,} positives / \"\n",
+ " f\"{shipped.negative_embeddings.shape[0]:,} negatives\")\n",
+ "print(f\"resized: {smaller.positive_embeddings.shape[0]:,} positives / \"\n",
+ " f\"{smaller.negative_embeddings.shape[0]:,} negatives\")\n",
+ "print(f\"took {elapsed_ms:.1f} ms - no encoding involved\")\n",
+ "\n",
+ "# The original is untouched, which is what makes it safe to loop over sizes. If each\n",
+ "# call shrank the receiver, run two would start from run one's leftovers.\n",
+ "print(f\"\\noriginal still intact: {shipped.positive_embeddings.shape[0]:,} positives\")"
+ ],
+ "execution_count": 26,
+ "outputs": [
+ {
+ "output_type": "stream",
+ "text": [
+ "original: 1,516 positives / 15,160 negatives\n",
+ "resized: 500 positives / 2,500 negatives\n",
+ "took 2.2 ms - no encoding involved\n",
+ "\n",
+ "original still intact: 1,516 positives\n"
+ ],
+ "name": "stdout"
+ }
+ ],
+ "id": "0a5d9eed"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 4. The tuning harness, cheapest entry point first\n",
+ "\n",
+ "`sentinel.simulation` arrived in 2.0 and is what the **Tuning aggregation strategies**\n",
+ "section above runs on. That section uses the two convenience entry points; this one shows\n",
+ "the full ladder, including the single-aggregator step underneath them.\n",
+ "\n",
+ "The harness is layered by cost. Scoring runs the model and is the only expensive step, so\n",
+ "you do it once and then evaluate as many ways as you like for free:\n",
+ "\n",
+ "| Function | What it does | Cost |\n",
+ "|---|---|---|\n",
+ "| `score_groups` | Scores every observation in every group | runs the model |\n",
+ "| `evaluate_groups` | Collapses each group to one number with **one** aggregator, then measures the separation | free |\n",
+ "| `compare_aggregators` | Runs `evaluate_groups` once per aggregator on the same scores | free |\n",
+ "| `run_grid_search` | Wraps all of the above and sweeps hyperparameters | re-scores per `top_k` |\n",
+ "\n",
+ "Two different things get called \"metrics\" here. The **summarize metric** (the\n",
+ "aggregator) is *how* a group's many scores become one number, and it is what you sweep.\n",
+ "The **evaluation metric** is *how good* that separation is, and you do not choose one -\n",
+ "every row reports all three families."
+ ],
+ "id": "1ffe03e2"
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-03T23:56:25.103361Z",
+ "iopub.status.busy": "2026-08-03T23:56:25.103234Z",
+ "iopub.status.idle": "2026-08-03T23:56:25.161903Z",
+ "shell.execute_reply": "2026-08-03T23:56:25.160411Z"
+ }
+ },
+ "source": [
+ "from sentinel.simulation import DEFAULT_AGGREGATORS\n",
+ "\n",
+ "# Four \"sources\" built from the sentences held out above. The two positive sources are\n",
+ "# mostly ordinary chatter with a couple of hateful lines mixed in - the realistic\n",
+ "# pattern, and the one the appendix on skewness above is about.\n",
+ "groups = [\n",
+ " LabeledGroup(\n",
+ " name=\"hateful_a\", label=1,\n",
+ " observations=held_out_ordinary[:9] + held_out_hateful[:1],\n",
+ " ),\n",
+ " LabeledGroup(\n",
+ " name=\"hateful_b\", label=1,\n",
+ " observations=held_out_ordinary[1:] + held_out_hateful[1:],\n",
+ " ),\n",
+ " LabeledGroup(name=\"ordinary_a\", label=0, observations=held_out_ordinary[:10]),\n",
+ " LabeledGroup(name=\"ordinary_b\", label=0, observations=held_out_ordinary),\n",
+ "]\n",
+ "for group in groups:\n",
+ " print(f\" {group.name:11} label={group.label} {len(group.observations)} observations\")\n",
+ "\n",
+ "print(\"\\nbuilt-in summarize metrics:\", \", \".join(DEFAULT_AGGREGATORS))\n",
+ "\n",
+ "# Score once...\n",
+ "scored_once = score_groups(demo_index, groups, top_k=3)\n",
+ "print(f\"\\nscored {len(scored_once)} groups; \"\n",
+ " f\"{scored_once[0].name} has {len(scored_once[0].observation_scores)} observation scores\")\n",
+ "\n",
+ "# ...then evaluate with a single aggregator, for free.\n",
+ "row = evaluate_groups(\n",
+ " scored_once, DEFAULT_AGGREGATORS[\"skewness\"], aggregator_name=\"skewness\"\n",
+ ")\n",
+ "print(f\"\\nskewness alone -> roc_auc={row['roc_auc']:.2f} f1={row['f1']:.2f}\")\n",
+ "print(f\" ranking: roc_auc={row['roc_auc']:.2f} recall_at_n={row['recall_at_n']:.2f}\")\n",
+ "print(f\" threshold: precision={row['precision']:.2f} recall={row['recall']:.2f}\")\n",
+ "print(f\" separation: mean_separation={row['mean_separation']:.3f} ks={row['ks_statistic']:.2f}\")\n",
+ "\n",
+ "# n_positive here counts positive GROUPS, not index rows - the distinction that the\n",
+ "# index_ prefix exists to protect.\n",
+ "print(f\"\\nn_groups={row['n_groups']} n_positive={row['n_positive']} (positive sources)\")"
+ ],
+ "execution_count": 27,
+ "outputs": [
+ {
+ "output_type": "stream",
+ "text": [
+ " hateful_a label=1 10 observations\n",
+ " hateful_b label=1 10 observations\n",
+ " ordinary_a label=0 10 observations\n",
+ " ordinary_b label=0 10 observations\n",
+ "\n",
+ "built-in summarize metrics: skewness, mean_of_positives, top_k_mean, percentile_score, softmax_weighted_mean, max_score\n",
+ "\n",
+ "scored 4 groups; hateful_a has 10 observation scores\n",
+ "\n",
+ "skewness alone -> roc_auc=1.00 f1=1.00\n",
+ " ranking: roc_auc=1.00 recall_at_n=1.00\n",
+ " threshold: precision=1.00 recall=1.00\n",
+ " separation: mean_separation=0.333 ks=1.00\n",
+ "\n",
+ "n_groups=4 n_positive=2 (positive sources)\n"
+ ],
+ "name": "stdout"
+ }
+ ],
+ "id": "5f7f90b1"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 5. Grid search over the index itself\n",
+ "\n",
+ "`run_grid_search` can now sweep index size and negative ratio, not just `top_k` and the\n",
+ "per-observation threshold. Index size is usually the highest-leverage knob, and\n",
+ "`subsample()` is what makes exploring it affordable.\n",
+ "\n",
+ "Same four sources as above, so the only thing changing is the index."
+ ],
+ "id": "b9abde0c"
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-03T23:56:25.164197Z",
+ "iopub.status.busy": "2026-08-03T23:56:25.164107Z",
+ "iopub.status.idle": "2026-08-03T23:56:25.270108Z",
+ "shell.execute_reply": "2026-08-03T23:56:25.268331Z"
+ }
+ },
+ "source": [
+ "grid = pd.DataFrame(\n",
+ " run_grid_search(\n",
+ " demo_index,\n",
+ " groups,\n",
+ " n_positive_values=[3, 6], # new in 2.0: index size\n",
+ " neg_to_pos_ratios=[0.5, 1.0], # new in 2.0: index balance\n",
+ " top_k_values=[1, 3],\n",
+ " min_score_values=[0.0],\n",
+ " index_seed=42, # reproducible index configurations\n",
+ " )\n",
+ ")\n",
+ "\n",
+ "configurations = grid[[\"index_n_positive\", \"index_neg_to_pos_ratio\"]].drop_duplicates()\n",
+ "print(f\"\\n{len(grid)} rows across {len(configurations)} index configurations\")\n",
+ "grid[[\n",
+ " \"aggregator\", \"index_n_positive\", \"index_neg_to_pos_ratio\",\n",
+ " \"index_n_positive_actual\", \"index_n_negative_actual\", \"top_k\", \"roc_auc\",\n",
+ "]].head(6)"
+ ],
+ "execution_count": 28,
+ "outputs": [
+ {
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "48 rows across 4 index configurations\n"
+ ],
+ "name": "stdout"
+ },
+ {
+ "output_type": "execute_result",
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
\n",
+ "
aggregator
\n",
+ "
index_n_positive
\n",
+ "
index_neg_to_pos_ratio
\n",
+ "
index_n_positive_actual
\n",
+ "
index_n_negative_actual
\n",
+ "
top_k
\n",
+ "
roc_auc
\n",
+ "
\n",
+ " \n",
+ " \n",
+ "
\n",
+ "
0
\n",
+ "
skewness
\n",
+ "
3
\n",
+ "
0.5
\n",
+ "
3
\n",
+ "
1
\n",
+ "
1
\n",
+ "
1.0
\n",
+ "
\n",
+ "
\n",
+ "
1
\n",
+ "
mean_of_positives
\n",
+ "
3
\n",
+ "
0.5
\n",
+ "
3
\n",
+ "
1
\n",
+ "
1
\n",
+ "
1.0
\n",
+ "
\n",
+ "
\n",
+ "
2
\n",
+ "
top_k_mean
\n",
+ "
3
\n",
+ "
0.5
\n",
+ "
3
\n",
+ "
1
\n",
+ "
1
\n",
+ "
1.0
\n",
+ "
\n",
+ "
\n",
+ "
3
\n",
+ "
percentile_score
\n",
+ "
3
\n",
+ "
0.5
\n",
+ "
3
\n",
+ "
1
\n",
+ "
1
\n",
+ "
1.0
\n",
+ "
\n",
+ "
\n",
+ "
4
\n",
+ "
softmax_weighted_mean
\n",
+ "
3
\n",
+ "
0.5
\n",
+ "
3
\n",
+ "
1
\n",
+ "
1
\n",
+ "
1.0
\n",
+ "
\n",
+ "
\n",
+ "
5
\n",
+ "
max_score
\n",
+ "
3
\n",
+ "
0.5
\n",
+ "
3
\n",
+ "
1
\n",
+ "
1
\n",
+ "
1.0
\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " aggregator index_n_positive index_neg_to_pos_ratio \\\n",
+ "0 skewness 3 0.5 \n",
+ "1 mean_of_positives 3 0.5 \n",
+ "2 top_k_mean 3 0.5 \n",
+ "3 percentile_score 3 0.5 \n",
+ "4 softmax_weighted_mean 3 0.5 \n",
+ "5 max_score 3 0.5 \n",
+ "\n",
+ " index_n_positive_actual index_n_negative_actual top_k roc_auc \n",
+ "0 3 1 1 1.0 \n",
+ "1 3 1 1 1.0 \n",
+ "2 3 1 1 1.0 \n",
+ "3 3 1 1 1.0 \n",
+ "4 3 1 1 1.0 \n",
+ "5 3 1 1 1.0 "
+ ]
+ },
+ "metadata": {},
+ "execution_count": 28
+ }
+ ],
+ "id": "578b8945"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Two details in that table worth pausing on\n",
+ "\n",
+ "**Requested versus actual.** A request is *clipped* when the index holds fewer examples\n",
+ "than you asked for, so `index_n_positive` (what you asked for) and\n",
+ "`index_n_positive_actual` (what you got) can differ. Without the actual counts, two rows\n",
+ "can look like a genuine size sweep while describing nearly the same index.\n",
+ "\n",
+ "**Why every index column is prefixed.** `evaluate_groups` already returns an\n",
+ "`n_positive`, meaning *the number of positive groups in your evaluation set*. That is an\n",
+ "entirely different number from the index size, and the two briefly shared one name - so\n",
+ "the index size silently overwrote the group count. Writing a column twice now raises\n",
+ "rather than overwriting."
+ ],
+ "id": "ee6247da"
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-03T23:56:25.272935Z",
+ "iopub.status.busy": "2026-08-03T23:56:25.272842Z",
+ "iopub.status.idle": "2026-08-03T23:56:25.281131Z",
+ "shell.execute_reply": "2026-08-03T23:56:25.279853Z"
+ }
+ },
+ "source": [
+ "# Same rows, two unrelated meanings, both surviving side by side.\n",
+ "# n_positive -> how many of our four sources are labelled positive (2)\n",
+ "# index_n_positive -> how many positive examples the index was built with (3 or 6)\n",
+ "grid[[\"n_positive\", \"index_n_positive\", \"index_n_positive_actual\"]].drop_duplicates()"
+ ],
+ "execution_count": 29,
+ "outputs": [
+ {
+ "output_type": "execute_result",
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
\n",
+ "
n_positive
\n",
+ "
index_n_positive
\n",
+ "
index_n_positive_actual
\n",
+ "
\n",
+ " \n",
+ " \n",
+ "
\n",
+ "
0
\n",
+ "
2
\n",
+ "
3
\n",
+ "
3
\n",
+ "
\n",
+ "
\n",
+ "
24
\n",
+ "
2
\n",
+ "
6
\n",
+ "
6
\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " n_positive index_n_positive index_n_positive_actual\n",
+ "0 2 3 3\n",
+ "24 2 6 6"
+ ]
+ },
+ "metadata": {},
+ "execution_count": 29
+ }
+ ],
+ "id": "fadb7c12"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 6. Observations are encoded once per sweep\n",
+ "\n",
+ "An observation's embedding depends on the **encoder**, never on the index it is scored\n",
+ "against - and a subsampled index shares its parent's model. So re-encoding the same text\n",
+ "on every pass was recomputing identical numbers.\n",
+ "\n",
+ "2.0 hoists the encoding out of the loops. The results are unchanged; only the time is."
+ ],
+ "id": "f159d274"
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-03T23:56:25.284893Z",
+ "iopub.status.busy": "2026-08-03T23:56:25.284565Z",
+ "iopub.status.idle": "2026-08-03T23:56:26.002174Z",
+ "shell.execute_reply": "2026-08-03T23:56:26.000855Z"
+ }
+ },
+ "source": [
+ "sweep = dict(\n",
+ " n_positive_values=[3, 6],\n",
+ " neg_to_pos_ratios=[0.5, 1.0],\n",
+ " top_k_values=[1, 2, 3],\n",
+ " min_score_values=[0.0],\n",
+ " index_seed=42,\n",
+ ")\n",
+ "\n",
+ "start = time.perf_counter()\n",
+ "uncached = run_grid_search(demo_index, groups, cache_observation_embeddings=False, **sweep)\n",
+ "uncached_seconds = time.perf_counter() - start\n",
+ "\n",
+ "start = time.perf_counter()\n",
+ "cached = run_grid_search(demo_index, groups, cache_observation_embeddings=True, **sweep)\n",
+ "cached_seconds = time.perf_counter() - start\n",
+ "\n",
+ "print(f\"re-encoding on every pass: {uncached_seconds:.2f}s\")\n",
+ "print(f\"encoding once: {cached_seconds:.2f}s\")\n",
+ "print(f\"saving: {uncached_seconds / cached_seconds:.1f}x\")\n",
+ "print(f\"identical results: {pd.DataFrame(uncached).equals(pd.DataFrame(cached))}\")"
+ ],
+ "execution_count": 30,
+ "outputs": [
+ {
+ "output_type": "stream",
+ "text": [
+ "re-encoding on every pass: 0.62s\n",
+ "encoding once: 0.09s\n",
+ "saving: 6.6x\n",
+ "identical results: True\n"
+ ],
+ "name": "stdout"
+ }
+ ],
+ "id": "5be653dc"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "The saving grows with the grid, because the single encoding pass is amortised over more\n",
+ "scoring passes. Pass `cache_observation_embeddings=False` if memory is tight - the cache\n",
+ "holds one embedding per observation.\n",
+ "\n",
+ "You can also hold the embeddings yourself, which is what you want when scoring the same\n",
+ "observations against several unrelated indices."
+ ],
+ "id": "6abcb44e"
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-03T23:56:26.004890Z",
+ "iopub.status.busy": "2026-08-03T23:56:26.004795Z",
+ "iopub.status.idle": "2026-08-03T23:56:26.079726Z",
+ "shell.execute_reply": "2026-08-03T23:56:26.076989Z"
+ }
+ },
+ "source": [
+ "embeddings = encode_observations(demo_index, groups)\n",
+ "print(f\"cached embeddings for {len(embeddings)} groups\")\n",
+ "\n",
+ "# The same embeddings, scored against two different indices built from one encoder.\n",
+ "comparisons = []\n",
+ "for name, candidate in (\n",
+ " (\"full\", demo_index),\n",
+ " (\"half-size\", demo_index.subsample(n_positive=3, seed=42)),\n",
+ "):\n",
+ " scored = score_groups(candidate, groups, top_k=3, observation_embeddings=embeddings)\n",
+ " for row in compare_aggregators(scored):\n",
+ " comparisons.append({\"index\": name, **row})\n",
+ "\n",
+ "pd.DataFrame(comparisons).pivot(\n",
+ " index=\"aggregator\", columns=\"index\", values=\"roc_auc\"\n",
+ ")"
+ ],
+ "execution_count": 31,
+ "outputs": [
+ {
+ "output_type": "stream",
+ "text": [
+ "cached embeddings for 4 groups\n"
+ ],
+ "name": "stdout"
+ },
+ {
+ "output_type": "execute_result",
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
index
\n",
+ "
full
\n",
+ "
half-size
\n",
+ "
\n",
+ "
\n",
+ "
aggregator
\n",
+ "
\n",
+ "
\n",
+ "
\n",
+ " \n",
+ " \n",
+ "
\n",
+ "
max_score
\n",
+ "
1.0
\n",
+ "
1.0
\n",
+ "
\n",
+ "
\n",
+ "
mean_of_positives
\n",
+ "
1.0
\n",
+ "
1.0
\n",
+ "
\n",
+ "
\n",
+ "
percentile_score
\n",
+ "
1.0
\n",
+ "
1.0
\n",
+ "
\n",
+ "
\n",
+ "
skewness
\n",
+ "
1.0
\n",
+ "
1.0
\n",
+ "
\n",
+ "
\n",
+ "
softmax_weighted_mean
\n",
+ "
1.0
\n",
+ "
1.0
\n",
+ "
\n",
+ "
\n",
+ "
top_k_mean
\n",
+ "
1.0
\n",
+ "
1.0
\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ "index full half-size\n",
+ "aggregator \n",
+ "max_score 1.0 1.0\n",
+ "mean_of_positives 1.0 1.0\n",
+ "percentile_score 1.0 1.0\n",
+ "skewness 1.0 1.0\n",
+ "softmax_weighted_mean 1.0 1.0\n",
+ "top_k_mean 1.0 1.0"
+ ]
+ },
+ "metadata": {},
+ "execution_count": 31
+ }
+ ],
+ "id": "21f7cb1a"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "At the lowest level this is just a new argument on `calculate_rare_class_affinity`.\n",
+ "Everything above is built on it, and you can reach for it directly when scoring the same\n",
+ "text repeatedly. Sentinel checks that the embeddings line up with the text, because\n",
+ "silently pairing an observation with the wrong vector would be far worse than an error."
+ ],
+ "id": "5f3f3643"
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-03T23:56:26.082839Z",
+ "iopub.status.busy": "2026-08-03T23:56:26.082736Z",
+ "iopub.status.idle": "2026-08-03T23:56:26.106353Z",
+ "shell.execute_reply": "2026-08-03T23:56:26.105172Z"
+ }
+ },
+ "source": [
+ "texts = [\"those people are a plague on this country\", \"the bus route changed again\"]\n",
+ "vectors = demo_index.sentence_model.encode(texts, **demo_index.encoding_kwargs)\n",
+ "\n",
+ "inline = demo_index.calculate_rare_class_affinity(texts)\n",
+ "reused = demo_index.calculate_rare_class_affinity(texts, vectors)\n",
+ "print(f\"same scores either way: {inline.observation_scores == reused.observation_scores}\")\n",
+ "for observation, score in reused.observation_scores.items():\n",
+ " print(f\" {score:.3f} {observation}\")\n",
+ "\n",
+ "# A mismatch is refused rather than quietly scoring against the wrong vectors.\n",
+ "try:\n",
+ " demo_index.calculate_rare_class_affinity(texts, vectors[:1])\n",
+ "except ValueError as error:\n",
+ " print(f\"\\nrefused: {error}\")"
+ ],
+ "execution_count": 32,
+ "outputs": [
+ {
+ "output_type": "stream",
+ "text": [
+ "same scores either way: True\n",
+ " 0.202 those people are a plague on this country\n",
+ " 0.000 the bus route changed again\n",
+ "\n",
+ "refused: sample_embeddings has 1 rows but text_samples has 2 entries. Scoring them together would pair each observation with the wrong embedding, so refusing to continue.\n"
+ ],
+ "name": "stdout"
+ }
+ ],
+ "id": "a196e6d6"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Every aggregator separates this example perfectly, which tells you the example is easy\n",
+ "rather than that the aggregators are equivalent - with four sources and a vocabulary this\n",
+ "clean, there is nothing to distinguish them. The comparison only becomes interesting on\n",
+ "real data, which is what the **Tuning aggregation strategies** section above does.\n",
+ "\n",
+ "What this cell does show is the mechanism: one set of embeddings, computed once, scored\n",
+ "against two different indices without re-encoding anything."
+ ],
+ "id": "84d95551"
+ },
+ {
+ "cell_type": "markdown",
+ "id": "26713735",
+ "metadata": {},
+ "source": [
+ "## 7. Putting it together: a real sweep on the podcast data\n",
+ "\n",
+ "Sections 1-6 used a toy fixture so they would run in seconds. This one uses the real\n",
+ "evaluation set built earlier in the notebook - **30 controversial episodes against 30\n",
+ "Lex Fridman episodes, 22,885 segments in total** - and needs the cells above to have\n",
+ "been run.\n",
+ "\n",
+ "The **Tuning aggregation strategies** section above could only sweep `top_k` and the\n",
+ "threshold, because in 1.0 those were the only knobs that did not require rebuilding the\n",
+ "index. 2.0 adds the index itself as an axis, so we can ask a question that was\n",
+ "previously impractical: *how much does index size and balance actually matter?*"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "44967325",
+ "metadata": {},
+ "source": [
+ "### Why this is affordable now\n",
+ "\n",
+ "Encoding 22,885 segments takes about two minutes. Scoring them against an index, once\n",
+ "they are embedded, takes about a second and a half.\n",
+ "\n",
+ "Before 2.0 every configuration paid the encoding cost again, so a 27-configuration sweep\n",
+ "meant 27 encodes. That is the difference between a coffee break and most of an hour."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 33,
+ "id": "862f6743",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-04T00:25:55.065354Z",
+ "iopub.status.busy": "2026-08-04T00:25:55.065256Z",
+ "iopub.status.idle": "2026-08-04T00:27:53.508923Z",
+ "shell.execute_reply": "2026-08-04T00:27:53.507546Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "60 episodes, 22,885 segments\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "encode once: 117.0s\n",
+ "one scoring pass: 1.4s\n",
+ "\n",
+ "27-configuration sweep\n",
+ " re-encoding each time: 53.3 min\n",
+ " encoding once: 2.6 min\n"
+ ]
+ }
+ ],
+ "source": [
+ "import time\n",
+ "\n",
+ "from sentinel.simulation import encode_observations\n",
+ "\n",
+ "total_segments = sum(len(g.observations) for g in groups)\n",
+ "print(f\"{len(groups)} episodes, {total_segments:,} segments\")\n",
+ "\n",
+ "start = time.perf_counter()\n",
+ "cached = encode_observations(index, groups)\n",
+ "encode_seconds = time.perf_counter() - start\n",
+ "\n",
+ "start = time.perf_counter()\n",
+ "score_groups(index, groups, top_k=5, observation_embeddings=cached)\n",
+ "pass_seconds = time.perf_counter() - start\n",
+ "\n",
+ "passes = 3 * 3 * 3 # sizes x ratios x top_k, the sweep below\n",
+ "print(f\"\\nencode once: {encode_seconds:6.1f}s\")\n",
+ "print(f\"one scoring pass: {pass_seconds:6.1f}s\")\n",
+ "print(f\"\\n{passes}-configuration sweep\")\n",
+ "print(f\" re-encoding each time: {passes * (encode_seconds + pass_seconds) / 60:5.1f} min\")\n",
+ "print(f\" encoding once: {(encode_seconds + passes * pass_seconds) / 60:5.1f} min\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "93b03f6c",
+ "metadata": {},
+ "source": [
+ "### The sweep\n",
+ "\n",
+ "Three index sizes, three negative ratios, three `top_k` values and two thresholds,\n",
+ "across all six aggregators. The index is rebuilt by `subsample()` for each\n",
+ "(size, ratio) pair, which costs milliseconds."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 34,
+ "id": "92eb2e67",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-04T00:27:53.511288Z",
+ "iopub.status.busy": "2026-08-04T00:27:53.511184Z",
+ "iopub.status.idle": "2026-08-04T00:30:21.354617Z",
+ "shell.execute_reply": "2026-08-04T00:30:21.353380Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "324 rows in 148s\n",
+ "\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
\n",
+ "
aggregator
\n",
+ "
index_n_positive
\n",
+ "
index_neg_to_pos_ratio
\n",
+ "
index_n_negative_actual
\n",
+ "
top_k
\n",
+ "
min_score_to_consider
\n",
+ "
roc_auc
\n",
+ "
f1
\n",
+ "
\n",
+ " \n",
+ " \n",
+ "
\n",
+ "
288
\n",
+ "
skewness
\n",
+ "
1516
\n",
+ "
10.0
\n",
+ "
15160
\n",
+ "
3
\n",
+ "
0.0
\n",
+ "
0.995556
\n",
+ "
0.966667
\n",
+ "
\n",
+ "
\n",
+ "
72
\n",
+ "
skewness
\n",
+ "
250
\n",
+ "
10.0
\n",
+ "
2500
\n",
+ "
3
\n",
+ "
0.0
\n",
+ "
0.987778
\n",
+ "
0.949153
\n",
+ "
\n",
+ "
\n",
+ "
192
\n",
+ "
skewness
\n",
+ "
750
\n",
+ "
10.0
\n",
+ "
7500
\n",
+ "
5
\n",
+ "
0.0
\n",
+ "
0.986667
\n",
+ "
0.935484
\n",
+ "
\n",
+ "
\n",
+ "
84
\n",
+ "
skewness
\n",
+ "
250
\n",
+ "
10.0
\n",
+ "
2500
\n",
+ "
5
\n",
+ "
0.0
\n",
+ "
0.985556
\n",
+ "
0.947368
\n",
+ "
\n",
+ "
\n",
+ "
312
\n",
+ "
skewness
\n",
+ "
1516
\n",
+ "
10.0
\n",
+ "
15160
\n",
+ "
10
\n",
+ "
0.0
\n",
+ "
0.985556
\n",
+ "
0.937500
\n",
+ "
\n",
+ "
\n",
+ "
180
\n",
+ "
skewness
\n",
+ "
750
\n",
+ "
10.0
\n",
+ "
7500
\n",
+ "
3
\n",
+ "
0.0
\n",
+ "
0.985556
\n",
+ "
0.967742
\n",
+ "
\n",
+ "
\n",
+ "
300
\n",
+ "
skewness
\n",
+ "
1516
\n",
+ "
10.0
\n",
+ "
15160
\n",
+ "
5
\n",
+ "
0.0
\n",
+ "
0.984444
\n",
+ "
0.952381
\n",
+ "
\n",
+ "
\n",
+ "
264
\n",
+ "
skewness
\n",
+ "
1516
\n",
+ "
5.0
\n",
+ "
7580
\n",
+ "
5
\n",
+ "
0.0
\n",
+ "
0.982222
\n",
+ "
0.949153
\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " aggregator index_n_positive index_neg_to_pos_ratio \\\n",
+ "288 skewness 1516 10.0 \n",
+ "72 skewness 250 10.0 \n",
+ "192 skewness 750 10.0 \n",
+ "84 skewness 250 10.0 \n",
+ "312 skewness 1516 10.0 \n",
+ "180 skewness 750 10.0 \n",
+ "300 skewness 1516 10.0 \n",
+ "264 skewness 1516 5.0 \n",
+ "\n",
+ " index_n_negative_actual top_k min_score_to_consider roc_auc f1 \n",
+ "288 15160 3 0.0 0.995556 0.966667 \n",
+ "72 2500 3 0.0 0.987778 0.949153 \n",
+ "192 7500 5 0.0 0.986667 0.935484 \n",
+ "84 2500 5 0.0 0.985556 0.947368 \n",
+ "312 15160 10 0.0 0.985556 0.937500 \n",
+ "180 7500 3 0.0 0.985556 0.967742 \n",
+ "300 15160 5 0.0 0.984444 0.952381 \n",
+ "264 7580 5 0.0 0.982222 0.949153 "
+ ]
+ },
+ "execution_count": 34,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "start = time.perf_counter()\n",
+ "grid = pd.DataFrame(\n",
+ " run_grid_search(\n",
+ " index,\n",
+ " groups,\n",
+ " n_positive_values=[250, 750, 1516], # 1516 is the whole index\n",
+ " neg_to_pos_ratios=[1.0, 5.0, 10.0],\n",
+ " top_k_values=[3, 5, 10],\n",
+ " min_score_values=[0.0, 0.1],\n",
+ " index_seed=42,\n",
+ " )\n",
+ ")\n",
+ "print(f\"{len(grid)} rows in {time.perf_counter() - start:.0f}s\\n\")\n",
+ "\n",
+ "best = grid.sort_values(\"roc_auc\", ascending=False)\n",
+ "best[[\n",
+ " \"aggregator\", \"index_n_positive\", \"index_neg_to_pos_ratio\",\n",
+ " \"index_n_negative_actual\", \"top_k\", \"min_score_to_consider\", \"roc_auc\", \"f1\",\n",
+ "]].head(8)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "07c94386",
+ "metadata": {},
+ "source": [
+ "### What each axis is worth\n",
+ "\n",
+ "Averaging over everything else shows which knobs move the needle. The `max` column\n",
+ "matters as much as the `mean`: a setting can be poor on average yet contain the single\n",
+ "best configuration."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 35,
+ "id": "3207b644",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-04T00:30:21.357046Z",
+ "iopub.status.busy": "2026-08-04T00:30:21.356965Z",
+ "iopub.status.idle": "2026-08-04T00:30:21.370510Z",
+ "shell.execute_reply": "2026-08-04T00:30:21.369418Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "--- index_n_positive ---\n",
+ " mean max\n",
+ "index_n_positive \n",
+ "1516 0.791 0.996\n",
+ "750 0.759 0.987\n",
+ "250 0.718 0.988\n",
+ "\n",
+ "--- index_neg_to_pos_ratio ---\n",
+ " mean max\n",
+ "index_neg_to_pos_ratio \n",
+ "1.0 0.773 0.939\n",
+ "5.0 0.767 0.982\n",
+ "10.0 0.727 0.996\n",
+ "\n",
+ "--- top_k ---\n",
+ " mean max\n",
+ "top_k \n",
+ "3 0.797 0.996\n",
+ "5 0.771 0.987\n",
+ "10 0.700 0.986\n",
+ "\n",
+ "--- aggregator ---\n",
+ " mean max\n",
+ "aggregator \n",
+ "top_k_mean 0.809 0.890\n",
+ "max_score 0.794 0.871\n",
+ "skewness 0.765 0.996\n",
+ "percentile_score 0.737 0.919\n",
+ "softmax_weighted_mean 0.716 0.931\n",
+ "mean_of_positives 0.715 0.934\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "for axis in (\"index_n_positive\", \"index_neg_to_pos_ratio\", \"top_k\", \"aggregator\"):\n",
+ " summary = (\n",
+ " grid.groupby(axis)[\"roc_auc\"]\n",
+ " .agg([\"mean\", \"max\"])\n",
+ " .round(3)\n",
+ " .sort_values(\"mean\", ascending=False)\n",
+ " )\n",
+ " print(f\"--- {axis} ---\")\n",
+ " print(summary.to_string())\n",
+ " print()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 36,
+ "id": "9b8cf896",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-08-04T00:30:21.372910Z",
+ "iopub.status.busy": "2026-08-04T00:30:21.372832Z",
+ "iopub.status.idle": "2026-08-04T00:30:21.771663Z",
+ "shell.execute_reply": "2026-08-04T00:30:21.770501Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# How index size and balance interact, for the aggregator that wins overall.\n",
+ "winner = best.iloc[0][\"aggregator\"]\n",
+ "sub = grid[(grid[\"aggregator\"] == winner) & (grid[\"min_score_to_consider\"] == 0.0)]\n",
+ "pivot = sub.pivot_table(\n",
+ " index=\"index_n_positive\", columns=\"index_neg_to_pos_ratio\", values=\"roc_auc\", aggfunc=\"max\"\n",
+ ")\n",
+ "\n",
+ "fig, ax = plt.subplots(figsize=(7, 4))\n",
+ "im = ax.imshow(pivot.values, cmap=\"viridis\", aspect=\"auto\")\n",
+ "ax.set_xticks(range(len(pivot.columns)), [str(c) for c in pivot.columns])\n",
+ "ax.set_yticks(range(len(pivot.index)), [str(i) for i in pivot.index])\n",
+ "ax.set_xlabel(\"negatives per positive\")\n",
+ "ax.set_ylabel(\"positive examples in index\")\n",
+ "ax.set_title(f\"best ROC-AUC for '{winner}' by index shape\")\n",
+ "for i in range(len(pivot.index)):\n",
+ " for j in range(len(pivot.columns)):\n",
+ " ax.text(j, i, f\"{pivot.values[i, j]:.3f}\", ha=\"center\", va=\"center\", color=\"white\")\n",
+ "fig.colorbar(im, ax=ax, label=\"ROC-AUC\")\n",
+ "plt.tight_layout()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9f393199",
+ "metadata": {},
+ "source": [
+ "### Conclusion\n",
+ "\n",
+ "**A bigger index is reliably better.** Mean ROC-AUC climbs with every step up in size -\n",
+ "250, then 750, then the full 1,516 positives - and the single best configuration uses\n",
+ "the whole index. There is no plateau within this range, which suggests the index is if\n",
+ "anything still too small.\n",
+ "\n",
+ "**Fewer neighbours beat more.** `top_k=3` wins on both mean and max, and performance\n",
+ "falls off steadily at 5 and 10. Widening the neighbourhood pulls in weaker matches that\n",
+ "dilute the signal.\n",
+ "\n",
+ "**Balance is the subtle one.** Averaged across everything, a 1:1 ratio looks best and\n",
+ "10:1 looks worst - yet the single best configuration in the whole sweep uses 10:1. More\n",
+ "negatives help the aggregator that can exploit them and hurt the ones that cannot, so\n",
+ "the average hides the effect entirely.\n",
+ "\n",
+ "**The same trap catches the aggregator.** Ranked by *average* ROC-AUC, `top_k_mean`\n",
+ "comes first and `skewness` only third. Ranked by *best achievable*, `skewness` is far\n",
+ "ahead of everything else. In other words skewness is the most configuration-sensitive\n",
+ "aggregator: mediocre when badly tuned, and the clear winner when tuned well.\n",
+ "\n",
+ "That is the argument for sweeping rather than picking a default. Choosing the aggregator\n",
+ "by its average would have selected `top_k_mean` and left roughly a tenth of a point of\n",
+ "ROC-AUC on the table - and it is exactly the kind of interaction you cannot see without\n",
+ "varying the index alongside everything else, which is what 2.0 makes cheap enough to do."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Summary\n",
+ "\n",
+ "| Feature | What it replaces |\n",
+ "|---|---|\n",
+ "| `from_texts()` | An eight-step manual build, two steps of which fail silently |\n",
+ "| `subsample()` | Re-encoding the whole corpus to try a smaller index |\n",
+ "| Index sweep axes | Only being able to sweep `top_k` and the threshold |\n",
+ "| Cached observation embeddings | Re-encoding identical text on every pass |\n",
+ "| Persisted corpus | Explanations degrading to row numbers after a reload |\n",
+ "| Seeded `load()` | A saved index scoring differently on every load |\n",
+ "\n",
+ "One caveat on the numbers above: this appendix uses a deliberately tiny example set so it\n",
+ "runs in seconds. Treat the timings as illustrations of the shape of the improvement\n",
+ "rather than as benchmarks - on a real corpus the encoding cost dominates far more, so the\n",
+ "savings are correspondingly larger."
+ ],
+ "id": "cb0944e7"
}
],
"metadata": {
diff --git a/poetry.lock b/poetry.lock
index 01a4cba..03994e0 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -7,7 +7,6 @@ description = "Happy Eyeballs for asyncio"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"},
{file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"},
@@ -20,7 +19,6 @@ description = "Async http client/server framework (asyncio)"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b"},
{file = "aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5"},
@@ -155,7 +153,7 @@ propcache = ">=0.2.0"
yarl = ">=1.17.0,<2.0"
[package.extras]
-speedups = ["Brotli (>=1.2)", "aiodns (>=3.3.0)", "backports.zstd", "brotlicffi (>=1.2)"]
+speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""]
[[package]]
name = "aiosignal"
@@ -164,7 +162,6 @@ description = "aiosignal: a list of registered asynchronous callbacks"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"},
{file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"},
@@ -181,7 +178,6 @@ description = "A light, configurable Sphinx theme"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"},
{file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"},
@@ -194,7 +190,6 @@ description = "High-level concurrency and networking framework on top of asyncio
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"},
{file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"},
@@ -206,7 +201,7 @@ idna = ">=2.8"
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras]
-trio = ["trio (>=0.31.0)", "trio (>=0.32.0)"]
+trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""]
[[package]]
name = "appnope"
@@ -215,7 +210,7 @@ description = "Disable App Nap on macOS >= 10.9"
optional = false
python-versions = ">=3.6"
groups = ["examples"]
-markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and platform_system == \"Darwin\""
+markers = "platform_system == \"Darwin\""
files = [
{file = "appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c"},
{file = "appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee"},
@@ -228,7 +223,6 @@ description = "Argon2 for Python"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741"},
{file = "argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1"},
@@ -244,7 +238,6 @@ description = "Low-level CFFI bindings for Argon2"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f"},
{file = "argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b"},
@@ -287,7 +280,6 @@ description = "Better dates & times for Python"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205"},
{file = "arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7"},
@@ -308,7 +300,6 @@ description = "Annotate AST trees with source code positions"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933"},
{file = "asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2"},
@@ -325,7 +316,6 @@ description = "Simple LRU cache for asyncio"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943"},
{file = "async_lru-2.0.5.tar.gz", hash = "sha256:481d52ccdd27275f42c43a928b4a50c3bfb2d67af4e78b170e3e0bb39c66e5bb"},
@@ -341,7 +331,7 @@ description = "Timeout context manager for asyncio programs"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version < \"3.11\""
+markers = "python_version == \"3.10\""
files = [
{file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"},
{file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"},
@@ -354,7 +344,6 @@ description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"},
{file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"},
@@ -367,14 +356,13 @@ description = "Internationalization utilities"
optional = false
python-versions = ">=3.8"
groups = ["docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"},
{file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"},
]
[package.extras]
-dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"]
+dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""]
[[package]]
name = "beautifulsoup4"
@@ -383,7 +371,6 @@ description = "Screen-scraping library"
optional = false
python-versions = ">=3.7.0"
groups = ["docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9"},
{file = "beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7"},
@@ -407,7 +394,6 @@ description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e"},
{file = "black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0"},
@@ -460,7 +446,6 @@ description = "An easy safelist-based HTML-sanitizing tool."
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e"},
{file = "bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f"},
@@ -480,7 +465,6 @@ description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["main", "docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"},
{file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"},
@@ -493,7 +477,6 @@ description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
@@ -591,7 +574,6 @@ description = "Validate configuration and produce human readable error messages.
optional = false
python-versions = ">=3.8"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
@@ -604,7 +586,6 @@ description = "The Real First Universal Charset Detector. Open, modern and activ
optional = false
python-versions = ">=3.7"
groups = ["main", "docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"},
{file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"},
@@ -708,7 +689,6 @@ description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"},
{file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"},
@@ -728,7 +708,7 @@ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
-markers = {main = "(python_version <= \"3.11\" or python_version >= \"3.12\") and platform_system == \"Windows\"", dev = "(sys_platform == \"win32\" or platform_system == \"Windows\") and (python_version <= \"3.11\" or python_version >= \"3.12\")", docs = "(python_version <= \"3.11\" or python_version >= \"3.12\") and sys_platform == \"win32\"", examples = "(platform_system == \"Windows\" or sys_platform == \"win32\") and (python_version <= \"3.11\" or python_version >= \"3.12\")"}
+markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\" or platform_system == \"Windows\"", docs = "sys_platform == \"win32\"", examples = "platform_system == \"Windows\" or sys_platform == \"win32\""}
[[package]]
name = "comm"
@@ -737,7 +717,6 @@ description = "Jupyter Python Comm implementation, for usage in ipykernel, xeus-
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417"},
{file = "comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971"},
@@ -753,7 +732,6 @@ description = "Python library for calculating contours of 2D quadrilateral grids
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "contourpy-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:880ea32e5c774634f9fcd46504bf9f080a41ad855f4fef54f5380f5133d343c7"},
{file = "contourpy-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:76c905ef940a4474a6289c71d53122a4f77766eef23c03cd57016ce19d0f7b42"},
@@ -839,7 +817,6 @@ description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"},
{file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"},
@@ -951,7 +928,7 @@ files = [
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
-toml = ["tomli"]
+toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "cycler"
@@ -960,7 +937,6 @@ description = "Composable style cycles"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"},
{file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"},
@@ -977,7 +953,6 @@ description = "HuggingFace community-driven open-source library of datasets"
optional = false
python-versions = ">=3.8.0"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "datasets-2.21.0-py3-none-any.whl", hash = "sha256:25e4e097110ce28824b746a107727ada94024cba11db8bc588d468414692b65a"},
{file = "datasets-2.21.0.tar.gz", hash = "sha256:998f85a8460f1bd982e5bd058f8a0808eef424249e3df1e8cdd594ccd0dc8ba2"},
@@ -1001,9 +976,9 @@ xxhash = "*"
[package.extras]
apache-beam = ["apache-beam (>=2.26.0)"]
-audio = ["librosa", "soundfile (>=0.12.1)", "soxr (>=0.4.0)"]
+audio = ["librosa", "soundfile (>=0.12.1)", "soxr (>=0.4.0) ; python_version >= \"3.9\""]
benchmarks = ["tensorflow (==2.12.0)", "torch (==2.0.1)", "transformers (==4.30.1)"]
-dev = ["Pillow (>=9.4.0)", "absl-py", "decorator", "elasticsearch (<8.0.0)", "faiss-cpu (>=1.8.0.post1)", "jax (>=0.3.14)", "jaxlib (>=0.3.14)", "joblib (<1.3.0)", "joblibspark", "librosa", "lz4", "moto[server]", "polars[timezone] (>=0.20.0)", "protobuf (<4.0.0)", "py7zr", "pyspark (>=3.4)", "pytest", "pytest-datadir", "pytest-xdist", "rarfile (>=4.0)", "ruff (>=0.3.0)", "s3fs", "s3fs (>=2021.11.1)", "soundfile (>=0.12.1)", "soxr (>=0.4.0)", "sqlalchemy", "tensorflow (>=2.16.0)", "tensorflow (>=2.6.0)", "tensorflow (>=2.6.0)", "tiktoken", "torch", "torch (>=2.0.0)", "transformers", "transformers (>=4.42.0)", "typing-extensions (>=4.6.1)", "zstandard"]
+dev = ["Pillow (>=9.4.0)", "absl-py", "decorator", "elasticsearch (<8.0.0)", "faiss-cpu (>=1.8.0.post1)", "jax (>=0.3.14) ; sys_platform != \"win32\"", "jaxlib (>=0.3.14) ; sys_platform != \"win32\"", "joblib (<1.3.0)", "joblibspark", "librosa", "lz4", "moto[server]", "polars[timezone] (>=0.20.0)", "protobuf (<4.0.0)", "py7zr", "pyspark (>=3.4)", "pytest", "pytest-datadir", "pytest-xdist", "rarfile (>=4.0)", "ruff (>=0.3.0)", "s3fs", "s3fs (>=2021.11.1)", "soundfile (>=0.12.1)", "soxr (>=0.4.0) ; python_version >= \"3.9\"", "sqlalchemy", "tensorflow (>=2.16.0) ; python_version >= \"3.10\"", "tensorflow (>=2.6.0)", "tensorflow (>=2.6.0) ; python_version < \"3.10\"", "tiktoken", "torch", "torch (>=2.0.0)", "transformers", "transformers (>=4.42.0)", "typing-extensions (>=4.6.1)", "zstandard"]
docs = ["s3fs", "tensorflow (>=2.6.0)", "torch", "transformers"]
jax = ["jax (>=0.3.14)", "jaxlib (>=0.3.14)"]
metrics-tests = ["Werkzeug (>=1.0.1)", "accelerate", "bert-score (>=0.3.6)", "jiwer", "langdetect", "mauve-text", "nltk (<3.8.2)", "requests-file (>=1.5.1)", "rouge-score", "sacrebleu", "sacremoses", "scikit-learn", "scipy", "sentencepiece", "seqeval", "six (>=1.15.0,<1.16.0)", "spacy (>=3.0.0)", "texttable (>=1.6.3)", "tldextract", "tldextract (>=3.1.0)", "toml (>=0.10.1)", "typer (<0.5.0)"]
@@ -1011,8 +986,8 @@ quality = ["ruff (>=0.3.0)"]
s3 = ["s3fs"]
tensorflow = ["tensorflow (>=2.6.0)"]
tensorflow-gpu = ["tensorflow (>=2.6.0)"]
-tests = ["Pillow (>=9.4.0)", "absl-py", "decorator", "elasticsearch (<8.0.0)", "faiss-cpu (>=1.8.0.post1)", "jax (>=0.3.14)", "jaxlib (>=0.3.14)", "joblib (<1.3.0)", "joblibspark", "librosa", "lz4", "moto[server]", "polars[timezone] (>=0.20.0)", "protobuf (<4.0.0)", "py7zr", "pyspark (>=3.4)", "pytest", "pytest-datadir", "pytest-xdist", "rarfile (>=4.0)", "s3fs (>=2021.11.1)", "soundfile (>=0.12.1)", "soxr (>=0.4.0)", "sqlalchemy", "tensorflow (>=2.16.0)", "tensorflow (>=2.6.0)", "tiktoken", "torch (>=2.0.0)", "transformers (>=4.42.0)", "typing-extensions (>=4.6.1)", "zstandard"]
-tests-numpy2 = ["Pillow (>=9.4.0)", "absl-py", "decorator", "elasticsearch (<8.0.0)", "jax (>=0.3.14)", "jaxlib (>=0.3.14)", "joblib (<1.3.0)", "joblibspark", "librosa", "lz4", "moto[server]", "polars[timezone] (>=0.20.0)", "protobuf (<4.0.0)", "py7zr", "pyspark (>=3.4)", "pytest", "pytest-datadir", "pytest-xdist", "rarfile (>=4.0)", "s3fs (>=2021.11.1)", "soundfile (>=0.12.1)", "soxr (>=0.4.0)", "sqlalchemy", "tiktoken", "torch (>=2.0.0)", "typing-extensions (>=4.6.1)", "zstandard"]
+tests = ["Pillow (>=9.4.0)", "absl-py", "decorator", "elasticsearch (<8.0.0)", "faiss-cpu (>=1.8.0.post1)", "jax (>=0.3.14) ; sys_platform != \"win32\"", "jaxlib (>=0.3.14) ; sys_platform != \"win32\"", "joblib (<1.3.0)", "joblibspark", "librosa", "lz4", "moto[server]", "polars[timezone] (>=0.20.0)", "protobuf (<4.0.0)", "py7zr", "pyspark (>=3.4)", "pytest", "pytest-datadir", "pytest-xdist", "rarfile (>=4.0)", "s3fs (>=2021.11.1)", "soundfile (>=0.12.1)", "soxr (>=0.4.0) ; python_version >= \"3.9\"", "sqlalchemy", "tensorflow (>=2.16.0) ; python_version >= \"3.10\"", "tensorflow (>=2.6.0) ; python_version < \"3.10\"", "tiktoken", "torch (>=2.0.0)", "transformers (>=4.42.0)", "typing-extensions (>=4.6.1)", "zstandard"]
+tests-numpy2 = ["Pillow (>=9.4.0)", "absl-py", "decorator", "elasticsearch (<8.0.0)", "jax (>=0.3.14) ; sys_platform != \"win32\"", "jaxlib (>=0.3.14) ; sys_platform != \"win32\"", "joblib (<1.3.0)", "joblibspark", "librosa", "lz4", "moto[server]", "polars[timezone] (>=0.20.0)", "protobuf (<4.0.0)", "py7zr", "pyspark (>=3.4)", "pytest", "pytest-datadir", "pytest-xdist", "rarfile (>=4.0)", "s3fs (>=2021.11.1)", "soundfile (>=0.12.1)", "soxr (>=0.4.0) ; python_version >= \"3.9\"", "sqlalchemy", "tiktoken", "torch (>=2.0.0)", "typing-extensions (>=4.6.1)", "zstandard"]
torch = ["torch"]
vision = ["Pillow (>=9.4.0)"]
@@ -1023,7 +998,6 @@ description = "An implementation of the Debug Adapter Protocol for Python"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "debugpy-1.8.21-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:8eeab7b5462f683452c57c0126aaa5ec4e974ddb705f39ba87dff8818c8e08f9"},
{file = "debugpy-1.8.21-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:0fddfdc130ac6d8bfc0415b0409822fa901c8f310e5c945ac5653a0352532344"},
@@ -1064,7 +1038,6 @@ description = "Decorators for Humans"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c"},
{file = "decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82"},
@@ -1077,7 +1050,6 @@ description = "XML bomb protection for Python stdlib modules"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
@@ -1090,7 +1062,6 @@ description = "serialize all of Python"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"},
{file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"},
@@ -1107,7 +1078,6 @@ description = "Distribution utilities"
optional = false
python-versions = "*"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b"},
{file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"},
@@ -1120,7 +1090,6 @@ description = "Docutils -- Python Documentation Utilities"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"},
{file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"},
@@ -1133,7 +1102,7 @@ description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
groups = ["dev", "examples"]
-markers = "python_version < \"3.11\""
+markers = "python_version == \"3.10\""
files = [
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
@@ -1152,14 +1121,13 @@ description = "Get the currently executing AST node of a frame, and other inform
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"},
{file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"},
]
[package.extras]
-tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"]
+tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""]
[[package]]
name = "fastjsonschema"
@@ -1168,7 +1136,6 @@ description = "Fastest Python implementation of JSON schema"
optional = false
python-versions = "*"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463"},
{file = "fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de"},
@@ -1179,15 +1146,14 @@ devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benc
[[package]]
name = "filelock"
-version = "3.19.1"
+version = "3.32.2"
description = "A platform independent file lock."
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.10"
groups = ["main", "dev", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
- {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"},
- {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"},
+ {file = "filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82"},
+ {file = "filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8"},
]
[[package]]
@@ -1197,7 +1163,6 @@ description = "the modular source code checker: pep8 pyflakes and co"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"},
{file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"},
@@ -1215,7 +1180,6 @@ description = "Tools to manipulate font files"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "fonttools-4.60.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e36fadcf7e8ca6e34d490eef86ed638d6fd9c55d2f514b05687622cfc4a7050"},
{file = "fonttools-4.60.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e500fc9c04bee749ceabfc20cb4903f6981c2139050d85720ea7ada61b75d5c"},
@@ -1278,17 +1242,17 @@ files = [
]
[package.extras]
-all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "pycairo", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.45.0)", "unicodedata2 (>=17.0.0)", "xattr", "zopfli (>=0.1.4)"]
+all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.45.0)", "unicodedata2 (>=17.0.0) ; python_version <= \"3.14\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"]
graphite = ["lz4 (>=1.7.4.2)"]
-interpolatable = ["munkres", "pycairo", "scipy"]
+interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""]
lxml = ["lxml (>=4.0)"]
pathops = ["skia-pathops (>=0.5.0)"]
plot = ["matplotlib"]
repacker = ["uharfbuzz (>=0.45.0)"]
symfont = ["sympy"]
-type1 = ["xattr"]
-unicode = ["unicodedata2 (>=17.0.0)"]
-woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
+type1 = ["xattr ; sys_platform == \"darwin\""]
+unicode = ["unicodedata2 (>=17.0.0) ; python_version <= \"3.14\""]
+woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"]
[[package]]
name = "fqdn"
@@ -1297,7 +1261,6 @@ description = "Validates fully-qualified domain names against RFC 1123, so that
optional = false
python-versions = ">=2.7, !=3.0, !=3.1, !=3.2, !=3.3, !=3.4, <4"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014"},
{file = "fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f"},
@@ -1310,7 +1273,6 @@ description = "A list-like structure which implements collections.abc.MutableSeq
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"},
{file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"},
@@ -1451,7 +1413,6 @@ description = "File-system specification"
optional = false
python-versions = ">=3.8"
groups = ["main", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "fsspec-2024.6.1-py3-none-any.whl", hash = "sha256:3cb443f8bcd2efb31295a5b9fdb02aee81d8452c80d28f97a6d0959e6cee101e"},
{file = "fsspec-2024.6.1.tar.gz", hash = "sha256:fad7d7e209dd4c1208e3bbfda706620e0da5142bebbd9c384afb95b07e798e49"},
@@ -1495,7 +1456,6 @@ description = "A clean customisable Sphinx documentation theme."
optional = false
python-versions = ">=3.8"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "furo-2024.8.6-py3-none-any.whl", hash = "sha256:6cd97c58b47813d3619e63e9081169880fbe331f0ca883c871ff1f3f11814f5c"},
{file = "furo-2024.8.6.tar.gz", hash = "sha256:b63e4cee8abfc3136d3bc03a3d45a76a850bada4d6374d24c1716b0e01394a01"},
@@ -1505,7 +1465,7 @@ files = [
beautifulsoup4 = "*"
pygments = ">=2.7"
sphinx = ">=6.0,<9.0"
-sphinx-basic-ng = ">=1.0.0.beta2"
+sphinx-basic-ng = ">=1.0.0b2"
[[package]]
name = "h11"
@@ -1514,7 +1474,6 @@ description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
@@ -1527,7 +1486,7 @@ description = "Fast transfer of large files with the Hugging Face Hub."
optional = false
python-versions = ">=3.8"
groups = ["main", "examples"]
-markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and (platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\")"
+markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\""
files = [
{file = "hf_xet-1.5.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4a5ecb9cda8512ba2aa8ee5d37c87a1422992165892d653098c7b90247481c3b"},
{file = "hf_xet-1.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8764488197c1d7b1378c8438c18d2eea902e150dbca0b0f0d2d32603fb9b5576"},
@@ -1558,7 +1517,6 @@ description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
@@ -1581,7 +1539,6 @@ description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
@@ -1594,7 +1551,7 @@ httpcore = "==1.*"
idna = "*"
[package.extras]
-brotli = ["brotli", "brotlicffi"]
+brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
@@ -1607,7 +1564,6 @@ description = "Client library to download and publish models, datasets and other
optional = false
python-versions = ">=3.8.0"
groups = ["main", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270"},
{file = "huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a"},
@@ -1624,16 +1580,16 @@ tqdm = ">=4.42.1"
typing-extensions = ">=3.7.4.3"
[package.extras]
-all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0)", "mypy (>=1.14.1,<1.15.0)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
+all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
cli = ["InquirerPy (==0.3.4)"]
-dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0)", "mypy (>=1.14.1,<1.15.0)", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
+dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"]
fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"]
hf-transfer = ["hf_transfer (>=0.1.4)"]
hf-xet = ["hf-xet (>=1.1.2,<2.0.0)"]
inference = ["aiohttp"]
mcp = ["aiohttp", "mcp (>=1.8.0)", "typer"]
oauth = ["authlib (>=1.3.2)", "fastapi", "httpx", "itsdangerous"]
-quality = ["libcst (>=1.4.0)", "mypy (==1.15.0)", "mypy (>=1.14.1,<1.15.0)", "ruff (>=0.9.0)", "ty"]
+quality = ["libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "ruff (>=0.9.0)", "ty"]
tensorflow = ["graphviz", "pydot", "tensorflow"]
tensorflow-testing = ["keras (<3.0)", "tensorflow"]
testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"]
@@ -1647,7 +1603,6 @@ description = "File identification library for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"},
{file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"},
@@ -1663,7 +1618,6 @@ description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.9"
groups = ["main", "docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
@@ -1679,61 +1633,11 @@ description = "Getting image size from png/jpeg/jpeg2000/gif file"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "imagesize-1.5.0-py2.py3-none-any.whl", hash = "sha256:32677681b3f434c2cb496f00e89c5a291247b35b1f527589909e008057da5899"},
{file = "imagesize-1.5.0.tar.gz", hash = "sha256:8bfc5363a7f2133a89f0098451e0bcb1cd71aba4dc02bbcecb39d99d40e1b94f"},
]
-[[package]]
-name = "importlib-metadata"
-version = "8.7.1"
-description = "Read metadata from Python packages"
-optional = false
-python-versions = ">=3.9"
-groups = ["docs", "examples"]
-markers = "python_version < \"3.10\""
-files = [
- {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"},
- {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"},
-]
-
-[package.dependencies]
-zipp = ">=3.20"
-
-[package.extras]
-check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
-cover = ["pytest-cov"]
-doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-enabler = ["pytest-enabler (>=3.4)"]
-perf = ["ipython"]
-test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
-type = ["mypy (<1.19)", "pytest-mypy (>=1.0.1)"]
-
-[[package]]
-name = "importlib-resources"
-version = "6.5.2"
-description = "Read resources from Python packages"
-optional = false
-python-versions = ">=3.9"
-groups = ["examples"]
-markers = "python_version < \"3.10\""
-files = [
- {file = "importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec"},
- {file = "importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c"},
-]
-
-[package.dependencies]
-zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
-
-[package.extras]
-check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
-cover = ["pytest-cov"]
-doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-enabler = ["pytest-enabler (>=2.2)"]
-test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"]
-type = ["pytest-mypy"]
-
[[package]]
name = "iniconfig"
version = "2.1.0"
@@ -1741,7 +1645,6 @@ description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
@@ -1754,7 +1657,6 @@ description = "IPython Kernel for Jupyter"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "ipykernel-6.31.0-py3-none-any.whl", hash = "sha256:abe5386f6ced727a70e0eb0cf1da801fa7c5fa6ff82147747d5a0406cd8c94af"},
{file = "ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6"},
@@ -1789,7 +1691,6 @@ description = "IPython: Productive Interactive Computing"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "ipython-8.18.1-py3-none-any.whl", hash = "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397"},
{file = "ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27"},
@@ -1806,7 +1707,6 @@ prompt-toolkit = ">=3.0.41,<3.1.0"
pygments = ">=2.4.0"
stack-data = "*"
traitlets = ">=5"
-typing-extensions = {version = "*", markers = "python_version < \"3.10\""}
[package.extras]
all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"]
@@ -1828,7 +1728,6 @@ description = "Jupyter interactive widgets"
optional = false
python-versions = ">=3.7"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e"},
{file = "ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668"},
@@ -1851,7 +1750,6 @@ description = "Operations with ISO 8601 durations"
optional = false
python-versions = ">=3.7"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042"},
{file = "isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9"},
@@ -1867,7 +1765,6 @@ description = "An autocompletion tool for Python that can be used for text edito
optional = false
python-versions = ">=3.6"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"},
{file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"},
@@ -1888,7 +1785,6 @@ description = "A very fast and expressive template engine."
optional = false
python-versions = ">=3.7"
groups = ["main", "docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
@@ -1907,7 +1803,7 @@ description = "Lightweight pipelining with Python functions"
optional = true
python-versions = ">=3.9"
groups = ["main"]
-markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"sbert\""
+markers = "extra == \"sbert\""
files = [
{file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"},
{file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"},
@@ -1920,7 +1816,6 @@ description = "A Python implementation of the JSON5 data format."
optional = false
python-versions = ">=3.8.0"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "json5-0.15.0-py3-none-any.whl", hash = "sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618"},
{file = "json5-0.15.0.tar.gz", hash = "sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71"},
@@ -1933,7 +1828,6 @@ description = "Identify specific nodes in a JSON document (RFC 6901)"
optional = false
python-versions = ">=3.7"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"},
{file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"},
@@ -1946,7 +1840,6 @@ description = "An implementation of JSON Schema validation for Python"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"},
{file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"},
@@ -1958,7 +1851,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""}
-jsonschema-specifications = ">=2023.03.6"
+jsonschema-specifications = ">=2023.3.6"
referencing = ">=0.28.4"
rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""}
@@ -1978,7 +1871,6 @@ description = "The JSON Schema meta-schemas and vocabularies, exposed as a Regis
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"},
{file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"},
@@ -1994,7 +1886,6 @@ description = "Jupyter metapackage. Install all the Jupyter components in one go
optional = false
python-versions = "*"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83"},
{file = "jupyter-1.1.1.tar.gz", hash = "sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a"},
@@ -2015,14 +1906,12 @@ description = "Jupyter protocol implementation and client libraries"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f"},
{file = "jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419"},
]
[package.dependencies]
-importlib-metadata = {version = ">=4.8.3", markers = "python_version < \"3.10\""}
jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0"
python-dateutil = ">=2.8.2"
pyzmq = ">=23.0"
@@ -2031,7 +1920,7 @@ traitlets = ">=5.3"
[package.extras]
docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"]
-test = ["coverage", "ipykernel (>=6.14)", "mypy", "paramiko", "pre-commit", "pytest (<8.2.0)", "pytest-cov", "pytest-jupyter[client] (>=0.4.1)", "pytest-timeout"]
+test = ["coverage", "ipykernel (>=6.14)", "mypy", "paramiko ; sys_platform == \"win32\"", "pre-commit", "pytest (<8.2.0)", "pytest-cov", "pytest-jupyter[client] (>=0.4.1)", "pytest-timeout"]
[[package]]
name = "jupyter-console"
@@ -2040,7 +1929,6 @@ description = "Jupyter terminal console"
optional = false
python-versions = ">=3.7"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485"},
{file = "jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539"},
@@ -2066,7 +1954,6 @@ description = "Jupyter core package. A base package on which Jupyter projects re
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jupyter_core-5.8.1-py3-none-any.whl", hash = "sha256:c28d268fc90fb53f1338ded2eb410704c5449a358406e8a948b75706e24863d0"},
{file = "jupyter_core-5.8.1.tar.gz", hash = "sha256:0a5f9706f70e64786b75acba995988915ebd4601c8a52e534a40b51c95f59941"},
@@ -2088,7 +1975,6 @@ description = "Jupyter Event System library"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf"},
{file = "jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3"},
@@ -2116,14 +2002,12 @@ description = "Multi-Language Server WebSocket proxy for Jupyter Notebook/Lab se
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jupyter_lsp-2.3.1-py3-none-any.whl", hash = "sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81"},
{file = "jupyter_lsp-2.3.1.tar.gz", hash = "sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6"},
]
[package.dependencies]
-importlib_metadata = {version = ">=4.8.3", markers = "python_version < \"3.10\""}
jupyter_server = ">=1.1.2"
[[package]]
@@ -2133,7 +2017,6 @@ description = "The backend—i.e. core services, APIs, and REST endpoints—to J
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jupyter_server-2.18.2-py3-none-any.whl", hash = "sha256:fa5e46539ded65791838035a2b6001f13e54d5f64b8b3752eb1e91fdd641a5b8"},
{file = "jupyter_server-2.18.2.tar.gz", hash = "sha256:06b4f40d8a7a00bb39d5216859c81374a0e7cfefe6d8a5a7facc5a5c37c679a7"},
@@ -2171,7 +2054,6 @@ description = "A Jupyter Server Extension Providing Terminals."
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14"},
{file = "jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5"},
@@ -2192,7 +2074,6 @@ description = "JupyterLab computational environment"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jupyterlab-4.5.9-py3-none-any.whl", hash = "sha256:5ff0f908e8ac0afbed32b106fdef360f101c0a6654d1bf4a81e98a293ae1b336"},
{file = "jupyterlab-4.5.9.tar.gz", hash = "sha256:dd79a073fecae7a39066ea99e4627ed6c76269ac926e95a810e1e1df6358d865"},
@@ -2201,7 +2082,6 @@ files = [
[package.dependencies]
async-lru = ">=1.0.0"
httpx = ">=0.25.0,<1"
-importlib-metadata = {version = ">=4.8.3", markers = "python_version < \"3.10\""}
ipykernel = ">=6.5.0,<6.30.0 || >6.30.0"
jinja2 = ">=3.0.3"
jupyter-core = "*"
@@ -2229,7 +2109,6 @@ description = "Pygments theme using JupyterLab CSS variables"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780"},
{file = "jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d"},
@@ -2242,7 +2121,6 @@ description = "A set of server components for JupyterLab and JupyterLab like app
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968"},
{file = "jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c"},
@@ -2250,7 +2128,6 @@ files = [
[package.dependencies]
babel = ">=2.10"
-importlib-metadata = {version = ">=4.8.3", markers = "python_version < \"3.10\""}
jinja2 = ">=3.0.3"
json5 = ">=0.9.0"
jsonschema = ">=4.18.0"
@@ -2270,7 +2147,6 @@ description = "Jupyter interactive widgets for JupyterLab"
optional = false
python-versions = ">=3.7"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8"},
{file = "jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0"},
@@ -2283,7 +2159,6 @@ description = "A fast implementation of the Cassowary constraint solver"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "kiwisolver-1.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8a9c83f75223d5e48b0bc9cb1bf2776cf01563e00ade8775ffe13b0b6e1af3a6"},
{file = "kiwisolver-1.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:58370b1ffbd35407444d57057b57da5d6549d2d854fa30249771775c63b5fe17"},
@@ -2408,7 +2283,6 @@ description = "a modern parsing library"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12"},
{file = "lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905"},
@@ -2427,7 +2301,7 @@ description = "Mypyc runtime library"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and platform_python_implementation != \"PyPy\""
+markers = "platform_python_implementation != \"PyPy\""
files = [
{file = "librt-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34e47058fcc69a313293d6dee94216a4f30c929ae6f2476e58c5ba635aa639d5"},
{file = "librt-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dbdd5b6509d0c2a8fe72cf494c299a61dbd58142a90a4190664ae159e4a7b547"},
@@ -2530,7 +2404,6 @@ description = "Convert HTML to markdown."
optional = false
python-versions = "*"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "markdownify-0.11.6-py3-none-any.whl", hash = "sha256:ba35fe289d5e9073bcd7d2cad629278fe25f1a93741fcdc0bfb4f009076d8324"},
{file = "markdownify-0.11.6.tar.gz", hash = "sha256:009b240e0c9f4c8eaf1d085625dcd4011e12f0f8cec55dedf9ea6f7655e49bfe"},
@@ -2547,7 +2420,6 @@ description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.9"
groups = ["main", "docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
@@ -2647,7 +2519,6 @@ description = "Python plotting package"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "matplotlib-3.9.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c5fdd7abfb706dfa8d307af64a87f1a862879ec3cd8d0ec8637458f0885b9c50"},
{file = "matplotlib-3.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d89bc4e85e40a71d1477780366c27fb7c6494d293e1617788986f74e2a03d7ff"},
@@ -2696,7 +2567,6 @@ files = [
contourpy = ">=1.0.1"
cycler = ">=0.10"
fonttools = ">=4.22.0"
-importlib-resources = {version = ">=3.2.0", markers = "python_version < \"3.10\""}
kiwisolver = ">=1.3.1"
numpy = ">=1.23"
packaging = ">=20.0"
@@ -2714,7 +2584,6 @@ description = "Inline Matplotlib backend for Jupyter"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6"},
{file = "matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79"},
@@ -2733,7 +2602,6 @@ description = "McCabe checker, plugin for flake8"
optional = false
python-versions = ">=3.6"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
@@ -2746,7 +2614,6 @@ description = "A sane and fast Markdown parser with useful plugins and renderers
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "mistune-3.3.3-py3-none-any.whl", hash = "sha256:99de1585e42dcbd826faa9e11a202727a5e202e4e4722a4c69ac1ff615793dd7"},
{file = "mistune-3.3.3.tar.gz", hash = "sha256:c4c6c0c840b8637a2e9b8b6d607eb7c8f00888bf14c754409bcd339e848c2477"},
@@ -2762,7 +2629,6 @@ description = "Python library for arbitrary-precision floating-point arithmetic"
optional = false
python-versions = "*"
groups = ["main"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"},
{file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"},
@@ -2771,7 +2637,7 @@ files = [
[package.extras]
develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"]
docs = ["sphinx"]
-gmpy = ["gmpy2 (>=2.1.0a4)"]
+gmpy = ["gmpy2 (>=2.1.0a4) ; platform_python_implementation != \"PyPy\""]
tests = ["pytest (>=4.6)"]
[[package]]
@@ -2781,7 +2647,6 @@ description = "multidict implementation"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"},
{file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"},
@@ -2941,7 +2806,6 @@ description = "better multiprocessing and multithreading in Python"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "multiprocess-0.70.16-pp310-pypy310_pp73-macosx_10_13_x86_64.whl", hash = "sha256:476887be10e2f59ff183c006af746cb6f1fd0eadcfd4ef49e605cbe2659920ee"},
{file = "multiprocess-0.70.16-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d951bed82c8f73929ac82c61f01a7b5ce8f3e5ef40f5b52553b4f547ce2b08ec"},
@@ -2967,7 +2831,6 @@ description = "Optional static typing for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec"},
{file = "mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b"},
@@ -3030,7 +2893,6 @@ description = "Type system extensions for programs checked with the mypy type ch
optional = false
python-versions = ">=3.8"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
@@ -3043,7 +2905,6 @@ description = "A client library for executing notebooks. Formerly nbconvert's Ex
optional = false
python-versions = ">=3.9.0"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "nbclient-0.10.2-py3-none-any.whl", hash = "sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d"},
{file = "nbclient-0.10.2.tar.gz", hash = "sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193"},
@@ -3067,7 +2928,6 @@ description = "Convert Jupyter Notebooks (.ipynb files) to other formats."
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8"},
{file = "nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2"},
@@ -3077,7 +2937,6 @@ files = [
beautifulsoup4 = "*"
bleach = {version = "!=5.0.0", extras = ["css"]}
defusedxml = "*"
-importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.10\""}
jinja2 = ">=3.0"
jupyter-core = ">=4.7"
jupyterlab-pygments = "*"
@@ -3106,7 +2965,6 @@ description = "The Jupyter Notebook format"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b"},
{file = "nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a"},
@@ -3129,7 +2987,6 @@ description = "Patch asyncio to allow nested event loops"
optional = false
python-versions = ">=3.5"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"},
{file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"},
@@ -3142,7 +2999,6 @@ description = "Python package for creating and manipulating graphs and networks"
optional = false
python-versions = ">=3.9"
groups = ["main"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "networkx-3.2.1-py3-none-any.whl", hash = "sha256:f18c69adc97877c42332c170849c96cefa91881c99a7cb3e95b7c659ebdc1ec2"},
{file = "networkx-3.2.1.tar.gz", hash = "sha256:9f1bb5cf3409bf324e0a722c20bdb4c20ee39bf1c30ce8ae499c8502b0b5e0c6"},
@@ -3162,7 +3018,6 @@ description = "Node.js virtual environment builder"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"},
{file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"},
@@ -3175,7 +3030,6 @@ description = "Jupyter Notebook - A web-based notebook environment for interacti
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "notebook-7.5.7-py3-none-any.whl", hash = "sha256:1f95f79d117e47d20b5555b5c85a397d2cfecf136978aaab767cf0314b09165b"},
{file = "notebook-7.5.7.tar.gz", hash = "sha256:d6d59288a25303b25e1dcb71e9b017ec3a785f7d92f38b9bc288ca1970d5b0a8"},
@@ -3191,7 +3045,7 @@ tornado = ">=6.2.0"
[package.extras]
dev = ["hatch", "pre-commit"]
docs = ["myst-parser", "nbsphinx", "pydata-sphinx-theme", "sphinx (>=1.3.6)", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"]
-test = ["importlib-resources (>=5.0)", "ipykernel", "jupyter-server[test] (>=2.4.0,<3)", "jupyterlab-server[test] (>=2.28.0,<3)", "nbval", "pytest (>=7.0)", "pytest-console-scripts", "pytest-timeout", "pytest-tornasync", "requests"]
+test = ["importlib-resources (>=5.0) ; python_version < \"3.10\"", "ipykernel", "jupyter-server[test] (>=2.4.0,<3)", "jupyterlab-server[test] (>=2.28.0,<3)", "nbval", "pytest (>=7.0)", "pytest-console-scripts", "pytest-timeout", "pytest-tornasync", "requests"]
[[package]]
name = "notebook-shim"
@@ -3200,7 +3054,6 @@ description = "A shim layer for notebook traits and config"
optional = false
python-versions = ">=3.7"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef"},
{file = "notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb"},
@@ -3219,7 +3072,6 @@ description = "Fundamental package for array computing in Python"
optional = false
python-versions = ">=3.9"
groups = ["main", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"},
{file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"},
@@ -3266,7 +3118,7 @@ description = "CUBLAS native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08ed2686e9875d01b58e3cb379c6896df8e76c75e0d4a7f7dace3d7b6d9ef8eb"},
{file = "nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:235f728d6e2a409eddf1df58d5b0921cf80cfa9e72b9f2775ccb7b4a87984668"},
@@ -3280,7 +3132,7 @@ description = "CUDA profiling tools runtime libs."
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:166ee35a3ff1587f2490364f90eeeb8da06cd867bd5b701bf7f9a02b78bc63fc"},
{file = "nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_aarch64.whl", hash = "sha256:358b4a1d35370353d52e12f0a7d1769fc01ff74a191689d3870b2123156184c4"},
@@ -3296,7 +3148,7 @@ description = "NVRTC native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:5847f1d6e5b757f1d2b3991a01082a44aad6f10ab3c5c0213fa3e25bddc25a13"},
{file = "nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:35b0cc6ee3a9636d5409133e79273ce1f3fd087abb0532d2d2e8fff1fe9efc53"},
@@ -3310,7 +3162,7 @@ description = "CUDA Runtime native Libraries"
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6116fad3e049e04791c0256a9778c16237837c08b27ed8c8401e2e45de8d60cd"},
{file = "nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:d461264ecb429c84c8879a7153499ddc7b19b5f8d84c204307491989a365588e"},
@@ -3326,7 +3178,7 @@ description = "cuDNN runtime libraries"
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_cudnn_cu12-9.5.1.17-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9fd4584468533c61873e5fda8ca41bac3a38bcb2d12350830c69b0a96a7e4def"},
{file = "nvidia_cudnn_cu12-9.5.1.17-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:30ac3869f6db17d170e0e556dd6cc5eee02647abc31ca856634d5a40f82c15b2"},
@@ -3343,7 +3195,7 @@ description = "CUFFT native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d16079550df460376455cba121db6564089176d9bac9e4f360493ca4741b22a6"},
{file = "nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8510990de9f96c803a051822618d42bf6cb8f069ff3f48d93a8486efdacb48fb"},
@@ -3362,7 +3214,7 @@ description = "cuFile GPUDirect libraries"
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc23469d1c7e52ce6c1d55253273d32c565dd22068647f3aa59b3c6b005bf159"},
{file = "nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:8f57a0051dcf2543f6dc2b98a98cb2719c37d3cee1baba8965d57f3bbc90d4db"},
@@ -3375,7 +3227,7 @@ description = "CURAND native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:6e82df077060ea28e37f48a3ec442a8f47690c7499bff392a5938614b56c98d8"},
{file = "nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a42cd1344297f70b9e39a1e4f467a4e1c10f1da54ff7a85c12197f6c652c8bdf"},
@@ -3391,7 +3243,7 @@ description = "CUDA solver native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0ce237ef60acde1efc457335a2ddadfd7610b892d94efee7b776c64bb1cac9e0"},
{file = "nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9e49843a7707e42022babb9bcfa33c29857a93b88020c4e4434656a655b698c"},
@@ -3412,7 +3264,7 @@ description = "CUSPARSE native runtime libraries"
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d25b62fb18751758fe3c93a4a08eff08effedfe4edf1c6bb5afd0890fe88f887"},
{file = "nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7aa32fa5470cf754f72d1116c7cbc300b4e638d3ae5304cfa4a638a5b87161b1"},
@@ -3431,7 +3283,7 @@ description = "NVIDIA cuSPARSELt"
optional = false
python-versions = "*"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8371549623ba601a06322af2133c4a44350575f5a3108fb75f3ef20b822ad5f1"},
{file = "nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e5c8a26c36445dd2e6812f1177978a24e2d37cacce7e090f297a688d1ec44f46"},
@@ -3445,7 +3297,7 @@ description = "NVIDIA Collective Communication Library (NCCL) Runtime"
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c196e95e832ad30fbbb50381eb3cbd1fadd5675e587a548563993609af19522"},
{file = "nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:694cf3879a206553cc9d7dbda76b13efaf610fdb70a50cba303de1b0d1530ac6"},
@@ -3458,7 +3310,7 @@ description = "Nvidia JIT LTO Library"
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:eedc36df9e88b682efe4309aa16b5b4e78c2407eac59e8c10a6a47535164369a"},
{file = "nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cf4eaa7d4b6b543ffd69d6abfb11efdeb2db48270d94dfd3a452c24150829e41"},
@@ -3472,7 +3324,7 @@ description = "NVIDIA Tools Extension"
optional = false
python-versions = ">=3"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f44f8d86bb7d5629988d61c8d3ae61dddb2015dee142740536bc7481b022fe4b"},
{file = "nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:adcaabb9d436c9761fca2b13959a2d237c5f9fd406c8e4b723c695409ff88059"},
@@ -3488,7 +3340,7 @@ description = "A decorator to automatically detect mismatch when overriding a me
optional = false
python-versions = ">=3.6"
groups = ["examples"]
-markers = "python_version <= \"3.11\""
+markers = "python_version < \"3.12\""
files = [
{file = "overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49"},
{file = "overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a"},
@@ -3501,7 +3353,6 @@ description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev", "docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
@@ -3514,7 +3365,6 @@ description = "Powerful data structures for data analysis, time series, and stat
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c"},
{file = "pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a"},
@@ -3615,7 +3465,6 @@ description = "Utilities for writing pandoc filters in python"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc"},
{file = "pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e"},
@@ -3628,7 +3477,6 @@ description = "A Python Parser"
optional = false
python-versions = ">=3.6"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c"},
{file = "parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1"},
@@ -3645,7 +3493,6 @@ description = "Utility library for gitignore style pattern matching of file path
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"},
{file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"},
@@ -3663,7 +3510,7 @@ description = "Pexpect allows easy control of interactive console applications."
optional = false
python-versions = "*"
groups = ["examples"]
-markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and sys_platform != \"win32\""
+markers = "sys_platform != \"win32\""
files = [
{file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"},
{file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"},
@@ -3679,7 +3526,6 @@ description = "Python Imaging Library (Fork)"
optional = false
python-versions = ">=3.9"
groups = ["main", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"},
{file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"},
@@ -3795,7 +3641,7 @@ fpx = ["olefile"]
mic = ["olefile"]
test-arrow = ["pyarrow"]
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
-typing = ["typing-extensions"]
+typing = ["typing-extensions ; python_version < \"3.10\""]
xmp = ["defusedxml"]
[[package]]
@@ -3805,7 +3651,6 @@ description = "A small Python package for determining appropriate platform-speci
optional = false
python-versions = ">=3.9"
groups = ["dev", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"},
{file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"},
@@ -3823,7 +3668,6 @@ description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
@@ -3840,7 +3684,6 @@ description = "A framework for managing and maintaining multi-language pre-commi
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8"},
{file = "pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16"},
@@ -3860,7 +3703,6 @@ description = "Python client for the Prometheus monitoring system."
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1"},
{file = "prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28"},
@@ -3878,7 +3720,6 @@ description = "Library for building powerful interactive command lines in Python
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"},
{file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"},
@@ -3894,7 +3735,6 @@ description = "Accelerated property cache"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"},
{file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"},
@@ -4027,7 +3867,6 @@ description = "Cross-platform lib for process and system monitoring."
optional = false
python-versions = ">=3.6"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"},
{file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"},
@@ -4053,8 +3892,8 @@ files = [
]
[package.extras]
-dev = ["abi3audit", "black", "check-manifest", "colorama", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel", "wmi"]
-test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32", "setuptools", "wheel", "wmi"]
+dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
+test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
[[package]]
name = "ptyprocess"
@@ -4063,7 +3902,7 @@ description = "Run a subprocess in a pseudo terminal"
optional = false
python-versions = "*"
groups = ["examples"]
-markers = "(os_name != \"nt\" or sys_platform != \"win32\") and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "os_name != \"nt\" or sys_platform != \"win32\""
files = [
{file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
{file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
@@ -4076,7 +3915,6 @@ description = "Safely evaluate AST nodes without side effects"
optional = false
python-versions = "*"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"},
{file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"},
@@ -4092,7 +3930,6 @@ description = "Python library for Apache Arrow"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pyarrow-15.0.2-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:88b340f0a1d05b5ccc3d2d986279045655b1fe8e41aba6ca44ea28da0d1455d8"},
{file = "pyarrow-15.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eaa8f96cecf32da508e6c7f69bb8401f03745c050c1dd42ec2596f2e98deecac"},
@@ -4142,7 +3979,6 @@ description = "Python style guide checker"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"},
{file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"},
@@ -4155,7 +3991,7 @@ description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and implementation_name != \"PyPy\""
+markers = "implementation_name != \"PyPy\""
files = [
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
@@ -4168,7 +4004,6 @@ description = "passive checker of Python programs"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"},
{file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"},
@@ -4181,7 +4016,6 @@ description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["dev", "docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
@@ -4197,7 +4031,6 @@ description = "pyparsing - Classes and methods to define and execute parsing gra
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"},
{file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"},
@@ -4213,7 +4046,6 @@ description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
{file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
@@ -4238,7 +4070,6 @@ description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749"},
{file = "pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2"},
@@ -4259,7 +4090,6 @@ description = "pytest plugin to abort hanging tests"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"},
{file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"},
@@ -4275,7 +4105,6 @@ description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
@@ -4291,7 +4120,6 @@ description = "Python interpreter discovery"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
-markers = "python_version < \"3.10\""
files = [
{file = "python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe"},
{file = "python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3"},
@@ -4312,17 +4140,13 @@ description = "JSON Log Formatter for the Python Logging Package"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2"},
{file = "python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f"},
]
-[package.dependencies]
-typing_extensions = {version = "*", markers = "python_version < \"3.10\""}
-
[package.extras]
-dev = ["backports.zoneinfo", "black", "build", "freezegun", "mdx_truly_sane_lists", "mike", "mkdocs", "mkdocs-awesome-pages-plugin", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-material (>=8.5)", "mkdocstrings[python]", "msgspec", "mypy", "orjson", "pylint", "pytest", "tzdata", "validate-pyproject[all]"]
+dev = ["backports.zoneinfo ; python_version < \"3.9\"", "black", "build", "freezegun", "mdx_truly_sane_lists", "mike", "mkdocs", "mkdocs-awesome-pages-plugin", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-material (>=8.5)", "mkdocstrings[python]", "msgspec ; implementation_name != \"pypy\"", "mypy", "orjson ; implementation_name != \"pypy\"", "pylint", "pytest", "tzdata", "validate-pyproject[all]"]
[[package]]
name = "python-slugify"
@@ -4331,7 +4155,6 @@ description = "A Python slugify application that also handles Unicode"
optional = false
python-versions = ">=3.7"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"},
{file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"},
@@ -4350,7 +4173,6 @@ description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older
optional = false
python-versions = ">=3.8"
groups = ["dev"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"},
{file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"},
@@ -4406,7 +4228,6 @@ description = "World timezone definitions, modern and historical"
optional = false
python-versions = "*"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126"},
{file = "pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a"},
@@ -4419,7 +4240,7 @@ description = "Python for Windows Extensions"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "sys_platform == \"win32\" and platform_python_implementation != \"PyPy\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "sys_platform == \"win32\" and platform_python_implementation != \"PyPy\""
files = [
{file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"},
{file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"},
@@ -4451,7 +4272,7 @@ description = "Pseudo terminal support for Windows from Python."
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and os_name == \"nt\""
+markers = "os_name == \"nt\""
files = [
{file = "pywinpty-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:ff05f12d775b142b11c6fe085129bdd759b61cf7d41da6c745e78e3a1ef5bf40"},
{file = "pywinpty-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:340ccacb4d74278a631923794ccd758471cfc8eeeeee4610b280420a17ad1e82"},
@@ -4478,7 +4299,6 @@ description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
@@ -4562,7 +4382,6 @@ description = "Python bindings for 0MQ"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4"},
{file = "pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556"},
@@ -4668,7 +4487,6 @@ description = "JSON Referencing + Python"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"},
{file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"},
@@ -4686,7 +4504,6 @@ description = "Alternative regular expression module, to replace re."
optional = false
python-versions = ">=3.9"
groups = ["main"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e"},
{file = "regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f"},
@@ -4828,7 +4645,6 @@ description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.9"
groups = ["main", "docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
@@ -4851,7 +4667,6 @@ description = "A pure python RFC3339 validator"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"},
{file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"},
@@ -4867,7 +4682,6 @@ description = "Pure python rfc3986 validator"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9"},
{file = "rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055"},
@@ -4880,7 +4694,6 @@ description = "Helper functions to syntactically validate strings according to R
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f"},
{file = "rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d"},
@@ -4899,7 +4712,6 @@ description = "Python bindings to Rust's persistent data structures (rpds)"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "rpds_py-0.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:68afeec26d42ab3b47e541b272166a0b4400313946871cba3ed3a4fc0cab1cef"},
{file = "rpds_py-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74e5b2f7bb6fa38b1b10546d27acbacf2a022a8b5543efb06cfebc72a59c85be"},
@@ -5065,7 +4877,6 @@ description = ""
optional = false
python-versions = ">=3.9"
groups = ["main"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517"},
{file = "safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57"},
@@ -5113,7 +4924,7 @@ description = "A set of python modules for machine learning and data mining"
optional = true
python-versions = ">=3.9"
groups = ["main"]
-markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"sbert\""
+markers = "extra == \"sbert\""
files = [
{file = "scikit_learn-1.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d056391530ccd1e501056160e3c9673b4da4805eb67eb2bdf4e983e1f9c9204e"},
{file = "scikit_learn-1.6.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0c8d036eb937dbb568c6242fa598d551d88fb4399c0344d95c001980ec1c7d36"},
@@ -5169,7 +4980,7 @@ description = "Fundamental algorithms for scientific computing in Python"
optional = true
python-versions = ">=3.9"
groups = ["main"]
-markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"sbert\""
+markers = "extra == \"sbert\""
files = [
{file = "scipy-1.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:20335853b85e9a49ff7572ab453794298bcf0354d8068c5f6775a0eabf350aca"},
{file = "scipy-1.13.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:d605e9c23906d1994f55ace80e0125c587f96c020037ea6aa98d01b4bd2e222f"},
@@ -5213,14 +5024,13 @@ description = "Send file to trash natively under Mac OS X, Windows and Linux"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c"},
{file = "send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459"},
]
[package.extras]
-nativelib = ["pyobjc (>=9.0)", "pywin32 (>=305)"]
+nativelib = ["pyobjc (>=9.0) ; sys_platform == \"darwin\"", "pywin32 (>=305) ; sys_platform == \"win32\""]
test = ["pytest (>=8)"]
[[package]]
@@ -5230,7 +5040,7 @@ description = "State-of-the-Art Text Embeddings"
optional = true
python-versions = ">=3.9"
groups = ["main"]
-markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"sbert\""
+markers = "extra == \"sbert\""
files = [
{file = "sentence_transformers-3.4.1-py3-none-any.whl", hash = "sha256:e026dc6d56801fd83f74ad29a30263f401b4b522165c19386d8bc10dcca805da"},
{file = "sentence_transformers-3.4.1.tar.gz", hash = "sha256:68daa57504ff548340e54ff117bd86c1d2f784b21e0fb2689cf3272b8937b24b"},
@@ -5263,16 +5073,16 @@ files = [
{file = "setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3"},
{file = "setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef"},
]
-markers = {main = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version <= \"3.11\" or python_version >= \"3.12\"", examples = "python_version <= \"3.11\" or python_version >= \"3.12\""}
+markers = {main = "platform_system == \"Linux\" and platform_machine == \"x86_64\" or python_version >= \"3.12\""}
[package.extras]
-check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1)", "ruff (>=0.13.0)"]
-core = ["importlib_metadata (>=6)", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
+check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""]
+core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"]
cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
enabler = ["pytest-enabler (>=3.4)"]
-test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
-type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.18.*)", "pytest-mypy (>=1.0.1)"]
+test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
+type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""]
[[package]]
name = "six"
@@ -5281,7 +5091,6 @@ description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
@@ -5294,7 +5103,6 @@ description = "Utils for streaming large files (S3, HDFS, GCS, SFTP, Azure Blob
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "smart_open-7.5.0-py3-none-any.whl", hash = "sha256:87e695c5148bbb988f15cec00971602765874163be85acb1c9fb8abc012e6599"},
{file = "smart_open-7.5.0.tar.gz", hash = "sha256:f394b143851d8091011832ac8113ea4aba6b92e6c35f6e677ddaaccb169d7cb9"},
@@ -5306,13 +5114,13 @@ wrapt = "*"
[package.extras]
all = ["smart_open[azure,gcs,http,s3,ssh,webhdfs,zst]"]
azure = ["azure-common", "azure-core", "azure-storage-blob"]
-gcs = ["google-api-core (<2.28)", "google-cloud-storage (>=2.6.0)"]
+gcs = ["google-api-core (<2.28) ; python_version < \"3.10\"", "google-cloud-storage (>=2.6.0)"]
http = ["requests"]
s3 = ["boto3 (>=1.9.17)"]
ssh = ["paramiko"]
test = ["awscli", "flake8", "moto[server]", "numpy", "pyopenssl", "pytest", "pytest-rerunfailures", "pytest-timeout", "pytest-xdist[psutil]", "pytest_benchmark", "responses", "smart_open[all]"]
webhdfs = ["requests"]
-zst = ["backports.zstd (>=1.0.0)"]
+zst = ["backports.zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]]
name = "snowballstemmer"
@@ -5321,7 +5129,6 @@ description = "This package provides 36 stemmers for 34 languages generated from
optional = false
python-versions = ">=3.3"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752"},
{file = "snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260"},
@@ -5334,7 +5141,6 @@ description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.9"
groups = ["docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65"},
{file = "soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e"},
@@ -5347,7 +5153,6 @@ description = "Python documentation generator"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239"},
{file = "sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe"},
@@ -5359,7 +5164,6 @@ babel = ">=2.13"
colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""}
docutils = ">=0.20,<0.22"
imagesize = ">=1.3"
-importlib-metadata = {version = ">=6.0", markers = "python_version < \"3.10\""}
Jinja2 = ">=3.1"
packaging = ">=23.0"
Pygments = ">=2.17"
@@ -5385,7 +5189,6 @@ description = "A modern skeleton for Sphinx themes."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"},
{file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"},
@@ -5404,7 +5207,6 @@ description = "Add a copy button to each of your code cells."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd"},
{file = "sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e"},
@@ -5424,7 +5226,6 @@ description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple
optional = false
python-versions = ">=3.9"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"},
{file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"},
@@ -5442,7 +5243,6 @@ description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp
optional = false
python-versions = ">=3.9"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"},
{file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"},
@@ -5460,7 +5260,6 @@ description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML h
optional = false
python-versions = ">=3.9"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"},
{file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"},
@@ -5478,7 +5277,6 @@ description = "A sphinx extension which renders display math in HTML via JavaScr
optional = false
python-versions = ">=3.5"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
{file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
@@ -5494,7 +5292,6 @@ description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp d
optional = false
python-versions = ">=3.9"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"},
{file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"},
@@ -5512,7 +5309,6 @@ description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs
optional = false
python-versions = ">=3.9"
groups = ["docs"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"},
{file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"},
@@ -5530,7 +5326,6 @@ description = "Extract data from python stack frames and tracebacks for informat
optional = false
python-versions = "*"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"},
{file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"},
@@ -5551,7 +5346,6 @@ description = "Computer algebra system (CAS) in Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"},
{file = "sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517"},
@@ -5570,7 +5364,6 @@ description = "Tornado websocket backend for the Xterm.js Javascript terminal em
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0"},
{file = "terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e"},
@@ -5593,7 +5386,6 @@ description = "The most basic Text::Unidecode port"
optional = false
python-versions = "*"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
@@ -5606,7 +5398,7 @@ description = "threadpoolctl"
optional = true
python-versions = ">=3.9"
groups = ["main"]
-markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"sbert\""
+markers = "extra == \"sbert\""
files = [
{file = "threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb"},
{file = "threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e"},
@@ -5619,7 +5411,6 @@ description = "A tiny CSS parser"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289"},
{file = "tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7"},
@@ -5639,7 +5430,6 @@ description = ""
optional = false
python-versions = ">=3.9"
groups = ["main"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c"},
{file = "tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001"},
@@ -5682,7 +5472,7 @@ description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
groups = ["dev", "docs", "examples"]
-markers = "python_version < \"3.11\""
+markers = "python_version == \"3.10\""
files = [
{file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
{file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"},
@@ -5740,7 +5530,6 @@ description = "Tensors and Dynamic neural networks in Python with strong GPU acc
optional = false
python-versions = ">=3.9.0"
groups = ["main"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "torch-2.7.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a103b5d782af5bd119b81dbcc7ffc6fa09904c423ff8db397a1e6ea8fd71508f"},
{file = "torch-2.7.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:fe955951bdf32d182ee8ead6c3186ad54781492bf03d547d31771a01b3d6fb7d"},
@@ -5803,7 +5592,6 @@ description = "Tornado is a Python web framework and asynchronous networking lib
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163"},
{file = "tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100"},
@@ -5824,7 +5612,6 @@ description = "Fast, Extensible Progress Meter"
optional = false
python-versions = ">=3.8"
groups = ["main", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "tqdm-4.68.4-py3-none-any.whl", hash = "sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2"},
{file = "tqdm-4.68.4.tar.gz", hash = "sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520"},
@@ -5846,7 +5633,6 @@ description = "Traitlets Python configuration system"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92"},
{file = "traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722"},
@@ -5863,7 +5649,6 @@ description = "State-of-the-art Machine Learning for JAX, PyTorch and TensorFlow
optional = false
python-versions = ">=3.9.0"
groups = ["main"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "transformers-4.57.6-py3-none-any.whl", hash = "sha256:4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550"},
{file = "transformers-4.57.6.tar.gz", hash = "sha256:55e44126ece9dc0a291521b7e5492b572e6ef2766338a610b9ab5afbb70689d3"},
@@ -5939,7 +5724,7 @@ description = "A language and compiler for custom Deep Learning operations"
optional = false
python-versions = "*"
groups = ["main"]
-markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and (python_version <= \"3.11\" or python_version >= \"3.12\")"
+markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""
files = [
{file = "triton-3.3.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b74db445b1c562844d3cfad6e9679c72e93fdfb1a90a24052b03bb5c49d1242e"},
{file = "triton-3.3.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b31e3aa26f8cb3cc5bf4e187bf737cbacf17311e1112b781d4a059353dfd731b"},
@@ -5964,7 +5749,6 @@ description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev", "docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"},
{file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"},
@@ -5977,7 +5761,6 @@ description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"},
{file = "tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415"},
@@ -5990,7 +5773,6 @@ description = "RFC 6570 URI Template Processor"
optional = false
python-versions = ">=3.7"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7"},
{file = "uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363"},
@@ -6006,40 +5788,16 @@ description = "HTTP library with thread-safe connection pooling, file post, and
optional = false
python-versions = ">=3.9"
groups = ["main", "docs", "examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
]
[package.extras]
-brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"]
+brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
-zstd = ["backports-zstd (>=1.0.0)"]
-
-[[package]]
-name = "virtualenv"
-version = "20.35.4"
-description = "Virtual Python Environment builder"
-optional = false
-python-versions = ">=3.8"
-groups = ["dev"]
-markers = "python_version <= \"3.11\" and python_version >= \"3.10\" or python_version >= \"3.12\""
-files = [
- {file = "virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b"},
- {file = "virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c"},
-]
-
-[package.dependencies]
-distlib = ">=0.3.7,<1"
-filelock = ">=3.12.2,<4"
-platformdirs = ">=3.9.1,<5"
-typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
-
-[package.extras]
-docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
-test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
+zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]]
name = "virtualenv"
@@ -6048,7 +5806,6 @@ description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
-markers = "python_version < \"3.10\""
files = [
{file = "virtualenv-21.6.1-py3-none-any.whl", hash = "sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b"},
{file = "virtualenv-21.6.1.tar.gz", hash = "sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128"},
@@ -6056,7 +5813,7 @@ files = [
[package.dependencies]
distlib = ">=0.3.7,<1"
-filelock = {version = ">=3.16.1,<=3.19.1", markers = "python_version < \"3.10\""}
+filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""}
platformdirs = ">=3.9.1,<5"
python-discovery = ">=1.4.2"
typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
@@ -6068,7 +5825,6 @@ description = "Measures the displayed width of unicode strings in a terminal"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85"},
{file = "wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda"},
@@ -6081,7 +5837,6 @@ description = "A library for working with the color formats defined by HTML and
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "webcolors-24.11.1-py3-none-any.whl", hash = "sha256:515291393b4cdf0eb19c155749a096f779f7d909f7cceea072791cb9095b92e9"},
{file = "webcolors-24.11.1.tar.gz", hash = "sha256:ecb3d768f32202af770477b8b65f318fa4f566c22948673a977b00d589dd80f6"},
@@ -6094,7 +5849,6 @@ description = "Character encoding aliases for legacy web content"
optional = false
python-versions = "*"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
{file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
@@ -6107,7 +5861,6 @@ description = "WebSocket client for Python with low level API options"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"},
{file = "websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98"},
@@ -6125,7 +5878,6 @@ description = "Jupyter interactive widgets for Jupyter Notebook"
optional = false
python-versions = ">=3.7"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366"},
{file = "widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9"},
@@ -6138,7 +5890,6 @@ description = "Module for decorators, wrappers and monkey patching."
optional = false
python-versions = ">=3.9"
groups = ["main"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "wrapt-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:055e6fcfaa28e58c6a8c247d48b92be9d56f818b7068aa4f22b15b3343a09931"},
{file = "wrapt-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8374eb6b1a58809211e84ff835a182bb17ab2807a5bfef23204c8cff38178a00"},
@@ -6242,7 +5993,6 @@ description = "Python binding for xxHash"
optional = false
python-versions = ">=3.8"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "xxhash-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:27a9e475157f7315826118e3f3127909a0fe25f1b43d3d3be9c584f9d265f937"},
{file = "xxhash-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b2ce44bf8f4a1d01f418b3110ff8dff32fd3f3e836c0e06333c3725f243fa6c"},
@@ -6440,7 +6190,6 @@ description = "Yet another URL library"
optional = false
python-versions = ">=3.9"
groups = ["examples"]
-markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
files = [
{file = "yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e"},
{file = "yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f"},
@@ -6579,31 +6328,10 @@ idna = ">=2.0"
multidict = ">=4.0"
propcache = ">=0.2.1"
-[[package]]
-name = "zipp"
-version = "3.23.1"
-description = "Backport of pathlib-compatible object wrapper for zip files"
-optional = false
-python-versions = ">=3.9"
-groups = ["docs", "examples"]
-markers = "python_version < \"3.10\""
-files = [
- {file = "zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc"},
- {file = "zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110"},
-]
-
-[package.extras]
-check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
-cover = ["pytest-cov"]
-doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-enabler = ["pytest-enabler (>=2.2)"]
-test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
-type = ["pytest-mypy"]
-
[extras]
sbert = ["sentence-transformers"]
[metadata]
lock-version = "2.1"
-python-versions = ">=3.9,<4.0"
-content-hash = "5036e15a2000c6239d284fa557eaa832bc8a61f0d7189e71d14cf9bef4bdf25c"
+python-versions = ">=3.10,<4.0"
+content-hash = "7fa833c37c93c8375c3afad6122a8164c56620f52723c0659c13293a74f12de2"
diff --git a/pyproject.toml b/pyproject.toml
index 88c5a65..0812c66 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "sentinel"
-version = "1.0.0"
+version = "2.0.0"
description = "A Python library for local, contrastive semantic scoring of text to identify affinity towards predefined critical harm patterns."
authors = ["Younes Abouelnagah "]
readme = "README.md"
@@ -11,7 +11,6 @@ classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
@@ -20,7 +19,7 @@ classifiers = [
]
[tool.poetry.dependencies]
-python = ">=3.9,<4.0"
+python = ">=3.10,<4.0"
numpy = ">=1.21,<2.0"
safetensors = ">=0.4.0"
smart_open = ">=6.4.0"
diff --git a/src/sentinel/__init__.py b/src/sentinel/__init__.py
index 4b937ba..46964a8 100644
--- a/src/sentinel/__init__.py
+++ b/src/sentinel/__init__.py
@@ -20,38 +20,46 @@
from sentinel.sentinel_local_index import SentinelLocalIndex
from sentinel.score_formulae import (
- calculate_contrastive_score,
- skewness,
- mean_of_positives,
- top_k_mean,
- percentile_score,
- softmax_weighted_mean,
- max_score,
+ calculate_contrastive_score,
+ skewness,
+ mean_of_positives,
+ top_k_mean,
+ percentile_score,
+ softmax_weighted_mean,
+ max_score,
)
from sentinel.simulation import (
- DEFAULT_AGGREGATORS,
- LabeledGroup,
- GroupObservationScores,
- score_groups,
- evaluate_groups,
- compare_aggregators,
- run_grid_search,
+ DEFAULT_AGGREGATORS,
+ LabeledGroup,
+ GroupObservationScores,
+ encode_observations,
+ score_groups,
+ evaluate_groups,
+ compare_aggregators,
+ run_grid_search,
)
+# Kept in step with the version in pyproject.toml, which is the source of truth for
+# packaging. Duplicated here because importing package metadata at runtime fails when
+# the library is used straight from a source checkout rather than an installed wheel.
+__version__ = "2.0.0"
+
__all__ = [
- "SentinelLocalIndex",
- "calculate_contrastive_score",
- "skewness",
- "mean_of_positives",
- "top_k_mean",
- "percentile_score",
- "softmax_weighted_mean",
- "max_score",
- "DEFAULT_AGGREGATORS",
- "LabeledGroup",
- "GroupObservationScores",
- "score_groups",
- "evaluate_groups",
- "compare_aggregators",
- "run_grid_search",
+ "__version__",
+ "SentinelLocalIndex",
+ "calculate_contrastive_score",
+ "skewness",
+ "mean_of_positives",
+ "top_k_mean",
+ "percentile_score",
+ "softmax_weighted_mean",
+ "max_score",
+ "DEFAULT_AGGREGATORS",
+ "LabeledGroup",
+ "GroupObservationScores",
+ "encode_observations",
+ "score_groups",
+ "evaluate_groups",
+ "compare_aggregators",
+ "run_grid_search",
]
diff --git a/src/sentinel/sentinel_local_index.py b/src/sentinel/sentinel_local_index.py
index e101308..978474d 100644
--- a/src/sentinel/sentinel_local_index.py
+++ b/src/sentinel/sentinel_local_index.py
@@ -369,6 +369,7 @@ def from_texts(
cls,
positive_texts: List[str],
negative_texts: List[str],
+ *,
model_name: str = "all-MiniLM-L6-v2",
neg_to_pos_ratio: Optional[float] = None,
batch_size: int = 256,
@@ -478,6 +479,7 @@ def from_texts(
def load(
cls,
path: str,
+ *,
aws_access_key_id: Optional[str] = None,
aws_secret_access_key: Optional[str] = None,
negative_to_positive_ratio: Optional[float] = 5.0,
@@ -729,6 +731,8 @@ def subsample(
def calculate_rare_class_affinity(
self,
text_samples: List[str],
+ sample_embeddings: Optional[np.ndarray] = None,
+ *,
top_k: int = 5,
similarity_formula: Callable[[List[float], List[float]], float] = calculate_contrastive_score,
# Function to aggregate individual scores into an overall affinity score
@@ -755,6 +759,12 @@ def calculate_rare_class_affinity(
Args:
text_samples: List of text strings to evaluate for rare class affinity.
+ sample_embeddings: Optional pre-computed embeddings for ``text_samples``, in
+ the same order. Supplying them skips the encoding step, which is the only
+ expensive part of this method. Embeddings depend on the encoder, not on
+ the index contents, so one set can be reused across many indices built
+ from the same model - see
+ :func:`sentinel.simulation.encode_observations`.
top_k: Number of closest neighbors to consider when calculating the score.
similarity_formula: Function to calculate individual similarity scores.
aggregation_function: Function to aggregate individual scores into an overall score.
@@ -769,19 +779,31 @@ def calculate_rare_class_affinity(
Returns:
RareClassAffinityResult containing both the overall affinity score and
individual observation scores for each text sample.
+
+ Raises:
+ ValueError: If ``sample_embeddings`` is supplied with a different number of
+ rows than ``text_samples``, which would silently score each observation
+ against the wrong embedding.
"""
- # Merge the default encoding kwargs with any additional ones provided
- effective_encoding_kwargs = self.encoding_kwargs.copy()
- effective_encoding_kwargs["show_progress_bar"] = show_progress_bar
- effective_encoding_kwargs.update(encoding_additional_kwargs)
-
- # Encode the input samples to get their embeddings
- # We currently don't support multi-process encoding in this method, because it is meant for online scoring.
- # We can add it if needed, probably by just allowing the caller to pass sample embeddings instead of text.
- sample_embeddings = self.sentence_model.encode(
- text_samples,
- **effective_encoding_kwargs,
- )
+ if sample_embeddings is None:
+ # Merge the default encoding kwargs with any additional ones provided
+ effective_encoding_kwargs = self.encoding_kwargs.copy()
+ effective_encoding_kwargs["show_progress_bar"] = show_progress_bar
+ effective_encoding_kwargs.update(encoding_additional_kwargs)
+
+ # Encode the input samples to get their embeddings.
+ # We currently don't support multi-process encoding in this method, because
+ # it is meant for online scoring.
+ sample_embeddings = self.sentence_model.encode(
+ text_samples,
+ **effective_encoding_kwargs,
+ )
+ elif len(sample_embeddings) != len(text_samples):
+ raise ValueError(
+ f"sample_embeddings has {len(sample_embeddings)} rows but text_samples "
+ f"has {len(text_samples)} entries. Scoring them together would pair each "
+ f"observation with the wrong embedding, so refusing to continue."
+ )
# If we need to prevent exact matches (e.g., when scoring examples that are in the index),
# request an additional neighbor so we can skip the exact match later
diff --git a/src/sentinel/simulation.py b/src/sentinel/simulation.py
index 57150be..f77e426 100644
--- a/src/sentinel/simulation.py
+++ b/src/sentinel/simulation.py
@@ -58,7 +58,7 @@
import logging
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Callable, Dict, List, Mapping, Optional, Sequence
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Mapping, Optional, Sequence
import numpy as np
@@ -127,11 +127,122 @@ class GroupObservationScores:
observation_scores: np.ndarray
+# Columns run_grid_search attaches on top of whatever evaluate_groups returns. Every
+# index-describing column is prefixed, because evaluate_groups already returns an
+# "n_positive" meaning the number of positive *groups* in the evaluation set; an
+# unprefixed index size once overwrote it and silently turned evaluation metadata into
+# an index size. _add_columns turns any repeat of that mistake into an error.
+COLUMN_TOP_K = "top_k"
+COLUMN_INDEX_N_POSITIVE = "index_n_positive"
+COLUMN_INDEX_NEG_TO_POS_RATIO = "index_neg_to_pos_ratio"
+COLUMN_INDEX_N_POSITIVE_ACTUAL = "index_n_positive_actual"
+COLUMN_INDEX_N_NEGATIVE_ACTUAL = "index_n_negative_actual"
+
+GRID_SEARCH_COLUMNS = (
+ COLUMN_TOP_K,
+ COLUMN_INDEX_N_POSITIVE,
+ COLUMN_INDEX_NEG_TO_POS_RATIO,
+ COLUMN_INDEX_N_POSITIVE_ACTUAL,
+ COLUMN_INDEX_N_NEGATIVE_ACTUAL,
+)
+
+
+def _add_columns(row: Dict[str, Any], **columns: Any) -> Dict[str, Any]:
+ """Add columns to a result row, refusing to overwrite one that already exists.
+
+ Rows are plain dicts so they drop straight into ``pandas.DataFrame``, but that also
+ means a second write to the same key destroys the first with no error. That is how
+ an index size once replaced the positive-group count. Raising here turns a silent
+ data loss into a failing test.
+
+ Args:
+ row: The row to extend, modified in place.
+ **columns: Column name to value.
+
+ Returns:
+ The same row, for convenient chaining.
+
+ Raises:
+ ValueError: If any column is already present in the row.
+ """
+ clashes = sorted(name for name in columns if name in row)
+ if clashes:
+ raise ValueError(
+ f"Refusing to overwrite existing result column(s): {', '.join(clashes)}. "
+ f"Two different measurements are competing for one name; rename the new "
+ f"one rather than letting it replace the old value."
+ )
+ row.update(columns)
+ return row
+
+
+def encode_observations(
+ index: "SentinelLocalIndex",
+ groups: Sequence[LabeledGroup],
+ *,
+ show_progress_bar: bool = False,
+ encoding_additional_kwargs: Optional[Mapping[str, object]] = None,
+) -> Dict[str, np.ndarray]:
+ """Embed every group's observations once, so many scoring passes can share them.
+
+ Encoding is the only expensive step in a simulation, and an observation's embedding
+ depends on the encoder, never on the index it is scored against. A sweep that varies
+ index size, ratio or ``top_k`` therefore re-computes identical numbers on every pass
+ unless the embeddings are hoisted out, which is what this does.
+
+ The result stays valid across every index produced by
+ :meth:`~sentinel.sentinel_local_index.SentinelLocalIndex.subsample`, because a
+ subsampled copy shares the parent's sentence model and encoding kwargs.
+
+ Args:
+ index: A loaded :class:`~sentinel.sentinel_local_index.SentinelLocalIndex`.
+ groups: The labeled groups whose observations should be embedded.
+ show_progress_bar: Whether to show the encoder progress bar.
+ encoding_additional_kwargs: Extra keyword arguments forwarded to the encoder.
+
+ Returns:
+ Group name to the embeddings of that group's observations, in observation order.
+ Groups with no observations are omitted. Empty if ``index`` cannot encode on its
+ own, in which case callers simply fall back to encoding per pass.
+ """
+ if encoding_additional_kwargs is None:
+ encoding_additional_kwargs = {}
+
+ sentence_model = getattr(index, "sentence_model", None)
+ if sentence_model is None or not hasattr(index, "encoding_kwargs"):
+ # This harness deliberately accepts any index-like object, including test
+ # doubles that only implement calculate_rare_class_affinity. Those cannot be
+ # pre-encoded, so skip the optimisation rather than refusing to run.
+ LOG.debug(
+ "Index exposes no sentence model, so observations cannot be pre-encoded; "
+ "scoring will encode per pass."
+ )
+ return {}
+
+ encoding_kwargs = dict(index.encoding_kwargs)
+ encoding_kwargs["show_progress_bar"] = show_progress_bar
+ encoding_kwargs.update(encoding_additional_kwargs)
+
+ embeddings: Dict[str, np.ndarray] = {}
+ for group in groups:
+ observations = list(group.observations)
+ if not observations:
+ continue
+ embeddings[group.name] = sentence_model.encode(
+ observations,
+ **encoding_kwargs,
+ )
+
+ LOG.info("Encoded observations for %d groups", len(embeddings))
+ return embeddings
+
+
def score_groups(
index: "SentinelLocalIndex",
groups: Sequence[LabeledGroup],
*,
top_k: int = 5,
+ observation_embeddings: Optional[Mapping[str, np.ndarray]] = None,
show_progress_bar: bool = False,
encoding_additional_kwargs: Optional[Mapping[str, object]] = None,
) -> List[GroupObservationScores]:
@@ -150,6 +261,10 @@ def score_groups(
groups: The labeled groups to score.
top_k: Number of nearest neighbors used per observation. Changing this
changes the per-observation scores, so it requires re-scoring.
+ observation_embeddings: Optional embeddings from
+ :func:`encode_observations`, keyed by group name. Supplying them skips the
+ encoding step, which is what makes repeated scoring passes cheap. A group
+ missing from the mapping is encoded normally.
show_progress_bar: Whether to show the encoder progress bar.
encoding_additional_kwargs: Extra keyword arguments forwarded to the
encoder.
@@ -177,6 +292,15 @@ def score_groups(
# We only need the per-observation scores here, so we disable the
# explainability extras for speed and pass a trivial aggregation
# function (its output is ignored).
+ # Only forwarded when there is something to forward, so an index-like object
+ # that does not know about sample_embeddings keeps working unchanged.
+ cached = (
+ None
+ if observation_embeddings is None
+ else observation_embeddings.get(group.name)
+ )
+ cached_kwargs = {} if cached is None else {"sample_embeddings": cached}
+
result = index.calculate_rare_class_affinity(
observations,
top_k=top_k,
@@ -186,6 +310,7 @@ def score_groups(
explain=False,
include_neighbors=False,
encoding_additional_kwargs=encoding_additional_kwargs,
+ **cached_kwargs,
)
observation_scores = np.asarray(
@@ -537,15 +662,19 @@ def run_grid_search(
index: "SentinelLocalIndex",
groups: Sequence[LabeledGroup],
*,
+ # What to sweep, outermost axis first, mirroring the loop nesting below.
+ n_positive_values: Optional[Sequence[int]] = None,
+ neg_to_pos_ratios: Optional[Sequence[float]] = None,
top_k_values: Sequence[int] = (5,),
min_score_values: Sequence[float] = (0.1,),
aggregators: Optional[Mapping[str, Callable[[np.ndarray], float]]] = None,
+ # How each configuration is judged.
top_n: Optional[int] = None,
decision_threshold: Optional[float] = None,
- show_progress_bar: bool = False,
- n_positive_values: Optional[Sequence[int]] = None,
- neg_to_pos_ratios: Optional[Sequence[float]] = None,
+ # Execution.
index_seed: Optional[int] = None,
+ cache_observation_embeddings: bool = True,
+ show_progress_bar: bool = False,
) -> List[Dict[str, float]]:
"""Sweep hyperparameters and summarize metrics, returning a flat result table.
@@ -563,30 +692,32 @@ def run_grid_search(
The returned rows are easy to turn into a ``pandas.DataFrame`` for plotting.
- Cost warning: the number of scoring passes is
- ``len(n_positive_values) x len(neg_to_pos_ratios) x len(top_k_values)``. A 7x7
- size/ratio grid with three ``top_k`` values is 147 full passes. Note that the
- observation texts are re-encoded on every pass even though their embeddings do
- not depend on the index at all, so most of that cost is recomputing identical
- numbers. Letting callers pass pre-computed sample embeddings would collapse it
- to roughly one encoding pass; that is a separate change.
+ Cost: the sweep runs
+ ``len(n_positive_values) x len(neg_to_pos_ratios) x len(top_k_values)`` scoring
+ passes, so a 7x7 size/ratio grid with three ``top_k`` values is 147 of them. The
+ observations are encoded once up front and reused across all of them, because an
+ observation's embedding depends on the encoder rather than on the index being
+ varied. Each pass is then a nearest-neighbour search rather than a re-encode.
Args:
index: A loaded :class:`~sentinel.sentinel_local_index.SentinelLocalIndex`.
groups: The labeled groups to evaluate.
+ n_positive_values: Index sizes to try, as counts of positive examples.
+ ``None`` (the default) means one pass using the index exactly as given.
+ neg_to_pos_ratios: Negative-to-positive ratios to try. ``None`` (the
+ default) means one pass using the index exactly as given.
top_k_values: ``top_k`` values to try (each triggers a re-scoring).
min_score_values: Per-observation thresholds to try (evaluated cheaply).
aggregators: Name -> function map. Defaults to :data:`DEFAULT_AGGREGATORS`.
top_n: Size of the top-N slice for recall@N / precision@N.
decision_threshold: Cutoff for the classification family (``None`` =
best-F1).
- show_progress_bar: Whether to show the encoder progress bar.
- n_positive_values: Index sizes to try, as counts of positive examples.
- ``None`` (the default) means one pass using the index exactly as given.
- neg_to_pos_ratios: Negative-to-positive ratios to try. ``None`` (the
- default) means one pass using the index exactly as given.
index_seed: Seed for the subsampling, so each index configuration is
reproducible across runs.
+ cache_observation_embeddings: Whether to encode the observations once and reuse
+ them for every pass. Leave it on unless memory is tight: the cache holds one
+ embedding per observation, so a very large evaluation set can be sizeable.
+ show_progress_bar: Whether to show the encoder progress bar.
Returns:
A list of metric dicts, one per ``(n_positive, neg_to_pos_ratio, top_k,
@@ -617,6 +748,14 @@ def run_grid_search(
)
total_configurations = len(positive_options) * len(ratio_options)
+ # Hoisted out of every loop below: subsampling changes which rows the index holds,
+ # never the encoder, so one set of observation embeddings serves the whole sweep.
+ cached_embeddings: Optional[Dict[str, np.ndarray]] = None
+ if cache_observation_embeddings:
+ cached_embeddings = encode_observations(
+ index, groups, show_progress_bar=show_progress_bar
+ )
+
rows: List[Dict[str, float]] = []
configuration = 0
for n_positive in positive_options:
@@ -659,6 +798,7 @@ def run_grid_search(
working_index,
groups,
top_k=top_k,
+ observation_embeddings=cached_embeddings,
show_progress_bar=show_progress_bar,
)
for min_score in min_score_values:
@@ -671,16 +811,16 @@ def run_grid_search(
top_n=top_n,
decision_threshold=decision_threshold,
)
- row["top_k"] = int(top_k)
- # Every index-describing column is prefixed, because
- # evaluate_groups already returns an "n_positive" meaning the
- # number of positive *groups* in the evaluation set. Reusing
- # that name here overwrote it, silently replacing evaluation
- # metadata with an index size.
- row["index_n_positive"] = n_positive
- row["index_neg_to_pos_ratio"] = ratio
- row["index_n_positive_actual"] = n_positive_actual
- row["index_n_negative_actual"] = n_negative_actual
+ _add_columns(
+ row,
+ **{
+ COLUMN_TOP_K: int(top_k),
+ COLUMN_INDEX_N_POSITIVE: n_positive,
+ COLUMN_INDEX_NEG_TO_POS_RATIO: ratio,
+ COLUMN_INDEX_N_POSITIVE_ACTUAL: n_positive_actual,
+ COLUMN_INDEX_N_NEGATIVE_ACTUAL: n_negative_actual,
+ },
+ )
rows.append(row)
return rows
diff --git a/tests/test_from_texts.py b/tests/test_from_texts.py
index 0353920..179234e 100644
--- a/tests/test_from_texts.py
+++ b/tests/test_from_texts.py
@@ -197,6 +197,42 @@ def test_without_a_ratio_every_negative_is_encoded(self, monkeypatch):
assert spy.encoded[1] == negatives
assert index.negative_corpus == negatives
+ @pytest.mark.integration
+ def test_precomputed_embeddings_match_encoding_inline(self):
+ """Passing embeddings must score identically to letting the index encode.
+
+ This is what lets a sweep encode once and reuse the result: the embeddings
+ depend on the encoder, not on the index they are scored against.
+ """
+ index = SentinelLocalIndex.from_texts(
+ positive_texts=self.POSITIVE,
+ negative_texts=self.NEGATIVE,
+ model_name="sentence-transformers/all-MiniLM-L6-v2",
+ )
+ texts = ["harmful unsafe behavior", "normal regular activity"]
+
+ inline = index.calculate_rare_class_affinity(texts)
+ precomputed = index.calculate_rare_class_affinity(
+ texts,
+ index.sentence_model.encode(texts, **index.encoding_kwargs),
+ )
+
+ assert precomputed.observation_scores == inline.observation_scores
+
+ @pytest.mark.integration
+ def test_mismatched_embeddings_raise(self):
+ """Too few embeddings would pair observations with the wrong vectors."""
+ index = SentinelLocalIndex.from_texts(
+ positive_texts=self.POSITIVE,
+ negative_texts=self.NEGATIVE,
+ model_name="sentence-transformers/all-MiniLM-L6-v2",
+ )
+ texts = ["one", "two", "three"]
+ too_few = index.sentence_model.encode(texts[:2], **index.encoding_kwargs)
+
+ with pytest.raises(ValueError, match="sample_embeddings has 2 rows"):
+ index.calculate_rare_class_affinity(texts, too_few)
+
@pytest.mark.parametrize(
"kwargs,message",
[
diff --git a/tests/test_simulation.py b/tests/test_simulation.py
index 3256c3c..7ebb707 100644
--- a/tests/test_simulation.py
+++ b/tests/test_simulation.py
@@ -30,7 +30,9 @@
DEFAULT_AGGREGATORS,
GroupObservationScores,
LabeledGroup,
+ _add_columns,
compare_aggregators,
+ encode_observations,
evaluate_groups,
run_grid_search,
score_groups,
@@ -297,6 +299,207 @@ def _two_groups():
]
+class _SpyEncoder:
+ """Sentence model stand-in that counts how many texts it was asked to encode."""
+
+ def __init__(self):
+ self.encoded_batches = []
+
+ def encode(self, texts, **kwargs):
+ self.encoded_batches.append(list(texts))
+ # The observation text doubles as its score elsewhere in these tests, so keep
+ # the embedding trivially derived from it to stay debuggable.
+ return np.array([[float(t)] * 4 for t in texts], dtype=float)
+
+
+class _EncodingStubIndex(_StubSubsamplableIndex):
+ """Stub that can pre-encode, so the caching path can be exercised without a model."""
+
+ def __init__(self, n_positive=100, n_negative=500, encoder=None):
+ super().__init__(n_positive, n_negative)
+ self.sentence_model = encoder if encoder is not None else _SpyEncoder()
+ self.encoding_kwargs = {"normalize_embeddings": True}
+
+ def calculate_rare_class_affinity(self, text_samples, sample_embeddings=None, **kwargs):
+ # Record whether this pass was handed cached embeddings, so a test can assert
+ # the cache is actually reaching the scorer rather than being dropped.
+ self.calls.append({**kwargs, "used_cache": sample_embeddings is not None})
+ if sample_embeddings is None:
+ self.sentence_model.encode(text_samples)
+ observation_scores = {text: float(text) for text in text_samples}
+ return SimpleNamespace(observation_scores=observation_scores)
+
+ def subsample(self, n_positive=None, neg_to_pos_ratio=None, seed=None):
+ smaller = super().subsample(
+ n_positive=n_positive, neg_to_pos_ratio=neg_to_pos_ratio, seed=seed
+ )
+ # A real subsample() shares the parent's model, which is exactly why one set of
+ # observation embeddings stays valid across a whole sweep.
+ rebuilt = _EncodingStubIndex(
+ smaller.positive_embeddings.shape[0],
+ smaller.negative_embeddings.shape[0],
+ encoder=self.sentence_model,
+ )
+ rebuilt.calls = self.calls
+ rebuilt.subsample_calls = self.subsample_calls
+ return rebuilt
+
+
+def _rows_equal(left, right):
+ """Compare result rows, treating NaN as equal to NaN.
+
+ Some metrics are legitimately NaN on small fixtures (Cohen's d needs spread within
+ a class), and NaN never equals itself, so a plain ``==`` would report a difference
+ where none exists.
+ """
+ if len(left) != len(right):
+ return False
+ for row_a, row_b in zip(left, right):
+ if row_a.keys() != row_b.keys():
+ return False
+ for key in row_a:
+ a, b = row_a[key], row_b[key]
+ both_nan = (
+ isinstance(a, float)
+ and isinstance(b, float)
+ and np.isnan(a)
+ and np.isnan(b)
+ )
+ if not both_nan and a != b:
+ return False
+ return True
+
+
+class TestObservationEmbeddingCache:
+ """Encoding observations once and reusing them across scoring passes."""
+
+ def test_encoder_runs_once_regardless_of_sweep_size(self):
+ """The whole point: encoding cost stops scaling with the number of passes.
+
+ An observation's embedding depends on the encoder, never on the index it is
+ scored against, so a sweep that re-encodes per pass is recomputing identical
+ numbers.
+ """
+ index = _EncodingStubIndex()
+ run_grid_search(
+ index,
+ _two_groups(),
+ n_positive_values=[10, 20],
+ neg_to_pos_ratios=[1.0, 2.0],
+ top_k_values=[3, 5],
+ min_score_values=[0.0],
+ )
+
+ # 2 sizes x 2 ratios x 2 top_k = 8 scoring passes over 2 groups.
+ assert len(index.calls) == 8 * len(_two_groups())
+ assert all(call["used_cache"] for call in index.calls)
+ # But only one encode per group, up front.
+ assert len(index.sentence_model.encoded_batches) == len(_two_groups())
+
+ def test_disabling_the_cache_encodes_every_pass(self):
+ """The opt-out still works, for callers who cannot spare the memory."""
+ index = _EncodingStubIndex()
+ run_grid_search(
+ index,
+ _two_groups(),
+ top_k_values=[3, 5],
+ min_score_values=[0.0],
+ cache_observation_embeddings=False,
+ )
+
+ assert not any(call["used_cache"] for call in index.calls)
+ assert len(index.sentence_model.encoded_batches) == 2 * len(_two_groups())
+
+ def test_cached_and_uncached_results_are_identical(self):
+ """Caching is an optimisation, so it must not change a single number."""
+ groups = [
+ LabeledGroup(name="pos_a", label=1, observations=["0.9", "0.8"]),
+ LabeledGroup(name="pos_b", label=1, observations=["0.85", "0.7"]),
+ LabeledGroup(name="neg_a", label=0, observations=["0.2", "0.1"]),
+ LabeledGroup(name="neg_b", label=0, observations=["0.15", "0.05"]),
+ ]
+ kwargs = dict(
+ n_positive_values=[10, 20],
+ neg_to_pos_ratios=[1.0],
+ top_k_values=[3, 5],
+ min_score_values=[0.0, 0.1],
+ index_seed=42,
+ )
+ cached = run_grid_search(
+ _EncodingStubIndex(), groups, cache_observation_embeddings=True, **kwargs
+ )
+ uncached = run_grid_search(
+ _EncodingStubIndex(), groups, cache_observation_embeddings=False, **kwargs
+ )
+
+ # Guards against the comparison passing vacuously if everything were NaN.
+ assert cached and all(not np.isnan(row["roc_auc"]) for row in cached)
+ assert _rows_equal(cached, uncached)
+
+ def test_index_without_an_encoder_still_runs(self):
+ """Index-like objects that cannot pre-encode fall back instead of failing.
+
+ This harness deliberately accepts test doubles that only implement
+ calculate_rare_class_affinity, so the optimisation has to be skippable.
+ """
+ index = _StubSubsamplableIndex() # no sentence_model at all
+ rows = run_grid_search(
+ index, _two_groups(), top_k_values=[3], min_score_values=[0.0]
+ )
+
+ assert encode_observations(index, _two_groups()) == {}
+ assert len(rows) == len(DEFAULT_AGGREGATORS)
+
+ def test_score_groups_accepts_precomputed_embeddings(self):
+ """The embeddings can also be reused directly, without a grid search."""
+ index = _EncodingStubIndex()
+ groups = _two_groups()
+ embeddings = encode_observations(index, groups)
+
+ assert set(embeddings) == {"pos", "neg"}
+ index.sentence_model.encoded_batches.clear()
+
+ scored = score_groups(index, groups, top_k=3, observation_embeddings=embeddings)
+
+ assert [s.name for s in scored] == ["pos", "neg"]
+ assert index.sentence_model.encoded_batches == []
+
+
+class TestResultColumnCollisions:
+ """A second write to one column used to destroy the first, silently."""
+
+ def test_adding_an_existing_column_raises(self):
+ row = {"n_positive": 2}
+ with pytest.raises(ValueError, match="n_positive"):
+ _add_columns(row, n_positive=10)
+ # The original value survives the refusal.
+ assert row["n_positive"] == 2
+
+ def test_adding_new_columns_succeeds(self):
+ row = {"roc_auc": 0.9}
+ _add_columns(row, top_k=5, index_n_positive=10)
+ assert row == {"roc_auc": 0.9, "top_k": 5, "index_n_positive": 10}
+
+ def test_rows_still_expand_into_one_column_each(self):
+ """Rows stay plain dicts so pd.DataFrame(rows) keeps working as documented."""
+ pd = pytest.importorskip("pandas")
+ rows = run_grid_search(
+ _EncodingStubIndex(),
+ _two_groups(),
+ top_k_values=[3],
+ min_score_values=[0.0],
+ n_positive_values=[10],
+ )
+
+ frame = pd.DataFrame(rows)
+ assert len(frame) == len(rows)
+ for column in ("n_positive", "index_n_positive", "index_n_positive_actual"):
+ assert column in frame.columns
+ # The evaluation metadata and the index size remain distinct columns.
+ assert frame["n_positive"].tolist() == [1] * len(rows)
+ assert frame["index_n_positive"].tolist() == [10] * len(rows)
+
+
def test_grid_search_without_index_axes_never_subsamples():
"""The default path must not touch the index at all.