feat: add hidden entity traits (Workstream A) + idempotent migration

Entity.traits (alignment/power/volatility/deceptiveness, 0.0-1.0 each) is
rolled once at mint time in entities.py, seeded from the entity's
signature via random.Random(f"traits:{signature}") — a separate rng
namespace from normalize_profile's existing "norm:" rng, and never fed
into mint_prompt, so persona text stays fully decoupled from ground
truth. normalize_profile now includes "traits" in its returned dict;
fallback_profile inherits it for free since it already delegates to
normalize_profile.

Adds the new JSONB column to the Entity model (default {}) and the
idempotent `ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits ...`
migration line to main.py's lifespan, per the live-Postgres migration
convention this spec introduces (no Alembic in this repo).

Tests cover trait value ranges, signature-determinism, and
persona/trait independence (same persona template pairs with a wide
spread of alignment rolls across signatures), plus a regression check
that mint_prompt's signature never grows a traits parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-23 11:15:25 +00:00
parent 6d8c6f2496
commit e5bc253105
4 changed files with 117 additions and 0 deletions

View File

@@ -96,6 +96,23 @@ def parse_mint_response(text: str) -> dict | None:
return profile return profile
def roll_traits(signature: str) -> dict:
"""Roll the four hidden truth-traits for an entity, seeded from its
signature. These are never derived from — or fed into — the LLM persona
prompt (see `mint_prompt`, which never sees this function's output):
persona text must stay fully decoupled from ground truth, so a
convincing "sweet old lady" persona can pair with any alignment roll.
A separate `random.Random` namespace (`"traits:"` vs. `normalize_profile`'s
`"norm:"`) keeps this roll independent of the cosmetic-defaults rng."""
rng = random.Random(f"traits:{signature}")
return {
"alignment": rng.uniform(0.0, 1.0),
"power": rng.uniform(0.0, 1.0),
"volatility": rng.uniform(0.0, 1.0),
"deceptiveness": rng.uniform(0.0, 1.0),
}
def normalize_profile(profile: dict, signature: str) -> dict: def normalize_profile(profile: dict, signature: str) -> dict:
"""Coerce an LLM (or fallback) profile into the exact shape the DB and """Coerce an LLM (or fallback) profile into the exact shape the DB and
frontend expect, filling gaps with signature-deterministic defaults.""" frontend expect, filling gaps with signature-deterministic defaults."""
@@ -138,6 +155,7 @@ def normalize_profile(profile: dict, signature: str) -> dict:
}, },
"quotes": [str(q)[:200] for q in quotes[:4] if isinstance(q, str)] "quotes": [str(q)[:200] for q in quotes[:4] if isinstance(q, str)]
or rng.sample(_QUOTE_BANK, 2), or rng.sample(_QUOTE_BANK, 2),
"traits": roll_traits(signature),
} }

View File

@@ -6,6 +6,7 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
import app.models # noqa: F401 — registers models on Base.metadata before create_all import app.models # noqa: F401 — registers models on Base.metadata before create_all
from app.config import settings from app.config import settings
@@ -45,6 +46,12 @@ async def lifespan(app: FastAPI):
AUDIO_DIR.mkdir(parents=True, exist_ok=True) AUDIO_DIR.mkdir(parents=True, exist_ok=True)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# No Alembic in this repo — `create_all` never alters existing
# tables, so columns added to live models need a manual, idempotent
# migration here. Safe to run on every startup.
await conn.execute(text(
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}'::jsonb"
))
cleanup_task = asyncio.create_task(_session_cleanup_loop()) cleanup_task = asyncio.create_task(_session_cleanup_loop())
try: try:
yield yield

View File

@@ -24,6 +24,7 @@ class Entity(Base):
signature: Mapped[str] = mapped_column(String(64), unique=True, index=True) signature: Mapped[str] = mapped_column(String(64), unique=True, index=True)
voice_profile: Mapped[dict] = mapped_column(JSONB, default=dict) voice_profile: Mapped[dict] = mapped_column(JSONB, default=dict)
visual_profile: Mapped[dict] = mapped_column(JSONB, default=dict) visual_profile: Mapped[dict] = mapped_column(JSONB, default=dict)
traits: Mapped[dict] = mapped_column(JSONB, default=dict)
sample_quotes: Mapped[list] = mapped_column(JSONB, default=list) sample_quotes: Mapped[list] = mapped_column(JSONB, default=list)
contact_count: Mapped[int] = mapped_column(Integer, default=0) contact_count: Mapped[int] = mapped_column(Integer, default=0)
discovered_by: Mapped[uuid.UUID | None] = mapped_column( discovered_by: Mapped[uuid.UUID | None] = mapped_column(

View File

@@ -1,9 +1,15 @@
import inspect
from app.entities import ( from app.entities import (
fallback_profile, fallback_profile,
normalize_profile, normalize_profile,
parse_mint_response, parse_mint_response,
roll_traits,
signature_from_anomalies, signature_from_anomalies,
) )
from app.llm import prompts as llm_prompts
TRAIT_KEYS = ("alignment", "power", "volatility", "deceptiveness")
def _anomaly(freq, mag): def _anomaly(freq, mag):
@@ -60,3 +66,88 @@ def test_fallback_profile_is_deterministic_and_valid():
assert one["name"] assert one["name"]
assert one["rarity"] in ("common", "uncommon", "rare", "mythic") assert one["rarity"] in ("common", "uncommon", "rare", "mythic")
assert one["quotes"] assert one["quotes"]
# --- hidden traits (Workstream A) -------------------------------------
def test_roll_traits_values_are_in_range():
traits = roll_traits("some-signature-1")
assert set(traits.keys()) == set(TRAIT_KEYS)
for key in TRAIT_KEYS:
assert isinstance(traits[key], float)
assert 0.0 <= traits[key] <= 1.0
def test_roll_traits_is_deterministic_for_same_signature():
one = roll_traits("repeatable-signature")
two = roll_traits("repeatable-signature")
assert one == two
def test_roll_traits_varies_by_signature():
values = {tuple(roll_traits(f"sig-{i}").values()) for i in range(20)}
# 20 distinct signatures should not collapse onto a single roll.
assert len(values) > 1
def test_normalize_profile_includes_traits_in_range():
profile = normalize_profile({"name": "Hollow Briar"}, "traits-norm-signature")
traits = profile["traits"]
assert set(traits.keys()) == set(TRAIT_KEYS)
for key in TRAIT_KEYS:
assert 0.0 <= traits[key] <= 1.0
def test_normalize_profile_traits_match_roll_traits_for_signature():
signature = "cross-check-signature"
profile = normalize_profile({"name": "X"}, signature)
assert profile["traits"] == roll_traits(signature)
def test_normalize_profile_traits_are_signature_deterministic():
one = normalize_profile({"name": "A"}, "det-signature")
two = normalize_profile({"name": "B", "rarity": "rare"}, "det-signature")
# Same signature -> same traits every call, regardless of the rest of
# the (LLM-supplied or fallback) profile passed in.
assert one["traits"] == two["traits"]
def test_fallback_profile_includes_traits_in_range():
profile = fallback_profile("fallback-traits-signature")
traits = profile["traits"]
assert set(traits.keys()) == set(TRAIT_KEYS)
for key in TRAIT_KEYS:
assert 0.0 <= traits[key] <= 1.0
def test_fallback_profile_traits_are_signature_deterministic():
one = fallback_profile("fallback-det-signature")
two = fallback_profile("fallback-det-signature")
assert one["traits"] == two["traits"]
def test_persona_is_independent_of_rolled_traits():
"""Persona text must stay fully decoupled from hidden truth: pairing the
exact same persona string with many different signatures should surface
a spread of alignment rolls, not a single value tied to the persona."""
fixed_persona = "A sweet old lady who just wants to chat about her garden."
alignments = set()
for i in range(40):
profile = normalize_profile(
{"name": "Nana", "persona": fixed_persona}, f"persona-independence-{i}"
)
assert profile["persona"] == fixed_persona
alignments.add(round(profile["traits"]["alignment"], 3))
# A meaningful spread (not clustered on one or two values) shows
# alignment isn't derived from — or gated by — the persona text.
assert len(alignments) > 20
def test_mint_prompt_never_receives_traits():
"""`mint_prompt` builds the LLM system/user prompt; traits must never be
one of its inputs, so hidden ground truth can't leak into persona
generation."""
params = set(inspect.signature(llm_prompts.mint_prompt).parameters)
assert "traits" not in params
assert params == {"signature", "channel", "anomaly_summary", "voice_ids"}