Compare commits

...
18 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
21 changed files with 2092 additions and 173 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.
+74 -2
View File
@@ -16,12 +16,84 @@ jobs:
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@v4
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
uses: Comfy-Org/publish-node-action@v1
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_cache/
# pre-commit stuff:
.pre-commit-cache/
# PyPI configuration file
.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!
+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__"]
+263 -137
View File
@@ -1,39 +1,120 @@
# 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 torch
import math
from enum import Enum, auto
from typing import Any
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.EPS,
"chroma": 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:
@classmethod
def INPUT_TYPES(s):
return {"required": { "model": ("MODEL",),
"skew": ("FLOAT", {"default": 4.0, "min": -30.0, "max": 30.0, "step": 0.01}),
"stretch": ("FLOAT", {"default": 2.0, "min": -30.0, "max": 30.0, "step": 0.01}),
"squash": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}),
}}
return {
"required": {
"model": ("MODEL", {"tooltip": "Input model to apply NRS to"}),
"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",)
FUNCTION = "patch"
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
@@ -42,6 +123,7 @@ class NRS:
prediction-type flag, then map it to the enum.
Defaults to EPS if all else fails.
"""
def _canon(p):
if p is None:
return ""
@@ -61,7 +143,12 @@ class NRS:
for attr in ("model_type", "prediction_type", "parameterization"):
p = _canon(getattr(obj, attr, None))
if p:
return _RAW_TO_ENUM.get(p, PredictionType.UNKNOWN)
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"):
@@ -70,153 +157,192 @@ class NRS:
seen.add(id(child))
queue.append(child)
# 3) default ------------------------------------------------------
return PredictionType.UNKNOWN
def _convert_to_eps_space(self, x_orig, sig_root, sigma, cond, uncond):
x_div = None
eps_cond = cond
eps_uncond = uncond
if self.__pred_type == PredictionType.V:
# v → ε conversion
logging.debug(f"NRS._convert_to_eps_space: generating x_div, eps_cond, and eps_uncond for v-pred")
x_div = x_orig / (sigma ** 2 + 1)
# 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}")
eps_cond = ((x_div - (x_orig - cond)) * sig_root) / (sigma)
eps_uncond = ((x_div - (x_orig - uncond)) * sig_root) / (sigma)
elif self.__pred_type == PredictionType.EPS:
logging.debug(f"NRS._convert_to_eps_space: already in eps, no pre-scale needed")
pass # already in ε space
elif self.__pred_type == PredictionType.X0:
raise NotImplementedError("NRS._convert_to_eps_space: x0-prediction not supported yet.")
else:
raise RuntimeError("NRS._convert_to_eps_space: Could not determine prediction type for this model.")
return x_div, eps_cond, eps_uncond
# 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
def _finalize_from_eps_space(self, x_orig, x_div, x_final, sig_root, sigma):
nrs_result = x_final
if self.__pred_type == PredictionType.V:
# ε → v conversion
logging.debug(f"NRS._finalize_from_eps_space: generating cfg_result for v-pred")
nrs_result = x_orig - (x_div - x_final * sigma / sig_root)
elif self.__pred_type == PredictionType.EPS:
# already in ε space
logging.debug(f"NRS._finalize_from_eps_space: already in eps, no post-scale needed")
pass
elif self.__pred_type == PredictionType.X0:
raise NotImplementedError("NRS._finalize_from_eps_space: x0-prediction not supported yet.")
else:
raise RuntimeError("NRS._finalize_from_eps_space: Could not determine prediction type for this model.")
return nrs_result
def _convert_to_v_space(self, x_orig, sig_root, sigma, cond, uncond):
x_div = None
v_cond = cond
v_uncond = uncond
if self.__pred_type == PredictionType.V:
logging.debug(f"NRS._convert_to_v_space: already in v, no pre-scale needed")
pass # already in v space
elif self.__pred_type == PredictionType.EPS:
# ε → v conversion
logging.debug(f"NRS._convert_to_v_space: generating x_div, v_cond, and v_uncond for eps")
x_div = x_orig / (sigma ** 2 + 1)
factor = sigma / sig_root
# 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}")
v_cond = x_orig - (x_div - cond * factor)
v_uncond = x_orig - (x_div - uncond * factor)
elif self.__pred_type == PredictionType.X0:
raise NotImplementedError("NRS._convert_to_v_space: x0-prediction not supported yet.")
else:
raise RuntimeError("NRS._convert_to_v_space: Could not determine prediction type for this model.")
return x_div, v_cond, v_uncond
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 _finalize_from_v_space(self, x_orig, x_div, x_final, sig_root, sigma):
nrs_result = x_final
if self.__pred_type == PredictionType.V:
# already in v space
logging.debug(f"NRS._finalize_from_v_space: already in v, no post-scale needed")
pass
elif self.__pred_type == PredictionType.EPS:
# v → ε conversion
logging.debug(f"NRS._finalize_from_v_space: generating cfg_result for eps")
nrs_result = (x_div - (x_orig - x_final)) * (sig_root / sigma)
elif self.__pred_type == PredictionType.X0:
raise NotImplementedError("NRS._finalize_from_v_space: x0-prediction not supported yet.")
else:
raise RuntimeError("NRS._finalize_from_v_space: Could not determine prediction type for this model.")
return nrs_result
def patch(self, model, skew, stretch, squash):
self.__pred_type = self._get_pred_type(model) if not hasattr(self, "__pred_type") else self.__pred_type
self.__OPERATION_SPACE = PredictionType.V
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):
logging.debug(f"NRS.nrs: Skew: {skew}, Stretch: {stretch}, Squash: {squash}")
# self.__pred_type = self.__pred_type if self.__pred_type is not None else self._get_pred_type(model)
cond = args["cond"]
uncond = args["uncond"]
x_orig = args["input"]
sigma = args["sigma"]
sigma = sigma.view(sigma.shape[:1] + (1,) * (cond.ndim - 1))
sig_root = (sigma ** 2 + 1).sqrt()
x_div, nrs_cond, nrs_uncond = None, None, None
match self.__OPERATION_SPACE:
case PredictionType.V:
x_div, nrs_cond, nrs_uncond = self._convert_to_v_space(x_orig, sig_root, sigma, cond, uncond)
case PredictionType.EPS:
x_div, nrs_cond, nrs_uncond = self._convert_to_eps_space(x_orig, sig_root, sigma, cond, uncond)
case PredictionType.X0:
raise RuntimeError("NRS.nrs: x0-prediction not supported yet.")
case PredictionType.UNKNOWN:
raise RuntimeError("NRS.nrs: Could not determine prediction type for this operation.")
case _:
raise RuntimeError("NRS.nrs: Invalid PredictionType used.")
def _dot(a, b):
return (a*b).sum(dim=1, keepdim=True) # [B,C,W,H] => [B,1,W,H]
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]
def _nrm2(v):
return _dot(v, v)
if not warned["done"]:
for stream in x_streams:
if stream.shape[1] == 1:
logging.warning(
f"NRS.nrs: routed stream has a singleton reduction axis {tuple(stream.shape)}; "
"NRS geometry (dot/proj/skew) will degenerate to a no-op on this stream."
)
warned["done"] = True
break
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)
results = [
self._apply_guidance(
x_streams[i],
cond_streams[i],
uncond_streams[i],
sigma,
skew,
stretch,
squash,
pred_type,
)
for i in range(len(cond_streams))
]
# Skew/Steer Conf based on rejection of uncond on cond
u_rej_c = nrs_uncond - u_on_c
skewed = stretched - (skew * u_rej_c)
if len(results) == 1:
return results[0]
# 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
if _comfy_utils is not None and hasattr(_comfy_utils, "pack_latents"):
return _comfy_utils.pack_latents(results)[0]
return _pack_latents(results)
squash_scale = (1 - squash) + (squash * (cond_len / nrs_len))
x_final = skewed * squash_scale
match self.__OPERATION_SPACE:
case PredictionType.V:
return self._finalize_from_v_space(x_orig, x_div, x_final, sig_root, sigma)
case PredictionType.EPS:
return self._finalize_from_eps_space(x_orig, x_div, x_final, sig_root, sigma)
case PredictionType.X0:
raise RuntimeError("NRS.nrs: x0-prediction not supported yet.")
case PredictionType.UNKNOWN:
raise RuntimeError("NRS.nrs: Could not determine prediction type for this operation.")
case _:
raise RuntimeError("NRS.nrs: Invalid PredictionType used.")
m = model.clone()
m.set_model_sampler_cfg_function(nrs, True)
return (m, )
return (m,)
NODE_CLASS_MAPPINGS = {
"NRS": NRS,
+60 -5
View File
@@ -9,6 +9,8 @@ NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidanc
2. NRS replaces CFG with 3 new knobs.
3. NRS lets you to create cooler outputs than CFG.
**Contributing**: See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
> [!TIP]
> Skip to the [Beginner How-To](#beginner-how-to) if you want to just get started.
@@ -18,7 +20,7 @@ NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidanc
<img align="right" src="Examples/NRS_graph.png" alt="Graph of NRS vs CFG" style="width: 40%; float: right;">
### NRS is Applied in Three Steps:
0. ***V-Space**: Optional pre-NRS step* If the model is not using v-prediction, we transform the EPS `cond` and `uncond` into v-prediction space before continuing, then revert to eps-space before return.
0. ***V-Space**: pre-NRS step* The sampler hands NRS its `cond`/`uncond` as `x - x0` for every variance-preserving parameterization (EPS, v-prediction, and x0-prediction alike), so NRS recovers the true velocity `v` from them and runs its geometry in v-prediction space, then inverts the transform before returning. This one v-space path handles EPS, v-pred, and x0 models identically. Flow-matching models (flux, chroma, wan, and other flow/CONST families) are operated natively — their prediction is already a pure scalar multiple of the velocity, so no v-space conversion is applied.
1. **Skewing**: The conditioned output tensor is skewed away from the direction of the rejection of the unconditioned tensor on the conditioned tensor. This lengthens the tensor in a direction perpendicular to its direction without affecting the positive guidance. The tensor is displaced by the rejection multiplied by the Skew parameter.[^1]
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]
@@ -39,7 +41,7 @@ NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidanc
| Prompt | |
| ---------- | --- |
| Tool | [Stable Diffusion WebUI reForge](https://github.com/Panchovix/stable-diffusion-webui-reForge) |
| Tool | [Stable Diffusion WebUI reForge](https://github.com/Panchovix/stable-diffusion-webui-reForge) |
| Sampler | DPM++ 2M |
| Scheduler | Align Your Steps |
| Steps | 25 |
@@ -61,18 +63,71 @@ NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidanc
## Beginner How-To
1. Set Skew to 1/2 of your normal CFG Scale setting and Stretch to your full normal CFG Scale. Set Squash to 0.0.<br>
*Alternatively, try starting at 1/1/1 to get a baseline.*
*Alternatively, try starting with the default of 2/4/0.5, or at 1/1/1 to get a baseline.*
2. Test some outputs. Results should be similar in quality to CFG.
3. Adjust Skew to change the intensity of your outputs adherence to your positive and negative prompts. This primarily effects composition of the output.
4. Adjust Stretch to intensify your positive prompt's aspects and colors where they differ from the negative prompt. This primarily effects color and texture.
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]
> [!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]
> [!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 |
| --- | --- | --- |
+2 -2
View File
@@ -1,5 +1,5 @@
from .NRS.nodes_NRS import *
NODE_CLASS_MAPPINGS = {"NRS": NRS}
NODE_DISPLAY_NAME_MAPPINS = {"NRS": "Negative Rejection Steering"}
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
NODE_DISPLAY_NAME_MAPPINGS = {"NRS": "Negative Rejection Steering"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+1 -1
View File
@@ -8,4 +8,4 @@ This is the sister knob to Skew. This is the accelerator. We want to go 'faster'
[^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.
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.
+45 -1
View File
@@ -1,8 +1,13 @@
[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 = "0.7.1"
version = "1.1.0"
requires-python = ">=3.10"
license = {file = "LICENSE"}
readme = "README.md"
@@ -10,7 +15,46 @@ readme = "README.md"
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"]
+50 -25
View File
@@ -1,19 +1,22 @@
import gradio as gr
import logging
import sys
import traceback
from functools import partial
from modules import scripts, script_callbacks
from typing import Any
import gradio as gr
from modules import script_callbacks, scripts
from NRS.nodes_NRS import NRS
class NRSScript(scripts.Script):
def __init__(self):
super().__init__()
self.enabled = False
self.skew = 4.0
self.stretch = 2.0
self.squash = 0.0
self.skew = 2.00
self.stretch = 4.00
self.squash = 0.50
sorting_priority = 5
@@ -22,22 +25,39 @@ class NRSScript(scripts.Script):
def show(self, is_img2img):
return scripts.AlwaysVisible
def ui(self, *args, **kwargs):
with gr.Accordion(open=False, label=self.title()):
enabled = gr.Checkbox(label="Enable NRS", value=self.enabled)
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)
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)
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,
)
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(
lambda x: self.update_enabled(x),
inputs=[enabled]
)
enabled.change(lambda x: self.update_enabled(x), inputs=[enabled])
return (enabled, skew, stretch, squash)
def update_enabled(self, value):
self.enabled = value
@@ -70,22 +90,28 @@ class NRSScript(scripts.Script):
unet = NRS().patch(unet, self.skew, self.stretch, self.squash)[0]
p.sd_model.forge_objects.unet = unet
p.extra_generation_params.update({
"NRS_enabled": True,
"NRS_skew": self.skew,
"NRS_stretch": self.stretch,
"NRS_squash": self.squash,
})
p.extra_generation_params.update(
{
"NRS_enabled": True,
"NRS_skew": self.skew,
"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
def set_value(p, x: Any, xs: Any, *, field: str):
if not hasattr(p, "_nrs_xyz"):
p._nrs_xyz = {}
p._nrs_xyz[field] = x
def make_axis_on_xyz_grid():
xyz_grid = None
for script in scripts.scripts_data:
@@ -98,10 +124,7 @@ def make_axis_on_xyz_grid():
axis = [
xyz_grid.AxisOption(
"(NRS) Enabled",
str,
partial(set_value, field="enabled"),
choices=lambda: ["True", "False"]
"(NRS) Enabled", str, partial(set_value, field="enabled"), choices=lambda: ["True", "False"]
),
xyz_grid.AxisOption(
"(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):
xyz_grid.axis_options.extend(axis)
def on_before_ui():
try:
make_axis_on_xyz_grid()
@@ -133,4 +157,5 @@ def on_before_ui():
file=sys.stderr,
)
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" },
]