Compare commits

..
55 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
Reithan 2ca0df18bc Sort imports in test_pred_type to satisfy ruff I001 (#35)
## What
Sorts the import block at `tests/test_pred_type.py:18` so `ruff` (rule
I001) passes clean on `main`.

```diff
-from NRS.nodes_NRS import PredictionType, _RAW_TO_ENUM, NRS
+from NRS.nodes_NRS import _RAW_TO_ENUM, NRS, PredictionType
```

## Why
Gate-1 (pre-refactor checkpoint) requires `ruff` + `pytest` green on
`main`. This was the only outstanding lint violation.

## Verification
- `uvx ruff check .` → All checks passed!
- `uvx --with torch pytest -q` → 24 passed

Import-sort only; no behavior change.
2026-08-14 00:56:48 -07:00
Reithan ffeb7fe4f3 PR-1: cleanup — remove mangled guard, amputate dead operation-space arms, add detection tests (#34)
## Summary

Behavior-preserving cleanup ahead of the H3 flow-fix work (see
`.claude/plans/nrs_h3_fix_plan.2.md`, fact 8 / Phase 3 / Sequencing
PR-1). No output change — pure refactor plus a regression net.

### Changes to `NRS/nodes_NRS.py`
- **Removed the broken name-mangling guard.** `hasattr(self,
"__pred_type")` checked the literal name while assignment created
`_NRS__pred_type`, so the guard never fired. `patch()` now computes
`pred_type = self._get_pred_type(model)` unconditionally and passes it
as a closure local / explicit parameter into `nrs()`,
`_convert_to_v_space`, and `_finalize_from_v_space`. This preserves the
(correct) always-redetect behavior and removes a latent cross-model
aliasing bug. The `hasattr` string was **not** "repaired" — doing so
would introduce a stale-cache bug since ComfyUI reuses node instances
across queue runs.
- **Amputated dead operation-space arms.** `self.__OPERATION_SPACE` was
hardcoded to `PredictionType.V`, making both `match` blocks and the
`_convert_to_eps_space`/`_finalize_from_eps_space` helpers unreachable.
Removed them; the `nrs()` hook now calls the V-space
conversion/finalization directly. The inner V-vs-EPS conversion math
inside the V-space helpers is untouched — that is the real per-model
algebra, not dead code. FLOW is added in a later PR.

### Tests
- New `tests/test_pred_type.py`: parametrized coverage of all 11
`_RAW_TO_ENUM` entries plus `_get_pred_type` walks over stub models
(direct-attribute path and enhanced-detection fallback). These pin
**current** behavior (flow-family → EPS) as a regression net for the
FLOW reclassification PR.

## Verification
- `24/24` tests pass (21 new + 3 pre-existing smoke) from a clean
checkout.
- Greps for `self.__pred_type`, `_NRS__pred_type`,
`self.__OPERATION_SPACE` all empty.
- Diff: `NRS/nodes_NRS.py` +16/-87, `tests/test_pred_type.py` +111 —
behavior-preserving, well under 500 LoC.
2026-08-13 20:46:47 -07:00
Reithan 07f5ea0dbe Add version increment check to publish workflow (#33)
## Summary
- Add version increment check before publishing to Comfy registry
- Compare current version in pyproject.toml against HEAD~1
- Skip publishing successfully if version is unchanged (prevents
unnecessary publish attempts)
- Fail workflow if version is downgraded (prevents registry conflicts)
- Use Python with semantic versioning for robust comparison (handles
0.7.10 > 0.7.9 correctly)

## Changes
- Modified `.github/workflows/publish.yml`:
  - Added `fetch-depth: 2` to checkout step to access HEAD~1
  - Added new version check step with Python script
  - Made publish step conditional on version increment

## Test Plan
- [x] All pre-commit hooks pass
- [x] All tests pass
- [ ] Test unchanged version: Modify pyproject.toml metadata (not
version), workflow should skip publish
- [ ] Test version increment: Bump version, workflow should publish
- [ ] Test version downgrade: Downgrade version, workflow should fail
- [ ] Test invalid version: Malformed version, workflow should fail

The workflow can be tested using manual `workflow_dispatch` trigger
after merge.
2026-05-15 18:05:58 -07:00
Reithan ee6dc40393 Add git hooks and development infrastructure (#32)
## Summary

Establishes a complete development infrastructure for the project with
automated quality checks, testing, and contributor guidelines.

- Add pre-commit hooks for automated linting and quality checks
- Configure ruff for code linting and formatting (120 char line length,
Python 3.10+)
- Set up pytest with mocked torch/gradio dependencies for CI/CD
compatibility
- Create comprehensive CONTRIBUTING.md with setup instructions and
workflow guidance
- Add uv.lock for reproducible dependency resolution
- Replace trivial tests with functional interface tests

## Key Components

### Git Hooks (.pre-commit-config.yaml)
- Pre-commit: ruff linting/formatting, trailing whitespace, YAML
validation, prevents direct commits to main
- Pre-push: runs pytest test suite, prevents direct pushes to main

### Testing (tests/)
- Functional tests for ComfyUI node and WebUI script interfaces
- Mock torch, gradio, and WebUI modules via conftest.py (no heavy
dependencies in dev env)
- Tests verify API contracts without requiring full ComfyUI/WebUI
runtime

### Documentation (CONTRIBUTING.md)
- Development setup with uv package manager
- Git workflow and branch protection guidelines
- Linting, testing, and pre-commit hook usage
- Code style guidelines and commit message conventions

## Test Plan

- [x] Pre-commit hooks run on commit (linting, formatting, checks)
- [x] Pre-push hooks run pytest successfully
- [x] All tests pass in isolated venv
- [x] CONTRIBUTING.md instructions verified
- [x] No IDE diagnostics or issues
2026-05-15 16:08:45 -07:00
Reithan cd1f2e76f4 Fix Node.js 20 deprecation warnings in GitHub Actions
- Update actions/checkout from @v4 to @v5 for Node.js 24 support
- Update Comfy-Org/publish-node-action from @v1 to @main per official docs
- Add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable
2026-05-15 14:17:02 -07:00
Reithan a70d1d09bb Fix prediction type detection for WAN/RES4LYF samplers (#30)
## Summary

Fixes issue #20 where NRS would fail with `RuntimeError: "Could not
determine prediction type for this model"` when using certain samplers
like WanKSamplerAdvanced and RES4LYF ClownsharKsampler.

## Changes Made

- **Enhanced prediction type mapping**: Added support for FLOW models
(`"flow"`, `"wan"`, `"const"` → `PredictionType.EPS`)
- **Improved model introspection**: Added `model_sampling` class
inspection and `model.model.model_type` enum detection
- **Graceful fallback**: Replaced RuntimeErrors with safe EPS defaults
when prediction type cannot be determined
- **Better debugging**: Added warning logs when using fallback
prediction types
- **Documentation**: Updated README with sampler compatibility
information
- **Version bump**: 0.7.3 → 0.7.4

## Technical Details

The issue occurred because WAN and similar samplers use **FLOW model
types** (ModelType.FLOW) that implement the CONST prediction class,
which are fundamentally different from traditional EPS/V-prediction
models. The original code only checked for string attributes and failed
to recognize these newer model architectures.

This fix follows the established pattern from commits fc38b5c (flux
support) and ba145c4 (chroma support) while adding robust fallback
mechanisms.

## Testing

- ✅ Manual integration testing planned with WanKSamplerAdvanced
- ✅ Manual integration testing planned with RES4LYF ClownsharKsampler  
- ✅ Backwards compatibility maintained for existing samplers
- ✅ Enhanced logging for debugging unrecognized models

## Risk Assessment

**Low Risk**: Only enhances existing detection logic without changing
core mathematical operations. Adds fallback instead of removing
functionality.

Closes #20

---------

Signed-off-by: Reithan <bo122081@hotmail.com>
2026-05-15 13:57:06 -07:00
Reithan b034c3f09f Improve setup documentation and ComfyUI UX (#29)
## Summary
Addresses documentation gaps identified in issue #25 by improving setup
instructions and user experience across all supported platforms.

## Changes Made

### 📚 README Enhancements
- **ComfyUI Setup Section**: Added collapsible setup instructions with:
  - Clear workflow explanation (Model → NRS Node → KSampler)
  - Prominent note that CFG setting on KSampler is ignored
  - Pro tip for validating NRS is working
  - Reference to the workflow image from issue #25

- **A1111/Forge/reForge Setup Section**: Added setup instructions
explaining:
  - Extension installation and enabling process
  - CFG Scale is ignored when NRS is active
  - Reference to Beginner How-To for parameter guidance

- **StabilityMatrix Integration**: Added section highlighting native
support with link to https://lykos.ai/

### 🖥️ ComfyUI Node UX Improvements
- **Node Description**: Added clear explanation that NRS replaces CFG
and KSampler CFG will be ignored
- **Parameter Tooltips**: Added helpful guidance directly in the
interface:
  - **Skew**: Explains direction steering, suggests starting with CFG/2
- **Stretch**: Explains positive intensification, suggests normal CFG
value
- **Squash**: Explains effect softening, recommends keeping low
initially

## Problem Solved
This addresses the confusion reported in issue #25 where users struggled
with:
- How to configure CFG values in ComfyUI workflows
- Understanding the relationship between NRS and CFG
- Lack of example workflows and clear setup guidance

## Test Plan
- [x] Verify README renders correctly with collapsible sections
- [ ] Test ComfyUI node shows tooltips when hovering over parameters
- [ ] Confirm node description appears in ComfyUI interface
- [x] Validate links work correctly (StabilityMatrix)

Fixes #25
2026-05-15 11:59:20 -07:00
Reithan 2583c237f2 Bump version from 0.7.2 to 0.7.3
Signed-off-by: Reithan <bo122081@hotmail.com>
2026-02-13 17:41:23 -08:00
Reithan c83958c457 Update default parameter values for NRS node and script (#26)
Updates the default parameter values for the Negative Rejection Steering
implementation to improve usability and user experience.

## Changes
- **Skew**: 4.0 → 2.00
- **Stretch**: 2.0 → 5.00  
- **Squash**: 0.0 → 0.75

These new defaults (2/5/0.75) provide a better starting point for users,
as documented in the updated README.

## Files Modified
- `NRS/nodes_NRS.py`: Updated default values in INPUT_TYPES
- `scripts/negative_rejection_steering_script.py`: Updated default
initialization values
- `README.md`: Updated beginner how-to guide to reference the new
defaults

---------

Signed-off-by: Reithan <bo122081@hotmail.com>
2026-02-13 17:36:43 -08:00
Reithan a0b2d99bc7 Bump version from 0.7.1 to 0.7.2 2025-12-04 20:44:46 -08:00
Reithan ba145c4722 Add 'chroma' to _RAW_TO_ENUM mapping (#23)
Ad support for chroma as EPS.
2025-12-04 18:29:03 -08:00
Reithan c21dbfe1e5 Update README.md (#21) 2025-09-11 20:27:24 -07:00
Alexander Brown fc38b5c998 Add flux to the Prediction Type inference (#18)
Testing it in Comfy, it seems like it works as `EPS`, though it also
worked with `V`.
2025-07-31 23:23:09 -07:00
Reithan d26fcf6fc8 Update pyproject.toml 2025-07-26 04:44:24 -07:00
Reithan 3d8827f132 Cleanup old math versions and fix variables (#16) 2025-07-26 04:34:34 -07:00
Reithan 62bef2e275 Update README.md 2025-07-21 04:01:10 -07:00
Reithan fecdfe01df Update README.md 2025-07-21 04:00:01 -07:00
Reithan c5610837e4 Update README.md 2025-07-21 03:59:41 -07:00
Reithan 21ac7cf0cb Update pyproject.toml (#14) 2025-07-21 03:56:18 -07:00
Reithan 4930d862d5 Update publish.yml 2025-07-21 03:50:11 -07:00
e8b727f914 Add pyproject.toml for Custom Node Registry (#7)
Hey! My name is Robin and I'm from [comfy-org](https://comfy.org/)! We
would love to have you join the Comfy Registry, a public collection of
custom nodes which lets authors publish nodes by version and automate
testing against existing workflows.

The registry is already integrated with ComfyUI-Manager, and we want it
to be the default place users install nodes from eventually. We do a
security-scan of every node to improve safety. Feel free to read up more
on the registry
[here](https://docs.comfy.org/registry/overview#introduction)

Action Required:

- [ ] Go to the [registry](https://registry.comfy.org). Login and create
a publisher id (everything after the `@` sign on your registry profile).
- [ ] Add the publisher id into the pyproject.toml file.
- [ ] Merge the separate Github Actions PR, then merge this PR.

If you want to publish the node manually, [install the
cli](https://docs.comfy.org/comfy-cli/getting-started#install-cli) by
running `pip install comfy-cli`, then run `comfy node publish`

Otherwise, if you have any questions, please message me on discord at
robinken or join our [server](https://discord.com/invite/comfyorg)!

---------

Co-authored-by: snomiao <snomiao+comfy-pr@gmail.com>
Co-authored-by: Reithan <bo122081@hotmail.com>
2025-07-21 03:46:38 -07:00
Reithan 389aedfd17 upload icon 2025-07-21 03:43:44 -07:00
99824b2ee5 Add Github Action for Publishing to Comfy Registry (#6)
This PR adds a Github Action (publish-node-action) that will publish an
updated version of your custom node to the
[registry](https://registry.comfy.org/) whenever the `pyproject.toml`
file changes. The pyproject.toml defines the custom node version you
want to publish (added in another PR). Make sure you update the version
number in `pyproject.toml` when you make a change that should be
published to everyone!

Action Required:

- [ ] Make sure the trigger branch (`master` or `main`) in
`publish.yaml` matches the branch you want to use as the publishing
branch. It will only trigger when the pyproject.toml gets updated on
that branch.
- [ ] Create an api key on the Registry for publishing from Github.
[Instructions](https://docs.comfy.org/registry/publishing#create-an-api-key-for-publishing).
- [ ] Add it to your Github Repository Secrets as
`REGISTRY_ACCESS_TOKEN`.

Please message me on Discord at robinken or join our
[server](https://discord.com/invite/comfyorg) server if you have any
questions!

---------

Co-authored-by: snomiao <snomiao+comfy-pr@gmail.com>
Co-authored-by: Reithan <bo122081@hotmail.com>
2025-07-21 01:50:39 -07:00
Reithan 4bb226aabb Update README.md (#13) 2025-07-21 01:18:32 -07:00
Reithan 60b5127cf4 Update to Math v0.6.0 (#12)
- [X] add math 0.6
- [X] update readme
- [X] add example images
- [X] upload permanent copy of graph image
2025-07-21 01:14:06 -07:00
Reithan e72afd4189 update note 2025-07-20 07:20:35 -07:00
Reithan 98a9b6d656 minor optimizations 2025-07-20 07:13:07 -07:00
Reithan e0988d3b24 Fix detection of model type (#11)
- fix some scaling issues
2025-07-20 04:04:54 -07:00
Reithan 932c7b2136 fix batch size error when applying scale as broadcast 2025-07-19 20:24:28 -07:00
Reithan 50ddd2ac47 Update math to 0.5.0 (#10)
- [X] detects v-pred/eps and uses appropriate pre/post scaling
- [X] supports detection in Forge, Comfy and various loaders/models
2025-07-19 17:59:27 -07:00
Reithan 3768f4a768 Update ComfyUI compatibility (#5) 2025-04-13 22:13:50 -07:00
Reithan e82622cefe Update negative_rejection_steering_script.py 2025-03-29 05:13:16 -07:00
Reithan 793914ced3 Update README.md (#4)
Correctd steps
2025-03-28 15:52:13 -07:00
Reithan 46540aa7bc Update README.md
Add example images
2025-03-24 00:58:59 -07:00
Reithan 873034b095 add init file for ComfyUI 2025-03-24 00:53:57 -07:00
Reithan 7d399643dd remove unneeded import 2025-03-24 00:53:44 -07:00
Reithan 50033c2622 add user examples 2025-03-24 00:52:15 -07:00
Reithan 36eb9d592b Update README.md
fix typo
2025-03-22 19:02:55 -07:00
Reithan bc50983954 Update README.md
tl;dr added
2025-03-22 19:01:27 -07:00
Reithan 0c37c6b124 Update README.md
Hide math stuff to help overwhelm
2025-03-22 18:56:38 -07:00
Reithan 8d7d9281f7 Update README.md
update tip verbiage
2025-03-22 18:51:30 -07:00
Reithan e55881afe2 Update README.md
"it's" to "its"
2025-03-22 18:40:25 -07:00
Reithan 0e8e508213 Update README.md
Turn params to bullet points
2025-03-22 18:36:09 -07:00
Reithan ff69ac386d Update README.md
Move image
2025-03-22 18:32:58 -07:00
Reithan 5be3c4c4f1 Update README.md
Update wording and instructions for clarity.
2025-03-22 18:31:19 -07:00
Reithan 81b836d2f1 Update README.md
Add Interactive Graph to Readme
2025-03-22 18:18:59 -07:00
Reithan a57c16a624 Update README.md 2025-03-22 05:06:20 -07:00
28 changed files with 2270 additions and 201 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.
+99
View File
@@ -0,0 +1,99 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
- master
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'Reithan' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- name: Check out code
uses: actions/checkout@v5
with:
submodules: true
fetch-depth: 2 # Need HEAD and HEAD~1 for version comparison
- name: Check version increment
id: version_check
run: |
python3 << 'EOF'
import sys
import os
import subprocess
from packaging.version import parse as parse_version
def extract_version(file_content):
"""Extract version string from pyproject.toml content."""
for line in file_content.split('\n'):
if line.strip().startswith('version = '):
version_str = line.split('=', 1)[1].strip()
return version_str.strip('"').strip("'")
return None
def get_version_at_commit(commit_ref):
"""Get version from pyproject.toml at a specific commit."""
try:
result = subprocess.run(
['git', 'show', f'{commit_ref}:pyproject.toml'],
capture_output=True,
text=True,
check=True
)
return extract_version(result.stdout)
except subprocess.CalledProcessError:
return None
# Get current and previous versions
current_version_str = get_version_at_commit('HEAD')
if not current_version_str:
print("::error::Could not extract current version from pyproject.toml")
sys.exit(1)
previous_version_str = get_version_at_commit('HEAD~1')
# Handle first commit case
if not previous_version_str:
print(f"::notice::First version commit detected: {current_version_str}")
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write("should_publish=true\n")
sys.exit(0)
# Parse and compare versions
try:
current_version = parse_version(current_version_str)
previous_version = parse_version(previous_version_str)
except Exception as e:
print(f"::error::Invalid version format - {e}")
print(f"Current: {current_version_str}, Previous: {previous_version_str}")
sys.exit(1)
# Version comparison logic
if current_version > previous_version:
print(f"::notice::Version increment detected: {previous_version_str} -> {current_version_str}")
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write("should_publish=true\n")
elif current_version == previous_version:
print(f"::notice::Version unchanged: {current_version_str} - skipping publish")
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write("should_publish=false\n")
else:
print(f"::error::Version downgrade detected: {previous_version_str} -> {current_version_str}")
print("::error::Version must increase. Downgrades would conflict with published versions.")
sys.exit(1)
EOF
- name: Publish Custom Node
if: steps.version_check.outputs.should_publish == 'true'
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.COMFY_REGISTRY_KEY }}
+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
+8
View File
@@ -170,5 +170,13 @@ cython_debug/
# Ruff stuff: # Ruff stuff:
.ruff_cache/ .ruff_cache/
# pre-commit stuff:
.pre-commit-cache/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# AI agents
.claude/settings.local.json
.claude/docs/**
+52
View File
@@ -0,0 +1,52 @@
# See https://pre-commit.com for more information
repos:
# Standard pre-commit hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ['--maxkb=1000']
- id: check-merge-conflict
- id: mixed-line-ending
args: ['--fix=lf']
# Ruff linter and formatter
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.12
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
# Main branch protection (pre-commit)
- repo: local
hooks:
- id: prevent-commit-to-main
name: Prevent commits to main branch
entry: bash -c 'BRANCH=$(git branch --show-current); if [ "$BRANCH" = "main" ]; then echo "ERROR - Direct commits to main are not allowed. Create a feature branch instead."; exit 1; fi'
language: system
stages: [pre-commit]
always_run: true
pass_filenames: false
# Test execution (pre-push only)
- repo: local
hooks:
- id: run-tests
name: Run pytest with branch-coverage gate
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
stages: [pre-push]
always_run: true
pass_filenames: false
- id: prevent-push-to-main
name: Prevent pushes to main branch
entry: bash -c 'BRANCH=$(git branch --show-current); if [ "$BRANCH" = "main" ]; then echo "ERROR - Direct pushes to main are not allowed. Use pull requests instead."; exit 1; fi'
language: system
stages: [pre-push]
always_run: true
pass_filenames: false
+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.
+266
View File
@@ -0,0 +1,266 @@
# Contributing to Negative Rejection Steering
Thank you for your interest in contributing! This document provides guidelines for setting up your development environment and contributing to the project.
## Development Setup
### Prerequisites
- Python 3.10 or higher
- [uv](https://docs.astral.sh/uv/getting-started/installation/) package manager
- Git
### Initial Setup
1. **Clone the repository** (if you haven't already):
```bash
git clone https://github.com/Reithan/negative_rejection_steering.git
cd negative_rejection_steering
```
2. **Create virtual environment with uv**:
```bash
uv venv
```
3. **Activate virtual environment**:
```bash
# Git Bash (Windows)
source .venv/Scripts/activate
# Linux/Mac
source .venv/bin/activate
# Windows CMD
.venv\Scripts\activate.bat
# Windows PowerShell
.venv\Scripts\Activate.ps1
```
4. **Install development dependencies**:
```bash
uv pip install -e ".[dev]"
```
5. **Install git hooks**:
```bash
pre-commit install
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**:
```bash
# Run hooks manually on all files
pre-commit run --all-files
# Check that ruff works
ruff check .
```
## Git Workflow
### Protected Branches
- **Direct commits to `main` are blocked** by git hooks
- **Direct pushes to `main` are blocked** by git hooks
- All changes must go through feature branches and pull requests
### Recommended Workflow
1. **Create a feature branch**:
```bash
git checkout -b feature/your-feature-name
```
Or for bug fixes:
```bash
git checkout -b fix/bug-description
```
2. **Make your changes and commit**:
```bash
git add <files>
git commit -m "Your commit message"
```
The pre-commit hook will automatically:
- Run ruff linting and auto-fix issues
- Check for trailing whitespace, missing final newlines, etc.
- Block the commit if you're on the main branch
If the linter auto-fixes files, you'll need to re-stage and commit again.
3. **Push your branch**:
```bash
git push origin feature/your-feature-name
```
The pre-push hook will:
- Run tests (if pytest is available)
- Block the push if you're on the main branch
4. **Create a pull request** on GitHub
5. **Merge after review**
### Bypassing Hooks (Emergency Only)
If you must bypass hooks (NOT recommended):
```bash
git commit --no-verify # Skip pre-commit hooks
git push --no-verify # Skip pre-push hooks
```
**Warning**: Only use `--no-verify` in emergencies. Bypassing hooks may:
- Introduce linting issues
- Break Continuous Integration/Continuous Deployment pipelines
- Allow untested code to be pushed
## Development Commands
### Linting
```bash
# Check for linting issues
ruff check .
# Auto-fix linting issues
ruff check --fix .
# Format code
ruff format .
# Check a specific file
ruff check NRS/nodes_NRS.py
```
### Testing
```bash
# Run all tests
pytest
# Run with verbose output
pytest -v
# Run specific test file
pytest tests/test_smoke.py
# Run specific test function
pytest tests/test_smoke.py::test_file_structure
```
**Note**: Tests use mocked versions of torch and gradio (via conftest.py) since these dependencies are provided by ComfyUI/WebUI at runtime.
### Pre-commit Hooks
```bash
# Run all hooks manually
pre-commit run --all-files
# Run specific hook
pre-commit run ruff --all-files
pre-commit run ruff-format --all-files
# Update hook versions
pre-commit autoupdate
```
## Code Style Guidelines
This project uses **ruff** for linting and formatting with the following configuration:
- **Line length**: 120 characters
- **Target Python version**: 3.10+
- **Enabled checks**: pycodestyle (E/W), pyflakes (F), isort (I), pep8-naming (N), pyupgrade (UP)
### Special Cases
- **ComfyUI API conventions**: The `INPUT_TYPES` method and `s` parameter naming are required by ComfyUI's API and are exempted from normal naming rules
- **Star imports in `__init__.py`**: Required for ComfyUI node discovery
## Commit Message Guidelines
Write clear, concise commit messages:
- Use imperative mood ("Add feature" not "Added feature")
- Keep first line under 72 characters
- Add detailed description in the body if needed
Good examples:
```
Add support for XYZ model type
Fix crash when prediction type is unknown
Update README with installation instructions
```
Bad examples:
```
fixed stuff
WIP
Updated code
```
## Pull Request Guidelines
When submitting a pull request:
1. **Keep PRs focused**: One feature or fix per PR
2. **Update documentation**: If you add features, update README.md
3. **Test your changes**: Ensure the extension works in ComfyUI/reForge
4. **Run pre-commit hooks**: Make sure all checks pass
5. **Describe your changes**: Explain what and why in the PR description
## Project Structure
```
negative_rejection_steering/
├── NRS/
│ └── nodes_NRS.py # Main NRS node implementation
├── scripts/
│ └── negative_rejection_steering_script.py # Gradio UI for reForge
├── tests/
│ └── test_smoke.py # Smoke tests
├── __init__.py # ComfyUI node exports
├── pyproject.toml # Project config, dependencies, tool config
├── .pre-commit-config.yaml # Git hooks configuration
├── .gitignore
├── README.md
├── LICENSE
└── CONTRIBUTING.md # This file
```
## Getting Help
- **Issues**: Report bugs or request features via [GitHub Issues](https://github.com/Reithan/negative_rejection_steering/issues)
- **Discussions**: For questions or general discussion
- **Pull Requests**: Review the PR guidelines above before submitting
## License
By contributing to this project, you agree that your contributions will be licensed under the same license as the project (see LICENSE file).
---
Thank you for contributing to Negative Rejection Steering!
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

+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__"]
+322 -157
View File
@@ -1,184 +1,349 @@
import ldm_patched.modules.model_base # 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
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
class PredictionType(Enum):
EPS = auto() # ε-prediction
V = auto() # v-prediction
X0 = auto() # x₀-prediction
FLOW = auto() # flow-matching / velocity — operated natively, no VP conversion
UNKNOWN = auto() # couldn’t detect / new scheduler
_RAW_TO_ENUM = {
"eps": PredictionType.EPS,
"epsilon": PredictionType.EPS,
"flux": PredictionType.FLOW,
"chroma": PredictionType.FLOW,
"flow": PredictionType.FLOW, # FLOW models (WAN, etc.) operated natively
"wan": PredictionType.FLOW, # WAN21 is FLOW-based
"const": PredictionType.FLOW, # CONST prediction class used in FLOW models
"v": PredictionType.V,
"v_prediction": PredictionType.V,
"x0": PredictionType.X0,
"sample": PredictionType.X0,
}
# fmt: on
class NRS: class NRS:
@classmethod @classmethod
def INPUT_TYPES(s): def INPUT_TYPES(s):
return {"required": { "model": ("MODEL",), return {
"skew": ("FLOAT", {"default": 2.0, "min": -30.0, "max": 30.0, "step": 0.01}), "required": {
"stretch": ("FLOAT", {"default": 2.0, "min": -30.0, "max": 30.0, "step": 0.01}), "model": ("MODEL", {"tooltip": "Input model to apply NRS to"}),
"squash": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), "skew": (
}} "FLOAT",
{
"default": 2.00,
"min": -30.0,
"max": 30.0,
"step": 0.01,
"tooltip": "Changes the 'direction' of generation, steering away from negative prompt elements. Start with CFG/2.",
},
),
"stretch": (
"FLOAT",
{
"default": 4.00,
"min": -30.0,
"max": 30.0,
"step": 0.01,
"tooltip": "Intensifies positive prompt elements. Start with your normal CFG value.",
},
),
"squash": (
"FLOAT",
{
"default": 0.50,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Softens Skew/Stretch effects, adding micro-detailing. Keep low initially.",
},
),
}
}
RETURN_TYPES = ("MODEL",) RETURN_TYPES = ("MODEL",)
FUNCTION = "patch" FUNCTION = "patch"
CATEGORY = "advanced/model" CATEGORY = "advanced/model"
DESCRIPTION = "Negative Rejection Steering (NRS) replaces CFG with more nuanced guidance. IMPORTANT: Set your KSampler CFG to any value (it will be ignored). Connect your model through this node before sampling."
def _get_pred_type(self, model) -> PredictionType:
"""
In order to support Comfy, Forge, and possibly other models
and various loaders.
Walk common wrappers until we find something that looks like a
prediction-type flag, then map it to the enum.
Defaults to EPS if all else fails.
"""
def _canon(p):
if p is None:
return ""
if isinstance(p, bytes):
p = p.decode(errors="ignore")
if isinstance(p, Enum):
p = p.name
return str(p).strip().lower()
# Breadth-first search through a few well-known wrappers.
queue, seen = [model], set()
while queue:
obj = queue.pop(0)
# 1) direct hit on this object ---------------------------------
for attr in ("model_type", "prediction_type", "parameterization"):
p = _canon(getattr(obj, attr, None))
if p:
pred_type = _RAW_TO_ENUM.get(p, PredictionType.UNKNOWN)
if pred_type != PredictionType.UNKNOWN:
logging.debug(
f"NRS._get_pred_type: Found prediction type '{p}' from attribute '{attr}' -> {pred_type}"
)
return pred_type
# 2) enqueue child containers we care about -------------------
for attr in ("model", "diffusion_model", "config", "scheduler", "inner_model", "model_sampling"):
child = getattr(obj, attr, None)
if child is not None and id(child) not in seen:
seen.add(id(child))
queue.append(child)
# 3) enhanced detection for FLOW models (WAN, Flux, etc.) -------
try:
# Check model_sampling class type for FLOW models
if hasattr(model, "model_sampling") and model.model_sampling is not None:
sampling_class_name = type(model.model_sampling).__name__.lower()
logging.debug(f"NRS._get_pred_type: Found model_sampling class: {sampling_class_name}")
# CONST class is used by FLOW models (WAN21, Flux, etc.)
if "const" in sampling_class_name:
logging.debug("NRS._get_pred_type: Detected FLOW model via CONST sampling class -> FLOW")
return PredictionType.FLOW
elif "v_prediction" in sampling_class_name:
logging.debug("NRS._get_pred_type: Detected V-prediction model via sampling class -> V")
return PredictionType.V
elif "eps" in sampling_class_name:
logging.debug("NRS._get_pred_type: Detected EPS model via sampling class -> EPS")
return PredictionType.EPS
# Check model.model.model_type enum for newer models
if hasattr(model, "model") and hasattr(model.model, "model_type"):
model_type_str = _canon(str(model.model.model_type))
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:
logging.debug("NRS._get_pred_type: Detected FLOW/Flux model via model_type -> FLOW")
return PredictionType.FLOW
elif "v_prediction" in model_type_str:
logging.debug("NRS._get_pred_type: Detected V-prediction model via model_type -> V")
return PredictionType.V
elif "eps" in model_type_str:
logging.debug("NRS._get_pred_type: Detected EPS model via model_type -> EPS")
return PredictionType.EPS
except Exception as e:
logging.debug(f"NRS._get_pred_type: Exception during enhanced detection: {e}")
# 4) safe default (matches docstring promise) --------------------
logging.warning("NRS._get_pred_type: Could not determine prediction type for model. Using EPS as fallback.")
logging.debug(
f"NRS._get_pred_type: Model structure: {[attr for attr in dir(model) if not attr.startswith('_')]}"
)
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):
"""Convert the (x - x0) guidance vectors into v-prediction space before the NRS geometry.
The sampler hook delivers cond/uncond as `x - x0` for every parameterization (the
model's raw output is converted to a denoised x0 before NRS sees it), so the true
velocity is recovered the same way regardless of EPS/V/X0:
v = (cond - A)/factor = (x/(sigma^2+1) - x0) * sig_root/sigma
with A = x*sigma^2/(sigma^2+1), factor = sigma/sqrt(sigma^2+1).
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
logging.debug("NRS._convert_to_v_space: converting VP prediction to v-space")
factor = sigma / sig_root
a_off = x_orig - x_orig / (sigma**2 + 1) # A = x*sigma^2/(sigma^2+1)
return (cond - a_off) / factor, (uncond - a_off) / factor
def _finalize_from_v_space(self, x_orig, x_final, sig_root, sigma, pred_type):
"""Invert _convert_to_v_space so the hook returns `x - x0_final`. Round-trips exactly."""
if not self._is_vp(pred_type):
logging.debug("NRS._finalize_from_v_space: flow/const operated natively (identity)")
return x_final
factor = sigma / sig_root
a_off = x_orig - x_orig / (sigma**2 + 1)
return a_off + x_final * factor
def _apply_guidance(self, x_orig, cond, uncond, sigma, skew, stretch, squash, pred_type):
"""Run the NRS geometry pipeline on a single (already-unpacked, channels-first) stream."""
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)
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}")
cond = args["cond"] cond = args["cond"]
uncond = args["uncond"] uncond = args["uncond"]
sigma = args["sigma"]
sigma = sigma.view(sigma.shape[:1] + (1,) * (cond.ndim - 1))
x_orig = args["input"] x_orig = args["input"]
sigma = args["sigma"]
logging.debug(f"NRS.nrs: Skew: {skew}, Stretch: {stretch}, Squash: {squash}") shapes = getattr(args["model"], "latent_shapes", None)
if shapes and len(shapes) > 1:
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]
#rescale cfg has to be done on v-pred model output if not warned["done"]:
x = x_orig / (sigma * sigma + 1.0) for stream in x_streams:
cond = ((x - (x_orig - cond)) * (sigma ** 2 + 1.0) ** 0.5) / (sigma) if stream.shape[1] == 1:
uncond = ((x - (x_orig - uncond)) * (sigma ** 2 + 1.0) ** 0.5) / (sigma) logging.warning(
logging.debug(f"NRS.nrs: generated cond and uncond") 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
x_final = None results = [
match "v0.4.5": self._apply_guidance(
case "v1": x_streams[i],
# displace cond by rejection of uncond on cond cond_streams[i],
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True) uncond_streams[i],
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True) sigma,
u_on_c = (u_dot_c / c_dot_c) * cond skew,
u_rej_c = uncond - u_on_c stretch,
displaced = (cond - skew * u_rej_c) squash,
logging.debug(f"NRS.nrs: displaced") pred_type,
)
for i in range(len(cond_streams))
]
# squash displaced vector towards len(cond) based on squash scale if len(results) == 1:
d_len_sq = torch.sum(displaced * displaced, dim=-1, keepdim=True) return results[0]
squash_scale = (1 - squash) + squash * ((c_dot_c/d_len_sq) ** 0.5)
squashed = displaced * squash_scale
logging.debug(f"NRS.nrs: squashed")
# stretch turned vector towards cond based on stretch scale if _comfy_utils is not None and hasattr(_comfy_utils, "pack_latents"):
sq_dot_c = torch.sum(squashed * cond, dim=-1, keepdim=True) return _comfy_utils.pack_latents(results)[0]
sq_on_c = (sq_dot_c / c_dot_c) * cond return _pack_latents(results)
x_final = squashed + sq_on_c * stretch
logging.debug(f"NRS.nrs: final")
case "v2":
# displace cond by rejection of uncond on cond
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
u_on_c_mag = (u_dot_c / c_dot_c)
u_on_c = u_on_c_mag * cond
u_rej_c = uncond - u_on_c
displaced = cond + stretch * (cond - torch.clamp(u_dot_c / c_dot_c, min=0, max=1) * cond) - skew * u_rej_c
logging.debug(f"NRS.nrs: displaced & stretched")
# squash displaced vector towards len(cond) based on squash scale
d_len_sq = torch.sum(displaced * displaced, dim=-1, keepdim=True)
squash_scale = (1 - squash) + squash * ((c_dot_c/d_len_sq) ** 0.5)
x_final = displaced * squash_scale
logging.debug(f"NRS.nrs: final")
case "v3":
# displace cond by rejection of uncond on cond
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
u_on_c_mag = (u_dot_c / c_dot_c)
u_on_c = u_on_c_mag * cond
u_rej_c = uncond - u_on_c
displaced = (cond - skew * u_rej_c)
logging.debug(f"NRS.nrs: displaced")
# squash displaced vector towards len(cond) based on squash scale
d_len_sq = torch.sum(displaced * displaced, dim=-1, keepdim=True)
squash_scale = (1 - squash) + squash * ((c_dot_c/d_len_sq) ** 0.5)
# stretch vector towards 2*len(cond) - len(u_on_c)
c_len = c_dot_c ** 0.5
stretch_scale = (1 - stretch) + stretch * (2 * c_len - u_on_c_mag)/c_len
x_final = displaced * squash_scale * stretch_scale
logging.debug(f"NRS.nrs: final")
case "v4":
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
u_on_c_mag = (u_dot_c / c_dot_c)
u_on_c = u_on_c_mag * cond
u_rej_c = uncond - u_on_c
rej_dor_rej = torch.sum(u_rej_c * u_rej_c, dim=-1, keepdim=True)
x_final = (cond - squash * u_rej_c + stretch * cond * ((rej_dor_rej/c_dot_c) ** 0.5))
logging.debug(f"NRS.nrs: displaced")
case "v0.4.1":
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
u_on_c_mag = (u_dot_c / c_dot_c)
u_on_c = u_on_c_mag * cond
u_rej_c = uncond - u_on_c
rej_dor_rej = torch.sum(u_rej_c * u_rej_c, dim=-1, keepdim=True)
stretched = cond + stretch * cond * ((rej_dor_rej/c_dot_c) ** 0.5)
skewed = stretched - skew * u_rej_c
sk_dot_sk = torch.sum(skewed * skewed, dim=-1, keepdim=True)
squash_scale = (1 - squash) + squash * ((c_dot_c/sk_dot_sk) ** 0.5)
x_final = skewed * squash_scale
logging.debug(f"NRS.nrs: displaced")
case "v0.4.2":
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
u_on_c_mag = (u_dot_c / c_dot_c)
u_on_c = u_on_c_mag * cond
u_rej_c = uncond - u_on_c
proj_len = torch.sum(u_on_c * u_on_c, dim=-1, keepdim=True) ** 0.5
cond_len = c_dot_c ** 0.5
stretched = cond * (1 + stretch * torch.abs(cond_len - proj_len) / cond_len)
skewed = stretched - skew * u_rej_c
sk_dot_sk = torch.sum(skewed * skewed, dim=-1, keepdim=True)
squash_scale = (1 - squash) + squash * cond_len / (sk_dot_sk ** 0.5)
x_final = skewed * squash_scale
logging.debug(f"NRS.nrs: displaced")
case "v0.4.3":
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
u_on_c_mag = (u_dot_c / c_dot_c)
u_on_c = u_on_c_mag * cond
u_rej_c = uncond - u_on_c
proj_len = torch.sum(u_on_c * u_on_c, dim=-1, keepdim=True) ** 0.5
cond_len = c_dot_c ** 0.5
stretched = cond * (1 + stretch * (cond_len - proj_len) / cond_len)
skewed = stretched - skew * u_rej_c
sk_dot_sk = torch.sum(skewed * skewed, dim=-1, keepdim=True)
squash_scale = (1 - squash) + squash * cond_len / (sk_dot_sk ** 0.5)
x_final = skewed * squash_scale
logging.debug(f"NRS.nrs: displaced")
case "v0.4.4":
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
u_on_c_mag = (u_dot_c / c_dot_c)
u_on_c = u_on_c_mag * cond
u_rej_c = uncond - u_on_c
cond_len = c_dot_c ** 0.5
proj_diff = cond - u_on_c
proj_diff_len = torch.sum(proj_diff * proj_diff, dim=-1, keepdim=True) ** 0.5
stretched = cond * (1 + stretch * proj_diff_len / cond_len)
skewed = stretched - skew * u_rej_c
sk_dot_sk = torch.sum(skewed * skewed, dim=-1, keepdim=True)
squash_scale = (1 - squash) + squash * cond_len / (sk_dot_sk ** 0.5)
x_final = skewed * squash_scale
logging.debug(f"NRS.nrs: displaced")
case "v0.4.5":
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
u_on_c_mag = (u_dot_c / c_dot_c)
u_on_c = u_on_c_mag * cond
u_rej_c = uncond - u_on_c
cond_len = c_dot_c ** 0.5
proj_diff = cond - u_on_c
# Amplify Cond based on length compared to projection of uncond
stretched = cond + (stretch * proj_diff)
# Skew/Steer Conf based on rejection of uncond on cond
skewed = stretched - skew * u_rej_c
# Squash final length back down to original length of cond
sk_dot_sk = torch.sum(skewed * skewed, dim=-1, keepdim=True)
squash_scale = (1 - squash) + squash * cond_len / (sk_dot_sk ** 0.5)
x_final = skewed * squash_scale
return x_orig - (x - x_final * sigma / (sigma * sigma + 1.0) ** 0.5)
m = model.clone() m = model.clone()
m.set_model_sampler_cfg_function(nrs, True) m.set_model_sampler_cfg_function(nrs, True)
return (m, ) return (m,)
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"NRS": NRS, "NRS": NRS,
} }
+125 -18
View File
@@ -1,27 +1,134 @@
[![CodeQL](https://github.com/Reithan/negative_rejection_steering/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/Reithan/negative_rejection_steering/actions/workflows/github-code-scanning/codeql)
[![ComfyUI Registry](https://github.com/Reithan/negative_rejection_steering/actions/workflows/publish.yml/badge.svg)](https://registry.comfy.org/nodes/negative_rejection_steering)
# Negative Rejection Steering # Negative Rejection Steering
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. 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.
This is accomplised in 3 steps: #### _**TL;DR**_:
1. **Displacement**: The conditioned output tensor is displaced in the direction of the rejection of the unconditioned tensor on the conditioned tensor. This lengthens the tensor in a direction perpendicular to it's direction without affecting the positive guidance. The tensor is displaced by the rejection x the Displacement parameter. 1. CFG is a bad 'knob'
2. **Squashing**: The displaced tensor is rescaled towards the original length of the conditioned tensor. This means for high displacement scaling values the tensor 'turns' away from the unconditioned direction, which for very negative displacements, it turns towards the unconditioned tensor. 0 displacement outputs the original conditioned tensor. 2. NRS replaces CFG with 3 new knobs.
3. **Stretching**: The post-squash 'steered' tensor is stretched towards the direction of the original conditioned tensor. The more sharp the steering the less pronounced the stretch is, with fully aligned tensors being stretched the full stretch scale parameter. 1x stretch adds 100% length to the tensor. 3. NRS lets you to create cooler outputs than CFG.
# Alpha Release **Contributing**: See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
Implements NRS with Skew, Stretch, and Squash parameters.
## Parameters > [!TIP]
Skew and Stretch are roughly similar to CFG, but decomposed, with `Stretch + Skew = 2 * CFG`, roughly. > Skip to the [Beginner How-To](#beginner-how-to) if you want to just get started.
**Skew** changes the 'direction' of generation, which should result in changes to the content and composition of the image. ### Math Demonstration
**Stretch** changes to 'amplification' of generation, which should result in stronger prompt representation. <details>
**Squash** 'normalizes' the resulting guidance back towards the original amplitude with 1.0 being the same amplitude, while 0.0 is the unmodified amplitude resulting from the Squash and Stretch functions. <summary>Expand for explanation of algorithm</summary>
<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:
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]
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]
[^1]: All operations are done per feature across the step's batch, width, and height.
[Interactive Graph on Math3D.org](https://www.math3d.org/aTJW4UZtCh)
</details>
## Examples of NRS Effects
**Skew**
![Skew Example](Examples/skew_array.png)
**Stretch**
![Stretch Example](Examples/stretch_array.png)
**Squash**
![Squash Example](Examples/squash_matrix.png)
<details>
<summary><small>Generation details for reproduction</small></summary>
| Prompt | |
| ---------- | --- |
| Tool | [Stable Diffusion WebUI reForge](https://github.com/Panchovix/stable-diffusion-webui-reForge) |
| Sampler | DPM++ 2M |
| Scheduler | Align Your Steps |
| Steps | 25 |
| Dimensions | 912 x 624 |
| Seed | `1334103348` |
| Model | [Lobotomized Mix v1.5](https://civitai.com/models/1144932) |
| Embeddings | [Lazy Embeddings for ALL illustrious NoobAI...](https://civitai.com/models/1302719), [Smooth Embeddings](https://civitai.com/models/1065154) |
| Positive | lazypos, [Smooth_Quality\|SmoothNoob_Quality], BREAK<br>very awa, masterpiece, best quality, year 2024, newest, highres, absurdres,<br>1girl, samurai archer, cyberpunk cityscape, rain-soaked rooftop, neon reflection puddles, volumetric mist,<br>photorealistic, digital art,<br>dramatic rim lighting, shallow depth of field, low angle viewpoint |
| Negative | lazyloli, lazynsfw, BREAK<br>lazyhand, SmoothNegative_Hands-neg, BREAK<br>[Smooth_Negative-neg\|SmoothNoob_Negative-neg], BREAK<br>lowres, worst quality, worst aesthetic, bad quality, jpeg artifacts, scan artifacts,<br>blurry, deformed anatomy, bad hands, extra fingers, missing fingers, mutated hands,<br>watermark, logo, text, nsfw |
</details>
### Explanation of Effects
#### Skew
**Skew** changes the 'direction' of your generation, altering the image generation to 'steer' away from negative prompt elements as they conflict with your positive prompt. Increasing Skew will change scene composition, geometry, and scene elements to ensure that the final image aligns with the intention of your prompt pair.
#### Stretch
**Stretch** changes the intensity of generated elements that align more with your positive prompt than the negative. This 'hits the gas' on any elements that are more strongly aligned with your positive prompt than your negative, and 'hit the brakes' on the opposite.
#### Squash
**Squash** is the speed limit. At 0.0 Squash, each diffusion step receives the full intensity you set from Skew and Stretch, while 1.0 Squash ensures each step has only the original step size output by the model. This setting will only remove intensity unless you have a non-zero Skew value. Squash will 'soften' the effects of Skew and Stretch as it's raised, but the 'removed' Skew and Stretch intensity is replaced by enhanced micro-detailing and 'burn'. Squash should generally be left low and used as a 'finishing' step after dialing in a decent Skew and Stretch value.
## Beginner How-To ## Beginner How-To
1. Set Squash to 0.0 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>
2. Set Skew & Stretch each to your normal CFG Scale setting *Alternatively, try starting with the default of 2/4/0.5, or at 1/1/1 to get a baseline.*
3. Test some generation. Results should be 'similar' in quality to CFG 2. Test some outputs. Results should be similar in quality to CFG.
4. Adjust Skew up/down to change content and composition 3. Adjust Skew to change the intensity of your outputs adherence to your positive and negative prompts. This primarily effects composition of the output.
5. Adjust Stretch up/down to change strength of image aspects and colors 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.
6. Adjust Squash up to remove artifacts and color burn (these will tend to be replaced by additional or extraneous details and elements) 5. Adjust Squash to soften Skew and Stretch's effects. The intensity removed from Skew and Stretch will generally become additional micro-detailing and elements.
**Tip**: You can experiment with negative values for Skew and Stretch as well to see what the model 'believes' your negative prompt 'means'. > [!TIP]
> You can experiment with negative values for each setting as well. This can be useful to understand how the model interpreting your negative prompt.
> [!WARNING]
> Don't set NRS values to negatives if there are things in your negative prompt you **actually** don't want to see.
## Setup & Installation
### ComfyUI
<details>
<summary>ComfyUI Setup Instructions</summary>
#### Installation
Install via ComfyUI Manager or manually clone this repository into your `ComfyUI/custom_nodes/` directory.
#### Usage
1. **Important**: Ignore the CFG setting on your KSampler node - NRS replaces CFG entirely
2. Connect your model through the **Negative Rejection Steering** node before sampling
3. Configure NRS parameters (Skew/Stretch/Squash) instead of using CFG
#### Basic Workflow
```
Model → NRS Node → KSampler
```
**Pro tip**: To verify NRS is working correctly, set CFG to an extremely high value (like 30). If your output looks normal, NRS is functioning properly. If the output appears "turbo fried," check your node connections.
**Sampler Compatibility**: NRS now supports advanced samplers including WanKSamplerAdvanced, RES4LYF samplers, and FLOW models (WAN21, Flux) with enhanced prediction type detection.
![ComfyUI Workflow Example](https://github.com/user-attachments/assets/edaa36a4-9ad8-4a35-bad3-dda80138b996)
</details>
### Automatic1111 / Forge / reForge
<details>
<summary>WebUI Setup Instructions</summary>
#### Installation
1. Install the extension through the Extensions tab in your WebUI
2. Enable the extension and restart your WebUI
#### Usage
Once installed and enabled, the NRS settings panel will appear in your generation interface. When NRS is active:
- **CFG Scale is ignored** - the WebUI may still show the CFG setting, but it has no effect
- Use the NRS parameters (Skew/Stretch/Squash) to control generation instead
- Follow the same parameter guidelines from the [Beginner How-To](#beginner-how-to) section
</details>
### 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 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
| User | CFG | NRS |
| --- | --- | --- |
| Mohnjiles from StabilityMatrix | ![CFG Example](Examples/mohnjiles_cfg.png) | ![NRS Example](Examples/mohnjiles_nrs.png) |
+5
View File
@@ -0,0 +1,5 @@
from .NRS.nodes_NRS import *
NODE_CLASS_MAPPINGS = {"NRS": NRS}
NODE_DISPLAY_NAME_MAPPINGS = {"NRS": "Negative Rejection Steering"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+11
View File
@@ -0,0 +1,11 @@
# The 'purpose' or 'intention' behind our 3 knobs
## SKEW
This is the primary 'steering' knob. This will 'turn' the 'direction' the current denoising step is traveling in the latent space. If we define the 'default' (cond) direction as 'prior step -> cond' then we're just applying a 'lateral' skew to that direction to 'turn' it 'away' from the 'unintended' direction (uncond)
## STRETCH
This is the sister knob to Skew. This is the accelerator. We want to go 'faster' into the intended direction (cond) the less aligned it is with the unintended direction (uncond). Think of this like a combination of brakes + gas. If we're headed directly for a brick wall (uncond is in the same direction as cond), we want to apply no acceleration, or negative acceleration. If we're traveling directly away from danger (uncond is in the opposite direction of cond) then we want to stomp the gas and get as far away as we can. There's only 1 problem with this BASIC-level description: as we get further into generation, regardless of pos/neg promp, cond & uncon will naturally align to be the same vector[^1]. In the last stop of inference, cond and uncond will be basically identical if nothing has fucked up. So whatever math we apply here needs to take the progressive alignment of cond & uncond into account. That's why were/are scaling only on the projection difference right now, rather than the full projection.
[^1]: This is more true in eps than v-pred. Stretch is inherently more powerful in v-pred based models than eps models.
## SQUASH
This is out 'safety' knob. Think of this like a 'limiter' in a car. This sets the 'top speed' we can go to some multiple of the 'default' speed the model would 'like to' go. i.e. whatever length of directional vector the model produces prior to any skewing or stretching is treated as the 'default' length with Squash=1.0 ensuring we only every go that 'speed' and no more, while Squash=0.0 lets us go any speed we want based on the other 2 knobs. GENERALLY we'll be leaving Squash at 0.0 unless we need it for specific generations.
+60
View File
@@ -0,0 +1,60 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
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."
authors = [{name = "Bryan O'Malley", email = "bo122081@hotmail.com"}]
version = "1.1.0"
requires-python = ">=3.10"
license = {file = "LICENSE"}
readme = "README.md"
[project.urls]
Repository = "https://github.com/Reithan/negative_rejection_steering"
# Used by Comfy Registry https://comfyregistry.org
[tool.setuptools.packages.find]
include = ["NRS*", "scripts*"]
exclude = ["tests*", "Examples*"]
[tool.comfy]
PublisherId = "reithan"
DisplayName = "Negative Rejection Steering"
Icon = "https://raw.githubusercontent.com/Reithan/negative_rejection_steering/main/icon.png"
[project.optional-dependencies]
dev = [
"pre-commit>=3.7.0",
"pytest>=8.0.0",
"ruff>=0.6.0",
]
[tool.ruff]
line-length = 120
target-version = "py310"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
]
ignore = [
"E501", # line too long (handled by formatter)
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "F403", "F405"] # Allow star imports and undefined names in __init__.py (ComfyUI pattern)
"NRS/nodes_NRS.py" = ["N802", "N804"] # Allow INPUT_TYPES and 's' parameter naming (ComfyUI API convention)
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_functions = "test_*"
addopts = "-v --tb=short"
norecursedirs = [".git", ".venv", "NRS", "scripts", "Examples"]
+51 -26
View File
@@ -1,43 +1,63 @@
import gradio as gr
import logging import logging
import sys import sys
import traceback
from functools import partial from functools import partial
from modules import scripts, script_callbacks
from typing import Any from typing import Any
import gradio as gr
from modules import script_callbacks, scripts
from NRS.nodes_NRS import NRS from NRS.nodes_NRS import NRS
class NRSScript(scripts.Script): class NRSScript(scripts.Script):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.enabled = False self.enabled = False
self.skew = 2.0 self.skew = 2.00
self.stretch = 2.0 self.stretch = 4.00
self.squash = 1.0 self.squash = 0.50
sorting_priority = 5 sorting_priority = 5
def title(self): def title(self):
return "Negative Rejection Steering for reForge" return "Negative Rejection Steering"
def show(self, is_img2img): def show(self, is_img2img):
return scripts.AlwaysVisible return scripts.AlwaysVisible
def ui(self, *args, **kwargs): def ui(self, *args, **kwargs):
with gr.Accordion(open=False, label=self.title()): with gr.Accordion(open=False, label=self.title()):
enabled = gr.Checkbox(label="Enable NRS", value=self.enabled) enabled = gr.Checkbox(label="Enable NRS", value=self.enabled)
gr.HTML("<p><i>Adjust the settings for Negative Rejection Steering.</i></p>") gr.HTML("<p><i>Adjust the settings for Negative Rejection Steering.</i></p>")
skew = gr.Slider(label="NRS Skew Scale", info="Adjusts the amount guidance is steered.", minimum=-30.0, maximum=30.0, step=0.01, value=self.skew) skew = gr.Slider(
stretch = gr.Slider(label="NRS Stretch Scale", info="Adjusts the amount guidance is amplified.", minimum=-30.0, maximum=30.0, step=0.01, value=self.stretch) label="NRS Skew Scale",
squash = gr.Slider(label="NRS Squash Multiplier", info="Adjusts the amount final guidance is normalized.", minimum=0.0, maximum=1.0, step=0.01, value=self.squash) info="Adjusts the amount guidance is steered.",
minimum=-30.0,
maximum=30.0,
step=0.01,
value=self.skew,
)
stretch = gr.Slider(
label="NRS Stretch Scale",
info="Adjusts the amount guidance is amplified.",
minimum=-30.0,
maximum=30.0,
step=0.01,
value=self.stretch,
)
squash = gr.Slider(
label="NRS Squash Multiplier",
info="Adjusts the amount final guidance is normalized.",
minimum=0.0,
maximum=1.0,
step=0.01,
value=self.squash,
)
enabled.change( enabled.change(lambda x: self.update_enabled(x), inputs=[enabled])
lambda x: self.update_enabled(x),
inputs=[enabled]
)
return (enabled, skew, stretch, squash) return (enabled, skew, stretch, squash)
def update_enabled(self, value): def update_enabled(self, value):
self.enabled = value self.enabled = value
@@ -70,22 +90,28 @@ class NRSScript(scripts.Script):
unet = NRS().patch(unet, self.skew, self.stretch, self.squash)[0] unet = NRS().patch(unet, self.skew, self.stretch, self.squash)[0]
p.sd_model.forge_objects.unet = unet p.sd_model.forge_objects.unet = unet
p.extra_generation_params.update({ p.extra_generation_params.update(
"NRS_enabled": True, {
"NRS_skew": self.skew, "NRS_enabled": True,
"NRS_stretch": self.stretch, "NRS_skew": self.skew,
"NRS_squash": self.squash, "NRS_stretch": self.stretch,
}) "NRS_squash": self.squash,
}
)
logging.debug(f"NRS: Enabled: {self.enabled}, Squash: {self.skew}, Stretch: {self.stretch}, Squash: {self.squash}") logging.debug(
f"NRS: Enabled: {self.enabled}, Skew: {self.skew}, Stretch: {self.stretch}, Squash: {self.squash}"
)
return return
def set_value(p, x: Any, xs: Any, *, field: str): def set_value(p, x: Any, xs: Any, *, field: str):
if not hasattr(p, "_nrs_xyz"): if not hasattr(p, "_nrs_xyz"):
p._nrs_xyz = {} p._nrs_xyz = {}
p._nrs_xyz[field] = x p._nrs_xyz[field] = x
def make_axis_on_xyz_grid(): def make_axis_on_xyz_grid():
xyz_grid = None xyz_grid = None
for script in scripts.scripts_data: for script in scripts.scripts_data:
@@ -98,10 +124,7 @@ def make_axis_on_xyz_grid():
axis = [ axis = [
xyz_grid.AxisOption( xyz_grid.AxisOption(
"(NRS) Enabled", "(NRS) Enabled", str, partial(set_value, field="enabled"), choices=lambda: ["True", "False"]
str,
partial(set_value, field="enabled"),
choices=lambda: ["True", "False"]
), ),
xyz_grid.AxisOption( xyz_grid.AxisOption(
"(NRS) Skew", "(NRS) Skew",
@@ -123,6 +146,7 @@ def make_axis_on_xyz_grid():
if not any(x.label.startswith("(NRS)") for x in xyz_grid.axis_options): if not any(x.label.startswith("(NRS)") for x in xyz_grid.axis_options):
xyz_grid.axis_options.extend(axis) xyz_grid.axis_options.extend(axis)
def on_before_ui(): def on_before_ui():
try: try:
make_axis_on_xyz_grid() make_axis_on_xyz_grid()
@@ -133,4 +157,5 @@ def on_before_ui():
file=sys.stderr, file=sys.stderr,
) )
script_callbacks.on_before_ui(on_before_ui) script_callbacks.on_before_ui(on_before_ui)
+30
View File
@@ -0,0 +1,30 @@
"""Pytest configuration - mock torch and gradio since they're provided by ComfyUI/WebUI at runtime."""
import sys
from unittest.mock import MagicMock
# Create mock base class for WebUI Script
class MockScript:
"""Mock base class for WebUI scripts."""
AlwaysVisible = "AlwaysVisible" # Mock the AlwaysVisible constant
# Mock torch, gradio, and WebUI modules before any imports happen
# This allows pytest to discover and import tests without these heavy dependencies
sys.modules["torch"] = MagicMock()
# Mock gradio with return_value configured for common patterns
mock_gradio = MagicMock()
mock_gradio.Accordion = MagicMock(return_value=MagicMock(__enter__=MagicMock(), __exit__=MagicMock()))
sys.modules["gradio"] = mock_gradio
# Mock WebUI modules with proper base class
mock_modules = MagicMock()
mock_modules.scripts = MagicMock()
mock_modules.scripts.Script = MockScript
mock_modules.script_callbacks = MagicMock()
sys.modules["modules"] = mock_modules
sys.modules["modules.scripts"] = mock_modules.scripts
sys.modules["modules.script_callbacks"] = mock_modules.script_callbacks
+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
+205
View File
@@ -0,0 +1,205 @@
"""Regression tests for NRS._get_pred_type, _RAW_TO_ENUM mappings, and the
V/FLOW/EPS operation-space conversion helpers.
PR-3 reclassified the flow-matching family (flux, chroma, flow, wan, const)
from PredictionType.EPS onto a new PredictionType.FLOW, which is operated
natively (identity conversion, no VP ε<->v algebra). These tests pin that
post-reclassification behavior at both detection sites (the _RAW_TO_ENUM
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
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# Add project root to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from NRS.nodes_NRS import _RAW_TO_ENUM, NRS, PredictionType
def _make_model_sampling(class_name):
"""Build an instance whose type name matches class_name, for class-name fingerprinting."""
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:
"""Minimal stand-in for a model object walked by _get_pred_type."""
def __init__(self, model_type=None, model_sampling=None, inner_model_type=None):
if model_type is not None:
self.model_type = model_type
if model_sampling is not None:
self.model_sampling = model_sampling
if inner_model_type is not None:
# Emulates model.model.model_type used by the enhanced-detection fallback.
self.model = _StubModel(model_type=inner_model_type)
@pytest.mark.parametrize(
"raw,expected",
[
("eps", PredictionType.EPS),
("epsilon", PredictionType.EPS),
("flux", PredictionType.FLOW),
("chroma", PredictionType.FLOW),
("flow", PredictionType.FLOW),
("wan", PredictionType.FLOW),
("const", PredictionType.FLOW),
("v", PredictionType.V),
("v_prediction", PredictionType.V),
("x0", PredictionType.X0),
("sample", PredictionType.X0),
],
)
def test_raw_to_enum_mapping(raw, expected):
"""Pin the current _RAW_TO_ENUM dict mappings."""
assert _RAW_TO_ENUM[raw] == expected
def test_raw_to_enum_unknown_raw_not_present():
"""Unrecognized raw strings are not in the dict; callers fall back to UNKNOWN."""
assert "totally-unrecognized" not in _RAW_TO_ENUM
class TestGetPredTypeDirectAttribute:
"""_get_pred_type's direct-hit path via model_type/prediction_type/parameterization."""
def test_model_type_v_prediction(self):
node = NRS()
model = _StubModel(model_type="v_prediction")
assert node._get_pred_type(model) == PredictionType.V
def test_model_type_eps(self):
node = NRS()
model = _StubModel(model_type="eps")
assert node._get_pred_type(model) == PredictionType.EPS
def test_model_type_x0(self):
node = NRS()
model = _StubModel(model_type="x0")
assert node._get_pred_type(model) == PredictionType.X0
def test_model_type_flow_is_flow(self):
"""Flow-family models resolve to FLOW (native operation, no VP conversion)."""
model = _StubModel(model_type="flow")
assert NRS()._get_pred_type(model) == PredictionType.FLOW
def test_model_type_wan_is_flow(self):
model = _StubModel(model_type="wan")
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:
"""The model_sampling class-name and model.model.model_type fallback paths.
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"))
assert NRS()._get_pred_type(model) == PredictionType.FLOW
def test_model_sampling_v_prediction_class_is_v(self):
model = _StubModel(model_sampling=_make_model_sampling("ModelSamplingV_Prediction"))
assert NRS()._get_pred_type(model) == PredictionType.V
def test_model_sampling_eps_class_is_eps(self):
model = _StubModel(model_sampling=_make_model_sampling("ModelSamplingEps"))
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):
"""Fully-unrecognized models fall back to EPS (documented default)."""
model = _StubModel()
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
+91
View File
@@ -0,0 +1,91 @@
"""Functional tests for NRS ComfyUI node and WebUI script."""
import inspect
import sys
from pathlib import Path
# Add project root to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
def test_nrs_node_comfyui_interface():
"""Test that NRS node has required ComfyUI interface."""
from NRS.nodes_NRS import NRS
# Test class can be instantiated
node = NRS()
assert node is not None
# Test INPUT_TYPES classmethod exists and returns proper structure
assert hasattr(NRS, "INPUT_TYPES")
assert callable(NRS.INPUT_TYPES)
input_types = NRS.INPUT_TYPES()
assert isinstance(input_types, dict)
assert "required" in input_types
assert "model" in input_types["required"]
assert "skew" in input_types["required"]
assert "stretch" in input_types["required"]
assert "squash" in input_types["required"]
# Test patch method exists with correct signature
assert hasattr(node, "patch")
assert callable(node.patch)
sig = inspect.signature(node.patch)
params = list(sig.parameters.keys())
assert "model" in params
assert "skew" in params
assert "stretch" in params
assert "squash" in params
# Test required class attributes
assert hasattr(NRS, "RETURN_TYPES")
assert NRS.RETURN_TYPES == ("MODEL",)
assert hasattr(NRS, "FUNCTION")
assert NRS.FUNCTION == "patch"
assert hasattr(NRS, "CATEGORY")
assert NRS.CATEGORY == "advanced/model"
def test_nrs_script_webui_interface():
"""Test that NRSScript has required WebUI/Gradio interface."""
from scripts.negative_rejection_steering_script import NRSScript
# Test class can be instantiated
script = NRSScript()
assert script is not None
# Test required methods exist
assert hasattr(script, "title")
assert callable(script.title)
assert isinstance(script.title(), str)
assert hasattr(script, "show")
assert callable(script.show)
assert hasattr(script, "ui")
assert callable(script.ui)
assert hasattr(script, "process_before_every_sampling")
assert callable(script.process_before_every_sampling)
# Test process_before_every_sampling has correct signature
sig = inspect.signature(script.process_before_every_sampling)
params = list(sig.parameters.keys())
# Note: 'self' is not included in signature, only other parameters
assert "p" in params
def test_prediction_type_enum():
"""Test PredictionType enum has required values."""
from NRS.nodes_NRS import PredictionType
# Test enum has required prediction types
assert hasattr(PredictionType, "EPS")
assert hasattr(PredictionType, "V")
assert hasattr(PredictionType, "X0")
assert hasattr(PredictionType, "UNKNOWN")
# Test enum values are distinct
assert PredictionType.EPS != PredictionType.V
assert PredictionType.V != PredictionType.X0
assert PredictionType.X0 != PredictionType.UNKNOWN
+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
+349
View File
@@ -0,0 +1,349 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
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" },
]
[[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]]
name = "filelock"
version = "3.29.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
]
[[package]]
name = "identify"
version = "2.6.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "negative-rejection-steering"
version = "1.0.0"
source = { editable = "." }
[package.optional-dependencies]
dev = [
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0" },
]
provides-extras = ["dev"]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pre-commit"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ 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" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "python-discovery"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
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" }
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/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/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.15.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
{ url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
{ url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
{ url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
{ url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
{ url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
{ url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
{ url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
{ url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
{ url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
{ url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
{ 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]]
name = "virtualenv"
version = "21.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
{ 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" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
]