Compare commits

...
5 Commits
Author SHA1 Message Date
semantic-release-bot 5b5c635be6 chore(release): 2.4.0 [skip ci] 2026-06-18 02:53:08 +00:00
Dumitru Picalau ccb8392219 Merge pull request #4 from DumiFlex/feat/inline-weights-pool-ui-debug
fix: inline branch weights + wildcard pool UI + debug warnings
2026-06-18 05:52:28 +03:00
DumiFlex d8c20728cb fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs
Three pool/UI fixes. (B) The MOD dot now lights on filter / pick-range / exclude-null changes — the category_filter check ran nonEmptyArr() on a string and silently missed, so toggling the pool filter never marked the instance modified. (C/D) The wildcard card subtitle shows 'X of Y options' when a filter narrows the pool, and '· picks N[-M]' when the instance multi-picks. (F) A nested @{uuid} ref to a live-library wildcard not in the chain no longer shows a false BROKEN REF — the Context node DB-expands its catalog at run, so it resolves; only refs absent from BOTH chain and library still flag (and those already emit unknown_ref in the debug panel). Bumps the extension bundle budget 431->432 KB.
2026-06-18 05:42:34 +03:00
DumiFlex 0e9e5db486 feat(engine): emit a constraint source-missing debug warning
A constraint with both ends absent from the chain emitted only the target-focused constraint_never_applied warning, so WP Debug showed one issue while the editor row flagged both source AND target missing. The pipeline now also emits constraint_source_missing (with a matching DebugViewer label) when the constraint's source wildcard isn't in the chain, so both ends surface in the debug panel.
2026-06-18 05:37:55 +03:00
DumiFlex a9516996b4 fix(engine): apply N:: branch weights in simple inline {a|b|c} choices
Simple inline picks resolved a branch uniformly and never stripped the N:: weight prefix, so {2::a|b} could emit the literal 2::a and {2::|, long shadows} leaked 2::. _resolve_inline_pick now strips the prefix and weights the pick when any branch carries one, staying byte-for-byte seed-stable for unweighted choices. Mirrored in the TS preview resolver (expandInlineChoices); multi-pick blocks already handled weights.
2026-06-18 05:33:24 +03:00
14 changed files with 275 additions and 17 deletions
+15
View File
@@ -1,3 +1,18 @@
## 🎉 Wildcard Pipeline 2.4.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #4 from DumiFlex/feat/inline-weights-pool-ui-debug ([ccb8392](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ccb8392)), closes [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4)
* fix(engine): apply N:: branch weights in simple inline {a|b|c} choices ([a951699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a951699))
* fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs ([d8c2072](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d8c2072))
* feat(engine): emit a constraint source-missing debug warning ([0e9e5db](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9e5db))
</details>
## 🎉 Wildcard Pipeline 2.3.1
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
+38
View File
@@ -613,6 +613,44 @@ class PipelineEngine:
# otherwise render as plain text).
"message": message,
})
# Source-side companion warning. The `never_applied` message
# above is target-focused; when the constraint's SOURCE
# wildcard is ALSO absent from the chain, surface that
# distinctly so the Debug viewer shows BOTH ends (mirrors the
# editor's separate "source missing" / "target missing" row
# badges). A constraint registers by its own module position,
# so it can sit in the bucket with neither end present.
source_uuid = c.get("source_wildcard_id", "")
if source_uuid and not _target_present_in_chain(
source_uuid, modules, catalog,
):
src_row = (
catalog.get(source_uuid) if isinstance(catalog, dict) else None
)
source_name = (
(src_row.get("name", "") or "")
if isinstance(src_row, dict) else ""
)
src_suffix = _build_ref_name_suffix(source_name)
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
if isinstance(warnings_bucket, list):
warnings_bucket.append({
"type": "constraint_source_missing",
"severity": "warn",
"module_id": cid,
"source_field": "",
"position": 0,
"token_index": None,
"detail": {
"constraint_id": cid,
"source_wildcard_id": source_uuid,
},
"message": (
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — "
f"its source @{{{source_uuid}{src_suffix}}} wildcard isn't in "
f"this chain, so it never registers a source pick."
),
})
# SP3 partial-reach finalisation: a constraint that DID fire but
# whose `next N` / `pick` selector matched FEWER occurrences than
+13 -2
View File
@@ -169,8 +169,19 @@ def _resolve_inline_pick(
branches: list[str] = tok.meta.get("branches", [])
if not branches:
return ""
chosen_idx = ctx.rng.randrange(len(branches))
chosen = branches[chosen_idx]
# Per-branch weights (`N::value`), same micro-syntax the multi-pick block
# honors. Strip the prefix off every branch so it never leaks into output;
# only switch to weighted selection when a branch actually carries a weight,
# so unweighted `{a|b|c}` keeps its byte-for-byte seed sequence (legacy
# randrange path — changing the draw would shift every existing seed).
values = [_strip_branch_weight(b) for b in branches]
has_weights = any(v != b for v, b in zip(values, branches, strict=True))
if has_weights:
weights = [_parse_branch_weight(b) for b in branches]
chosen_idx = _weighted_pick_index(weights, ctx.rng)
else:
chosen_idx = ctx.rng.randrange(len(branches))
chosen = values[chosen_idx]
if not chosen:
return ""
nested_tokens = tokenize_text(chosen)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.3.1",
"version": "2.4.0",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-wildcard-pipeline"
version = "2.3.1"
version = "2.4.0"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
+7 -1
View File
@@ -165,7 +165,13 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// - instance-modal scroll/sticky: sticky header + runtime/footer dock +
// own-scrollbar inner lists (options/values/rules/exceptions) + compact
// exceptions trash, across all 6 edit modals.
const TOTAL_LIMIT = 431 * 1024; // 431 KB
// Bumped 431 -> 432 KB on 2026-06-18 for the syntax + UI fix batch: inline
// `{N::a|b}` branch weights (engine + TS resolver mirror), the filter / pick /
// exclude-null MOD-dot fix, the "X of Y options" + "picks N-M" wildcard
// subtitle, the library-aware nested-ref check (kills the false BROKEN REF),
// and the constraint source-missing debug warning + label. ~0.4 KB net gzip
// across the ContextWidget + conflicts chunks; entry stays put.
const TOTAL_LIMIT = 432 * 1024; // 432 KB
function gzipSize(path) {
return gzipSync(readFileSync(path)).length;
+43 -7
View File
@@ -29,6 +29,7 @@ import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-c
import ModuleRow from "./ModuleRow.vue";
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
import { buildBundleInsertion, type BundleLibraryEntry } from "./bundles/insert";
import { isEnabled, type WildcardOption, type InstanceLike } from "./editors/wildcard/probability";
import { buildLibraryChildrenWithIntegrity, toChildSnapshot } from "./bundles/save";
import {
cascadeRestoreForBundle,
@@ -2027,6 +2028,9 @@ const conflicts = computed<Conflict[]>(() => {
props.downstreamWildcardUuids,
props.downstreamNestedReachUuids,
pairOrphanByUid,
// Live-library uuids — a nested @{} ref to any of these resolves at run
// time (DB-expanded catalog), so it must not read as a broken ref.
new Set(Object.keys(libraryHashes.value ?? {})),
);
// Filter by user's validation-strictness preference. The accessor
// reads from the same module-level state map the panel onChange
@@ -2806,7 +2810,16 @@ function isModified(m: ModuleEntry): boolean {
if (nonEmptyObj(inst.option_weights)) return true;
if (inst.mode && inst.mode !== "random") return true;
if (inst.pinned_option_id) return true;
if (nonEmptyArr(inst.category_filter)) return true;
// `category_filter` is a STRING expr — the old `nonEmptyArr()` test
// never matched it, so toggling the pool filter never lit the dot.
if (typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") return true;
if (inst.exclude_null) return true;
// Pick-range override (multi-pick / narrowed). Sparse — absent or 1..1
// is the library default; anything else is a behavioural override.
if (inst.pick_min != null && inst.pick_min !== 1) return true;
if (inst.pick_max != null && inst.pick_max !== 1) return true;
if (inst.pick_independent) return true;
if (typeof inst.pick_separator === "string" && inst.pick_separator !== "") return true;
return false;
case "fixed_values":
// Library-tracked: `values_overrides` non-empty = user edited entries,
@@ -3181,14 +3194,37 @@ function summaryTokens(m: ModuleEntry): SummaryToken[] {
// Card summary respects the per-instance binding override so a
// renamed wildcard reads as `$<override>` on the canvas. Engine
// applies the same precedence at run time.
const inst = m.instance?.variable_binding;
const instBind = m.instance?.variable_binding;
const payloadBinding = (p.var_binding as string | undefined)?.trim();
const binding = (typeof inst === "string" && inst.trim()) || payloadBinding;
const opts = Array.isArray(p.options) ? p.options.length : 0;
const binding = (typeof instBind === "string" && instBind.trim()) || payloadBinding;
const head: SummaryToken = binding ? v(binding) : lit("wildcard");
return opts
? [head, lit(` · ${opts} option${opts === 1 ? "" : "s"}`)]
: [head];
const allOpts = (Array.isArray(p.options) ? p.options : []) as WildcardOption[];
const total = allOpts.length;
if (!total) return [head];
const inst = (m.instance ?? {}) as InstanceLike & { pick_min?: number; pick_max?: number };
const pmin = typeof inst.pick_min === "number" ? inst.pick_min : 1;
const pmax = typeof inst.pick_max === "number" ? Math.max(pmin, inst.pick_max) : pmin;
const multiActive = pmax > 1;
// "X of Y options" only when a filter / enabled-subset actually narrows
// the pool — a plain count when nothing is filtering (keeps the common
// case clean). The null-option drop in multi mode alone never triggers it.
const hasFilter =
(typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") ||
Array.isArray(inst.enabled_options);
const eligible = allOpts.filter((o) => isEnabled(o, inst, multiActive)).length;
const tokens: SummaryToken[] = [
head,
lit(
hasFilter && eligible < total
? ` · ${eligible} of ${total} options`
: ` · ${total} option${total === 1 ? "" : "s"}`,
),
];
// Multi-pick hint — surface when the instance resolves more than one.
if (pmax > 1) {
tokens.push(lit(pmin === pmax ? ` · picks ${pmin}` : ` · picks ${pmin}-${pmax}`));
}
return tokens;
}
case "combine": {
// Same per-instance precedence as wildcard: rebinding via the v2
+1
View File
@@ -182,6 +182,7 @@ interface WarningEntry {
const WARNING_LABELS: Record<string, string> = {
constraint_never_applied: "Constraint never applied",
constraint_partial_reach: "Constraint partial reach",
constraint_source_missing: "Constraint source missing",
constraint_register_failed: "Constraint failed to register",
constraint_factor_ignored_on_allow: "Constraint factor ignored (allow)",
unknown_constraint_mode: "Unknown constraint mode",
+26
View File
@@ -1294,6 +1294,32 @@ describe("scanConflicts — wildcard_broken_nested_ref", () => {
});
});
it("does NOT flag when the ref resolves to a LIBRARY wildcard not in the chain", () => {
// `deadbeef` isn't in the chain, but it's a live-library wildcard — the
// Context node fills it from the DB at run (`_expand_catalog_via_live_db`),
// so the ref resolves and must NOT read as broken.
const value: ContextWidgetValue = {
version: 1,
modules: [wildcardOpt("w1", "phrase", "see @{deadbeef}")],
};
const out = scanConflicts(value, [], [], [], [], null, new Set(["deadbeef"]));
expect(out.find((c) => c.type === "wildcard_broken_nested_ref")).toBeUndefined();
});
it("STILL flags a ref absent from BOTH the chain and the library", () => {
const value: ContextWidgetValue = {
version: 1,
modules: [wildcardOpt("w1", "phrase", "see @{deadbeef}")],
};
const out = scanConflicts(value, [], [], [], [], null, new Set(["other999"]));
expect(out).toContainEqual({
moduleId: "w1",
variable: "deadbeef",
type: "wildcard_broken_nested_ref",
severity: "warning",
});
});
it("keys the conflict to the module's _uid when present", () => {
const value: ContextWidgetValue = {
version: 1,
+13 -1
View File
@@ -451,6 +451,13 @@ export function scanConflicts(
// handed and reads `isOrphan` off that. Same reach math, narrower
// visibility.
constraintPairOrphanByUid: Map<string, boolean> | null = null,
// Uuids of every wildcard in the live library. A nested `@{uuid}` ref to a
// library wildcard that ISN'T in this chain still resolves at run time — the
// Context node expands its catalog from the live DB (`_expand_catalog_via_live_db`),
// so the ref is NOT broken. Only flag refs absent from BOTH chain + library.
// Constraint TARGET reachability deliberately stays chain-only (a constraint
// needs a downstream instance to re-weight — library presence isn't enough).
knownLibraryUuids: ReadonlySet<string> = new Set(),
): Conflict[] {
const upstream = new Set(upstreamVars);
const upstreamUuids = new Set(upstreamWildcardUuids);
@@ -618,6 +625,11 @@ export function scanConflicts(
const inDownstreamNested = downstreamNestedReach.has(uuid);
return inLocalDirect || inUpstream || inDownstream || inLocalNestedAfter || inDownstreamNested;
};
// Nested `@{uuid}` refs additionally resolve from the live library catalog
// (the engine pulls them from the DB at run time even when they're not in
// the chain), so a ref to any known library wildcard is NOT a broken ref.
const isNestedRefResolvable = (uuid: string, i: number): boolean =>
isUuidResolvable(uuid, i) || knownLibraryUuids.has(uuid);
for (let i = 0; i < value.modules.length; i++) {
const m = value.modules[i];
if (!isModuleEffectivelyEnabled(m, bundleEnabled)) continue;
@@ -828,7 +840,7 @@ export function scanConflicts(
const uuid = match[1];
if (seenRef.has(uuid)) continue;
seenRef.add(uuid);
if (!isUuidResolvable(uuid, i)) {
if (!isNestedRefResolvable(uuid, i)) {
out.push({ moduleId: m._uid ?? m.id, variable: uuid, type, severity: "warning" });
}
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { expandInlineChoices } from "./resolver";
describe("expandInlineChoices — branch weights (engine parity)", () => {
it("strips the N:: weight prefix — never leaks into output", () => {
for (let i = 0; i < 50; i++) {
const out = expandInlineChoices("{2::a|b}");
expect(["a", "b"]).toContain(out);
expect(out).not.toContain("::");
}
});
it("a zero-weight branch is never picked", () => {
for (let i = 0; i < 50; i++) {
expect(expandInlineChoices("{1::keep|0::drop}")).toBe("keep");
}
});
it("user repro: {0::|, long shadows} resolves to the non-empty branch", () => {
for (let i = 0; i < 50; i++) {
expect(expandInlineChoices("{0::|, long shadows}")).toBe(", long shadows");
}
});
it("unweighted choices still resolve to one branch verbatim", () => {
const out = expandInlineChoices("{a|b|c}");
expect(["a", "b", "c"]).toContain(out);
});
it("a literal foo::bar (non-numeric prefix) is left intact", () => {
// Only a NUMERIC `N::` prefix is a weight; `http::x` style stays put.
expect(expandInlineChoices("{x::y|x::y}")).toBe("x::y");
});
});
+41 -4
View File
@@ -49,10 +49,34 @@ export function pickWeightedOption(
return options[options.length - 1];
}
/** Parse the `N::` branch-weight prefix — 1 when absent, 0 when ≤0, 1 on a
* non-numeric prefix. Mirrors the engine's `_parse_branch_weight`. */
function parseBranchWeight(branch: string): number {
const i = branch.indexOf("::");
if (i < 0) return 1;
const prefix = branch.slice(0, i);
if (prefix.trim() === "") return 1;
const n = Number(prefix);
if (Number.isNaN(n)) return 1;
return n <= 0 ? 0 : n;
}
/** Strip a numeric `N::` weight prefix; leave the branch untouched when the
* prefix isn't a number (a literal `foo::bar` survives). Mirrors the
* engine's `_strip_branch_weight`. */
function stripBranchWeight(branch: string): string {
const i = branch.indexOf("::");
if (i < 0) return branch;
const prefix = branch.slice(0, i);
if (prefix.trim() === "" || Number.isNaN(Number(prefix))) return branch;
return branch.slice(i + 2);
}
/**
* Expand inline `{a|b|c}` choices in a value string. Each occurrence is
* resolved to one of its `|`-separated branches at random. Nested braces are
* resolved innermost-first via a fixpoint loop.
* resolved to one of its `|`-separated branches at random (weighted when a
* branch carries an `N::` prefix). Nested braces are resolved innermost-first
* via a fixpoint loop.
*
* Important — this function MUST NOT touch `@{...}` refs. The previous
* implementation delegated to `resolveTokens` which tokenized `@{8hex}`
@@ -74,8 +98,21 @@ export function expandInlineChoices(text: string): string {
const next = out.replace(
/\{([^{}]*\|[^{}]*)\}/g,
(_, content: string) => {
const branches = content.split("|");
return branches[Math.floor(Math.random() * branches.length)];
const raw = content.split("|");
const values = raw.map(stripBranchWeight);
const hasWeights = values.some((v, i) => v !== raw[i]);
if (!hasWeights) {
return values[Math.floor(Math.random() * values.length)];
}
const weights = raw.map(parseBranchWeight);
const total = weights.reduce((a, w) => a + Math.max(0, w), 0);
if (total <= 0) return values[0];
let r = Math.random() * total;
for (let i = 0; i < values.length; i++) {
r -= Math.max(0, weights[i]);
if (r <= 0) return values[i];
}
return values[values.length - 1];
},
);
if (next === out) break;
+27
View File
@@ -388,6 +388,33 @@ def test_resolve_inline_pick_empty_branch_valid():
assert out in {"a", "", "c"}
def test_resolve_inline_pick_strips_branch_weight_prefix():
"""`{N::value}` weight prefix must never leak into the output. Regression:
simple inline picks ignored `N::`, emitting the literal `2::a`."""
for seed in range(12):
ctx = _ctx(rng=random.Random(seed))
out = resolve_text("{2::a|b}", ctx)
assert out in {"a", "b"}, f"seed {seed}: {out!r}"
assert "::" not in out, f"seed {seed}: weight prefix leaked: {out!r}"
def test_resolve_inline_pick_respects_branch_weight():
"""A zero-weight branch is never picked — weighting drives the choice."""
for seed in range(12):
ctx = _ctx(rng=random.Random(seed))
assert resolve_text("{1::keep|0::drop}", ctx) == "keep", f"seed {seed}"
def test_resolve_inline_pick_weighted_empty_branch():
"""User repro `{2::|, long shadows}`: the `2::` empty branch emits empty,
never the literal prefix. Zero-weight variant pins the assertion."""
for seed in range(12):
ctx = _ctx(rng=random.Random(seed))
assert resolve_text("{0::|, long shadows}", ctx) == ", long shadows"
out = resolve_text("{2::|, long shadows}", ctx)
assert out in {"", ", long shadows"}, f"seed {seed}: {out!r}"
def test_resolve_inline_pick_single_branch_is_text_token():
"""`{just_one}` is NOT a pick (no pipe) — tokenizer returns it as text,
so resolver passes through verbatim."""
+15
View File
@@ -603,6 +603,21 @@ class TestConstraintComposition:
]
assert excludes_all, "expected an excludes-all warning when both fire"
def test_constraint_both_ends_missing_emits_source_and_target_warnings(self):
"""A constraint whose SOURCE and TARGET wildcards are both absent from
the chain surfaces BOTH a `constraint_never_applied` (target-focused)
AND a `constraint_source_missing` warning — so the Debug viewer shows
both ends, matching the editor's separate per-end row badges."""
ctx = _run([
_wildcard("present", "color", [{"id": "c1", "value": "red", "weight": 1}]),
_constraint("cX", source="missing_src", target="missing_tgt", exceptions=[
{"source": "a", "target": "b", "mode": "exclude", "factor": 0},
]),
], seed=1)
types = [w.get("type") for w in ctx.get("__wp_warnings__", [])]
assert "constraint_never_applied" in types, types
assert "constraint_source_missing" in types, types
def test_first_mode_constraint_yields_to_let_other_survive(self):
"""The pre-SP3 'only the first constraint fires' outcome is still
reachable by marking c2 `first` and giving it no earlier target: