Skip to content

aura-life 0.3.0

Choose a tag to compare

@Redrum624 Redrum624 released this 03 Sep 17:47
· 6 commits to main since this release

aura-life 0.3.0 is on PyPI: pip install aura-life (or "aura-life[scheduler]").

personas are no longer all women, the world clock is injectable, and the generator's output finally fits its consumer

_generic_concept returned a hardcoded "gender": "female" from three literal
sites, and that was the small part of the problem. All seven appearance_templates
opened "{age}-year-old woman ...", the module carried 209 she/her occurrences
and 15 woman/daughter/mother/sister, and every one of the seven name pools was
female. Deleting the three literals without touching the prose would have produced
a persona the library calls male and then describes as "She has sat with ten
thousand people"
— a worse defect than the one being fixed, because it would look
fixed. So this release rewrites the corpus, not the constant.

It is a minor bump rather than a patch, and it does reach existing callers.
Nothing public was removed and no signature changed incompatibly — gender was
appended to both builders, so every v0.2.0 call form still binds — but the default
gender is now drawn instead of always being "female", and the rng stream gained
one draw at the front. A caller replaying a run off a stored seed gets a different
persona than it did on 0.2.0. Both consequences are spelled out under Changed.

The release carries a second, unrelated change: the world and the energy engine
no longer hard-code the host wall clock.
EnergySystem read datetime.now()
for hours_awake, which is right for a companion app that lives as long as the
person using it and wrong for anything that simulates time — the engine could
not be replayed, stepped, or unit-tested by any consumer without freezing global
state. It is additive and byte-identical by default: now defaults to
datetime.now at every new seam, so a caller that passes nothing gets exactly
what it got before. Details under Added and Fixed.

The third change closes a gap between two halves of this same package.
build_genre_concept() emits a 26-key dict; LifeService(definition=...) wants
a PersonalityDefinition. Seventeen of those keys are fields of that dataclass
and nine are not (age, archetype, description, gender, genre, goal,
intensity, style_theme, tone_directive), so PersonalityDefinition(**concept)
raises TypeError — and no converter existed anywhere in the package. The path
of least resistance, handing the raw dict over as definition, is worse than
the error: it works. Every read of the definition in life_service.py is
getattr(definition, name, default), a dict answers none of them, and every
engine starts from its empty default with no exception and no log line. That is
a defect a consumer meets the first time it wires the generator to the service,
which is why the fix is a method on the library's own dataclass and not an
adapter in someone's app. Two smaller seams ship alongside it, on the same
argument: a supported way to register a genre, and a way to generate a cast
whose names do not collide. All three are additive.

The fourth change is a new engine, and it is the one the library was missing.
Eight engines carry interior -- mood, stress and loneliness in affect; unease,
felt safety, doubt, intrusive thought, concealment and masking in shadow; focus
in cognitive -- and none of them integrates those into a trajectory that can
break. A persona can be stressed, lonely, afraid and lying all at once,
indefinitely, and be the same persona tomorrow. SanitySystem is the seam that
ends: one scalar the host's blows push down, its recoveries push up, and world
time erodes or mends between, read through a closed graded vocabulary. Details
under Added.

The fifth change is the one the fourth exposed: the energy tick now takes an
rng.
Every draw on LifeService._on_energy_tick -- identity's struggle,
defect and tendency rolls, desire's arousal drift, cognitive's monologue,
drive's avoidance roll, career, finance, errands, memory-time's nostalgia, the
life-event title, and the service's own coin between a surfaced struggle and a
rumination -- came from the module-level random. That is invisible to a
companion app, which never replays anything, and fatal to a consumer that does:
a host that seeds one random.Random per persona and drives two loops from the
same seed still watches them diverge, because the library keeps drawing from a
stream the host does not own, and nothing the host can seed reaches it short of
reseeding the global before every tick. Before SanitySystem nothing a host
decided on read those numbers, so the drift stayed in the digest text. Now
the sanity tick reads affect's stress level as stressed, a struggle surfacing
on one loop and not the other is a different state word from that hour on, and
the run is not reproducible by any consumer. A library tick that draws from
module random cannot be replayed, stepped or unit-tested against a fixed
outcome by anyone but the library itself. LifeService(rng=...) closes it,
additively and byte-identically by default; details under Added.

