From 8c3f61ea6d37bacc285a39c331ad1af03e91a3ce Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 1 Jul 2026 16:43:43 +0200 Subject: [PATCH] Route clothing choices through clothing seed --- builder_prompt_route.py | 4 +- generate_prompt_batches.py | 15 ++- pair_cast.py | 10 +- pair_rows.py | 5 +- prompt_builder.py | 2 + row_category_route.py | 29 +++- row_generation.py | 25 +++- tools/prompt_smoke.py | 263 ++++++++++++++++++++++++++++++++++++- 8 files changed, 331 insertions(+), 22 deletions(-) diff --git a/builder_prompt_route.py b/builder_prompt_route.py index 58ca6bc..77ed259 100644 --- a/builder_prompt_route.py +++ b/builder_prompt_route.py @@ -146,13 +146,14 @@ def build_prompt_result(request: PromptBuildRequest, deps: PromptBuildDependenci parsed_location_config = deps.parse_location_config(request.location_config) parsed_composition_config = deps.parse_composition_config(request.composition_config) content_rng = deps.axis_rng(parsed_seed_config, "content", seed, row_number) + clothing_rng = deps.axis_rng(parsed_seed_config, "clothing", seed, row_number) pose_axis_rng = deps.axis_rng(parsed_seed_config, "pose", seed, row_number) person_rng = deps.axis_rng(parsed_seed_config, "person", seed, row_number) expression_rng = deps.axis_rng(parsed_seed_config, "expression", seed, row_number) clothing = request.clothing if request.clothing in ("full", "minimal", "random") else "full" poses = request.poses if request.poses in ("standard", "evocative", "random") else "standard" figure = request.figure if request.figure in ("curvy", "balanced", "bombshell", "random") else "curvy" - clothing = deps.pick_clothing_mode(content_rng, clothing, minimal_ratio) + clothing = deps.pick_clothing_mode(clothing_rng, clothing, minimal_ratio) poses = deps.pick_pose_mode(pose_axis_rng, poses, pose_ratio) figure = deps.pick_figure_bias(person_rng, figure) minimal_ratio = None @@ -185,6 +186,7 @@ def build_prompt_result(request: PromptBuildRequest, deps: PromptBuildDependenci minimal_ratio, pose_ratio, seed, + seed_config=parsed_seed_config, ) elif category in ("woman", "man", "couple", "group_or_layout") and not exact_custom_subcategory: branch = "built_in" diff --git a/generate_prompt_batches.py b/generate_prompt_batches.py index db2391e..5550189 100755 --- a/generate_prompt_batches.py +++ b/generate_prompt_batches.py @@ -3059,16 +3059,17 @@ def row_base(index: int, batch: int, subject: str, age: str, body: str, scene_sl } -def make_single(index: int, batch: int, rng: random.Random, gender: str, expr_deck: ExpressionDeck, clothing: str = "full", ethnicity: str = "any", poses: str = "standard", backside_bias: float = 0.0, figure: str = "curvy", no_plus: bool = False, no_black: bool = False) -> dict: +def make_single(index: int, batch: int, rng: random.Random, gender: str, expr_deck: ExpressionDeck, clothing: str = "full", ethnicity: str = "any", poses: str = "standard", backside_bias: float = 0.0, figure: str = "curvy", no_plus: bool = False, no_black: bool = False, clothing_rng: random.Random | None = None) -> dict: minimal = clothing == "minimal" + wardrobe_rng = clothing_rng or rng if gender == "woman": subject, age, body, skin, hair, eyes = choose_woman(rng, ethnicity, no_plus, no_black) - clothes = choose(rng, WOMEN_CLOTHES_MINIMAL if minimal else WOMEN_CLOTHES) + clothes = choose(wardrobe_rng, WOMEN_CLOTHES_MINIMAL if minimal else WOMEN_CLOTHES) figure_note = choose(rng, figure_pool(figure)) else: men_pool = by_ethnicity(MEN, ethnicity) subject, age, body, skin, hair, eyes = choose(rng, men_pool) - clothes = choose(rng, MEN_CLOTHES_MINIMAL if minimal else MEN_CLOTHES) + clothes = choose(wardrobe_rng, MEN_CLOTHES_MINIMAL if minimal else MEN_CLOTHES) figure_note = "" body_phrase = make_body_phrase(body, figure_note) @@ -3119,7 +3120,7 @@ def make_single(index: int, batch: int, rng: random.Random, gender: str, expr_de return row -def make_couple(index: int, batch: int, rng: random.Random, expr_deck: ExpressionDeck, clothing: str = "full", ethnicity: str = "any", no_plus: bool = False) -> dict: +def make_couple(index: int, batch: int, rng: random.Random, expr_deck: ExpressionDeck, clothing: str = "full", ethnicity: str = "any", no_plus: bool = False, clothing_rng: random.Random | None = None) -> dict: primary_subject, subject_phrase, pose = choose(rng, COUPLE_TYPES) if ethnicity == "asian": subject_phrase = { @@ -3140,7 +3141,7 @@ def make_couple(index: int, batch: int, rng: random.Random, expr_deck: Expressio if no_plus: body_options = [b for b in body_options if "plus" not in b and "fat" not in b] body = choose(rng, body_options) - outfits = choose(rng, COUPLE_OUTFITS_MINIMAL if clothing == "minimal" else COUPLE_OUTFITS) + outfits = choose(clothing_rng or rng, COUPLE_OUTFITS_MINIMAL if clothing == "minimal" else COUPLE_OUTFITS) expr_a, expr_b = expr_deck.draw_two() row = row_base(index, batch, primary_subject, ages, body, scene_slug, composition) @@ -3164,7 +3165,7 @@ def make_couple(index: int, batch: int, rng: random.Random, expr_deck: Expressio return row -def make_group_or_layout(index: int, batch: int, rng: random.Random, expr_deck: ExpressionDeck, clothing: str = "full", ethnicity: str = "any", no_plus: bool = False) -> dict: +def make_group_or_layout(index: int, batch: int, rng: random.Random, expr_deck: ExpressionDeck, clothing: str = "full", ethnicity: str = "any", no_plus: bool = False, clothing_rng: random.Random | None = None) -> dict: minimal = clothing == "minimal" group_outfits = "minimal beachwear and lingerie-inspired outfits" if minimal else "stylish revealing party outfits" if ethnicity == "asian": @@ -3203,7 +3204,7 @@ def make_group_or_layout(index: int, batch: int, rng: random.Random, expr_deck: ) return row - layout_slug, layout_desc = choose(rng, LAYOUTS_MINIMAL if minimal else LAYOUTS_FULL) + layout_slug, layout_desc = choose(clothing_rng or rng, LAYOUTS_MINIMAL if minimal else LAYOUTS_FULL) if ethnicity == "asian": layout_desc = layout_desc.replace("adult", "Asian adult") elif ethnicity == "white_asian": diff --git a/pair_cast.py b/pair_cast.py index de4afee..c967f44 100644 --- a/pair_cast.py +++ b/pair_cast.py @@ -166,15 +166,15 @@ def softcore_partner_styling( choose: Choose, slot_softcore_outfit: SlotSoftcoreOutfit, ) -> dict[str, Any]: - content_rng = axis_rng(seed_config, "content", seed, row_number + 421) + clothing_rng = axis_rng(seed_config, "clothing", seed, row_number + 421) pose_rng = axis_rng(seed_config, "pose", seed, row_number + 421) pov_set = set(pov_labels or []) outfits: list[str] = [] for index in range(max(0, women_count - 1)): label = chr(ord("B") + index) full_label = f"Woman {label}" - outfit = slot_softcore_outfit((label_map or {}).get(full_label), content_rng) or choose( - content_rng, + outfit = slot_softcore_outfit((label_map or {}).get(full_label), clothing_rng) or choose( + clothing_rng, pair_options.INSTA_OF_SOFTCORE_PARTNER_WOMEN_OUTFITS, ) sentence = pair_clothing.softcore_outfit_sentence(full_label, outfit) @@ -185,8 +185,8 @@ def softcore_partner_styling( full_label = f"Man {label}" if full_label in pov_set: continue - outfit = slot_softcore_outfit((label_map or {}).get(full_label), content_rng) or choose( - content_rng, + outfit = slot_softcore_outfit((label_map or {}).get(full_label), clothing_rng) or choose( + clothing_rng, pair_options.INSTA_OF_SOFTCORE_PARTNER_MEN_OUTFITS, ) sentence = pair_clothing.softcore_outfit_sentence(full_label, outfit) diff --git a/pair_rows.py b/pair_rows.py index 6acc7d0..d7e511b 100644 --- a/pair_rows.py +++ b/pair_rows.py @@ -74,6 +74,7 @@ def build_insta_pair_rows_result( soft_seed_config = parsed_softcore_seed_config or parsed_seed_config hard_seed_config = parsed_hardcore_seed_config or parsed_seed_config soft_content_rng = axis_rng(soft_seed_config, "content", seed, row_number + 311) + soft_clothing_rng = axis_rng(soft_seed_config, "clothing", seed, row_number + 311) soft_pose_rng = axis_rng(soft_seed_config, "pose", seed, row_number + 313) hard_content_rng = axis_rng(hard_seed_config, "content", seed, row_number + 317) soft_person_rng = axis_rng(soft_seed_config, "person", seed, row_number) @@ -147,8 +148,8 @@ def build_insta_pair_rows_result( if not soft_expression_enabled: soft_row = disable_row_expression(soft_row, soft_expression_intensity_source) - primary_softcore_outfit = slot_softcore_outfit(primary_slot, soft_content_rng) - soft_row["item"] = primary_softcore_outfit or softcore_outfit(soft_content_rng, softcore_level_key) + primary_softcore_outfit = slot_softcore_outfit(primary_slot, soft_clothing_rng) + soft_row["item"] = primary_softcore_outfit or softcore_outfit(soft_clothing_rng, softcore_level_key) soft_row["pose"] = softcore_pose(soft_pose_rng, softcore_level_key) soft_row["item_label"] = ( "Insta/OF softcore body exposure" diff --git a/prompt_builder.py b/prompt_builder.py index 51bdf03..81f8366 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -1441,6 +1441,7 @@ def _build_direct_builtin_row( minimal_clothing_ratio: float | None, standard_pose_ratio: float | None, seed: int, + seed_config: dict[str, int] | None = None, ) -> dict[str, Any]: return row_generation_policy.build_direct_builtin_row( category, @@ -1456,6 +1457,7 @@ def _build_direct_builtin_row( minimal_clothing_ratio, standard_pose_ratio, seed, + seed_config=seed_config, ) diff --git a/row_category_route.py b/row_category_route.py index be82204..217e606 100644 --- a/row_category_route.py +++ b/row_category_route.py @@ -50,6 +50,32 @@ def is_pose_content_category(category: dict[str, Any], subcategory: dict[str, An return bool(tokens.intersection({"pose", "poses", "sex", "sexual"})) +def is_clothing_content_category(category: dict[str, Any], subcategory: dict[str, Any]) -> bool: + haystack = " ".join( + str(value) + for value in ( + category.get("name", ""), + category.get("slug", ""), + category.get("item_label", ""), + subcategory.get("name", ""), + subcategory.get("slug", ""), + subcategory.get("item_label", ""), + ) + ).lower() + tokens = set(re.findall(r"[a-z0-9]+", haystack)) + if tokens.intersection({"clothes", "clothing", "outfit", "outfits", "streetwear", "menswear", "daywear", "activewear", "lingerie", "wardrobe"}): + return True + return any( + phrase in haystack + for phrase in ( + "casual clothes", + "men casual clothes", + "couple casual clothes", + "provocative erotic clothes", + ) + ) + + def cast_count_adjustment( requested_women_count: int, requested_men_count: int, @@ -266,7 +292,8 @@ def select_category_item_route_result( ) is_pose_category = is_pose_content_category(category, subcategory) - content_axis = "pose" if is_pose_category else "content" + is_clothing_category = is_clothing_content_category(category, subcategory) + content_axis = "pose" if is_pose_category else ("clothing" if is_clothing_category else "content") content_rng = seed_policy.axis_rng(seed_config, content_axis, seed, row_number) item = row_item_policy.weighted_choice(content_rng, _list_from(subcategory.get("items", [subcategory["name"]]))) item_text, item_name, item_axis_values, item_template_metadata = row_item_policy.compose_item( diff --git a/row_generation.py b/row_generation.py index eaefa11..0f2c4dd 100644 --- a/row_generation.py +++ b/row_generation.py @@ -116,15 +116,19 @@ def build_direct_builtin_row( minimal_clothing_ratio: float | None, standard_pose_ratio: float | None, seed: int, + seed_config: dict[str, int] | None = None, ) -> dict[str, Any]: rng = random.Random(seed_policy.row_seed(seed, row_number)) + clothing_rng = None + if seed_config is not None: + clothing_rng = seed_policy.axis_rng(seed_config, "clothing", seed, row_number) expr_deck = g.ExpressionDeck( g.EXPRESSIONS, random.Random(seed_policy.row_seed(g.EXPRESSION_SEED + seed, row_number)), ) batch = max(1, ((row_number - 1) // g.BATCH_SIZE) + 1) index = start_index + row_number - 1 - row_clothing = pick_clothing_mode(rng, clothing, minimal_clothing_ratio) + row_clothing = pick_clothing_mode(clothing_rng or rng, clothing, minimal_clothing_ratio) row_poses = pick_pose_mode(rng, poses, standard_pose_ratio) if category == "woman": @@ -141,13 +145,26 @@ def build_direct_builtin_row( figure, no_plus_women, no_black, + clothing_rng=clothing_rng, ) elif category == "man": - row = g.make_single(index, batch, rng, "man", expr_deck, row_clothing, ethnicity, row_poses, backside_bias, figure) + row = g.make_single( + index, + batch, + rng, + "man", + expr_deck, + row_clothing, + ethnicity, + row_poses, + backside_bias, + figure, + clothing_rng=clothing_rng, + ) elif category == "couple": - row = g.make_couple(index, batch, rng, expr_deck, row_clothing, ethnicity, no_plus_women) + row = g.make_couple(index, batch, rng, expr_deck, row_clothing, ethnicity, no_plus_women, clothing_rng=clothing_rng) elif category == "group_or_layout": - row = g.make_group_or_layout(index, batch, rng, expr_deck, row_clothing, ethnicity, no_plus_women) + row = g.make_group_or_layout(index, batch, rng, expr_deck, row_clothing, ethnicity, no_plus_women, clothing_rng=clothing_rng) else: raise ValueError(f"Unknown built-in category: {category}") diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 5996fdc..5f969f0 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -2493,7 +2493,7 @@ def smoke_row_category_route_policy() -> None: hardcore_position_config={}, ) _expect(casual_route["category"]["slug"] == "casual_clothes", "Row category route selected wrong casual category") - _expect(casual_route["content_axis"] == "content", "Non-pose category should use content seed axis") + _expect(casual_route["content_axis"] == "clothing", "Casual clothes category should use clothing seed axis") _expect(casual_route["is_pose_category"] is False, "Non-pose category should not be marked as pose content") exposed_route = row_category_route.select_category_item_route( @@ -2507,9 +2507,35 @@ def smoke_row_category_route_policy() -> None: hardcore_position_config={}, ) _expect(exposed_route["subcategory"]["slug"] == "sheer_exposed", "Row category route selected wrong exposed category") - _expect(exposed_route["content_axis"] == "content", "Exposed clothing slug should not be treated as pose content") + _expect(exposed_route["content_axis"] == "clothing", "Exposed clothing slug should use clothing seed axis") _expect(exposed_route["is_pose_category"] is False, "Exposed clothing slug should not be marked as pose content") + generic_categories = [ + { + "name": "Lighting mood", + "slug": "lighting_mood", + "subcategories": [ + { + "name": "Window light", + "slug": "window_light", + "items": ["soft window glow", "warm rim light"], + } + ], + } + ] + generic_route = row_category_route.select_category_item_route( + category_choice="Lighting mood", + subcategory_choice="Window light", + seed_config=seed_cfg, + seed=2303, + row_number=1, + women_count=1, + men_count=0, + hardcore_position_config={}, + categories=generic_categories, + ) + _expect(generic_route["content_axis"] == "content", "Generic non-clothing category should keep content seed axis") + def smoke_row_generation_policy() -> None: _expect(pb._ratio_or_none(-1) is None, "Prompt builder ratio helper should treat negative as unset") @@ -2557,6 +2583,36 @@ def smoke_row_generation_policy() -> None: pb._build_direct_builtin_row(**direct_args) == row_generation.build_direct_builtin_row(**direct_args), "Prompt builder direct built-in row should delegate to row_generation", ) + direct_random_args = {**direct_args, "clothing": "random", "minimal_clothing_ratio": 0.5} + direct_locked = row_generation.build_direct_builtin_row( + **direct_random_args, + seed_config=seed_config.parse_seed_config(pb.build_seed_lock_config_json(base_seed=5050)), + ) + direct_clothing_reroll = row_generation.build_direct_builtin_row( + **direct_random_args, + seed_config=seed_config.parse_seed_config( + pb.build_seed_lock_config_json(base_seed=5050, reroll_axis="clothing", reroll_seed=5052) + ), + ) + direct_stable_keys = ("primary_subject", "age_band", "body_type", "scene", "composition", "pose_mode") + _expect( + tuple(direct_locked.get(key) for key in direct_stable_keys) + == tuple(direct_clothing_reroll.get(key) for key in direct_stable_keys), + "Direct built-in clothing reroll should keep non-clothing row fields stable", + ) + _expect( + ( + direct_locked.get("clothing_mode"), + direct_locked.get("prompt"), + direct_locked.get("caption"), + ) + != ( + direct_clothing_reroll.get("clothing_mode"), + direct_clothing_reroll.get("prompt"), + direct_clothing_reroll.get("caption"), + ), + "Direct built-in clothing reroll should change clothing mode or clothing text", + ) auto_args = dict( row_number=2, start_index=41, @@ -14336,6 +14392,209 @@ def smoke_seed_config_policy() -> None: break _expect(pose_changed, "pose reroll should change pose/action metadata while cast and scene stay locked") + clothing_axis_seed = 42001 + + def clothing_category_row(seed_config_value: str | dict[str, Any], *, name: str = "seed_config_policy_clothing_axis") -> dict[str, Any]: + return pb.build_prompt( + category="Casual clothes", + subcategory="Casual clothes / Streetwear", + row_number=1, + start_index=1, + seed=clothing_axis_seed, + clothing="random", + ethnicity="any", + poses="standard", + backside_bias=0.0, + figure="curvy", + no_plus_women=False, + no_black=False, + minimal_clothing_ratio=0.5, + standard_pose_ratio=-1, + trigger=Trigger, + prepend_trigger_to_prompt=True, + extra_positive="", + extra_negative="", + seed_config=seed_config_value, + women_count=1, + men_count=0, + expression_enabled=True, + expression_intensity=0.6, + ) + + def clothing_trace(row: dict[str, Any]) -> str: + trace = row.get("generation_trace") if isinstance(row.get("generation_trace"), dict) else {} + return str(trace.get("clothing") or "") + + clothing_locked_row = clothing_category_row(pb.build_seed_lock_config_json(base_seed=clothing_axis_seed)) + content_pose_row = clothing_category_row( + pb.build_seed_lock_config_json( + base_seed=clothing_axis_seed, + reroll_axis="content_pose", + reroll_seed=clothing_axis_seed + 2, + ), + name="seed_config_policy_content_pose_locked_clothing", + ) + clothing_stable_fields = ("item", "scene_text", "subject_phrase") + _expect( + tuple(clothing_locked_row.get(key) for key in clothing_stable_fields) + == tuple(content_pose_row.get(key) for key in clothing_stable_fields), + "content_pose reroll should keep clothing-category item, scene, and subject stable when clothing is locked", + ) + _expect( + clothing_trace(clothing_locked_row) == clothing_trace(content_pose_row), + "content_pose reroll should not change prompt clothing mode when clothing seed is locked", + ) + + clothing_reroll_row = clothing_category_row( + pb.build_seed_lock_config_json( + base_seed=clothing_axis_seed, + reroll_axis="clothing", + reroll_seed=clothing_axis_seed + 2, + ), + name="seed_config_policy_clothing_reroll", + ) + _expect( + tuple(clothing_locked_row.get(key) for key in ("scene_text", "pose", "subject_phrase")) + == tuple(clothing_reroll_row.get(key) for key in ("scene_text", "pose", "subject_phrase")), + "clothing reroll should keep scene, pose, and subject stable", + ) + _expect( + ( + clothing_trace(clothing_locked_row), + clothing_locked_row.get("item"), + ) + != ( + clothing_trace(clothing_reroll_row), + clothing_reroll_row.get("item"), + ), + "clothing reroll should change clothing mode or outfit text", + ) + + content_clothing_row = clothing_category_row( + pb.build_seed_lock_config_json( + base_seed=clothing_axis_seed, + reroll_axis="content_clothing", + reroll_seed=clothing_axis_seed + 2, + ), + name="seed_config_policy_content_clothing_reroll", + ) + _expect( + clothing_trace(clothing_locked_row) != clothing_trace(content_clothing_row), + "content_clothing reroll should change prompt clothing mode for clothing categories", + ) + _expect( + clothing_locked_row.get("item") != content_clothing_row.get("item"), + "content_clothing reroll should change outfit text for clothing categories", + ) + + outfit_alias_config = seed_config.parse_seed_config(pb.build_seed_lock_config_json(base_seed=clothing_axis_seed)) + outfit_alias_config.pop("clothing_seed", None) + outfit_alias_config["content_seed"] = clothing_axis_seed + outfit_alias_config["outfit_seed"] = clothing_axis_seed + 2 + outfit_alias_row = clothing_category_row(outfit_alias_config, name="seed_config_policy_outfit_alias") + _expect( + clothing_trace(outfit_alias_row) == clothing_trace(clothing_reroll_row), + "outfit_seed alias should drive prompt clothing mode ahead of content_seed", + ) + _expect( + outfit_alias_row.get("item") == clothing_reroll_row.get("item"), + "outfit_seed alias should drive clothing-category outfit text ahead of content_seed", + ) + + def pair_row_outfit(seed_config_value: dict[str, int]) -> str: + route = pair_rows.build_insta_pair_rows_result( + row_number=1, + start_index=1, + seed=clothing_axis_seed, + active_trigger=Trigger, + parsed_seed_config=seed_config_value, + options={ + "softcore_cast": "solo", + "softcore_expression_enabled": False, + "softcore_expression_intensity": 0.5, + "hardcore_expression_enabled": False, + "hardcore_expression_intensity": 0.5, + "hardcore_detail_density": "standard", + }, + ethnicity="any", + figure="curvy", + no_plus_women=False, + no_black=False, + character_profile="", + character_cast="", + character_slot_map={}, + pov_character_labels=[], + hard_women_count=1, + hard_men_count=1, + soft_category="Casual clothes", + soft_subcategory="Casual clothes / Streetwear", + softcore_level_key="suggestive", + hardcore_random_subcategory="Penetrative sex", + hardcore_position_config="", + location_config="", + composition_config="", + build_prompt=lambda **kwargs: {"prompt": "", "negative_prompt": "", "caption": ""}, + axis_rng=seed_config.axis_rng, + cast_expression_intensity_override=lambda *_args: (None, "disabled"), + context_from_character_slot=lambda *_args: {}, + apply_character_context_to_row=lambda row, _context: row, + disable_row_expression=lambda row, _source: row, + slot_softcore_outfit=lambda _slot, _rng: "", + softcore_outfit=lambda rng, _level: f"outfit-{rng.randint(1, 1000000)}", + softcore_pose=lambda rng, _level: f"pose-{rng.randint(1, 1000000)}", + softcore_item_prompt_label=lambda _level: "outfit", + pov_prompt_directive=lambda _labels: "", + pov_composition_prompt=lambda composition, _labels: str(composition), + ) + return str(route.soft_row.get("item") or "") + + pair_base_config = seed_config.parse_seed_config(pb.build_seed_lock_config_json(base_seed=clothing_axis_seed)) + pair_content_reroll_config = seed_config.parse_seed_config( + pb.build_seed_lock_config_json( + base_seed=clothing_axis_seed, + reroll_axis="content", + reroll_seed=clothing_axis_seed + 4, + ) + ) + pair_clothing_reroll_config = seed_config.parse_seed_config( + pb.build_seed_lock_config_json( + base_seed=clothing_axis_seed, + reroll_axis="clothing", + reroll_seed=clothing_axis_seed + 4, + ) + ) + _expect( + pair_row_outfit(pair_base_config) == pair_row_outfit(pair_content_reroll_config), + "Pair softcore primary outfit should stay stable across content reroll when clothing is locked", + ) + _expect( + pair_row_outfit(pair_base_config) != pair_row_outfit(pair_clothing_reroll_config), + "Pair softcore primary outfit should follow clothing reroll", + ) + + def partner_outfits(seed_config_value: dict[str, int]) -> list[str]: + return pair_cast.softcore_partner_styling( + seed_config=seed_config_value, + seed=clothing_axis_seed, + row_number=1, + women_count=2, + men_count=1, + pov_labels=[], + label_map={}, + axis_rng=seed_config.axis_rng, + choose=lambda rng, values: values[rng.randrange(len(values))], + slot_softcore_outfit=lambda _slot, _rng: "", + )["outfits"] + + _expect( + partner_outfits(pair_base_config) == partner_outfits(pair_content_reroll_config), + "Pair partner outfits should stay stable across content reroll when clothing is locked", + ) + _expect( + partner_outfits(pair_base_config) != partner_outfits(pair_clothing_reroll_config), + "Pair partner outfits should follow clothing reroll", + ) + def smoke_prompt_route_simulation_policy() -> None: report = prompt_route_simulation.run_simulation(seed=3901, include_prompts=False)