aura-life 0.3.0
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 scalarsanityin [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
-- morestruggles,character_defectsandintrusive_thought_themesmean 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 intensityover a closedBLOW_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 closedRECOVERY_KINDS
(rest,warmth,relief,answered,achieved), scaled by a resilience
of1 / 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. Enteringbreakingqueues one event the
host drains withdrain_events(); enteringbrokensets 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 anrng
is injected --rng=Noneis the default, so an existing seeded consumer's
sequence is untouched. -
LifeServicewires it like the other engines. Built from the definition
(a newsanity_rng=keyword passes the one-draw rng through;Noneby
default), exposed asLifeService.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) andexport_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.strainedor
worse: affect carries a stressor named"sanity", cleared whensound
again. A persona whose baseline already sits belowsound(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 of0.2(inhibition down, the
temptation bar forintrusive_winningdown 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 onlife_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
aboveSANITY_STRESSED_LEVEL(0.2, the floor of affect's own stress
description) -- not thestress.sourceslabels, which the service never
resolves (struggle:*,money worries) and which would otherwise erode
every persona with a struggle tobrokenin 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 recoversinhibition
toward its baseline every call and recomputesintrusive_winningfrom
scratch, so a one-shot nudge would have been a blip. The pull lowers the
baseline the tick recovers toward and is applied toinhibitionat once,
both ways;restraint_pressurereads it back. It travels in shadow's row
like the other baselines, because the drop is already in the stored
inhibitionand 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. -
SanitySystemon the facade --aura_life.__all__goes from 117 names to
118, andtests/api_surface.jsonis regenerated deliberately for that one
addition; nothing was removed. -
LifeService(rng=None)-- every draw on the energy-tick path comes from
the injectedrandom.Randomwhen one is given.None, the default, leaves
every draw on the module-levelrandomexactly 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 modulerandom: 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 additiverng=Nonekeyword on
its constructor and, where it has one, itsfrom_dict(the reload path
rebuilds engines from rows and would otherwise lose the seam):
IdentitySystem(struggle, defect and tendency rolls),DesireSystem
(five arousal draws intick),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 takesrng; the daily planner, activities,
chaos, the body's sleep and daily-health rolls, and the world's weather still
read the module.sanity_rngkeeps its own meaning (the one construction
draw) but now defaults torngwhen onlyrngis 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.jsonis not
touched. -
tests/test_tick_rng.py(5 cases): the 200-hour two-service replay; a
sealed tick that asserts modulerandom's state never moves across
_on_energy_tickwhile an rng is injected (the whole audit as one
assertion); the seam survivingreset_state();rng=Nonestill reading the
module (two runs under one module seed agree); and thesanity_rng
defaulting rule. -
Personas can be generated as female, male or nonbinary.
aura_life.personas.genre_randomizergainsGENDERS, aPRONOUNStoken table
andrender(text, gender, **extra);GenreSpecgainsname_pools(keyed by
GENDERS) andbuild_genre_concept()/build_blended_concept()gain a
genderargument.gender=Nonedraws one uniformly from the supplied rng; an
unknown value raisesValueErrornaming the value andGENDERS.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.jsonand
two tests intests/test_api_surface.pypin the genre_randomizer module — its
public names, the builders' parameter lists,GENDERS, thePRONOUNSkeys per
gender, theGenreSpecfields and the registry keys. That module is not
re-exported by the facade, sotests/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, provesGenreSpec.name_poolstill 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
PYTHONHASHSEEDvalues — 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.WorldEnvironmentgains anowconstructor argument (a
zero-argument callable returning a naivedatetime) and a public
WorldEnvironment.now()that every wall-clock read inside the class now goes
through.aura_life.energy.EnergySystemgains the samenowargument, a
matching one onEnergySystem.from_dict(), and a read-onlyEnergySystem.clock
property so a consumer can tell which clock is installed rather than infer it
from behaviour — the same reasonhooks.provider_for()exists for the hook
registry.LifeServicewires the two together: it handsself._world.nowto
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 ofdatetime.
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 overridesnow().LifeServicebinds
world.nowlate, so an override reaches the energy engine too, and falls back
todatetime.nowwhen the injected world exposes no callablenow(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), thefrom_dictrestore, the world-carries-it
wiring through a realLifeService, the subclass-overrides-now()shape, and
the duck-typed-world fallback. The determinism cases pin an absolute
hours_awakeas well as an equality, becausedatetime.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 (thetickclock read, or the
LifeServicewiring) 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 raisesTypeErrorthe 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 aLifeServicedefinition. It keeps the keys that
are fields, appliesoverrideslast, 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 fromprofile.dbto every reader downstream:
profile_db.load_profilemapsagetoage_range=str(age)and puts
genderandageintoappearance_details, andplace_generationreads
age_rangewhile the image path readsappearance_details— a converter
that dropped them would hand those readers an empty string and no gender. The
generator emitstheme_coloras"#RRGGBB"; the field is an ARGBintand
both loaders parse the string before the definition exists, sofrom_concept
runs the same parse (#RRGGBB/#AARRGGBB) and raisesTypeErroron
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: astrwhere aList[str]belongs would
iterate per character into the identity engine, so that raisesTypeError
naming the key; anOptionalcontainer acceptsNone.
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 realLifeServicefrom a converted concept
and assertscore_traits, the identity values, struggles, character defects,
the intrusive thought pool and the_is_aigate arrived, with the raw dict as
the negative control: same concept, every one of those empty. -
register_genre(spec, *, replace=False)andunregister_genre(key).
GENRE_REGISTRYwas 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 anIndexErrorfromrng.choiceon an empty pool or aKeyError
fromSHADOW_SCALE.register_genrechecks the spec up front and raises one
ValueErrornaming 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, ashadowthat is not aShadowSeedSpec, 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 —
unlessreplace=Trueis 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 theShadowSeedSpecwith 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_genrereturns whether the key was there. -
build_cast(genre, n, rng=None, gender=None, balance=True)—nconcepts
with distinct names. Each builder drawsrng.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 raisesValueError
naming the genre, the gender and both counts. Withgender=Nonethe cast is
dealt as evenly asnallows (12 → 4/4/4, the remainder to rng-picked
genders) in an rng-shuffled order;balance=Falsedraws every member's gender
independently, exactly asncalls tobuild_genre_conceptwould. Each member
runs through the same builder asbuild_genre_conceptwith its name
pre-assigned — nothing is patched into the dict afterwards — via a new
trailingname=Nonekeyword on the three builders.build_genre_concept
itself still passesname=None, so its draw sequence is byte-identical and
tests/fixtures/persona_parity_golden.jsondid not move.
tests/test_cast.py(46 cases) pins the distinctness per genre, the 4/4/4
split, thebalance=Falseparity with sequentialbuild_genre_conceptcalls,
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 passgender="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
passesworld.rngand 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 explicitgender=costs no draw at
all — so passinggender="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 achoice(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.jsonwas regenerated deliberately with
PARITY_WRITE_GOLDEN=1. tests/api_surface.jsonis 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:EnergySystemandWorldEnvironmentare 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.jsongained exactly three names —
build_cast,register_genre,unregister_genre— and lost none. It was
regenerated deliberately withAPI_SURFACE_WRITE_SNAPSHOT=1; thesignatures
block,GENDERS, thePRONOUNSkeys, theGenreSpecfields and the registry
keys are unchanged.tests/api_surface.jsonis still byte-identical to 0.2.0:
from_conceptis a method on a class that is not inaura_life.__all__.GenreSpec.builderis now annotatedCallable[..., dict]and a custom
builder is expected to accept a trailingname=None. A legacy three-argument
builder still works forbuild_genre_concept— it is called with three
arguments whenever no name is pre-assigned — butbuild_castneeds the fourth,
and says so.- Every wall-clock read in
WorldEnvironmentandEnergySystemnow goes
through the world's clock rather thandatetime.now()directly. With no
now=supplied that clock isdatetime.now, so the behaviour is unchanged
for every existing caller — but a subclass overridingWorldEnvironment.now()
now changes the weather draw's notion of night, theTimeOfDaybucket, the
season and the ambiance line as well as the energy curve, which is the point. GenreSpec.name_poolis retained and still reads as the female pool, and the
flatname_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
sevenappearance_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 throughrender(), so a token there would leak verbatim. Examples:
estranged daughter coming home→estranged child coming home,
single mother carrying it all→single parent carrying it all,
femme fatale who is tired of the role→fatal charmer who is tired of the role,club hostess→club host,girlfriend→partner. Archetype count,
order and meaning per genre are unchanged. Every other label, occupation, trait
and title is byte-identical. tests/fixtures/persona_parity_golden.jsonwas 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_tickcalledself._career.tick(),
self._finance.tick()andself._errands.tick()bare, so each read
datetime.now()-- while the catch-up path two thousand lines below already
passednow=timestamp.CareerSystem.tickregisters a workday offnow's
date and hour and only then draws (uniform, up to tworandom, achoice),
so with those draws on an injectedrngthe 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 defectrng=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 isdatetime.nowwhen 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).
FinanceSystemgains an additivenow=Noneon its constructor and
from_dictfor 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.pygains 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 throughrender(), 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 thePRONOUNS
table itself —grepfor she/her/he/his/woman/girl/daughter/… over
genre_randomizer.pyreturns only the five lines that define the table.
tests/test_gender_contract.pycloses 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_STRUGGLEShardcoded 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 nowchronic 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" andpicturing exactly what {subj} would do to themas "picturing exactly what they would do to them" — grammatical, but
unreadable, and prose in the female original. They are nowreplaying the way someone looked at {obj}andpicturing exactly what to do to them, the latter
matching its twin insexy.shadow.intrusive_thought_themesbyte 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()
readdatetime.now()to derivehours_awake, and seven other sites in the
class did the same —sleep(), thenow_hourfallbacks inshould_sleep()/
is_asleep()/adjust_for_time(),hours_since_wake(), the initial state and
thefrom_dict()restore.WorldEnvironmenthad seven more. Past
MAX_HOURS_AWAKEthe 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, andgenderwas appended afterrngin 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 explicitgender=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"
(ProfileDatabasedoes, at the schema default and at two insert sites) is
unaffected: a concept now always carries a resolvedgender, so that default is
only reached by a caller that supplies none. - The clock seam needs nothing done to it.
nowwas appended last to
WorldEnvironment.__init__,EnergySystem.__init__and
EnergySystem.from_dict, so positional calls still bind, and omitting it
installsdatetime.now— the behaviour every 0.2.0 caller already had. To opt
in, passWorldEnvironment(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
WorldEnvironmentand overridesnow()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
persisteddatetimein this library is —tick()raisesTypeError: can't subtract offset-naive and offset-aware datetimes. This is the same naivety
contractpersona_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 hastheme_coloras an ARGBint,age_rangeas
the generated age andappearance_detailsholdinggenderandage, the
same shapeload_profilereturns; nothing reads the"#RRGGBB"string off a
definition, so nothing should notice. - A custom
GenreSpec.builderneeds a trailingname=Noneparameter only if
you callbuild_caston that genre.build_genre_conceptstill calls a
three-argument builder with three arguments. Registering through
register_genreinstead of assigning intoGENRE_REGISTRYis recommended but
not required; the dict is still there.