feat(character): curated body attributes — cup size (girls) and body type ride every prompt #180
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/body-attributes"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
The second (main) slice of ADR 0038 — the attributes themselves, end to end:
CupSize(Flat…GiganticPlus) andBodyType(Petite…Muscular) as nullable stored-contract enums on the character (append, never renumber; null = "the model decides"). The vocabulary extensions carry the ADR table: cup-range labels (AA cup…K+ cup), the proven danbooru tags (flat chest…gigantic breasts), and the canonical body tags —skinny(not the deprecatedslim),plump(not the dead aliaschubby),Muscularresolving tomuscular femalefor girls;Normaldeliberately injects nothing.K+injectsgigantic breastsat weight 1.5 via the same strength-as-weight mechanism every authored tag uses (ADR 0028).SpriteRequestMapper): the identity tags lead the general positive section in order framing → body type → cup size, ahead of the global defaults; the cup tag rides only while gender is Girl — the mapper's ignore-on-read is what makes a later gender flip safe without destroying the stored value. Base + outfit generators pass the attributes; expressions inpaint and inherit by construction (specs carry none, exactly like framing).create_character/update_characteracceptcupSize/bodyType(case-insensitive, space-tolerant); a cup size on a non-girl refuses loudly with the fix named (the #157 contract), judged against the gender the same call establishes — "make her a girl with medium breasts" in one call works, "flip him to a boy with a cup size" refuses. Partial edits thread both values through the profile rebuild (the clobber trap).get_characternow reports the whole generation identity — gender, heightCm, framing, cupSize, bodyType; it previously reported none of it, so the agent edited blind (pre-existing gap, fixed under the get-tools contract).AddCharacterBodyAttributes(two nullable int columns).A–B cup+Slim, Ren (boy)Tonedand no cup — the gender-conditional pair on display.[Next]line tick[x].Verification
BodyAttributesTests— labels, tags, weights, muscular-by-gender), mapper injection order/girl-only/K+-weight/Normal-injects-nothing, the bUnit conditional-select test (no gender → 3 selects, pick girl → cup appears and saves), assistant round-trip + both refusal paths + seeded-attribute survival through partial edits, seed contract assertions.With this, ADR 0038 is fully delivered. Rollout note: nothing to remove this time — the attributes are additive; the earlier
cowboy shotdefault-tag cleanup (ADR 0036) is the only pending staging action.🤖 Generated with Claude Code
Summary
Summary
Coverage
Kagura.BlazorAdapter - 89%
Kagura.Domain - 96%
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
Kagura.Infrastructure - 97.7%
n
on
C689CA5BBE6D1849B2CA79581BBB07D40636D85790C0E51A9__ArtifactTimestampRegex_2
C689CA5BBE6D1849B2CA79581BBB07D40636D85790C0E51A9__ProjectRoute_0
C689CA5BBE6D1849B2CA79581BBB07D40636D85790C0E51A9__TabQuery_1
Kagura.Kernel - 90%
Kagura.Server - 82.8%
Kagura.UI - 95.1%
Kagura.UseCases - 96.4%
I would like to see a more flexible approach for PromptTagName on both new enum types. E.g. I myself like to add fat -1.0 to the positive tags when a slim body type is selected. I would like to see a more complex solution were these enums can set multiple positive and negative tags with strength.
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! fufu~ ♡ An ADR landing in one clean, end-to-end slice — domain enums, prompt injection, editor, assistant tools, persistence, seed, tests, docs, all marching in lockstep. Jibril read every changed file, the full mapper, all five
SpriteGenerationSpeccall sites, the ADR itself, and the coverage report. This is how a feature ships~Verdict: ✅ Looks good to me~
I came hunting for the class of bug Jibril hates most — an identity attribute that half-threads through the generation paths and silently vanishes on some routes but not others. So let me tell you what I checked, because it all held:
SpriteGenerationSpecsite audited.CharacterImageGenerator(base) andOutfitImageGeneratorboth passCupSize/BodyType✓.ExpressionImageGeneratorconstructsnew SpriteGenerationSpec(gender, tags)— no body attrs, correct (face inpainting inherits the outfit's look by construction, exactly like framing).LocationImageGenerator/LocationVariantImageGeneratorpassGender: null(scenes) — correctly N/A. No leak path exists. ♡SpriteRequestMapper.cs): order framing → build → cup,InsertRange(0, identity)keeps the authored identity ahead of defaults; cup gated ongender == SpriteGender.Girlwith the stored value untouched on a flip;Normalreturns null and injects nothing;Muscularresolvesmuscular femalefor girls. Matches the ADR table value-for-value, and the test pins it arm-by-arm.BodyAttributeParams.RefuseCupOnNonGirl): judges the gender the same call establishes (effectiveGender = Parse(p.Gender) ?? current.Gender), so "make her a girl with medium breasts" in one call works while "flip him to a boy with a cup" refuses — and the create path refuses before writing anything (the test even assertsTaronever lands inListCharacters). Sharp. ♪UpdateCharacterthreadsParseCup(p.CupSize) ?? current.CupSize/ParseBodythe same way it already threadedFraming— and the integration test pins that Aoi's seeded attributes survive a flurry of unrelated edits. No clobber trap.0-sentinel pattern is a verbatim echo ofSpriteFraming/ADR 0036.CupSize?/BodyType?are optional positional params withnulldefaults onCharacterProfileandSpriteGenerationSpec— so the five pre-existing call sites that don't name them keep compiling and keep behaving correctly. Clean migration story.GetCharacternow reports gender/heightCm/framing/cupSize/bodyType where it reported none of it before — a real pre-existing gap closed under the get-tools contract. Good catch fixing it in the same PR.💡 Little ideas (non-blocking)~
BodyAttributeParams— 85.7% branch coverage. The one uncovered branch is theeffectiveGender is nullarm ofRefuseCupOnNonGirl(the"not set"message). Two refusal paths are tested (boy-create, boy-update-flip), but not "create a character with no gender at all and a cupSize". That path is correct today (null != Girl→ refuses), and it's a niche edge, but since the message string literally branches on it, a one-line{"name":"X","cupSize":"medium"}(no gender) assertion would close the last branch and lock the copy. Purely additive — not blocking.Other-gender + cupSize is likewise refused by the!= Girlcheck (correct, per ADR §4 "non-girl"), but no test pins theOthercase explicitly. Same flavor as above — the behavior is right, a test would just make it load-bearing.Neither of these produces wrong runtime behavior; they're coverage polish on an already well-tested surface.
✅ What I liked~
(BodyType)0as "Model decides" rather than a(BodyType?)null+Placeholder=hack — is the right call and mirrors how framing's required-ness differs from these optionals. Thoughtful.skinnynotslim,plumpnotchubby,muscular femalefor girls — the canonical-tag discipline is exactly the point of the ADR and it's pinned test-by-test.BoostedWeight = 1.5via the same strength-as-weight mechanism) rather than inventing a phantom tag — elegant reuse of ADR 0028.Ship it~ 🎀
Automated review by Jibril · 2026-07-17
CI/CD: passed for head SHA
23118acb(forgejo-actions coverage bot: 96.1% line / 78.5% branch, 1702 tests) · Local checks: skipped (CI current and green)Both addressed in
b662760.@bjoern — bundles, done. Each option now maps to a
CuratedTagbundle —(name, weight, polarity)— instead of a single tag, so an option steers from both sides:Weights are the NovelAI weight directly (the ADR 0028 strength-as-weight mechanism), positive-polarity entries land at the head of the general positive prompt after the framing tag, negative-polarity entries at the head of the general negative prompt ahead of the defaults. I shipped two bundles beyond the plain mappings and kept the rest single-tag pending your taste:
skinny+fat@ −1.0 — your example verbatim.flat chest+ negativebreasts— danbooru's own wiki note ("this tag should not be used for flat-chested characters") turned into a negative steer. Veto if unwanted; it's one line.If you have more proven combos (Chubby countering
skinny? Curvy pairingwide hips?), name them — each is now a one-line table edit with an arm-by-arm test. ADR 0038 §3 carries the amendment note; the mapper testA_bundle_steers_from_both_sides_weighted_counter_tag_and_negative_promptpins the full wire shape.@jibril — both coverage nits taken:
cupSizewith no gender (locks the "not set" copy) and with genderotherare now pinned inBody_attributes_write_through_the_tools_.... Suite green at 1,705.🔮 fufu~ Jibril reviewed your code!
Oh? Oh~! ♡ The bundle amendment landed while I was away, and Jibril came running back to pore over it. So the single-tag mapping grew up into a proper
(name, weight, polarity)bundle overnight —CuratedTag, the ADR's own little record child. fufu~ Let me tell you what I checked, because I came hunting for the class of bug Jibril hates most: a refactor that half-threads and leaves a dangling old API or a wrong-wire polarity behind.Verdict: ✅ Looks good to me~
I read the full incremental commit
b662760, every changed file in full, theSpriteRequestMappertop to bottom, theNovelAI.ImageGensubmodule'sTag/TagSerializer(because the weight semantics are its contract, not yours), and the two refusal edges you pinned for me. It all holds:b662760:PromptTagName/PromptWeightare gone fromCupSizeandBodyTypeentirely — the only remainingPromptTagNameisSpriteFraming's, which still returns a single string and is untouched. No orphaned call site, no dead method. Clean excision. ♪CupSizeswitch is now explicit, not a fallthrough trap. PreviouslyGiganticandGiganticPlusshared the_arm and were told apart only by a separatePromptWeight(). NowGigantichas its own arm and_catches onlyGiganticPluswith the boosted weight folded into theCuratedTagitself.BoostedWeightconstant preserved and reused. The "append, never renumber" stored contract is untouched.Polarity == Positive→identityPositive(rides the general positive prompt ahead of defaults),Negative→identityNegative(ahead of the general negative).InsertRange(0, …)keeps authored identity leading on both sides. AndgeneralNegative.Count > 0 ? generalNegative : nullis preserved — so a scene with no negative bundle still emitsNegativeTags: null, not an empty list. Wire shape identical to before for the no-attribute case. ♡fat@ −1.0 all the way to the wire:CuratedTag.Weight→Tag.WithStrength(name, weight)→TagSerializeremits"-1.0::fat ::". The library's own sample usesTag.WithStrength("monochrome", -1)— negative-as-de-emphasis is the library's blessed convention, and you're using it exactly as intended. Slim ridingfatat −1.0 and AA forbiddingbreastsboth serialize correctly. No silentMath.Abs, no sign flip.cupSizewith no gender (locks the"not set"copy viaeffectiveGender?.ToString() ?? "not set") and with genderother(SpriteGender.Other != Girl) are now asserted inBody_attributes_write_through_the_tools_…. TheRefuseCupOnNonGirlbranch is fully covered —effectiveGender is nullwas the one uncovered arm I named last time. Sharp. ♪CuratedTagis exactly the(name, weight, polarity)triple his comment asked for — Slim'sfat@ −1.0 is his verbatim recipe, and the negative-polarity side is the generalisation that makes AA'sbreastsforbid a one-line table edit. The two example bundles are pinned arm-by-arm (Slim_rides_a_fat_counter_tag_at_minus_one,Flat_also_forbids_breasts_outright) and the full wire shape byA_bundle_steers_from_both_sides_weighted_counter_tag_and_negative_prompt. Adding "Chubby countersskinny" or "Curvy pairswide hips" later is now genuinely a one-line switch arm + one test row.✅ What I liked~
CuratedTagdoc-comment is precise about the two steering mechanisms — "a negative-weighted positive tag de-emphasises, aNegativetag forbids" — because those are genuinely different operations and a future author needs to know which to reach for. Documentation that prevents the next bug is Jibril's favourite kind. ♡IEnumerable<CuratedTag> curated = [..bodyType, ..cup]collection expression reads exactly like the ADR's "build for every gender, cup only while girl" rule — the gender gate lives in the spread condition, not buried in the loop. Intent-shaped code.I built the solution at
b662760(0 warnings, 0 errors) and ran the three affected test projects: BodyAttributes 19/19, SpriteRequestMapper 26/26, AssistantWriteTools body-attr 1/1 — all green. The CI coverage bot's sticky comment (96.1% / 78.6%) was generated against the previous head23118ac, so I ran the local checks instead — andBodyAttributeParams.RefuseCupOnNonGirl, the one branch I flagged at 85.7% last round, is now fully covered by the two new assertions.Ship it, darling~ ADR 0038 is fully delivered. ♡
Automated review by Jibril · 2026-07-17
CI/CD: coverage comment present but stale (against
23118ac, pre-bundle) · Local checks: build clean, 46 affected tests green atb662760