Compare commits

...
8 Commits
Author SHA1 Message Date
Reithan 1be05ad7d2 v1.1.0: Unify NRS on a single v-space path (EPS/V/X0) + X0 support; defaults 2/4/0.5 (#44)
## Summary
Collapses the experimental three-mode EPS switch
(`current`/`identity`/`true_v`) into a single, mathematically-correct
v-prediction-space path used by **all** variance-preserving
parameterizations — EPS, v-pred, and x0.

The sampler hook delivers `cond`/`uncond` as `x - x0` for every VP
parameterization, so NRS recovers the true velocity `v = (cond -
A)/factor` (with `A = x·σ²/(σ²+1)`, `factor = σ/√(σ²+1)`), runs its
geometry in v-space, and inverts exactly on return. This replaces the
prior EPS-only affine that operated on the wrong input space.

## Version: 1.1.0
MINOR, not a patch: this adds **X0 (sample) prediction support** — a new
backward-compatible capability — and **changes the default parameters**
(2/5/0.75 → 2/4/0.5). The node interface is unchanged (`eps_mode` never
shipped in 1.0.0), so it is **not** breaking/MAJOR. `pyproject.toml` and
`NRS/nodes_NRS.py __version__` are bumped to `1.1.0` in this PR; the
registry publish is triggered by the merge/release process, not by this
branch.

## Changes
- **`NRS/nodes_NRS.py`**: two-branch conversion — FLOW/CONST operated
natively (identity); all VP types (EPS/V/X0, UNKNOWN→VP) share one
true-v-space transform. Implements X0 (removes the
`NotImplementedError`). Removes `eps_mode` from every method and the
`nrs()` call. New defaults **skew 2 / stretch 4 / squash 0.5**.
- **`scripts/negative_rejection_steering_script.py`** (Forge/A1111):
removes the `eps_mode` test radio, process args, XYZ axis, and
extra-param; reverts `patch()` to 4 args; defaults 2/4/0.5.
- **`tests/test_pred_type.py`**: rewritten for the unified path — FLOW
is the sole native branch; EPS/V/X0/UNKNOWN all run the algebra.
- **`README.md`**: rewrites the V-Space step (now covers EPS/v-pred/x0
identically; flow-matching stays native) and updates the default to
2/4/0.5.
- **`CHANGELOG.md`**: `[1.1.0]` entry.
- **`pyproject.toml` / `NRS/nodes_NRS.py`**: version bumped 1.0.0 →
1.1.0.

## Rationale
Empirical A/B on an EPS checkpoint showed the true-v path is weakly
dominant: equal in normal ranges, burn-resistant at the failure edge,
and unlocks an on-style high-detail regime via squash (squash becomes a
detail/steering knob because the offset `A` is unscaled).

## Validation
- 39 passed / 9 skipped locally (torch tests skip; CI runs them).
Branch-coverage gate passed on push.
- ⚠️ **v-pred and x0 in v-space are math-validated but not yet
image-validated** — recommend a spot-check on a v-pred checkpoint before
merge/release.
2026-09-03 05:30:03 -07:00
Reithan 244f8c656f Fix NODE_DISPLAY_NAME_MAPPINGS typo and NRS skew log label (#43)
## Summary
Applies `.claude/patches/nrs-display-name-and-log-fix.patch`, fixing two
small defects:

- **`__init__.py`** — corrects the `NODE_DISPLAY_NAME_MAPPINS` typo to
`NODE_DISPLAY_NAME_MAPPINGS`. The misspelled name meant ComfyUI never
picked up the human-readable display name, and `__all__` exported a name
that didn't exist.
- **`scripts/negative_rejection_steering_script.py`** — the debug log
labeled `self.skew` as "Squash"; now correctly labeled "Skew".

## Test plan
- Pre-commit + pre-push hooks (ruff, pytest branch-coverage gate) passed
on push.
2026-09-03 01:49:16 -07:00
Reithan 5655dc0d5e Release v1.0.0: H3 packed-latent fix, flow-family reclassification, NRS-for-video docs (#41)
## v1.0.0 release

Documentation + version bump for the 1.0.0 release. No node logic
changes in this PR — the functional fixes (H3 packed-latent split, FLOW
reclassification) already merged via #34/#36/#37/#39.

### Headline
- **Flow-family models now use the correct FLOW prediction/operation
space** (#37). They were previously misclassified, so NRS applied the
wrong guidance geometry and underperformed on them — this is an enabling
fix, not a regression. Existing flow-model users will see changed
(better) output and should retune Skew/Stretch/Squash; the `pre-flow`
tag preserves prior behavior.
- **H3 / packed audio-video latents** are now unpacked per stream so NRS
steers on the real channel axis instead of collapsing to a silent no-op
(#36).

### What's in this PR
- `CHANGELOG.md` — new, with the full 1.0.0 entry (added/changed/notes).
- `README.md` — new **"NRS for Video"** section: disable caching
accelerators (EasyCache/TeaCache) and multistep samplers
(`res_multistep`, `dpmpp_2m`, …) with NRS on video; `euler_ancestral`
recommended; second-pass cost note. Video-specific — 2D unaffected.
- `pyproject.toml` + `NRS/nodes_NRS.py` — version bump `0.7.4` → `1.0.0`
(kept in sync per the version-equality test).
- `pyproject.toml` + `uv.lock` — declared `requires-python = ">=3.10"`
(matches ruff `target-version`) so dependency locking is deterministic
across environments; regenerated the lock (adds cp310 wheels + <3.11
backport deps).

### Validation
- `ruff check` clean; `pytest` 36 passed / 9 skipped; version-equality
test passes.
- `uv run` no longer drifts `uv.lock` (floor now pinned in pyproject).

### ⚠️ Merge = publish
Merging this to `main` bumps the version and **auto-publishes 1.0.0 to
the public ComfyUI registry (irreversible)**. Do not merge until
intended.
2026-08-14 06:55:00 -07:00
Reithan e0e48d12c8 Enforce branch-coverage gate on pre-push (mirror CI locally) (#40)
## What
Makes the local **pre-push** git hook enforce the same branch-coverage
gate as CI, so contributors catch coverage regressions before they push
(not just in CI). Two files change: the hook (`.pre-commit-config.yaml`)
and a doc note (`CONTRIBUTING.md`).

## The hook (`.pre-commit-config.yaml`, `run-tests` pre-push hook)
Mirrors `.github/workflows/test.yml`:
1. Runs the full suite with **real torch** + branch coverage: `pytest
--cov=NRS --cov-branch --cov-report=xml`.
2. Then the diff gate: `diff-cover coverage.xml
--compare-branch=origin/main --branch-coverage --fail-under=90` —
**blocks the push if changed code drops below 90% branch coverage**.
3. **Graceful skip** with a warning if `uv` isn't installed (unchanged
behavior); **fails loudly** if `origin/main` can't be resolved (nothing
to diff against).
4. Cleans up `coverage.xml`/`.coverage` on both success and failure
paths, while preserving the exit code so a failing gate still blocks.

All existing hooks are untouched: `prevent-push-to-main`,
`prevent-commit-to-main`, ruff, and the standard hooks. Only the
`run-tests` entry/name changed. Coverage stays on the **pre-push** stage
only — commits remain fast.

### Uses `uvx`, not `uv run` (important)
The invocations use `uvx --with torch --with pytest-cov --with
diff-cover ...`. An earlier `uv run` version was verified to **break the
push**: `uv run` syncs the project env and mutates `uv.lock` mid-hook,
which conflicts with pre-commit's stash/restore and aborts the push
(`Stashed changes conflicted with hook auto-fixes... Rolling back`).
`uvx` runs in an ephemeral env and never touches `uv.lock` — matching
how the project runs tests locally everywhere else.

## Docs (`CONTRIBUTING.md`)
Added a short note right after the `pre-commit install --hook-type
pre-push` instruction explaining the gate, the `uv` requirement (skips
with a warning otherwise), and that `origin/main` must be fetched for
the diff.

## Validation
- Ran the hook's exact command: **45 tests pass**, diff-cover passes;
`git status` after confirms `uv.lock` is **not** mutated (the
stash-conflict path can't recur).
- `uvx pre-commit run --hook-stage pre-push` completes with no
stash-conflict rollback.
- **This very PR's push exercised the hook live** — it ran the gate and
pushed cleanly.
- Only `.pre-commit-config.yaml` and `CONTRIBUTING.md` changed;
`pyproject.toml`/`uv.lock`/tests/`NRS/` untouched.

## Out-of-scope observation
The `ruff-format` hook has no `stages:` restriction, so a manual
`--all-files` dry-run reformats files broadly. Pre-existing; not
addressed here.
2026-08-14 02:40:33 -07:00
Reithan 950b55df5f Add __version__, patch-time version+pred-type log, platform-agnostic issue template (#39)
## What
Groundwork folded ahead of the held v1.0.0 release (publish-neutral —
**`pyproject.toml` is untouched**, so this does not trigger the registry
auto-publish):

1. **`NRS.__version__`** — added `__version__ = "0.7.4"` (mirrors the
current `pyproject.toml` version) in `NRS/nodes_NRS.py`, re-exported
from a new `NRS/__init__.py` so `NRS.__version__` is importable.
Commented to bump both together at release.
2. **Patch-time log line** — `patch()` now emits, right after
prediction-type detection:
`logging.info(f"NRS v{__version__}: prediction type detected ->
{pred_type.name}")`
So a user's console shows which NRS version ran and which prediction
space it selected (the key diagnostic for the flow-family
reclassification).
3. **GitHub bug-report issue form** —
`.github/ISSUE_TEMPLATE/bug_report.yml` + `config.yml`. Captures the NRS
version + detected prediction type (from the new log line),
model/sampler family, skew/stretch/squash values, expected vs actual,
repro, and console log.
- **Platform-agnostic:** NRS ships for ComfyUI *and* the A1111 family,
so the form has a **Platform / UI** dropdown — `ComfyUI, AUTOMATIC1111,
Forge, reForge, Forge Neo, Stability Matrix, Other` — with a free-text
follow-up and a generic "Platform version" field (no ComfyUI-only
wording).

## Tests
`tests/test_version.py`: asserts `NRS.__version__` is a valid `X.Y.Z`
string, matches `pyproject.toml` (catches future drift), and (via
`caplog`) that `patch()` emits the exact version + pred-type log line.

## Notes
- Adding `NRS/__init__.py` turned `NRS` from an implicit namespace
package into a real package, which newly triggered ruff's `N999` on the
pre-existing mixed-case filename `nodes_NRS.py`. Since `pyproject.toml`
is off-limits here, it's suppressed with an in-file `# ruff: noqa: N999`
pragma (with an explanatory comment) — consistent with the existing
per-file `N802/N804` carve-out for that same file. Renaming the file is
out of scope (breaks imports).

## Validation
- `ruff check .` → clean.
- `pytest --cov=NRS --cov-branch` → **45 passed** (42 + 3 new).
- diff-cover branch gate (mirrors CI) → **100%** on changed code.
- `pyproject.toml` / `uv.lock` untouched; issue-form YAML validated with
`yaml.safe_load`.
2026-08-14 02:24:47 -07:00
Reithan 352ac56295 Add CI: full test suite + >90% branch-coverage gate on diffs (#38)
## What
Adds the project's first **CI test workflow** —
`.github/workflows/test.yml` (job "CI"). Until now the only workflow was
`publish.yml`; tests never ran in CI. This gates every push to
`main`/`master` and every PR on (a) the full suite passing and (b)
**>90% by-branch coverage on changed code**.

## The workflow
- **Triggers:** `push` to `main`/`master`, and all `pull_request`s.
- **Env:** Python 3.12 (reliable CPU torch wheels),
`actions/checkout@v5` with `fetch-depth: 0` (diff-cover needs
base-branch history), `actions/setup-python@v5` with pip cache.
- **Real torch:** installs the CPU wheel from
`download.pytorch.org/whl/cpu`, so `tests/test_pack_split.py` (which
swaps the mock torch for real torch) actually executes rather than
skipping — its branch coverage counts.
- **Tests + coverage:** `pytest --cov=NRS --cov-branch --cov-report=xml
--cov-report=term-missing`. Any test failure fails the job.
- **Branch-coverage gate on the diff:** `diff-cover coverage.xml
--compare-branch=<base> --branch-coverage --fail-under=90`. Base is
`origin/${{ github.base_ref }}` for PRs (falling back to `origin/main`).
The `--branch-coverage` flag is required — diff-cover otherwise scores
only line coverage even when fed branch data in the Cobertura XML. Gate
is on **changed** code, not total project coverage, so a no-Python PR
passes trivially.

## Deliberately NOT touched
`pyproject.toml` is untouched (test deps live in the workflow), so this
change cannot trigger `publish.yml` or the held v1.0.0 version bump.

## Local validation
- `pytest --cov=NRS --cov-branch ...` → **42 passed**; total NRS branch
coverage ~79% (informational; the gate is diff-only, not total).
- `diff-cover coverage.xml --compare-branch=origin/main
--branch-coverage --fail-under=90` → passes (this branch changes no
Python).
- YAML parses cleanly; `coverage.xml`/`.coverage` already gitignored —
no artifacts committed.

## Note
Making this check **required** is a branch-protection repo setting you
control (GitHub → Settings → Branches).
2026-08-14 01:55:50 -07:00
Reithan c526ec9353 PR-3: FLOW operation space + flow-family reclassification (#37)
## What
Adds `PredictionType.FLOW` — an **identity operation space** — and
reclassifies the flow-matching family (`flux`, `chroma`, `flow`, `wan`,
`const`) off the VP ε-prediction path onto it. Flow models (H3, Flux,
Chroma, WAN) were resolving to `PredictionType.EPS`, so NRS ran the ε→v
/ v→ε σ-algebra on them using the wrong sigma convention. NRS's
projection/rejection geometry and norm ratios are invariant to a flow
model's per-sample velocity scale, so flow models are now operated
**natively with no conversion** (identity in, identity out).

## Changes (`NRS/nodes_NRS.py`)
- New enum member `PredictionType.FLOW`.
- `_convert_to_v_space` / `_finalize_from_v_space`: the `V` identity
branch is widened to `V | FLOW` (no ε↔v algebra, `x_div=None`,
cond/uncond/x_final passed through). **EPS** (SDXL/SD1.5), `X0`
(`NotImplementedError`), and the UNKNOWN fallback are untouched.
- `_RAW_TO_ENUM`: `flux`/`chroma`/`flow`/`wan`/`const` → `FLOW`
(comments updated). `eps`/`epsilon`/`v`/`v_prediction`/`x0`/`sample`
unchanged.
- `_get_pred_type` enhanced-detection fallback (the **second** detection
site): the CONST sampling-class branch and the `model.model.model_type`
flow/flux branch now return `FLOW` instead of `EPS` (log strings
updated). The `v_prediction`→V and `eps`→EPS branches are unchanged.
Changing only the dict would have silently left class-name /
model_type-detected models (WAN, H3) on the VP path.

## H3 specifically
MiniMax H3 needs **no special case**. It exposes `BaseModel.model_type`
as the `ModelType.FLOW` **Enum**; `_canon`'s Enum branch reduces it to
`"flow"`, an exact `_RAW_TO_ENUM` key → resolves to `FLOW` at the dict
direct-hit site. There is no `"h3"` raw type. A regression test pins
this exact Enum→canon→FLOW path.

## Tests (`tests/test_pred_type.py`)
- Flipped the flow-family pins (`flow`/`const`/`flux`/`chroma`/`wan`)
from EPS to **FLOW** — the PR-1 safety net doing its job.
- Fallback-site coverage: stubs whose only flow signal is a CONST-like
`model_sampling` class name, or a `model.model.model_type` string merely
*containing* `"flow"`/`"flux"` (not an exact dict key) — so the
enhanced-detection branch is genuinely exercised, not the dict.
- H3-representative test: an actual `enum.Enum` member
(`ModelType.FLOW`-shaped) resolves to FLOW via `_canon`'s Enum branch.
- FLOW identity round-trip through both conversion helpers; V-path
identity regression guard; EPS-path regression guard asserting the ε→v
algebra still runs.

- `uvx ruff check .` → All checks passed!
- `uvx --with torch pytest -q` → **42 passed** (up from 33)

## Rollout
Breaking for **all flow-family outputs** (Flux/Chroma/WAN/H3 produce
different images/video — retune skew/stretch). Baseline preserved at tag
`pre-flow`. This is the last breaking change before the **v1.0.0**
release.
2026-08-14 01:27:00 -07:00
Reithan d4ff852491 PR-2: pack-aware per-stream NRS routing + degeneracy tripwire (#36)
## What
Fixes NRS on MiniMax H3 (and any sampler that packs multiple streams).
ComfyUI hands the cfg hook a **flat packed latent** `[B,1,N]` whose
`dim=1` is a singleton, so every NRS reduction runs over a size-1 axis
and the projection geometry collapses — Skew becomes a silent no-op,
Stretch degenerates to elementwise CFG, Squash to a Stretch-reverter
(proven to float precision in Phase 0).

This PR unpacks the flat pack into its real channels-first per-stream
tensors, runs the **unchanged** NRS geometry per stream, then repacks.

## Changes (`NRS/nodes_NRS.py`)
- Guarded `import comfy.utils as _comfy_utils` (module still imports
with no ComfyUI present) + `import math`.
- Module-level pure-Python `_unpack_latents`/`_pack_latents` fallbacks
matching ComfyUI's pack contract (each stream reshaped to `(B,1,-1)` and
concatenated on the last dim; unpack slices `prod(shape[1:])` per
stream).
- Extracted `_apply_guidance(...)` — the per-stream geometry pipeline
(sigma reshape → `_convert_to_v_space` → dot/proj/stretch/skew/squash →
`_finalize_from_v_space`), **byte-for-byte identical** to the prior
inline math.
- Rewired `nrs()`: read `args["model"].latent_shapes`; when present with
`len>1`, unpack cond/uncond/input per stream (comfy-preferred,
pure-Python fallback), map `_apply_guidance`, repack; otherwise the
single-stream path is the **exact prior behavior** (regression no-op for
SDXL etc.).
- Permanent **degeneracy tripwire**: warns once per `patch()` if any
routed stream still has a singleton reduction axis — the silent-failure
class that cost the original Skew investigation days.

## Why this is safe
- Single-stream models are a verified byte-for-byte no-op (two
regression tests compare against a manually-computed `_apply_guidance`).
- Not H3 fingerprinting: any multi-stream packed sampler (LTXV AV
variants) benefits; older ComfyUI without the API falls through cleanly.
- Verified against the real 18MB H3 capture (not committed):
unpack→repack is bit-exact for cond/uncond/x_orig; streams recover as
`[1,24,72,38,22]` (video) / `[1,32,2,405]` (audio); per-stream guidance
produces large live deviations where the flat pack was near-degenerate —
Skew is alive again.

## Tests
New `tests/test_pack_split.py` (9 tests, real-torch via a module-scoped
isolation harness that restores the mock afterward): round-trip
pack/unpack, single- & multi-stream fallback regression, tripwire
fires/doesn't-fire, per-stream reduced shapes, and
non-degenerate-vs-degenerate rejection.

- `uvx ruff check .` → All checks passed!
- `uvx --with torch pytest -q` → **33 passed** (24 existing + 9 new)

Breaking for H3 outputs only. Baseline preserved at tag `pre-flow`.
2026-08-14 01:15:59 -07:00
16 changed files with 994 additions and 114 deletions
+160
View File
@@ -0,0 +1,160 @@
name: Bug Report
description: Report unexpected NRS behavior or a crash
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Please fill out as much detail as you can — NRS's guidance behavior is sensitive to model type and node settings, so precise details help a lot.
- type: dropdown
id: platform
attributes:
label: Platform / UI
description: Which UI/front-end are you running NRS through?
options:
- ComfyUI
- AUTOMATIC1111
- Forge
- reForge
- Forge Neo
- Stability Matrix
- Other (specify below)
validations:
required: true
- type: input
id: platform-other
attributes:
label: Platform / UI (if "Other")
description: If you selected "Other" above, name the platform/UI here.
placeholder: e.g. SD.Next, a custom fork, etc.
validations:
required: false
- type: input
id: platform-version
attributes:
label: Platform version
description: >
The version of whichever platform you selected above. Found in that
platform's UI (e.g. Help/About, Settings) or its startup console
output.
placeholder: e.g. 0.3.30 (ComfyUI) or v1.10.1 (AUTOMATIC1111/Forge)
validations:
required: true
- type: dropdown
id: model-family
attributes:
label: Model / sampler family
description: Which model or sampler family were you using when the issue occurred?
options:
- MiniMax H3
- Flux
- Chroma
- WAN
- SDXL
- SD 1.5
- Other (specify below)
validations:
required: true
- type: input
id: model-family-other
attributes:
label: Model / sampler family (if "Other")
description: If you selected "Other" above, name the model/sampler family here.
placeholder: e.g. custom checkpoint, HunyuanVideo, etc.
validations:
required: false
- type: input
id: nrs-version
attributes:
label: NRS version
description: >
Look for the NRS log line in your console/terminal:
`NRS v<version>: prediction type detected -> <TYPE>` (printed when
NRS runs, or check the extension's about/version info, depending on
your platform). Copy the version number from that line.
placeholder: e.g. 0.7.4
validations:
required: true
- type: input
id: pred-type
attributes:
label: Detected prediction type
description: >
From the same NRS log line as above
(`NRS v<version>: prediction type detected -> <TYPE>`), copy the
detected type (e.g. EPS, V, FLOW, UNKNOWN).
placeholder: e.g. FLOW
validations:
required: true
- type: input
id: skew
attributes:
label: Skew value
placeholder: e.g. 2.00
validations:
required: true
- type: input
id: stretch
attributes:
label: Stretch value
placeholder: e.g. 5.00
validations:
required: true
- type: input
id: squash
attributes:
label: Squash value
placeholder: e.g. 0.75
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What actually happened? Include screenshots if relevant.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Minimal steps (or an attached workflow JSON) to reproduce the issue.
placeholder: |
1. Load workflow...
2. Set Skew/Stretch/Squash to...
3. Queue prompt...
validations:
required: true
- type: textarea
id: console-log
attributes:
label: Console log output
description: >
Paste the relevant console output, including the
`NRS v<version>: prediction type detected -> <TYPE>` line and any
errors/warnings/tracebacks.
render: shell
validations:
required: true
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: NRS Discussions
url: https://github.com/Reithan/negative_rejection_steering/discussions
about: Ask questions or discuss ideas that aren't a bug report.
+48
View File
@@ -0,0 +1,48 @@
name: CI
on:
push:
branches:
- main
- master
pull_request:
permissions:
contents: read
jobs:
test:
name: Test suite with branch coverage gate
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install real CPU torch
run: pip install torch --index-url https://download.pytorch.org/whl/cpu
- name: Install test tooling
run: pip install pytest pytest-cov diff-cover
- name: Run tests with branch coverage
run: pytest --cov=NRS --cov-branch --cov-report=xml --cov-report=term-missing
- name: Determine base branch for diff-cover
id: base
run: |
base_ref="origin/main"
if [ "${{ github.event_name }}" = "pull_request" ] && git rev-parse --verify "origin/${{ github.base_ref }}" >/dev/null 2>&1; then
base_ref="origin/${{ github.base_ref }}"
fi
echo "ref=${base_ref}" >> "$GITHUB_OUTPUT"
- name: Enforce 90% branch coverage on changed code
run: diff-cover coverage.xml --compare-branch=${{ steps.base.outputs.ref }} --branch-coverage --fail-under=90
+2 -2
View File
@@ -36,8 +36,8 @@ repos:
- repo: local - repo: local
hooks: hooks:
- id: run-tests - id: run-tests
name: Run pytest tests name: Run pytest with branch-coverage gate
entry: bash -c 'if command -v uv > /dev/null 2>&1; then uv run pytest tests/ || exit 1; else echo "WARNING - uv not found, skipping tests"; fi' entry: bash -c 'if ! command -v uv > /dev/null 2>&1; then echo "WARNING - uv not found, skipping tests and coverage gate"; exit 0; fi; if ! git rev-parse --verify --quiet origin/main > /dev/null 2>&1; then echo "ERROR - origin/main not resolvable locally; fetch origin main and retry"; exit 1; fi; uvx --with torch --with pytest-cov --with diff-cover pytest --cov=NRS --cov-branch --cov-report=xml --cov-report=term-missing tests/; status=$?; if [ $status -ne 0 ]; then rm -f coverage.xml .coverage; exit $status; fi; uvx --with diff-cover diff-cover coverage.xml --compare-branch=origin/main --branch-coverage --fail-under=90; cov_status=$?; rm -f coverage.xml .coverage; exit $cov_status'
language: system language: system
stages: [pre-push] stages: [pre-push]
always_run: true always_run: true
+41
View File
@@ -0,0 +1,41 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.1.0] - Unreleased
### Added
- **X0 (sample) prediction support** (#44): x0-prediction models are now handled through the shared v-prediction-space path, alongside EPS and v-pred.
### Fixed
- **Correct v-space conversion for all variance-preserving parameterizations** (#44). The sampler hook delivers `cond`/`uncond` as `x - x0` for EPS, v-pred, and x0 alike, so NRS now recovers the true velocity `v = (cond - A)/factor` and runs its geometry in v-prediction space, then inverts exactly on return. This replaces the prior EPS-only affine, which operated on an incorrect input-space assumption. Flow-matching (FLOW/CONST) models remain operated natively — their prediction is already a pure scalar multiple of the velocity, so no conversion is applied.
### Changed / Upgrade notes
- **Default parameters changed** 2/5/0.75 → **2/4/0.5** (Skew/Stretch/Squash) in both the ComfyUI node and the A1111-family (Forge/reForge/Forge Neo) script.
- **v-prediction models now run the v-space conversion** instead of operating on the raw guidance. For typical config ranges the output change is expected to be minimal (verified on EPS; v-pred/x0 are math-validated but **not yet image-validated** — spot-check and retune if needed).
- **Reproducibility note:** the same seed + config may produce a slightly different image than 1.0.0 because of the corrected v-space handling and the new defaults.
## [1.0.0] - 2026-08-14
### Fixed
- Flow-family (flow-matching) models now use a dedicated FLOW prediction/operation space (#37) so NRS applies the correct guidance geometry to them. Previously these models were misclassified, causing NRS to operate on an incorrect prediction-type assumption and underperform. This is the headline fix in 1.0.0.
- Pack-aware per-stream NRS routing with a degeneracy tripwire (#36): NRS now unpacks multi-stream packed latents (e.g. MiniMax H3 audio+video) and applies the geometry per stream on the real channel axis, instead of collapsing to a silent no-op on the flat packed latent.
- Prediction-type detection for WAN / RES4LYF samplers (#30).
- Removed a mangled guard and dead operation-space code paths; added prediction-type detection tests (#34).
- Resolved Node.js 20 deprecation warnings in GitHub Actions.
### Added
- `__version__` string plus a patch-time log line announcing the version and detected prediction type; platform-agnostic GitHub issue template (#39).
- CI: full test suite with a >90% branch-coverage gate on diffs (#38); version-increment check in the publish workflow (#33); git hooks and development infrastructure (#32).
- Declared `requires-python` (>=3.10) so dependency locking is deterministic across environments.
### Changed / Upgrade notes
- Because flow-family models now use the correct FLOW space, NRS output for these models changes (for the better). Existing users of flow-matching models should retune Skew/Stretch/Squash. The `pre-flow` git tag preserves the prior behavior if a rollback is needed.
+6
View File
@@ -54,6 +54,12 @@ Thank you for your interest in contributing! This document provides guidelines f
pre-commit install --hook-type pre-push pre-commit install --hook-type pre-push
``` ```
The pre-push hook runs the full test suite with branch coverage and blocks
the push if changed code drops below 90% branch coverage (via pytest-cov +
diff-cover, mirroring CI). It needs `uv` installed — if `uv` isn't found,
the check is skipped with a warning — and `origin/main` fetched locally so
there's something to diff against.
6. **Verify setup**: 6. **Verify setup**:
```bash ```bash
+10
View File
@@ -0,0 +1,10 @@
"""NRS package init.
Re-exports `__version__` from nodes_NRS.py so `NRS.__version__` is
importable. This value must match the `version` field in pyproject.toml —
bump both together at release time.
"""
from .nodes_NRS import __version__
__all__ = ["__version__"]
+171 -84
View File
@@ -1,25 +1,66 @@
# ruff: noqa: N999 -- filename predates NRS/__init__.py; mixed-case "nodes_NRS"
# only became checkable once NRS became a regular (non-namespace) package here.
# Renaming it is out of scope (would break existing imports); pyproject.toml's
# per-file-ignores already carve out N802/N804 for this same file.
import logging import logging
import math
from enum import Enum, auto from enum import Enum, auto
import torch import torch
try:
import comfy.utils as _comfy_utils
except Exception:
_comfy_utils = None
# Must be bumped together with the `version` field in pyproject.toml at release time.
__version__ = "1.1.0"
def _unpack_latents(combined, latent_shapes):
"""Split a flat packed latent [B, 1, N] back into its per-stream tensors.
Mirrors comfy.utils.unpack_latents: for each shape in latent_shapes, take
math.prod(shape[1:]) elements off the last dim and reshape that [B, 1, n]
slice back to `shape`.
"""
streams = []
offset = 0
for shape in latent_shapes:
n = math.prod(shape[1:])
chunk = combined[:, :, offset : offset + n]
streams.append(chunk.reshape(shape))
offset += n
return streams
def _pack_latents(streams):
"""Pack a list of per-stream tensors [B, C, ...] into a flat [B, 1, N] tensor.
Mirrors comfy.utils.pack_latents: each stream is reshaped to (B, 1, -1)
and concatenated on the last dim.
"""
flat = [s.reshape(s.shape[0], 1, -1) for s in streams]
return torch.cat(flat, dim=-1)
# fmt: off # fmt: off
class PredictionType(Enum): class PredictionType(Enum):
EPS = auto() # ε-prediction EPS = auto() # ε-prediction
V = auto() # v-prediction V = auto() # v-prediction
X0 = auto() # x₀-prediction X0 = auto() # x₀-prediction
FLOW = auto() # flow-matching / velocity — operated natively, no VP conversion
UNKNOWN = auto() # couldn’t detect / new scheduler UNKNOWN = auto() # couldn’t detect / new scheduler
_RAW_TO_ENUM = { _RAW_TO_ENUM = {
"eps": PredictionType.EPS, "eps": PredictionType.EPS,
"epsilon": PredictionType.EPS, "epsilon": PredictionType.EPS,
"flux": PredictionType.EPS, "flux": PredictionType.FLOW,
"chroma": PredictionType.EPS, "chroma": PredictionType.FLOW,
"flow": PredictionType.EPS, # FLOW models (WAN, etc.) are EPS-compatible "flow": PredictionType.FLOW, # FLOW models (WAN, etc.) operated natively
"wan": PredictionType.EPS, # WAN21 is FLOW-based "wan": PredictionType.FLOW, # WAN21 is FLOW-based
"const": PredictionType.EPS, # CONST prediction class used in FLOW models "const": PredictionType.FLOW, # CONST prediction class used in FLOW models
"v": PredictionType.V, "v": PredictionType.V,
"v_prediction": PredictionType.V, "v_prediction": PredictionType.V,
"x0": PredictionType.X0, "x0": PredictionType.X0,
@@ -47,7 +88,7 @@ class NRS:
"stretch": ( "stretch": (
"FLOAT", "FLOAT",
{ {
"default": 5.00, "default": 4.00,
"min": -30.0, "min": -30.0,
"max": 30.0, "max": 30.0,
"step": 0.01, "step": 0.01,
@@ -57,7 +98,7 @@ class NRS:
"squash": ( "squash": (
"FLOAT", "FLOAT",
{ {
"default": 0.75, "default": 0.50,
"min": 0.0, "min": 0.0,
"max": 1.0, "max": 1.0,
"step": 0.01, "step": 0.01,
@@ -125,8 +166,8 @@ class NRS:
# CONST class is used by FLOW models (WAN21, Flux, etc.) # CONST class is used by FLOW models (WAN21, Flux, etc.)
if "const" in sampling_class_name: if "const" in sampling_class_name:
logging.debug("NRS._get_pred_type: Detected FLOW model via CONST sampling class -> EPS") logging.debug("NRS._get_pred_type: Detected FLOW model via CONST sampling class -> FLOW")
return PredictionType.EPS return PredictionType.FLOW
elif "v_prediction" in sampling_class_name: elif "v_prediction" in sampling_class_name:
logging.debug("NRS._get_pred_type: Detected V-prediction model via sampling class -> V") logging.debug("NRS._get_pred_type: Detected V-prediction model via sampling class -> V")
return PredictionType.V return PredictionType.V
@@ -140,8 +181,8 @@ class NRS:
logging.debug(f"NRS._get_pred_type: Found model.model.model_type: {model_type_str}") logging.debug(f"NRS._get_pred_type: Found model.model.model_type: {model_type_str}")
if "flow" in model_type_str or "flux" in model_type_str: if "flow" in model_type_str or "flux" in model_type_str:
logging.debug("NRS._get_pred_type: Detected FLOW/Flux model via model_type -> EPS") logging.debug("NRS._get_pred_type: Detected FLOW/Flux model via model_type -> FLOW")
return PredictionType.EPS return PredictionType.FLOW
elif "v_prediction" in model_type_str: elif "v_prediction" in model_type_str:
logging.debug("NRS._get_pred_type: Detected V-prediction model via model_type -> V") logging.debug("NRS._get_pred_type: Detected V-prediction model via model_type -> V")
return PredictionType.V return PredictionType.V
@@ -159,98 +200,144 @@ class NRS:
) )
return PredictionType.EPS return PredictionType.EPS
def _is_vp(self, pred_type):
"""VP (variance-preserving) parameterizations converted to v-space: EPS, V, X0.
UNKNOWN (and any unhandled type) falls back to VP/v-space. FLOW/CONST is the only
parameterization operated natively (see _convert_to_v_space).
"""
if pred_type in (PredictionType.EPS, PredictionType.V, PredictionType.X0):
return True
if pred_type == PredictionType.FLOW:
return False
logging.warning(f"NRS: unknown prediction type {pred_type}, treating as VP (v-space)")
return True
def _convert_to_v_space(self, x_orig, sig_root, sigma, cond, uncond, pred_type): def _convert_to_v_space(self, x_orig, sig_root, sigma, cond, uncond, pred_type):
x_div = None """Convert the (x - x0) guidance vectors into v-prediction space before the NRS geometry.
v_cond = cond
v_uncond = uncond
if pred_type == PredictionType.V:
logging.debug("NRS._convert_to_v_space: already in v, no pre-scale needed")
pass # already in v space
elif pred_type == PredictionType.EPS:
# ε → v conversion
logging.debug("NRS._convert_to_v_space: generating x_div, v_cond, and v_uncond for eps")
x_div = x_orig / (sigma**2 + 1)
factor = sigma / sig_root
v_cond = x_orig - (x_div - cond * factor) The sampler hook delivers cond/uncond as `x - x0` for every parameterization (the
v_uncond = x_orig - (x_div - uncond * factor) model's raw output is converted to a denoised x0 before NRS sees it), so the true
elif pred_type == PredictionType.X0: velocity is recovered the same way regardless of EPS/V/X0:
raise NotImplementedError("NRS._convert_to_v_space: x0-prediction not supported yet.") v = (cond - A)/factor = (x/(sigma^2+1) - x0) * sig_root/sigma
else: with A = x*sigma^2/(sigma^2+1), factor = sigma/sqrt(sigma^2+1).
# Fallback: treat UNKNOWN as EPS and convert to V-space
logging.warning(f"NRS._convert_to_v_space: Unknown prediction type {pred_type}, treating as EPS")
logging.debug("NRS._convert_to_v_space: generating x_div, v_cond, and v_uncond for eps (fallback)")
x_div = x_orig / (sigma**2 + 1)
factor = sigma / sig_root
v_cond = x_orig - (x_div - cond * factor)
v_uncond = x_orig - (x_div - uncond * factor)
return x_div, v_cond, v_uncond FLOW/CONST is operated natively: `x - x0 = sigma*out` is a pure scalar multiple of
the model's velocity (no additive offset), and the NRS geometry is scale-invariant,
so identity already runs on the native prediction. There is no VP v-space for
flow-matching (its sigma is a [0,1] flow time, not a VP karras sigma).
"""
if not self._is_vp(pred_type):
logging.debug("NRS._convert_to_v_space: flow/const operated natively (identity)")
return cond, uncond
def _finalize_from_v_space(self, x_orig, x_div, x_final, sig_root, sigma, pred_type): logging.debug("NRS._convert_to_v_space: converting VP prediction to v-space")
nrs_result = x_final factor = sigma / sig_root
if pred_type == PredictionType.V: a_off = x_orig - x_orig / (sigma**2 + 1) # A = x*sigma^2/(sigma^2+1)
# already in v space return (cond - a_off) / factor, (uncond - a_off) / factor
logging.debug("NRS._finalize_from_v_space: already in v, no post-scale needed")
pass def _finalize_from_v_space(self, x_orig, x_final, sig_root, sigma, pred_type):
elif pred_type == PredictionType.EPS: """Invert _convert_to_v_space so the hook returns `x - x0_final`. Round-trips exactly."""
# v → ε conversion if not self._is_vp(pred_type):
logging.debug("NRS._finalize_from_v_space: generating cfg_result for eps") logging.debug("NRS._finalize_from_v_space: flow/const operated natively (identity)")
nrs_result = (x_div - (x_orig - x_final)) * (sig_root / sigma) return x_final
elif pred_type == PredictionType.X0:
raise NotImplementedError("NRS._finalize_from_v_space: x0-prediction not supported yet.") factor = sigma / sig_root
else: a_off = x_orig - x_orig / (sigma**2 + 1)
# Fallback: treat UNKNOWN as EPS and convert from V-space return a_off + x_final * factor
logging.warning(f"NRS._finalize_from_v_space: Unknown prediction type {pred_type}, treating as EPS")
logging.debug("NRS._finalize_from_v_space: generating cfg_result for eps (fallback)") def _apply_guidance(self, x_orig, cond, uncond, sigma, skew, stretch, squash, pred_type):
nrs_result = (x_div - (x_orig - x_final)) * (sig_root / sigma) """Run the NRS geometry pipeline on a single (already-unpacked, channels-first) stream."""
return nrs_result sigma = sigma.view(sigma.shape[:1] + (1,) * (cond.ndim - 1))
sig_root = (sigma**2 + 1).sqrt()
# Convert (x - x0) guidance into v-space for all VP parameterizations (EPS/V/X0);
# FLOW/CONST runs natively.
nrs_cond, nrs_uncond = self._convert_to_v_space(x_orig, sig_root, sigma, cond, uncond, pred_type)
def _dot(a, b):
return (a * b).sum(dim=1, keepdim=True) # [B,C,W,H] => [B,1,W,H]
def _nrm2(v):
return _dot(v, v)
eps = torch.finfo(nrs_cond.dtype).eps
c_dot_c = _nrm2(nrs_cond) + eps # [B,1,W,H]
u_dot_c = _dot(nrs_uncond, nrs_cond) # [B,1,W,H]
u_on_c = (u_dot_c / c_dot_c) * nrs_cond # [B,1,W,H] * [B,C,H,W]
# Amplify Cond based on length compared to projection of uncond
proj_diff = nrs_cond - u_on_c
stretched = nrs_cond + (stretch * proj_diff)
# Skew/Steer Conf based on rejection of uncond on cond
u_rej_c = nrs_uncond - u_on_c
skewed = stretched - (skew * u_rej_c)
# Squash final length back down to original length of cond
cond_len = nrs_cond.norm(dim=1, keepdim=True)
nrs_len = skewed.norm(dim=1, keepdim=True) + eps
squash_scale = (1 - squash) + (squash * (cond_len / nrs_len))
x_final = skewed * squash_scale
return self._finalize_from_v_space(x_orig, x_final, sig_root, sigma, pred_type)
def patch(self, model, skew, stretch, squash): def patch(self, model, skew, stretch, squash):
pred_type = self._get_pred_type(model) pred_type = self._get_pred_type(model)
logging.info(f"NRS v{__version__}: prediction type detected -> {pred_type.name}")
warned = {"done": False}
def nrs(args): def nrs(args):
logging.debug(f"NRS.nrs: Skew: {skew}, Stretch: {stretch}, Squash: {squash}") logging.debug(f"NRS.nrs: Skew: {skew}, Stretch: {stretch}, Squash: {squash}")
cond = args["cond"] cond = args["cond"]
uncond = args["uncond"] uncond = args["uncond"]
x_orig = args["input"] x_orig = args["input"]
sigma = args["sigma"] sigma = args["sigma"]
sigma = sigma.view(sigma.shape[:1] + (1,) * (cond.ndim - 1))
sig_root = (sigma**2 + 1).sqrt()
# Operation space is hardcoded to V for now; FLOW is added in a later PR. shapes = getattr(args["model"], "latent_shapes", None)
x_div, nrs_cond, nrs_uncond = self._convert_to_v_space( if shapes and len(shapes) > 1:
x_orig, sig_root, sigma, cond, uncond, pred_type if _comfy_utils is not None and hasattr(_comfy_utils, "unpack_latents"):
) cond_streams = _comfy_utils.unpack_latents(cond, shapes)
uncond_streams = _comfy_utils.unpack_latents(uncond, shapes)
x_streams = _comfy_utils.unpack_latents(x_orig, shapes)
else:
cond_streams = _unpack_latents(cond, shapes)
uncond_streams = _unpack_latents(uncond, shapes)
x_streams = _unpack_latents(x_orig, shapes)
else:
cond_streams, uncond_streams, x_streams = [cond], [uncond], [x_orig]
def _dot(a, b): if not warned["done"]:
return (a * b).sum(dim=1, keepdim=True) # [B,C,W,H] => [B,1,W,H] for stream in x_streams:
if stream.shape[1] == 1:
logging.warning(
f"NRS.nrs: routed stream has a singleton reduction axis {tuple(stream.shape)}; "
"NRS geometry (dot/proj/skew) will degenerate to a no-op on this stream."
)
warned["done"] = True
break
def _nrm2(v): results = [
return _dot(v, v) self._apply_guidance(
x_streams[i],
cond_streams[i],
uncond_streams[i],
sigma,
skew,
stretch,
squash,
pred_type,
)
for i in range(len(cond_streams))
]
eps = torch.finfo(nrs_cond.dtype).eps if len(results) == 1:
c_dot_c = _nrm2(nrs_cond) + eps # [B,1,W,H] return results[0]
u_dot_c = _dot(nrs_uncond, nrs_cond) # [B,1,W,H]
u_on_c = (u_dot_c / c_dot_c) * nrs_cond # [B,1,W,H] * [B,C,H,W]
# Amplify Cond based on length compared to projection of uncond if _comfy_utils is not None and hasattr(_comfy_utils, "pack_latents"):
proj_diff = nrs_cond - u_on_c return _comfy_utils.pack_latents(results)[0]
stretched = nrs_cond + (stretch * proj_diff) return _pack_latents(results)
# Skew/Steer Conf based on rejection of uncond on cond
u_rej_c = nrs_uncond - u_on_c
skewed = stretched - (skew * u_rej_c)
# Squash final length back down to original length of cond
cond_len = nrs_cond.norm(dim=1, keepdim=True)
nrs_len = skewed.norm(dim=1, keepdim=True) + eps
squash_scale = (1 - squash) + (squash * (cond_len / nrs_len))
x_final = skewed * squash_scale
return self._finalize_from_v_space(x_orig, x_div, x_final, sig_root, sigma, pred_type)
m = model.clone() m = model.clone()
m.set_model_sampler_cfg_function(nrs, True) m.set_model_sampler_cfg_function(nrs, True)
+11 -2
View File
@@ -20,7 +20,7 @@ NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidanc
<img align="right" src="Examples/NRS_graph.png" alt="Graph of NRS vs CFG" style="width: 40%; float: right;"> <img align="right" src="Examples/NRS_graph.png" alt="Graph of NRS vs CFG" style="width: 40%; float: right;">
### NRS is Applied in Three Steps: ### NRS is Applied in Three Steps:
0. ***V-Space**: Optional pre-NRS step* If the model is not using v-prediction, we transform the EPS `cond` and `uncond` into v-prediction space before continuing, then revert to eps-space before return. 0. ***V-Space**: pre-NRS step* The sampler hands NRS its `cond`/`uncond` as `x - x0` for every variance-preserving parameterization (EPS, v-prediction, and x0-prediction alike), so NRS recovers the true velocity `v` from them and runs its geometry in v-prediction space, then inverts the transform before returning. This one v-space path handles EPS, v-pred, and x0 models identically. Flow-matching models (flux, chroma, wan, and other flow/CONST families) are operated natively — their prediction is already a pure scalar multiple of the velocity, so no v-space conversion is applied.
1. **Skewing**: The conditioned output tensor is skewed away from the direction of the rejection of the unconditioned tensor on the conditioned tensor. This lengthens the tensor in a direction perpendicular to its direction without affecting the positive guidance. The tensor is displaced by the rejection multiplied by the Skew parameter.[^1] 1. **Skewing**: The conditioned output tensor is skewed away from the direction of the rejection of the unconditioned tensor on the conditioned tensor. This lengthens the tensor in a direction perpendicular to its direction without affecting the positive guidance. The tensor is displaced by the rejection multiplied by the Skew parameter.[^1]
2. **Stretching**: The skewed tensor is stretched towards the direction of the original conditioned tensor based on its difference from the projection of uncond on cond. The stretch is multiplied by the Stretch parameter.[^1] 2. **Stretching**: The skewed tensor is stretched towards the direction of the original conditioned tensor based on its difference from the projection of uncond on cond. The stretch is multiplied by the Stretch parameter.[^1]
3. **Squashing**: The skewed and stretched tensor is rescaled towards the original length of the conditioned tensor. 100% squashing outputs the original length of the conditioned tensor simply 'steered' towards the skewed & squashed version's direction.[^1] 3. **Squashing**: The skewed and stretched tensor is rescaled towards the original length of the conditioned tensor. 100% squashing outputs the original length of the conditioned tensor simply 'steered' towards the skewed & squashed version's direction.[^1]
@@ -63,7 +63,7 @@ NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidanc
## Beginner How-To ## Beginner How-To
1. Set Skew to 1/2 of your normal CFG Scale setting and Stretch to your full normal CFG Scale. Set Squash to 0.0.<br> 1. Set Skew to 1/2 of your normal CFG Scale setting and Stretch to your full normal CFG Scale. Set Squash to 0.0.<br>
*Alternatively, try starting with the default of 2/5/0.75, or at 1/1/1 to get a baseline.* *Alternatively, try starting with the default of 2/4/0.5, or at 1/1/1 to get a baseline.*
2. Test some outputs. Results should be similar in quality to CFG. 2. Test some outputs. Results should be similar in quality to CFG.
3. Adjust Skew to change the intensity of your outputs adherence to your positive and negative prompts. This primarily effects composition of the output. 3. Adjust Skew to change the intensity of your outputs adherence to your positive and negative prompts. This primarily effects composition of the output.
4. Adjust Stretch to intensify your positive prompt's aspects and colors where they differ from the negative prompt. This primarily effects color and texture. 4. Adjust Stretch to intensify your positive prompt's aspects and colors where they differ from the negative prompt. This primarily effects color and texture.
@@ -119,6 +119,15 @@ Once installed and enabled, the NRS settings panel will appear in your generatio
### StabilityMatrix Integration ### StabilityMatrix Integration
NRS is available as a **natively supported module** in [StabilityMatrix](https://lykos.ai/), providing an easy installation and management option for users of that platform. NRS is available as a **natively supported module** in [StabilityMatrix](https://lykos.ai/), providing an easy installation and management option for users of that platform.
### NRS for Video
When using NRS with **video** models (e.g. MiniMax H3), two things need to be turned off or output quality suffers:
- **Caching accelerators** (EasyCache, TeaCache, etc.) — their change-thresholded caching skips model evaluations that NRS relies on. With NRS active, this causes motion stutter and audio artifacts.
- **Multistep samplers** (`res_multistep`, `dpmpp_2m`, `dpmpp_3m_sde`, and other history/"m" samplers) — they extrapolate NRS's guidance across steps, compounding instability over the clip. Use a memoryless sampler instead; **`euler_ancestral` is recommended** (`euler` and `heun` also work well).
NRS adds a second inference pass per step, like CFG, so video generation time increases accordingly. Consider reserving NRS for final generations or prompts that need extra adherence.
These caveats are video-specific — 2D image generation is unaffected.
## Submitted User Examples ## Submitted User Examples
| User | CFG | NRS | | User | CFG | NRS |
| --- | --- | --- | | --- | --- | --- |
+1 -1
View File
@@ -1,5 +1,5 @@
from .NRS.nodes_NRS import * from .NRS.nodes_NRS import *
NODE_CLASS_MAPPINGS = {"NRS": NRS} NODE_CLASS_MAPPINGS = {"NRS": NRS}
NODE_DISPLAY_NAME_MAPPINS = {"NRS": "Negative Rejection Steering"} NODE_DISPLAY_NAME_MAPPINGS = {"NRS": "Negative Rejection Steering"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+2 -1
View File
@@ -6,7 +6,8 @@ build-backend = "setuptools.build_meta"
name = "negative_rejection_steering" name = "negative_rejection_steering"
description = "NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidance with a more nuanced and composable steering of the generation process with better mathematical basis." description = "NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidance with a more nuanced and composable steering of the generation process with better mathematical basis."
authors = [{name = "Bryan O'Malley", email = "bo122081@hotmail.com"}] authors = [{name = "Bryan O'Malley", email = "bo122081@hotmail.com"}]
version = "0.7.4" version = "1.1.0"
requires-python = ">=3.10"
license = {file = "LICENSE"} license = {file = "LICENSE"}
readme = "README.md" readme = "README.md"
@@ -15,8 +15,8 @@ class NRSScript(scripts.Script):
super().__init__() super().__init__()
self.enabled = False self.enabled = False
self.skew = 2.00 self.skew = 2.00
self.stretch = 5.00 self.stretch = 4.00
self.squash = 0.75 self.squash = 0.50
sorting_priority = 5 sorting_priority = 5
@@ -100,7 +100,7 @@ class NRSScript(scripts.Script):
) )
logging.debug( logging.debug(
f"NRS: Enabled: {self.enabled}, Squash: {self.skew}, Stretch: {self.stretch}, Squash: {self.squash}" f"NRS: Enabled: {self.enabled}, Skew: {self.skew}, Stretch: {self.stretch}, Squash: {self.squash}"
) )
return return
+278
View File
@@ -0,0 +1,278 @@
"""Tests for pack-aware per-stream routing in NRS.nodes_NRS.
These tests need real torch (tensor math), but tests/conftest.py installs a
MagicMock in sys.modules["torch"] for the whole session so other test modules
can import without the heavy dependency. We swap the real torch module in for
the duration of this module only, then restore the mock so the rest of the
suite is unaffected.
"""
import sys
import pytest
_saved_torch = None
_saved_nodes_nrs = None
torch = None
nrs_module = None
def setup_module(module):
# NOTE: we deliberately avoid importlib.reload() here. reload() mutates
# the *existing* NRS.nodes_NRS module dict in place, and other test
# modules (e.g. test_pred_type.py) import PredictionType/NRS at
# collection time and keep those references for the whole session. Their
# methods' __globals__ point at that same dict, so an in-place reload
# would silently swap PredictionType out from under them (new class
# object, same name -> broken identity-based Enum equality). Instead we
# unregister the module from sys.modules and import it fresh: this
# creates an independent module object, leaving the original (still
# cached in other modules' namespaces) untouched. We restore the exact
# original module object on teardown.
global _saved_torch, _saved_nodes_nrs, torch, nrs_module
_saved_torch = sys.modules.get("torch")
sys.modules.pop("torch", None)
try:
import torch as real_torch
except ImportError:
pytest.skip("real torch unavailable", allow_module_level=True)
torch = real_torch
_saved_nodes_nrs = sys.modules.get("NRS.nodes_NRS")
sys.modules.pop("NRS.nodes_NRS", None)
import NRS.nodes_NRS as m
nrs_module = m
def teardown_module(module):
if _saved_torch is not None:
sys.modules["torch"] = _saved_torch
else:
sys.modules.pop("torch", None)
if _saved_nodes_nrs is not None:
sys.modules["NRS.nodes_NRS"] = _saved_nodes_nrs
else:
sys.modules.pop("NRS.nodes_NRS", None)
class _StubModelSampling:
"""Minimal stand-in that makes _get_pred_type fall back to EPS quickly."""
class _StubInnerModel:
def __init__(self, latent_shapes=None):
self.model_sampling = _StubModelSampling()
if latent_shapes is not None:
self.latent_shapes = latent_shapes
class _StubModel:
"""Stub for the outer ComfyUI ModelPatcher passed to NRS.patch()."""
def __init__(self, latent_shapes=None):
self.model = _StubInnerModel(latent_shapes)
self._captured_fn = None
def clone(self):
return self
def set_model_sampler_cfg_function(self, fn, flag):
self._captured_fn = fn
def _make_args(model, cond, uncond, x_orig, sigma):
return {
"model": model.model, # args["model"] is the inner model carrying latent_shapes
"cond": cond,
"uncond": uncond,
"input": x_orig,
"sigma": sigma,
}
# ---------------------------------------------------------------------------
# Phase 1: round-trip pack/unpack correctness
# ---------------------------------------------------------------------------
def test_roundtrip_unpack_repack_two_streams():
video = torch.randn(1, 4, 3, 2)
audio = torch.randn(1, 6, 5)
shapes = [video.shape, audio.shape]
packed = nrs_module._pack_latents([video, audio])
assert packed.shape == (1, 1, video.numel() + audio.numel())
unpacked = nrs_module._unpack_latents(packed, shapes)
assert len(unpacked) == 2
assert torch.allclose(unpacked[0], video)
assert torch.allclose(unpacked[1], audio)
repacked = nrs_module._pack_latents(unpacked)
assert torch.allclose(repacked, packed)
def test_roundtrip_single_stream():
x = torch.randn(1, 4, 8, 8)
shapes = [x.shape]
packed = nrs_module._pack_latents([x])
unpacked = nrs_module._unpack_latents(packed, shapes)
assert len(unpacked) == 1
assert torch.allclose(unpacked[0], x)
# ---------------------------------------------------------------------------
# Phase 2: fallback path (no latent_shapes) is a byte-for-byte regression no-op
# ---------------------------------------------------------------------------
def _run_nrs(model, cond, uncond, x_orig, sigma, skew=2.0, stretch=5.0, squash=0.75):
node = nrs_module.NRS()
(patched_model,) = node.patch(model, skew, stretch, squash)
fn = patched_model._captured_fn
args = _make_args(model, cond, uncond, x_orig, sigma)
return fn(args)
def test_fallback_no_latent_shapes_matches_single_stream_shape():
model = _StubModel(latent_shapes=None)
cond = torch.randn(2, 4, 8, 8)
uncond = torch.randn(2, 4, 8, 8)
x_orig = torch.randn(2, 4, 8, 8)
sigma = torch.rand(2) + 0.1
result = _run_nrs(model, cond, uncond, x_orig, sigma)
assert result.shape == x_orig.shape
# Regression check: manually compute the single-stream result the same
# way the pre-split code path did, and confirm equality.
node = nrs_module.NRS()
expected = node._apply_guidance(x_orig, cond, uncond, sigma, 2.0, 5.0, 0.75, nrs_module.PredictionType.EPS)
assert torch.allclose(result, expected)
def test_single_stream_latent_shapes_also_matches():
"""A model.latent_shapes list of length 1 must take the same code path."""
cond = torch.randn(1, 4, 5, 5)
uncond = torch.randn(1, 4, 5, 5)
x_orig = torch.randn(1, 4, 5, 5)
sigma = torch.rand(1) + 0.1
model = _StubModel(latent_shapes=[cond.shape])
result = _run_nrs(model, cond, uncond, x_orig, sigma)
node = nrs_module.NRS()
expected = node._apply_guidance(x_orig, cond, uncond, sigma, 2.0, 5.0, 0.75, nrs_module.PredictionType.EPS)
assert torch.allclose(result, expected)
# ---------------------------------------------------------------------------
# Phase 3: degeneracy tripwire
# ---------------------------------------------------------------------------
def test_tripwire_fires_on_flat_pack_without_latent_shapes(caplog):
model = _StubModel(latent_shapes=None)
cond = torch.randn(1, 1, 100)
uncond = torch.randn(1, 1, 100)
x_orig = torch.randn(1, 1, 100)
sigma = torch.rand(1) + 0.1
with caplog.at_level("WARNING"):
_run_nrs(model, cond, uncond, x_orig, sigma)
assert any("singleton reduction axis" in rec.message for rec in caplog.records)
def test_tripwire_does_not_fire_for_normal_single_stream(caplog):
model = _StubModel(latent_shapes=None)
cond = torch.randn(1, 4, 8, 8)
uncond = torch.randn(1, 4, 8, 8)
x_orig = torch.randn(1, 4, 8, 8)
sigma = torch.rand(1) + 0.1
with caplog.at_level("WARNING"):
_run_nrs(model, cond, uncond, x_orig, sigma)
assert not any("singleton reduction axis" in rec.message for rec in caplog.records)
# ---------------------------------------------------------------------------
# Phase 4: per-stream reduced shapes after unpack (H3-like video + audio)
# ---------------------------------------------------------------------------
def test_per_stream_reduced_shapes_after_unpack():
video = torch.randn(1, 24, 4, 3, 2)
audio = torch.randn(1, 32, 2, 5)
shapes = [video.shape, audio.shape]
packed = nrs_module._pack_latents([video, audio])
unpacked = nrs_module._unpack_latents(packed, shapes)
video_u, audio_u = unpacked
assert video_u.shape == video.shape
assert audio_u.shape == audio.shape
# Channels sit at dim 1 for both streams.
assert video_u.shape[1] == 24
assert audio_u.shape[1] == 32
video_reduced = video_u.sum(dim=1, keepdim=True)
audio_reduced = audio_u.sum(dim=1, keepdim=True)
assert video_reduced.shape == (1, 1, 4, 3, 2)
assert audio_reduced.shape == (1, 1, 2, 5)
# ---------------------------------------------------------------------------
# Phase 5: split restores non-degenerate rejection (proves Skew is alive)
# ---------------------------------------------------------------------------
def test_split_restores_nondegenerate_rejection():
"""On a real multi-channel stream, uncond's rejection on cond must not
collapse to ~0 -- this is the geometry that was silently dead on the flat
[B,1,N] pack before the unpack/repack fix.
"""
torch.manual_seed(0)
cond = torch.randn(1, 8, 4, 4)
# Make uncond non-parallel to cond so the rejection component is nonzero.
uncond = torch.randn(1, 8, 4, 4)
def _dot(a, b):
return (a * b).sum(dim=1, keepdim=True)
eps = torch.finfo(cond.dtype).eps
c_dot_c = _dot(cond, cond) + eps
u_dot_c = _dot(uncond, cond)
u_on_c = (u_dot_c / c_dot_c) * cond
u_rej_c = uncond - u_on_c
assert u_rej_c.abs().max().item() > 1e-4
def test_flat_pack_rejection_is_degenerate_without_split():
"""Sanity check for the bug this PR fixes: reducing over the flat pack's
singleton dim=1 axis collapses the rejection to exactly zero (up to
floating point noise from the eps regularization term).
"""
# float64 keeps the residual from the eps regularizer near the true
# machine epsilon instead of float32 accumulation noise, so the
# collapse-to-zero identity is exact enough to assert tightly.
packed_cond = torch.randn(1, 1, 100, dtype=torch.float64)
packed_uncond = torch.randn(1, 1, 100, dtype=torch.float64)
def _dot(a, b):
return (a * b).sum(dim=1, keepdim=True)
eps = torch.finfo(packed_cond.dtype).eps
c_dot_c = _dot(packed_cond, packed_cond) + eps
u_dot_c = _dot(packed_uncond, packed_cond)
u_on_c = (u_dot_c / c_dot_c) * packed_cond
u_rej_c = packed_uncond - u_on_c
assert u_rej_c.abs().max().item() < 1e-8
+113 -19
View File
@@ -1,14 +1,22 @@
"""Regression tests for NRS._get_pred_type and _RAW_TO_ENUM mappings. """Regression tests for NRS._get_pred_type, _RAW_TO_ENUM mappings, and the
V/FLOW/EPS operation-space conversion helpers.
These tests pin CURRENT behavior (flow-family names resolve to EPS) as a PR-3 reclassified the flow-matching family (flux, chroma, flow, wan, const)
safety net ahead of the FLOW reclassification planned for a later PR. If from PredictionType.EPS onto a new PredictionType.FLOW, which is operated
this file needs updating because flow-family names now map to natively (identity conversion, no VP ε<->v algebra). These tests pin that
PredictionType.FLOW, that is expected -- it means the reclassification post-reclassification behavior at both detection sites (the _RAW_TO_ENUM
landed and this net did its job. dict and the enhanced-detection fallback in _get_pred_type).
FLOW is the sole native path; every VP parameterization (EPS, V, X0, and the
UNKNOWN fallback) shares one ε/v/x0 -> v-space conversion through
_convert_to_v_space / _finalize_from_v_space. These tests cover the FLOW
identity round-trip and confirm the VP branches actually transform their inputs.
""" """
import enum
import sys import sys
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock
import pytest import pytest
@@ -23,6 +31,14 @@ def _make_model_sampling(class_name):
return type(class_name, (object,), {})() return type(class_name, (object,), {})()
class _ModelType(enum.Enum):
"""Mirrors ComfyUI's real model_type.ModelType Enum, as exposed by MiniMax H3's
BaseModel.model_type -- a genuine Enum member, not a raw string.
"""
FLOW = enum.auto()
class _StubModel: class _StubModel:
"""Minimal stand-in for a model object walked by _get_pred_type.""" """Minimal stand-in for a model object walked by _get_pred_type."""
@@ -41,11 +57,11 @@ class _StubModel:
[ [
("eps", PredictionType.EPS), ("eps", PredictionType.EPS),
("epsilon", PredictionType.EPS), ("epsilon", PredictionType.EPS),
("flux", PredictionType.EPS), ("flux", PredictionType.FLOW),
("chroma", PredictionType.EPS), ("chroma", PredictionType.FLOW),
("flow", PredictionType.EPS), ("flow", PredictionType.FLOW),
("wan", PredictionType.EPS), ("wan", PredictionType.FLOW),
("const", PredictionType.EPS), ("const", PredictionType.FLOW),
("v", PredictionType.V), ("v", PredictionType.V),
("v_prediction", PredictionType.V), ("v_prediction", PredictionType.V),
("x0", PredictionType.X0), ("x0", PredictionType.X0),
@@ -80,22 +96,39 @@ class TestGetPredTypeDirectAttribute:
model = _StubModel(model_type="x0") model = _StubModel(model_type="x0")
assert node._get_pred_type(model) == PredictionType.X0 assert node._get_pred_type(model) == PredictionType.X0
def test_model_type_flow_family_is_currently_eps(self): def test_model_type_flow_is_flow(self):
"""Flow-family models currently resolve to EPS (pre-reclassification).""" """Flow-family models resolve to FLOW (native operation, no VP conversion)."""
model = _StubModel(model_type="flow") model = _StubModel(model_type="flow")
assert NRS()._get_pred_type(model) == PredictionType.EPS assert NRS()._get_pred_type(model) == PredictionType.FLOW
def test_model_type_wan_is_currently_eps(self): def test_model_type_wan_is_flow(self):
model = _StubModel(model_type="wan") model = _StubModel(model_type="wan")
assert NRS()._get_pred_type(model) == PredictionType.EPS assert NRS()._get_pred_type(model) == PredictionType.FLOW
def test_h3_enum_model_type_resolves_to_flow(self):
"""MiniMax H3 exposes model.model.model_type as a real Enum member
(ModelType.FLOW), not a raw string. _canon's `isinstance(p, Enum)`
branch reduces it to `p.name` ("FLOW" -> "flow") before the
_RAW_TO_ENUM dict lookup, so this pins that Enum path -- as taken by
H3's real model_type attribute -- resolves at the direct-hit site.
"""
model = _StubModel(inner_model_type=_ModelType.FLOW)
assert NRS()._get_pred_type(model) == PredictionType.FLOW
class TestGetPredTypeEnhancedDetectionFallback: class TestGetPredTypeEnhancedDetectionFallback:
"""The model_sampling class-name and model.model.model_type fallback paths.""" """The model_sampling class-name and model.model.model_type fallback paths.
def test_model_sampling_const_class_is_eps(self): Each stub below is deliberately built so the only detectable signal lives
in the fallback (section 3) logic -- not an exact _RAW_TO_ENUM key hit
during the BFS walk -- so these tests genuinely exercise the fallback
branches rather than just re-testing the dict.
"""
def test_model_sampling_const_class_is_flow(self):
"""A CONST-like model_sampling class name is the only flow signal here."""
model = _StubModel(model_sampling=_make_model_sampling("ModelSamplingContinuousEDMConst")) model = _StubModel(model_sampling=_make_model_sampling("ModelSamplingContinuousEDMConst"))
assert NRS()._get_pred_type(model) == PredictionType.EPS assert NRS()._get_pred_type(model) == PredictionType.FLOW
def test_model_sampling_v_prediction_class_is_v(self): def test_model_sampling_v_prediction_class_is_v(self):
model = _StubModel(model_sampling=_make_model_sampling("ModelSamplingV_Prediction")) model = _StubModel(model_sampling=_make_model_sampling("ModelSamplingV_Prediction"))
@@ -105,7 +138,68 @@ class TestGetPredTypeEnhancedDetectionFallback:
model = _StubModel(model_sampling=_make_model_sampling("ModelSamplingEps")) model = _StubModel(model_sampling=_make_model_sampling("ModelSamplingEps"))
assert NRS()._get_pred_type(model) == PredictionType.EPS assert NRS()._get_pred_type(model) == PredictionType.EPS
def test_inner_model_type_flow_string_is_flow(self):
"""A model.model.model_type whose str() merely *contains* 'flow' (e.g. an
Enum repr like 'ModelType.FLOW') isn't an exact _RAW_TO_ENUM key, so the
BFS direct-hit path can't resolve it -- only the model.model.model_type
substring fallback can.
"""
model = _StubModel(inner_model_type="ModelType.FLOW")
assert NRS()._get_pred_type(model) == PredictionType.FLOW
def test_inner_model_type_flux_string_is_flow(self):
model = _StubModel(inner_model_type="ModelType.FLUX")
assert NRS()._get_pred_type(model) == PredictionType.FLOW
def test_unrecognized_model_defaults_to_eps(self): def test_unrecognized_model_defaults_to_eps(self):
"""Fully-unrecognized models fall back to EPS (documented default).""" """Fully-unrecognized models fall back to EPS (documented default)."""
model = _StubModel() model = _StubModel()
assert NRS()._get_pred_type(model) == PredictionType.EPS assert NRS()._get_pred_type(model) == PredictionType.EPS
class TestConvertToVSpaceBranches:
"""FLOW is the only native (identity) parameterization; every VP type
(EPS, V, X0, and the UNKNOWN fallback) now runs the shared ε/v/x0 -> v-space
algebra. FLOW identity needs no tensor math, so sentinel objects prove it;
the VP branches use MagicMock to confirm the algebra actually transforms.
"""
def test_flow_convert_is_identity(self):
node = NRS()
cond, uncond = object(), object()
v_cond, v_uncond = node._convert_to_v_space(object(), object(), object(), cond, uncond, PredictionType.FLOW)
assert v_cond is cond
assert v_uncond is uncond
def test_flow_finalize_is_identity(self):
node = NRS()
x_final = object()
result = node._finalize_from_v_space(object(), x_final, object(), object(), PredictionType.FLOW)
assert result is x_final
@pytest.mark.parametrize("pred_type", [PredictionType.EPS, PredictionType.V, PredictionType.X0])
def test_vp_convert_performs_algebra(self, pred_type):
"""EPS/V/X0 all run the ε->v conversion (cond/uncond are transformed,
not passed through)."""
node = NRS()
x_orig, sig_root, sigma = MagicMock(), MagicMock(), MagicMock()
cond, uncond = MagicMock(), MagicMock()
v_cond, v_uncond = node._convert_to_v_space(x_orig, sig_root, sigma, cond, uncond, pred_type)
assert v_cond is not cond
assert v_uncond is not uncond
@pytest.mark.parametrize("pred_type", [PredictionType.EPS, PredictionType.V, PredictionType.X0])
def test_vp_finalize_performs_algebra(self, pred_type):
node = NRS()
x_orig, x_final, sig_root, sigma = MagicMock(), MagicMock(), MagicMock(), MagicMock()
result = node._finalize_from_v_space(x_orig, x_final, sig_root, sigma, pred_type)
assert result is not x_final
def test_unknown_convert_falls_back_to_vp(self):
"""UNKNOWN (and any unhandled type) is treated as VP -> runs the algebra."""
node = NRS()
x_orig, sig_root, sigma = MagicMock(), MagicMock(), MagicMock()
cond, uncond = MagicMock(), MagicMock()
v_cond, v_uncond = node._convert_to_v_space(x_orig, sig_root, sigma, cond, uncond, PredictionType.UNKNOWN)
assert v_cond is not cond
assert v_uncond is not uncond
+54
View File
@@ -0,0 +1,54 @@
"""Tests for NRS package version metadata and the patch()-time version/pred-type log line."""
import re
import sys
from pathlib import Path
# Add project root to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
import NRS # noqa: E402
import NRS.nodes_NRS as nodes_NRS # noqa: E402, N812
_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
def test_version_is_nonempty_semver_string():
"""NRS.__version__ must be importable and look like a X.Y.Z version."""
assert isinstance(NRS.__version__, str)
assert NRS.__version__
assert _SEMVER_RE.match(NRS.__version__), f"__version__ {NRS.__version__!r} is not X.Y.Z"
def test_version_matches_pyproject():
"""__version__ must be kept in lock-step with pyproject.toml's version field."""
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
text = pyproject_path.read_text()
match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text)
assert match, "Could not find version in pyproject.toml"
assert NRS.__version__ == match.group(1)
class _StubModel:
"""Minimal stand-in that resolves to PredictionType.EPS via the direct-hit path."""
def __init__(self):
self.model_type = "eps"
def clone(self):
return self
def set_model_sampler_cfg_function(self, fn, flag):
self._captured_fn = fn
def test_patch_logs_version_and_pred_type(caplog):
"""patch() must announce the NRS version and detected prediction type."""
node = nodes_NRS.NRS()
model = _StubModel()
with caplog.at_level("INFO"):
node.patch(model, skew=2.0, stretch=5.0, squash=0.75)
expected = f"NRS v{NRS.__version__}: prediction type detected -> {nodes_NRS.PredictionType.EPS.name}"
assert any(expected in rec.message for rec in caplog.records)
Generated
+89 -2
View File
@@ -1,6 +1,6 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.11" requires-python = ">=3.10"
[[package]] [[package]]
name = "cfgv" name = "cfgv"
@@ -29,6 +29,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
] ]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.29.0" version = "3.29.0"
@@ -58,7 +70,7 @@ wheels = [
[[package]] [[package]]
name = "negative-rejection-steering" name = "negative-rejection-steering"
version = "0.7.4" version = "1.0.0"
source = { editable = "." } source = { editable = "." }
[package.optional-dependencies] [package.optional-dependencies]
@@ -143,10 +155,12 @@ version = "9.0.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" }, { name = "iniconfig" },
{ name = "packaging" }, { name = "packaging" },
{ name = "pluggy" }, { name = "pluggy" },
{ name = "pygments" }, { name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [ wheels = [
@@ -172,6 +186,15 @@ version = "6.0.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
@@ -246,6 +269,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
] ]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "21.3.3" version = "21.3.3"
@@ -255,6 +341,7 @@ dependencies = [
{ name = "filelock" }, { name = "filelock" },
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "python-discovery" }, { name = "python-discovery" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" }
wheels = [ wheels = [