diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 2086c58..be73e24 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -561,9 +561,9 @@ Improve later: - keep `tools/prompt_map_audit.py` passing; it now checks referenced expression/composition/scene pools, item-template axes, object-template - metadata values for both string and object templates, registered formatter - policy coverage for route families, and critical route documentation plus - expected smoke coverage. + metadata values for both string and object templates, category/subcategory + identity uniqueness, registered formatter policy coverage for route + families, and critical route documentation plus expected smoke coverage. ### Node / UI Path diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index ef3b0d0..b8a26cd 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -902,6 +902,9 @@ The script does not import ComfyUI. It parses the repo and prints: - JSON reference validation for every `scene_pools`, `expression_pools`, and `composition_pools` reference; - item template validation so `{placeholder}` names resolve to `item_axes`. +- category identity validation so custom category names/slugs do not collide + with built-in selectors, category identities stay unique, and exact + subcategory selectors cannot become ambiguous. - effective category route coverage so each normalized category path has usable item, scene, expression, composition, and hardcore route metadata before runtime fallbacks can hide a gap. @@ -920,9 +923,9 @@ The script does not import ComfyUI. It parses the repo and prints: Use its output to spot doc drift after adding a new node or pool. If a new node or pool appears there but not in this map, update the relevant route table. The -script exits nonzero when JSON pool references, item template axes, critical -route docs, critical route smoke registrations, or registered node display -names do not resolve. +script exits nonzero when JSON pool references, item template axes, category +identities, critical route docs, critical route smoke registrations, or +registered node display names do not resolve. ## Behavioral Smoke Helper diff --git a/tools/prompt_map_audit.py b/tools/prompt_map_audit.py index e618d8b..f7e25c5 100644 --- a/tools/prompt_map_audit.py +++ b/tools/prompt_map_audit.py @@ -118,6 +118,10 @@ AUDIT_DOC_SNIPPETS: tuple[tuple[str, str], ...] = ( "docs/prompt-pool-routing-map.md", "multi-seed route sweeps", ), + ( + "docs/prompt-pool-routing-map.md", + "category identity validation", + ), ( "docs/prompt-pool-routing-map.md", "node documentation validation", @@ -141,6 +145,15 @@ ALLOWED_PROMPT_ROW_READS: set[tuple[str, str]] = { ROUTE_POLICY_ACTION_EXCLUSIONS = {"default"} ROUTE_POLICY_POSITION_EXCLUSIONS = {"any"} +RESERVED_CATEGORY_CHOICES = { + "auto_weighted", + "auto_full", + "woman", + "man", + "couple", + "group_or_layout", + "custom_random", +} NODE_DOC_PATHS = ( "docs/prompt-pool-routing-map.md", "README.md", @@ -418,6 +431,117 @@ def _json_reference_errors(paths: list[Path]) -> list[tuple[str, str, str]]: return errors +def _identity_key(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "").strip()).lower() + + +def _identity_location(category: dict[str, Any], subcategory: dict[str, Any] | None = None) -> str: + category_slug = str(category.get("slug") or category.get("name") or "category").strip() + location = f"categories.{category_slug}" + if subcategory is not None: + sub_slug = str(subcategory.get("slug") or subcategory.get("name") or "subcategory").strip() + location = f"{location}.subcategories.{sub_slug}" + return location + + +def _add_duplicate_identity_error( + seen: dict[str, str], + *, + key: str, + location: str, + label: str, + errors: list[tuple[str, str, str]], +) -> None: + if not key: + return + previous = seen.get(key) + if previous is not None: + errors.append(("(category library)", location, f"duplicate {label}: {key!r} also used at {previous}")) + return + seen[key] = location + + +def _category_identity_errors(paths: list[Path]) -> list[tuple[str, str, str]]: + # Use the normalized library because that is the selector surface exposed by + # category/preset nodes and exact subcategory routing. + _ = paths + try: + categories = category_policy.load_category_library() + except Exception as exc: + return [("(category library)", "categories", f"cannot load categories: {exc}")] + + errors: list[tuple[str, str, str]] = [] + category_names: dict[str, str] = {} + category_slugs: dict[str, str] = {} + exact_selectors: dict[str, str] = {} + exact_slug_selectors: dict[str, str] = {} + + for category in categories: + location = _identity_location(category) + name = _identity_key(category.get("name")) + slug = _identity_key(category.get("slug")) + if not name: + errors.append(("(category library)", f"{location}.name", "missing normalized category name")) + if not slug: + errors.append(("(category library)", f"{location}.slug", "missing normalized category slug")) + if name in RESERVED_CATEGORY_CHOICES or slug in RESERVED_CATEGORY_CHOICES: + errors.append( + ( + "(category library)", + location, + "category identity collides with reserved built-in selector", + ) + ) + _add_duplicate_identity_error(category_names, key=name, location=location, label="category name", errors=errors) + _add_duplicate_identity_error(category_slugs, key=slug, location=location, label="category slug", errors=errors) + + subcategory_names: dict[str, str] = {} + subcategory_slugs: dict[str, str] = {} + for subcategory in category.get("subcategories") or []: + if not isinstance(subcategory, dict): + continue + sub_location = _identity_location(category, subcategory) + sub_name = _identity_key(subcategory.get("name")) + sub_slug = _identity_key(subcategory.get("slug")) + if not sub_name: + errors.append(("(category library)", f"{sub_location}.name", "missing normalized subcategory name")) + if not sub_slug: + errors.append(("(category library)", f"{sub_location}.slug", "missing normalized subcategory slug")) + _add_duplicate_identity_error( + subcategory_names, + key=sub_name, + location=sub_location, + label=f"subcategory name in {category.get('name')}", + errors=errors, + ) + _add_duplicate_identity_error( + subcategory_slugs, + key=sub_slug, + location=sub_location, + label=f"subcategory slug in {category.get('name')}", + errors=errors, + ) + + exact_selector = _identity_key(category_policy.exact_subcategory_selector(category, subcategory)) + slug_selector = f"{slug} / {sub_slug}" if slug and sub_slug else "" + _add_duplicate_identity_error( + exact_selectors, + key=exact_selector, + location=sub_location, + label="exact subcategory selector", + errors=errors, + ) + _add_duplicate_identity_error( + exact_slug_selectors, + key=slug_selector, + location=sub_location, + label="category/subcategory slug selector", + errors=errors, + ) + + return errors + + def _hardcore_template_metadata_errors(paths: list[Path]) -> list[tuple[str, str, str]]: errors: list[tuple[str, str, str]] = [] for path in paths: @@ -1064,6 +1188,13 @@ def main() -> int: return 1 print("OK: all JSON pool references and item template axes resolve.") + print("\n# Category Identity Validation") + category_identity_errors = _category_identity_errors(category_paths) + if category_identity_errors: + print_table(("Source", "Path", "Issue"), category_identity_errors) + return 1 + print("OK: category and subcategory identities are unique and selector-safe.") + print("\n# Hardcore Template Metadata Validation") hardcore_metadata_errors = _hardcore_template_metadata_errors(category_paths) if hardcore_metadata_errors: