Files
AetherTable/python/tests/test_dynasty_depth.py
John SmithandClaude 703f5c1e00 fix: sixth-audit remediation — gate reliability + honesty corrections (iteration 84)
- Gate reliability: autouse conftest fixture clears the shared auth
  rate-limit windows around every test; full suite now 3x-consecutive
  green (was nondeterministically red via cross-module bucket pressure).
- Test quality over claim-laundering: computedHP gains the real SRD
  hit-die table (Wizard/Sorcerer d6, Barbarian d12, per-die average
  growth) — the old flat d8 fallback had been pinned by a test falsely
  citing "hit-die max"; tests now cite SRD values explicitly.
- Scripted-mode social phase force-pins llm_gateway=None so an
  LLM-gatewayed persona can't leak upstream calls the report claims
  never happened; no-network test extended with a recording gateway.
- Dynasty test tautology replaced with real lineage assertions.
- Ambience-honesty test derives expected ids from AudioManager recipes
  (source of truth) with a drift guard, not the copied list.
- Tracker honesty note appended to iteration 79: default quest output
  changed materially; only quest_id and summary preserved.

Gates: pytest 547x3 green, vitest 69 green, build 13.3s.

Co-Authored-By: Claude <[email protected]>
2026-08-24 15:32:44 -04:00

347 lines
14 KiB
Python

"""
Pillar 8 iteration: dynasty depth tests.
Covers multi-generation lineage compounding + mutation, cross-house
alliance inheritance weighted by trait dominance/recessiveness, house
prestige scoring/ordering, generation-appropriate lore-graph injection,
and backwards compatibility of the legacy single-generation API.
"""
import random
import json
import pytest
from vtt_orchestrator.simulation.dynasty_engine import DynastyEngine, DynastyMember, NobleHouse
from vtt_orchestrator.lore.epistemic_graph import EpistemicLoreGraphManager
from vtt_orchestrator.schemas.models import EpistemicTier
IRON_WILL = "Iron Will (+2 Wisdom Saves)"
ARCANE_SPARK = "Arcane Spark (Innate Cantrip)"
SILVER_TONGUE = "Silver Tongue (+3 Persuasion)"
CURSED_BLOOD = "Cursed Bloodline (Disadvantage vs Necromancy)"
GRIFFIN_RIDER = "Griffin Rider (Aerial Mastery)"
class ScriptedRandom:
"""
Deterministic RNG stand-in. ``random()`` always returns the scripted
value, so every probabilistic gate with p > value passes and every
gate with p <= value fails. choice/sample/choices resolve
deterministically (argmax weight / head of sequence).
"""
def __init__(self, value: float):
self.value = value
def random(self) -> float:
return self.value
def choice(self, seq):
return seq[0]
def sample(self, seq, k):
return list(seq)[:k]
def choices(self, population, weights=None, k=1):
if weights:
idx = max(range(len(population)), key=lambda i: weights[i])
else:
idx = 0
return [population[idx]] * k
LEGACY_MEMBER_KEYS = {
"id", "name", "title", "generation", "is_alive", "traits",
"personality", "parent_ids", "spouse_id", "historical_event",
}
def _craft_house(engine: DynastyEngine, house_id: str, gen3_traits, gen2_traits=None, gen1_traits=None) -> NobleHouse:
"""Insert a minimal three-generation house with controlled trait pools."""
house = NobleHouse(
id=house_id,
name=f"House {house_id.split('_', 1)[1].title()}",
motto="Tested in Fire",
crest_icon="tower",
theme_color="#ffffff",
seat_of_power="Test Bastion",
primary_virtue="Verification",
)
founder = DynastyMember(
id=f"{house_id}_gen1_founder", name="Founder Test", title="Founder",
generation=1, is_alive=False,
traits=list(gen1_traits if gen1_traits is not None else gen3_traits[:1]),
personality="stub",
)
ruler = DynastyMember(
id=f"{house_id}_gen2_ruler", name="Ruler Test", title="Ruler",
generation=2, is_alive=True,
traits=list(gen2_traits if gen2_traits is not None else gen3_traits[:1]),
personality="stub", parent_ids=[founder.id],
)
spouse = DynastyMember(
id=f"{house_id}_gen2_spouse", name="Spouse Test", title="Consort",
generation=2, is_alive=True, traits=["Shadow Affinity (Darkvision 60ft)"],
personality="stub",
)
ruler.spouse_id = spouse.id
spouse.spouse_id = ruler.id
heir = DynastyMember(
id=f"{house_id}_gen3_heir", name="Heir Test", title="Heir",
generation=3, is_alive=True, traits=list(gen3_traits),
personality="stub", parent_ids=[ruler.id, spouse.id],
)
house.members = [founder, ruler, spouse, heir]
engine.houses[house_id] = house
return house
# ---------------------------------------------------------------------------
# 1. Multi-generation lineages: compounding + mutation, deterministic
# ---------------------------------------------------------------------------
def test_three_generation_lineage_compounds_across_extended_generations():
engine = DynastyEngine(seed=101)
engine.rng = ScriptedRandom(0.0) # every inheritance gate succeeds
house = _craft_house(
engine, "house_compound",
gen3_traits=[IRON_WILL],
gen2_traits=[IRON_WILL, SILVER_TONGUE],
gen1_traits=[IRON_WILL],
)
created = engine.extend_lineage("house_compound", additional_generations=2, mutation_rate=0.0)
assert len(created) == 4 # heir + spouse per generation
generations = {m.generation for m in created}
assert generations == {4, 5}
by_gen = {m.generation: m for m in house.members}
gen4_heir = by_gen[4]
gen5_heir = by_gen[5]
# Compounding: the trait carried by gens 1-3 survives into gens 4 AND 5.
assert IRON_WILL in gen4_heir.traits
assert IRON_WILL in gen5_heir.traits
# Parent-pool traits flow down (spouse traits included in the pool).
assert SILVER_TONGUE in gen4_heir.traits
# Lineage linkage: every extended generation descends from exactly ONE
# parent — the previous generation's blooded heir, never a married-in
# consort — and each heir is wed to its own generation's consort. (This
# replaced an earlier `... or True` tautology that asserted nothing.)
assert len(created) == 4
assert gen4_heir.parent_ids == ["house_compound_gen3_heir"]
assert gen5_heir.parent_ids == [gen4_heir.id]
assert gen4_heir.spouse_id == "house_compound_gen4_consort"
assert gen5_heir.spouse_id == "house_compound_gen5_consort"
def test_mutation_introduces_new_trait_at_configured_rate():
engine = DynastyEngine(seed=202)
engine.rng = ScriptedRandom(0.0)
_craft_house(engine, "house_mutant", gen3_traits=[IRON_WILL])
created = engine.extend_lineage("house_mutant", additional_generations=1, mutation_rate=1.0)
heir = next(m for m in created if m.parent_ids)
assert IRON_WILL in heir.traits # inherited
assert len(heir.traits) > 1 # mutation added something new
mutated = [t for t in heir.traits if t != IRON_WILL and "Darkvision" not in t]
assert mutated, "expected a mutated-in trait beyond the parental pool"
def test_lineage_extension_is_fully_deterministic_for_a_seed():
def run():
engine = DynastyEngine(seed=303)
_craft_house(engine, "house_det", gen3_traits=[ARCANE_SPARK, IRON_WILL])
engine.extend_lineage("house_det", additional_generations=3)
return json.dumps(
[[m.id, m.generation, sorted(m.traits)] for m in engine.houses["house_det"].members],
sort_keys=True,
)
assert run() == run()
def test_compounding_raises_inheritance_probability_monotonically():
engine = DynastyEngine(seed=404)
p_one_gen = engine.inheritance_probability(IRON_WILL, prior_generation_count=1)
p_three_gens = engine.inheritance_probability(IRON_WILL, prior_generation_count=3)
assert p_three_gens > p_one_gen
assert p_three_gens <= 0.95 # never guaranteed, even for deep bloodlines
# ---------------------------------------------------------------------------
# 2. Marriage / alliance between houses
# ---------------------------------------------------------------------------
def test_alliance_children_inherit_from_both_pools_per_dominance_weights():
engine = DynastyEngine(seed=505)
engine.rng = ScriptedRandom(0.6) # expresses only traits with high dominance
_craft_house(engine, "house_sun", gen3_traits=[IRON_WILL]) # dominance 0.70 -> p=0.65 > 0.6
_craft_house(engine, "house_shade", gen3_traits=[CURSED_BLOOD]) # dominance 0.15 -> p=0.375 < 0.6
pact = engine.form_alliance("house_sun", "house_shade", mutation_rate=0.0)
assert pact is not None
sun_child = engine.houses["house_sun"].members[-1]
shade_child = engine.houses["house_shade"].members[-1]
# Dominant trait from EITHER parent pool expresses in BOTH children...
assert IRON_WILL in sun_child.traits
assert IRON_WILL in shade_child.traits
# ...while the recessive curse is suppressed in both.
assert CURSED_BLOOD not in sun_child.traits
assert CURSED_BLOOD not in shade_child.traits
# Children are recorded as children of the cross-house marriage.
for child in (sun_child, shade_child):
assert set(child.parent_ids) == {pact["marriage"]["partners"][0], pact["marriage"]["partners"][1]}
assert sun_child.id in [c for c in pact["children"]]
# Marriage partners are cross-linked and both houses record the alliance.
assert pact["marriage"]["partners"][0] != pact["marriage"]["partners"][1]
assert engine.houses["house_sun"].feuds["house_shade"].startswith("Allied")
assert engine.houses["house_shade"].feuds["house_sun"].startswith("Allied")
def test_alliance_recessive_expression_when_rng_is_lenient():
engine = DynastyEngine(seed=606)
engine.rng = ScriptedRandom(0.1) # nearly everything expresses
_craft_house(engine, "house_a", gen3_traits=[CURSED_BLOOD])
_craft_house(engine, "house_b", gen3_traits=[GRIFFIN_RIDER])
engine.form_alliance("house_a", "house_b", mutation_rate=0.0)
child = engine.houses["house_a"].members[-1]
# Lenient gate: both pools contribute, including the recessive curse.
assert CURSED_BLOOD in child.traits
assert GRIFFIN_RIDER in child.traits
# ---------------------------------------------------------------------------
# 3. House prestige scoring and ordering
# ---------------------------------------------------------------------------
def test_prestige_ordering_matches_crafted_inputs():
engine = DynastyEngine(seed=707)
golden = _craft_house(engine, "house_golden", gen3_traits=[GRIFFIN_RIDER, ARCANE_SPARK])
cursed = _craft_house(engine, "house_cursed", gen3_traits=[CURSED_BLOOD])
golden.feuds["house_x"] = "Allied (Mutual Defense Treaty)"
cursed.feuds["house_y"] = "Blood Feud (Borderlands War)"
p_golden = engine.house_prestige("house_golden")
p_cursed = engine.house_prestige("house_cursed")
assert p_golden > p_cursed
ranked = engine.rank_houses()
# The crafted pair must appear in the global ranking in score order,
# regardless of where the seeded template houses land.
positions = {hid: i for i, (hid, _) in enumerate(ranked)}
assert positions["house_golden"] < positions["house_cursed"]
scores = [score for _, score in ranked]
assert scores == sorted(scores, reverse=True)
def test_alliance_and_depth_raise_prestige():
engine = DynastyEngine(seed=808)
_craft_house(engine, "house_rise", gen3_traits=[SILVER_TONGUE])
_craft_house(engine, "house_other", gen3_traits=[ARCANE_SPARK])
before = engine.house_prestige("house_rise")
engine.form_alliance("house_rise", "house_other", mutation_rate=0.0)
after_alliance = engine.house_prestige("house_rise")
assert after_alliance > before
before_depth = engine.house_prestige("house_rise")
engine.extend_lineage("house_rise", additional_generations=2, mutation_rate=0.0)
after_depth = engine.house_prestige("house_rise")
assert after_depth > before_depth
# ---------------------------------------------------------------------------
# 4. Lore-graph injection reflects the final dynastic state
# ---------------------------------------------------------------------------
def test_lore_injection_reflects_deep_lineage_and_alliances():
engine = DynastyEngine(seed=909)
_craft_house(engine, "house_lore", gen3_traits=[GRIFFIN_RIDER])
_craft_house(engine, "house_ally", gen3_traits=[SILVER_TONGUE])
engine.extend_lineage("house_lore", additional_generations=2, mutation_rate=0.0)
engine.form_alliance("house_lore", "house_ally", mutation_rate=0.0)
graph = EpistemicLoreGraphManager()
injected = engine.inject_lore_into_graph("house_lore", graph)
assert injected >= 5
rels = {(e["rel"], e["to"]) for e in graph.edges if e["from"] == "House Lore"}
deep = [edge for edge in rels if edge[0] == "HOLDS_UNBROKEN_BLOODLINE"]
assert deep and "5" in deep[0][1]
assert any(rel == "COMMANDS_PRESTIGE" for rel, _ in rels)
assert any(rel == "HOLDS_ALLIANCE_WITH" for rel, _ in rels)
# Deep-lineage canon is committed as validated canon, not rumor.
canon_edge = next(e for e in graph.edges if e["rel"] == "HOLDS_UNBROKEN_BLOODLINE")
assert canon_edge["tier"] == EpistemicTier.VALIDATED_CANON
def test_lore_injection_is_generation_appropriate_for_young_houses():
engine = DynastyEngine(seed=111)
graph = EpistemicLoreGraphManager()
engine.inject_lore_into_graph("house_vane", graph)
rels = {e["rel"] for e in graph.edges if e["from"] == "House Vane of Black Iron"}
assert "HOLDS_YOUNG_BLOODLINE" in rels # 3 recorded generations only
assert "HOLDS_UNBROKEN_BLOODLINE" not in rels # no deep-lineage claim
# Founding canon is validated; young-bloodline status stays proposed.
young_edge = next(e for e in graph.edges if e["rel"] == "HOLDS_YOUNG_BLOODLINE")
assert young_edge["tier"] == EpistemicTier.PROPOSED_FACT
# ---------------------------------------------------------------------------
# 5. Legacy single-generation API unchanged for existing callers
# ---------------------------------------------------------------------------
def test_legacy_single_generation_api_unchanged():
engine = DynastyEngine(seed=123)
payload = engine.get_dynasty_payload()
assert set(payload.keys()) == {"houses"}
assert len(payload["houses"]) == 3
vane = next(h for h in payload["houses"] if h["id"] == "house_vane")
assert len(vane["members"]) == 5
assert {m["generation"] for m in vane["members"]} == {1, 2, 3}
for m in vane["members"]:
assert set(m.keys()) == LEGACY_MEMBER_KEYS
assert len(m["traits"]) > 0
assert set(vane.keys()) == {
"id", "name", "motto", "crest_icon", "theme_color",
"seat_of_power", "primary_virtue", "members", "feuds",
}
# Unknown house id still yields zero injections.
assert engine.inject_lore_into_graph("house_missing", EpistemicLoreGraphManager()) == 0
def test_legacy_seed_output_stable_against_new_features():
"""Extending one house must not disturb the untouched template houses."""
engine_a = DynastyEngine(seed=77)
engine_b = DynastyEngine(seed=77)
_craft_house(engine_b, "house_extra", gen3_traits=[ARCANE_SPARK])
engine_b.extend_lineage("house_vane", additional_generations=1)
a_vane = engine_a.get_dynasty_payload()["houses"]
b_all = engine_b.get_dynasty_payload()["houses"]
assert len(a_vane) == 3
assert len(b_all) == 4
b_vane = next(h for h in b_all if h["id"] == "house_vane")
b_silverthorn = next(h for h in b_all if h["id"] == "house_silverthorn")
a_silverthorn = next(h for h in a_vane if h["id"] == "house_silverthorn")
assert b_silverthorn == a_silverthorn
assert len(next(m for m in b_vane["members"] if m["generation"] == 4)["traits"]) > 0