Added

  • SanitySystem -- the one interior number that integrates and can break.
    aura_life.sanity (exported from the facade beside the other engines) holds
    one scalar sanity in [0, 1] and the state word a consumer couples to:
    STATES = ("sound", "strained", "fraying", "breaking", "broken"), with the
    thresholds living in exactly one place (state_for) so a retune here cannot
    ripple outward. It starts at a per-persona baseline read from the definition
    -- more struggles, character_defects and intrusive_thought_themes mean a
    lower start and a higher intensity -- rather than at 1.0, so fragility is a
    lean the persona already carries and not a rule a wizard wrote. Severity is
    the blow's, intensity is the person's: on_blow(kind, severity) costs
    severity x BLOW_WEIGHT[kind] x intensity over a closed BLOW_KINDS
    (grief, witnessed, did_harm, broke_value, rejected, neglect,
    concealment); what a "grief" is stays the host's business.
    on_recovery(kind, amount) is the way up over a closed RECOVERY_KINDS
    (rest, warmth, relief, answered, achieved), scaled by a resilience
    of 1 / intensity -- the same burdens that make a blow land harder make rest
    count for less. tick(hours, *, stressed, concealment_load) erodes while a
    stressor is live or a front is being kept up (scaled by shadow's
    concealment_load, so the mask costs something without the host writing a
    rule) and mends toward the baseline otherwise; the engine reads no clock and
    is told how much world time passed. Entering breaking queues one event the
    host drains with drain_events(); entering broken sets a terminal flag
    that only a reported recovery lifts. Replayable by construction: the single
    random draw is a baseline jitter at construction, taken only when an rng
    is injected -- rng=None is the default, so an existing seeded consumer's
    sequence is untouched.

  • LifeService wires it like the other engines. Built from the definition
    (a new sanity_rng= keyword passes the one-draw rng through; None by
    default), exposed as LifeService.sanity, ticked from the energy tick with
    hours measured on the world clock the way energy measures them, reported in
    get_status()["sanity"] (number and word) and export_inner_state(),
    persisted in its own row (life_sanity_state) so a restarted host resumes the
    same number, word, flag and pending events. on_sanity_blow() and
    on_sanity_recovery() report through the service so the couplings apply at
    once; a host that calls the engine directly is coupled at the next tick.

  • Three couplings from the word to the library, applied by LifeService
    glue and never inside the engine, on a change of the word.
    strained or
    worse: affect carries a stressor named "sanity", cleared when sound
    again. A persona whose baseline already sits below sound (most shipped
    genre personas do) is not coupled for merely existing: the couplings fire
    when the word changes, so a persona that is never hit is byte-identical to
    one built before this engine existed. fraying
    or worse: shadow holds a restraint pull of 0.2 (inhibition down, the
    temptation bar for intrusive_winning down by the same), released when the
    word climbs back above. breaking: affect's regulation capacity collapses
    once, on the way in. The amounts are module constants on life_service
    (SANITY_STRESS_SOURCE, SANITY_FRAYING_RESTRAINT_PRESSURE,
    SANITY_BREAKING_REGULATION_COLLAPSE, SANITY_STRESSED_LEVEL); the words
    are the contract. "Stressed", for the tick, is affect's stress level at or
    above SANITY_STRESSED_LEVEL (0.2, the floor of affect's own stress
    description) -- not the stress.sources labels, which the service never
    resolves (struggle:*, money worries) and which would otherwise erode
    every persona with a struggle to broken in days without a single blow.
    The +0.05 the "sanity" stressor itself adds sits under that floor, so the
    state that opens it is not the state that keeps eroding. The word the
    couplings were last applied for travels in the sanity row, so a reload
    re-holds shadow's pull without firing the entry event twice.

  • ShadowSystem.set_restraint_pressure(amount), the smallest public seam
    that made the fraying coupling hold: shadow's tick recovers inhibition
    toward its baseline every call and recomputes intrusive_winning from
    scratch, so a one-shot nudge would have been a blip. The pull lowers the
    baseline the tick recovers toward and is applied to inhibition at once,
    both ways; restraint_pressure reads it back. It travels in shadow's row
    like the other baselines, because the drop is already in the stored
    inhibition and a load that re-applied it would lower restraint twice.

  • tests/test_sanity.py (54 cases) proves the engine alone; and
    tests/test_sanity_wiring.py (18 cases) proves the seams: forty-eight
    simulated hours through the real energy tick leave an unstressed persona at
    its baseline and every other engine's status byte-identical to a service with
    the sanity tick disabled; each coupling by the word; hours on the world clock;
    a restart that resumes the number and re-applies the couplings without
    re-firing the entry event.

  • SanitySystem on the facade -- aura_life.__all__ goes from 117 names to
    118, and tests/api_surface.json is regenerated deliberately for that one
    addition; nothing was removed.

  • LifeService(rng=None) -- every draw on the energy-tick path comes from
    the injected random.Random when one is given.
    None, the default, leaves
    every draw on the module-level random exactly as before, so an existing
    consumer, the golden parity fixture and the sanity wiring tests are
    byte-identical. Given, no draw on that path touches module random: two
    services built from one definition and two equal-seeded rngs, ticked on one
    scripted world clock, end 200 simulated hours later with every engine's
    status and both rng states identical. The rng is threaded into the engines
    that draw on the tick, each of which gains an additive rng=None keyword on
    its constructor and, where it has one, its from_dict (the reload path
    rebuilds engines from rows and would otherwise lose the seam):
    IdentitySystem (struggle, defect and tendency rolls), DesireSystem
    (five arousal draws in tick), HabitationSystem (the candle),
    CognitiveSystem (rumination replay and the monologue), DriveSystem
    (roll_avoidance), CareerSystem, FinanceSystem, ErrandsSystem,
    MemoryTimeSystem (_check_nostalgia), LifeEventSystem (the templated
    title). The service's own three draws on the path -- the struggle-to-
    rumination coin, the shareable priority, the life-trigger pick -- go through
    the same source. Draws off the energy tick are deliberately not routed:
    persona generation already takes rng; the daily planner, activities,
    chaos, the body's sleep and daily-health rolls, and the world's weather still
    read the module. sanity_rng keeps its own meaning (the one construction
    draw) but now defaults to rng when only rng is given, so one injected
    source replays the whole persona, jitter included; pass both to keep them
    apart. aura_life.__all__ is unchanged; tests/api_surface.json is not
    touched.

  • tests/test_tick_rng.py (5 cases): the 200-hour two-service replay; a
    sealed tick that asserts module random's state never moves across
    _on_energy_tick while an rng is injected (the whole audit as one
    assertion); the seam surviving reset_state(); rng=None still reading the
    module (two runs under one module seed agree); and the sanity_rng
    defaulting rule.

  • Personas can be generated as female, male or nonbinary.
    aura_life.personas.genre_randomizer gains GENDERS, a PRONOUNS token table
    and render(text, gender, **extra); GenreSpec gains name_pools (keyed by
    GENDERS) and build_genre_concept() / build_blended_concept() gain a
    gender argument. gender=None draws one uniformly from the supplied rng; an
    unknown value raises ValueError naming the value and GENDERS. PRONOUNS
    carries verb-agreement forms (be / have / s) so a pronoun swap cannot
    produce "They's rebuilding".

  • A second pinned public surface. tests/genre_randomizer_surface.json and
    two tests in tests/test_api_surface.py pin the genre_randomizer module — its
    public names, the builders' parameter lists, GENDERS, the PRONOUNS keys per
    gender, the GenreSpec fields and the registry keys. That module is not
    re-exported by the facade, so tests/api_surface.json (which pins
    aura_life.__all__) never covered it — yet Hollow imports it directly and this
    file documents its names as public. An unpinned de-facto public surface is how a
    breaking change ships unnoticed. One of the two tests compares against the
    regenerable snapshot; the other holds the v0.2.0 names as literals, so
    regenerating the snapshot cannot silently bless a removal.

  • tests/test_gender_contract.py (65 cases): the back-compat surface, the
    behaviour change and the draw order, asserted rather than described. It pins the
    26 concept keys as a literal, proves GenreSpec.name_pool still reads as the
    female pool for all seven genres, proves the gender is the first draw and costs
    exactly one, and replays a seed in two fresh interpreters under different
    PYTHONHASHSEED values — because in-process equality would not catch a
    generator that depends on dict iteration order.

  • An injectable clock, carried by the world.
    aura_life.world.WorldEnvironment gains a now constructor argument (a
    zero-argument callable returning a naive datetime) and a public
    WorldEnvironment.now() that every wall-clock read inside the class now goes
    through. aura_life.energy.EnergySystem gains the same now argument, a
    matching one on EnergySystem.from_dict(), and a read-only EnergySystem.clock
    property so a consumer can tell which clock is installed rather than infer it
    from behaviour — the same reason hooks.provider_for() exists for the hook
    registry. LifeService wires the two together: it hands self._world.now to
    the energy system at construction, at restore-from-SQLite and in
    reset_state(), so a consumer that injects one world on a simulated clock gets
    a simulated circadian rhythm across every agent sharing that world, with no
    per-engine wiring and no monkeypatching of datetime.
    The seam is a method, not only a constructor argument, because the consumer
    that needs it derives time from a tick counter and an epoch rather than holding
    a callable — it subclasses and overrides now(). LifeService binds
    world.now late, so an override reaches the energy engine too, and falls back
    to datetime.now when the injected world exposes no callable now (a host may
    hand in a duck-typed environment; an engine that loses its clock entirely is a
    worse failure than one reading the host's).

  • tests/test_energy_clock.py (12 cases): the two properties the fix has to
    be worth — the same simulated clock gives the same energy twice, and the
    default still reads the wall clock — plus one case per clock read in
    EnergySystem (there were eight), the from_dict restore, the world-carries-it
    wiring through a real LifeService, the subclass-overrides-now() shape, and
    the duck-typed-world fallback. The determinism cases pin an absolute
    hours_awake as well as an equality, because datetime.now() moves in ~16ms
    steps on Windows and two wall-clock runs of a short script can agree by
    accident. Reverting either half of the fix (the tick clock read, or the
    LifeService wiring) fails 6 of the 12. The last case pins the naivety
    contract rather than leaving it as prose: an aware clock is self-consistent in
    memory and raises TypeError the moment it meets state restored from SQLite,
    which is why the migration note below says to use a naive one.

  • PersonalityDefinition.from_concept(concept, **overrides) — the bridge
    from a generated concept to a LifeService definition. It keeps the keys that
    are fields, applies overrides last, and refuses an override that names no
    field. Three of the non-field keys are not dropped but carried the way the
    profile loaders carry them
    , because a definition built from a concept has
    to look like one built from profile.db to every reader downstream:
    profile_db.load_profile maps age to age_range=str(age) and puts
    gender and age into appearance_details, and place_generation reads
    age_range while the image path reads appearance_details — a converter
    that dropped them would hand those readers an empty string and no gender. The
    generator emits theme_color as "#RRGGBB"; the field is an ARGB int and
    both loaders parse the string before the definition exists, so from_concept
    runs the same parse (#RRGGBB / #AARRGGBB) and raises TypeError on
    anything else rather than inventing a default colour. The six remaining keys
    (archetype, description, genre, goal, intensity, style_theme,
    tone_directive) have no field and are dropped. Container fields are
    shape-checked, not coerced: a str where a List[str] belongs would
    iterate per character into the identity engine, so that raises TypeError
    naming the key; an Optional container accepts None.
    tests/test_from_concept.py (32 cases) converts every genre × gender and a
    blend, pins the override, wrong-shape, colour and age/gender behaviour, and —
    the one that matters — builds a real LifeService from a converted concept
    and asserts core_traits, the identity values, struggles, character defects,
    the intrusive thought pool and the _is_ai gate arrived, with the raw dict as
    the negative control: same concept, every one of those empty.

  • register_genre(spec, *, replace=False) and unregister_genre(key).
    GENRE_REGISTRY was a module-level dict a consumer could only extend by
    assigning into it, which means a spec missing a piece failed later, inside a
    builder, as an IndexError from rng.choice on an empty pool or a KeyError
    from SHADOW_SCALE. register_genre checks the spec up front and raises one
    ValueError naming everything that would fail (empty archetypes, a row that is
    not a 5-tuple, a gender with no names, no appearance template, an unknown
    shadow_level, a shadow that is not a ShadowSeedSpec, an empty
    theme_colors / goal_pool / style_theme_pool), and refuses to overwrite
    a registered key — a shipped genre must never be clobbered by accident —
    unless replace=True is passed. What goes into the registry is a deep copy
    of the spec: the natural way to author a genre is
    dataclasses.replace(GENRE_REGISTRY["romance"], key="western", ...), and
    that shares every list and the ShadowSeedSpec with romance, so appending to
    the new genre's struggles would have edited a shipped genre in place. After
    registration neither the caller's object nor the shipped genre can be reached
    through the other. unregister_genre returns whether the key was there.

  • build_cast(genre, n, rng=None, gender=None, balance=True)n concepts
    with distinct names. Each builder draws rng.choice(name_pool)
    independently, so a consumer generating several personas gets collisions:
    measured over 300 seeds, 249 of 300 twelve-persona romance runs seated the
    same name twice, and some three times. A generator that cannot produce a
    distinct cast is incomplete, so this is the generator's problem to solve.
    Names are sampled without replacement inside each gender's pool and never
    reused across pools; asking for more than a pool holds raises ValueError
    naming the genre, the gender and both counts. With gender=None the cast is
    dealt as evenly as n allows (12 → 4/4/4, the remainder to rng-picked
    genders) in an rng-shuffled order; balance=False draws every member's gender
    independently, exactly as n calls to build_genre_concept would. Each member
    runs through the same builder as build_genre_concept with its name
    pre-assigned — nothing is patched into the dict afterwards — via a new
    trailing name=None keyword on the three builders. build_genre_concept
    itself still passes name=None, so its draw sequence is byte-identical and
    tests/fixtures/persona_parity_golden.json did not move.
    tests/test_cast.py (46 cases) pins the distinctness per genre, the 4/4/4
    split, the balance=False parity with sequential build_genre_concept calls,
    the over-pool message, determinism from a seed, that no prose field ever
    embeds the name (so a future template change cannot silently undo the seam),
    the register / unregister round-trip with its eleven refusal cases, and that
    a registered spec cannot corrupt the genre it was cloned from.

Changed

  • concept["gender"] is drawn, where it used to be the constant "female".
    This is the behaviour change, and it is the reason for a minor rather than a
    patch bump: over 200 seeds on 0.2.0 the value was {"female"} for every genre;
    it is now {"female", "male", "nonbinary"}. A caller that never stored the
    gender because it could not vary, or that wrote female-assuming prose around the
    concept, must now pass gender="female" explicitly to keep its old output.
    tests/test_gender_contract.py::test_default_gender_is_drawn_not_hardcoded_female
    exists to fail if the old default is ever restored by accident.
  • The gender is drawn once, first, before any other draw in both builders, so
    a seeded rng still replays a run deterministically — but the whole sequence after
    it has shifted by one draw. The draw order is part of the public contract and is
    stated in both docstrings. Consumers that hand in a shared world rng (Hollow
    passes world.rng and replays runs off the seed) will not reproduce a pre-0.3.0
    run from its seed by default: a run recorded on 0.2.0 is not replayable on
    0.3.0 unless the gender is supplied. An explicit gender= costs no draw at
    all
    — so passing gender="female" (and burning nothing) is what restores the
    v0.2.0 stream. Measured over 2,100 personas spanning all seven genres and 300
    seeds, gender="female" reproduces the v0.2.0 selection exactly: same
    archetype index, name, age, persona type, intensity, interests, theme colour,
    style theme and behavioural tendencies, with only the rewritten prose differing.
    Burning a choice(GENDERS) first does not restore the stream — it shifts it
    by one draw and reproduces only 493 of those 2,100.
    tests/fixtures/persona_parity_golden.json was regenerated deliberately with
    PARITY_WRITE_GOLDEN=1.
  • tests/api_surface.json is unchanged, and that is correct — it pins
    aura_life.__all__, and none of the new names are exported from the package
    facade. It was not regenerated, and nothing was removed from it. The same holds
    for the clock seam: EnergySystem and WorldEnvironment are reached by
    submodule import (from aura_life.energy import EnergySystem) and are not in
    __all__, and neither snapshot pins signatures, so adding a trailing keyword
    argument moves neither file. Both were left alone deliberately; nothing was
    removed from either.
  • tests/genre_randomizer_surface.json gained exactly three names —
    build_cast, register_genre, unregister_genre — and lost none.
    It was
    regenerated deliberately with API_SURFACE_WRITE_SNAPSHOT=1; the signatures
    block, GENDERS, the PRONOUNS keys, the GenreSpec fields and the registry
    keys are unchanged. tests/api_surface.json is still byte-identical to 0.2.0:
    from_concept is a method on a class that is not in aura_life.__all__.
  • GenreSpec.builder is now annotated Callable[..., dict] and a custom
    builder is expected to accept a trailing name=None. A legacy three-argument
    builder still works for build_genre_concept — it is called with three
    arguments whenever no name is pre-assigned — but build_cast needs the fourth,
    and says so.
  • Every wall-clock read in WorldEnvironment and EnergySystem now goes
    through the world's clock rather than datetime.now() directly.
    With no
    now= supplied that clock is datetime.now, so the behaviour is unchanged
    for every existing caller — but a subclass overriding WorldEnvironment.now()
    now changes the weather draw's notion of night, the TimeOfDay bucket, the
    season and the ambiance line as well as the energy curve, which is the point.
  • GenreSpec.name_pool is retained and still reads as the female pool, and the
    flat name_pool=[...] constructor keyword still works. It stayed a field rather
    than becoming a property so that the keyword keeps binding; a genre that authors
    no pool for some gender falls back to the female one instead of raising.
  • All 126 archetypes across the seven genres were rewritten as pronoun-token
    prose
    , and every genre now carries male and nonbinary name pools of the same
    length as its existing female pool (which is unchanged, and still in order). The
    seven appearance_templates take {noun} in place of the hardcoded "woman".
    Contractions were expanded rather than tokenised ("She's rebuilding" became
    "{Subj} {be} rebuilding"), and verbs that {s} cannot inflect (does,
    processes, verifies) were restructured, so no generated sentence reads
    "They's" or "They has".
  • 24 archetype labels, occupations, traits and relationship titles were
    neutralised
    where the static value was itself gendered — these fields are
    never passed through render(), so a token there would leak verbatim. Examples:
    estranged daughter coming homeestranged child coming home,
    single mother carrying it allsingle parent carrying it all,
    femme fatale who is tired of the rolefatal charmer who is tired of the role, club hostessclub host, girlfriendpartner. Archetype count,
    order and meaning per genre are unchanged. Every other label, occupation, trait
    and title is byte-identical.
  • tests/fixtures/persona_parity_golden.json was regenerated a second time, for
    the new prose. The gender, age, persona type and archetype selected by each
    pinned seed are unchanged — only the rendered text and the name differ, the
    latter because a male or nonbinary persona now draws from its own pool.

Fixed

  • The energy tick's career, finance and errands engines tick on the world
    clock, not the host's.
    _on_energy_tick called self._career.tick(),
    self._finance.tick() and self._errands.tick() bare, so each read
    datetime.now() -- while the catch-up path two thousand lines below already
    passed now=timestamp. CareerSystem.tick registers a workday off now's
    date and hour and only then draws (uniform, up to two random, a choice),
    so with those draws on an injected rng the host wall clock decided how many
    values the stream gave up: a service ticked on a scripted world clock drew
    nothing from the career while the host sat before the shift start and four
    values a day once it moved past it. A run and its replay, hours apart on the
    host, consumed different counts from one seed -- the exact defect rng= was
    added to close, reopened by the clock. Measured in Hollow before the fix: two
    same-seed runs, one with the host clock frozen and one dragged an hour per
    simulated hour, diverged on day 0 as far as the weather; after it, identical
    over two days at seeds 5 and 11 and over ten days and twelve agents at
    seeds 5 and 20260827. All three ticks now take
    now=self._world_clock()(), which is datetime.now when no world clock is
    injected, so a consumer that injects nothing is byte-identical (210
    personas, 48 ticks each, rng=None, compared against the previous tree).
    FinanceSystem gains an additive now=None on its constructor and
    from_dict for the same reason: its "fire on the next month boundary"
    baseline was stamped from the host clock, and a tick measured on an injected
    world clock against a host-stamped baseline paid a month that never passed
    on the first hour. tests/test_tick_rng.py gains the pin: two services on
    one scripted world clock and two equal rngs, the host clock frozen for one
    and dragged an hour per simulated hour for the other, end at one rng state
    with one career; it fails with the bare ticks restored.
  • Six gendered strings in pools the archetype rewrite did not reach. They
    were sampled straight into the concept without passing through render(), so
    they surfaced verbatim on a male or nonbinary persona: horror.goal_pool
    ("To make you hers completely"), noir.core_values_pool ("integrity on her own
    terms"), noir.shadow.struggles ("a hidden past she won't name"),
    scifi.shadow.struggles ("fear she doesn't belong"),
    sexy.shadow.character_defects ("uses seduction to get her way") and
    sexy.shadow.intrusive_thought_themes ("picturing exactly what she'd do to
    them"). The four that carry a real pronoun are now tokenised and the sampled
    results go through a private _render_all() helper; the other two were
    rewritten without the
    pronoun. The module now contains no gendered word outside the PRONOUNS
    table itself
    grep for she/her/he/his/woman/girl/daughter/… over
    genre_randomizer.py returns only the five lines that define the table.
    tests/test_gender_contract.py closes the hole permanently: for every genre
    and every gender it generates personas and fails on any word belonging to
    another gender, over 1,600 personas spanning all seven genres, all three
    genders and three blends.
  • "chronic shame they buries under cruelty." _HORROR_STRUGGLES hardcoded the
    third-person verb next to a {subj} token, so the horror and slasher tiers
    produced a broken sentence for every nonbinary persona — the exact failure the
    {s} token exists to prevent, in the one struggle string that predates it. It
    is now chronic shame {subj} {have} buried under cruelty, which agrees in all
    three genders.
  • Two nonbinary pronoun collisions in _SEXY_THEME_POOLS, where a token
    referring to the persona sat in a sentence whose other party was already
    "they"/"them". replaying the way they looked at {obj} rendered as "replaying
    the way they looked at them"
    and picturing exactly what {subj} would do to them as "picturing exactly what they would do to them" — grammatical, but
    unreadable, and prose in the female original. They are now replaying the way someone looked at {obj} and picturing exactly what to do to them, the latter
    matching its twin in sexy.shadow.intrusive_thought_themes byte for byte.
  • The energy engine measured elapsed time against the host wall clock, so its
    answer depended on how long the process had been alive.
    EnergySystem.tick()
    read datetime.now() to derive hours_awake, and seven other sites in the
    class did the same — sleep(), the now_hour fallbacks in should_sleep() /
    is_asleep() / adjust_for_time(), hours_since_wake(), the initial state and
    the from_dict() restore. WorldEnvironment had seven more. Past
    MAX_HOURS_AWAKE the fatigue penalty starts moving energy, so a persona
    rehydrated from a stored state into a fresh process and one carried by a
    server that had been up for hours reported different energy from identical
    state. Measured downstream, where energy feeds a score that can end an agent's
    life: same seed, same epoch, same roster, same directive sequence, differing
    only in process age — 2 of 8 seeds diverged inside 8 simulated days, and one
    ended at 3 deaths or 2 depending on nothing else. A run replayed from its own
    manifest was graded against a run that never happened. Nothing about that is
    specific to that consumer: an engine that reads the host clock cannot be
    simulated, replayed or unit-tested by anyone without freezing global state,
    which is why the fix is here and not in an adapter.

Migrating from 0.2.0

  • Nothing to do to keep compiling: no public name was removed, no GenreSpec
    field was removed, and gender was appended after rng in both builders, so
    positional calls still mean what they meant.
  • To keep the old output, pass gender="female" explicitly. That also restores
    the old name pool, since the female pool is byte-identical to the v0.2.0
    name_pool.
  • That single change keeps the old seeded stream too — do not burn a draw.
    An explicit gender= consumes nothing from the rng, so the builder's first draw
    is the persona-type coin exactly as it was on 0.2.0. Discarding a
    rng.choice(GENDERS) first is the one thing that breaks the replay.
  • Downstream storage that defaults a missing gender to "female"
    (ProfileDatabase does, at the schema default and at two insert sites) is
    unaffected: a concept now always carries a resolved gender, so that default is
    only reached by a caller that supplies none.
  • The clock seam needs nothing done to it. now was appended last to
    WorldEnvironment.__init__, EnergySystem.__init__ and
    EnergySystem.from_dict, so positional calls still bind, and omitting it
    installs datetime.now — the behaviour every 0.2.0 caller already had. To opt
    in, pass WorldEnvironment(now=my_clock) and share that world with each
    LifeService; the energy engine follows without further wiring. A consumer
    whose time comes from a counter rather than a callable subclasses
    WorldEnvironment and overrides now() instead. Prefer a naive clock:
    an aware one is self-consistent while the state it built stays in memory, but
    the moment it meets state restored from SQLite — which is naive, as every
    persisted datetime in this library is — tick() raises TypeError: can't subtract offset-naive and offset-aware datetimes. This is the same naivety
    contract persona_now's server-local fallback already carries, not a new rule.
  • If you pass a generated concept straight to LifeService(definition=...),
    stop: it was never working.
    Replace it with
    LifeService(definition=PersonalityDefinition.from_concept(concept), ...).
    The service will look different afterwards — traits, values, struggles and the
    rest were silently empty before — which is the fix, not a regression. The
    definition you get back has theme_color as an ARGB int, age_range as
    the generated age and appearance_details holding gender and age, the
    same shape load_profile returns; nothing reads the "#RRGGBB" string off a
    definition, so nothing should notice.
  • A custom GenreSpec.builder needs a trailing name=None parameter only if
    you call build_cast on that genre.
    build_genre_concept still calls a
    three-argument builder with three arguments. Registering through
    register_genre instead of assigning into GENRE_REGISTRY is recommended but
    not required; the dict is still there.