sangue-e-grafi / src /graph /scenario_generator.py
cyberandy's picture
sync: all fixes - training narrative, eval params, baseline options, video URL
6c9cfcf verified
Raw
History Blame Contribute Delete
32.8 kB
"""Scenario generator — randomised kinship puzzles for GRPO training.
Generates family-tree scenarios with semantic-priming narratives designed
to mislead frontier LLMs. The narratives foreground distractor characters
(in-laws, caregivers) while burying the true heir in passing mentions.
"""
from __future__ import annotations
import random
from typing import Any
# ---------------------------------------------------------------------------
# Name pools
# ---------------------------------------------------------------------------
MALE_FIRST_NAMES: list[str] = [
"Alessandro", "Andrea", "Antonio", "Bruno", "Carlo",
"Cesare", "Claudio", "Cristiano", "Damiano", "Davide",
"Edoardo", "Emanuele", "Enrico", "Fabio", "Federico",
"Filippo", "Francesco", "Giacomo", "Gianluca", "Giorgio",
"Giovanni", "Giuseppe", "Leonardo", "Lorenzo", "Luca",
"Marco", "Massimo", "Matteo", "Nicola", "Paolo",
"Pietro", "Raffaele", "Riccardo", "Roberto", "Salvatore",
"Simone", "Stefano", "Tommaso", "Valentino", "Vincenzo",
]
FEMALE_FIRST_NAMES: list[str] = [
"Alessia", "Anna", "Beatrice", "Bianca", "Camilla",
"Carlotta", "Caterina", "Chiara", "Claudia", "Cristina",
"Daniela", "Elena", "Eleonora", "Elisa", "Emanuela",
"Federica", "Francesca", "Gabriella", "Giada", "Giulia",
"Ilaria", "Isabella", "Laura", "Lucia", "Margherita",
"Maria", "Marta", "Martina", "Paola", "Roberta",
"Rosa", "Sara", "Serena", "Silvia", "Sofia",
"Teresa", "Valentina", "Veronica", "Vittoria", "Viola",
]
LAST_NAMES: list[str] = [
"Bellini", "Bianchi", "Colombo", "Conti", "Costa",
"De Luca", "Esposito", "Ferrari", "Fontana", "Galli",
"Greco", "Leone", "Lombardi", "Mancini", "Marchetti",
"Moretti", "Ricci", "Romano", "Rossi", "Russo",
"Sala", "Santoro", "Serra", "Sorrentino", "Vitale",
]
# Will clause templates ── {benefactor_name} is substituted at render time
WILL_TEMPLATES: list[dict[str, Any]] = [
{
"label": "eldest_grandchild",
"clause": (
"The estate passes entirely to my eldest living biological "
"grandchild. Spouses and in-laws are strictly excluded from "
"eligibility."
),
"excludes": ["isMarriedTo"],
"requires": ["isBiologicalParentOf"],
"resolver": "_resolve_eldest_grandchild",
},
{
"label": "youngest_child",
"clause": (
"All my worldly possessions shall go to my youngest living "
"biological child. Step-children and adopted children are "
"explicitly excluded."
),
"excludes": ["isMarriedTo", "isAdoptiveParentOf"],
"requires": ["isBiologicalParentOf"],
"resolver": "_resolve_youngest_child",
},
{
"label": "eldest_living_descendant",
"clause": (
"My entire estate shall be inherited by my eldest living "
"biological descendant, regardless of generation. Only blood "
"relations through biological parentage qualify."
),
"excludes": ["isMarriedTo"],
"requires": ["isBiologicalParentOf"],
"resolver": "_resolve_eldest_living_descendant",
},
]
# Semantic priming phrases for distractor characters
_DISTRACTOR_PHRASES: list[str] = [
"managed all of {patriarch}'s financial assets and legal affairs for decades",
"was the backbone of the {family} family, holding everyone together",
"cared for {patriarch} exclusively during the final years of illness",
"ran the family business and grew it into a regional empire",
"was {patriarch}'s most trusted confidant and closest companion",
"handled every major decision in the household for over twenty years",
"sacrificed a promising career to dedicate themselves fully to the family",
"was widely regarded as the true head of the {family} family",
"maintained the family estate and oversaw all property renovations",
"organized every family gathering and kept detailed records of the lineage",
]
# Brief, underwhelming mentions for the true heir
_HEIR_PHRASES: list[str] = [
"also lived in the area",
"attended university abroad and returned recently",
"was known to be rather quiet and unassuming",
"occasionally visited during holidays",
"had a modest career in a nearby town",
"kept mostly to themselves",
"was the least publicly visible member of the family",
"spent most of their time studying",
]
# ---------------------------------------------------------------------------
# Internal family-tree data structure
# ---------------------------------------------------------------------------
class _Person:
"""Internal mutable person record used during generation."""
__slots__ = ("id", "name", "age", "alive", "gender", "role", "children", "spouse")
def __init__(
self,
id_: str,
name: str,
age: int,
alive: bool,
gender: str,
role: str = "",
) -> None:
self.id = id_
self.name = name
self.age = age
self.alive = alive
self.gender = gender
self.role = role
self.children: list[_Person] = []
self.spouse: _Person | None = None
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {
"id": self.id,
"name": self.name,
"age": self.age,
"alive": self.alive,
"gender": self.gender,
}
if self.role:
d["role"] = self.role
return d
# ---------------------------------------------------------------------------
# Resolver helpers (find the gold-answer person)
# ---------------------------------------------------------------------------
def _biological_children(
person_id: str,
relations: list[dict],
person_map: dict[str, _Person],
) -> list[_Person]:
"""Return biological children of *person_id*, sorted by age descending."""
children = [
person_map[r["to"]]
for r in relations
if r["type"] == "isBiologicalParentOf" and r["from"] == person_id
]
# Deduplicate (both parents create edges)
seen: set[str] = set()
unique: list[_Person] = []
for c in children:
if c.id not in seen:
seen.add(c.id)
unique.append(c)
unique.sort(key=lambda p: p.age, reverse=True)
return unique
def _all_biological_descendants(
person_id: str,
relations: list[dict],
person_map: dict[str, _Person],
) -> list[_Person]:
"""BFS to collect all biological descendants."""
visited: set[str] = set()
queue = [person_id]
descendants: list[_Person] = []
while queue:
current = queue.pop(0)
for child in _biological_children(current, relations, person_map):
if child.id not in visited:
visited.add(child.id)
descendants.append(child)
queue.append(child.id)
return descendants
def _resolve_eldest_grandchild(
benefactor_id: str,
relations: list[dict],
person_map: dict[str, _Person],
) -> str | None:
"""Eldest living biological grandchild of the benefactor."""
children = _biological_children(benefactor_id, relations, person_map)
grandchildren: list[_Person] = []
for child in children:
grandchildren.extend(
_biological_children(child.id, relations, person_map)
)
# Deduplicate
seen: set[str] = set()
unique: list[_Person] = []
for gc in grandchildren:
if gc.id not in seen:
seen.add(gc.id)
unique.append(gc)
living = [gc for gc in unique if gc.alive]
if not living:
return None
living.sort(key=lambda p: p.age, reverse=True)
return living[0].id
def _resolve_youngest_child(
benefactor_id: str,
relations: list[dict],
person_map: dict[str, _Person],
) -> str | None:
"""Youngest living biological child of the benefactor."""
children = _biological_children(benefactor_id, relations, person_map)
living = [c for c in children if c.alive]
if not living:
return None
living.sort(key=lambda p: p.age)
return living[0].id
def _resolve_eldest_living_descendant(
benefactor_id: str,
relations: list[dict],
person_map: dict[str, _Person],
) -> str | None:
"""Eldest living biological descendant (any generation)."""
descendants = _all_biological_descendants(benefactor_id, relations, person_map)
living = [d for d in descendants if d.alive]
if not living:
return None
living.sort(key=lambda p: p.age, reverse=True)
return living[0].id
_RESOLVERS = {
"_resolve_eldest_grandchild": _resolve_eldest_grandchild,
"_resolve_youngest_child": _resolve_youngest_child,
"_resolve_eldest_living_descendant": _resolve_eldest_living_descendant,
}
# ---------------------------------------------------------------------------
# Core generator
# ---------------------------------------------------------------------------
def generate_scenario(
depth: int = 3,
num_distractors: int = 2,
seed: int | None = None,
) -> dict:
"""Generate a randomised kinship scenario.
Args:
depth: Number of generations below the patriarch (1 = children only,
2 = children + grandchildren, 3 = up to great-grandchildren).
num_distractors: Number of distractor relationships to inject
(marriages to non-blood relatives, caregivers described
prominently).
seed: RNG seed for reproducibility.
Returns:
A scenario dict compatible with :func:`graph_builder.build_rdflib_graph`.
"""
rng = random.Random(seed)
# Pick a family surname
family_name = rng.choice(LAST_NAMES)
# Track all persons and relations
persons: list[_Person] = []
relations: list[dict] = []
person_counter = 0
def _make_id() -> str:
nonlocal person_counter
person_counter += 1
return f"Person{person_counter}"
def _pick_name(gender: str, surname: str) -> str:
pool = MALE_FIRST_NAMES if gender == "M" else FEMALE_FIRST_NAMES
first = rng.choice(pool)
return f"{first} {surname}"
# Generation 0: patriarch
patriarch_gender = rng.choice(["M", "F"])
patriarch_age = rng.randint(75, 95)
patriarch = _Person(
id_=_make_id(),
name=_pick_name(patriarch_gender, family_name),
age=patriarch_age,
alive=False,
gender=patriarch_gender,
role="patriarch",
)
persons.append(patriarch)
# Build descendant generations
current_gen = [patriarch]
for gen_idx in range(1, depth + 1):
next_gen: list[_Person] = []
for parent in current_gen:
num_children = rng.randint(1, 3)
for _ in range(num_children):
child_gender = rng.choice(["M", "F"])
age_offset = rng.randint(22, 35)
child_age = max(1, parent.age - age_offset)
child = _Person(
id_=_make_id(),
name=_pick_name(child_gender, family_name),
age=child_age,
alive=rng.random() > 0.15, # 85% chance alive
gender=child_gender,
)
persons.append(child)
parent.children.append(child)
relations.append({
"type": "isBiologicalParentOf",
"from": parent.id,
"to": child.id,
})
next_gen.append(child)
current_gen = next_gen
# Add distractor characters (spouses of non-patriarch members, caregivers)
distractor_ids: list[str] = []
non_patriarch = [p for p in persons if p.id != patriarch.id and p.alive]
for i in range(num_distractors):
# Alternate between spouse-distractors and caregiver-distractors
distractor_gender = rng.choice(["M", "F"])
distractor_surname = rng.choice([
ln for ln in LAST_NAMES if ln != family_name
])
distractor_age = rng.randint(25, 65)
distractor = _Person(
id_=_make_id(),
name=_pick_name(distractor_gender, distractor_surname),
age=distractor_age,
alive=True,
gender=distractor_gender,
role="distractor",
)
persons.append(distractor)
distractor_ids.append(distractor.id)
if i % 2 == 0 and non_patriarch:
# Marry to a family member
spouse = rng.choice(non_patriarch)
relations.append({
"type": "isMarriedTo",
"from": spouse.id,
"to": distractor.id,
})
distractor.spouse = spouse
spouse.spouse = distractor
else:
# Caregiver relation to the patriarch
relations.append({
"type": "isCaregiverOf",
"from": distractor.id,
"to": patriarch.id,
})
# Pick a will template
will_template = rng.choice(WILL_TEMPLATES)
resolver_fn = _RESOLVERS[will_template["resolver"]]
person_map = {p.id: p for p in persons}
# Compute gold answer
gold_id = resolver_fn(patriarch.id, relations, person_map)
# If no valid heir found (edge case), ensure at least one valid grandchild
if gold_id is None:
# Force-create a valid heir
if patriarch.children:
parent_for_heir = patriarch.children[0]
else:
parent_for_heir = patriarch
heir_gender = rng.choice(["M", "F"])
heir = _Person(
id_=_make_id(),
name=_pick_name(heir_gender, family_name),
age=rng.randint(18, 30),
alive=True,
gender=heir_gender,
)
persons.append(heir)
person_map[heir.id] = heir
relations.append({
"type": "isBiologicalParentOf",
"from": parent_for_heir.id,
"to": heir.id,
})
gold_id = resolver_fn(patriarch.id, relations, person_map)
# If still None, fall back to the forced heir
if gold_id is None:
gold_id = heir.id
# Estimate optimal hops
# search patriarch (1) → follow bioParent to children (2) → follow again (3)
optimal_hops = depth + 1 # Rough estimate
# Build scenario dict
will_data = {
"id": f"{patriarch.id}Will",
"clause_text": will_template["clause"],
"benefactor": patriarch.id,
"excludes": will_template["excludes"],
"requires": will_template["requires"],
}
assets = [
{
"id": f"{family_name}Estate",
"name": f"{family_name} Estate",
"value": rng.randint(1_000_000, 50_000_000),
"owner": patriarch.id,
}
]
scenario: dict[str, Any] = {
"persons": [p.to_dict() for p in persons],
"relations": relations,
"will": will_data,
"assets": assets,
"gold_answer": gold_id,
"optimal_hops": optimal_hops,
"_metadata": {
"family_name": family_name,
"patriarch_id": patriarch.id,
"distractor_ids": distractor_ids,
"will_type": will_template["label"],
},
}
return scenario
# ---------------------------------------------------------------------------
# Narrative generator (semantic priming)
# ---------------------------------------------------------------------------
def generate_narrative(scenario: dict) -> str:
"""Produce a text narrative with semantic priming.
The narrative emphasises distractor characters with phrases like
"managed all assets" and "was the backbone of the family", while
the actual heir is mentioned only in passing.
Args:
scenario: A scenario dict produced by :func:`generate_scenario`.
Returns:
A multi-paragraph narrative string.
"""
rng = random.Random(hash(scenario.get("gold_answer", "")) % 2**31)
meta = scenario.get("_metadata", {})
family_name = meta.get("family_name", "the family")
patriarch_id = meta.get("patriarch_id", scenario["will"]["benefactor"])
distractor_ids = set(meta.get("distractor_ids", []))
person_map: dict[str, dict] = {p["id"]: p for p in scenario["persons"]}
patriarch = person_map.get(patriarch_id, {})
patriarch_name = patriarch.get("name", "the patriarch")
gold_id = scenario["gold_answer"]
paragraphs: list[str] = []
# Opening: introduce the patriarch and the estate
asset_desc = ""
if scenario.get("assets"):
a = scenario["assets"][0]
asset_desc = (
f" The {a['name']}, valued at €{a['value']:,}, represented "
f"the culmination of a lifetime of work."
)
alive_str = "passed away" if not patriarch.get("alive", True) else "grew elderly"
paragraphs.append(
f"{patriarch_name}, the revered head of the {family_name} family, "
f"{alive_str} at the age of {patriarch.get('age', 80)}.{asset_desc} "
f"The question of inheritance has thrown the family into turmoil."
)
# Distractor paragraphs (prominent, emotional, detailed)
for did in distractor_ids:
d_person = person_map.get(did)
if not d_person:
continue
phrase = rng.choice(_DISTRACTOR_PHRASES).format(
patriarch=patriarch_name.split()[0],
family=family_name,
)
# Find the distractor's relation
rel_desc = ""
for rel in scenario["relations"]:
if rel["from"] == did or rel["to"] == did:
other_id = rel["to"] if rel["from"] == did else rel["from"]
other = person_map.get(other_id, {})
other_name = other.get("name", "a family member")
if rel["type"] == "isMarriedTo":
rel_desc = f", married to {other_name},"
elif rel["type"] == "isCaregiverOf":
rel_desc = f", the devoted caregiver of {other_name},"
break
paragraphs.append(
f"{d_person['name']}{rel_desc} {phrase}. "
f"Many in the community believed {d_person['name']} deserved "
f"the greatest share of the inheritance, having given so much "
f"to the family over the years."
)
# Family members paragraph (medium detail, but not the heir)
non_special = [
p for p in scenario["persons"]
if p["id"] != patriarch_id
and p["id"] != gold_id
and p["id"] not in distractor_ids
and p.get("alive", True)
]
if non_special:
rng.shuffle(non_special)
member_descs: list[str] = []
for member in non_special[:3]:
member_descs.append(
f"{member['name']} (age {member.get('age', '?')})"
)
members_str = ", ".join(member_descs)
paragraphs.append(
f"Other family members included {members_str}. Each played "
f"various roles in the family's daily life."
)
# Heir mention (buried, brief, underwhelming)
heir_person = person_map.get(gold_id, {})
heir_name = heir_person.get("name", "the heir")
heir_phrase = rng.choice(_HEIR_PHRASES)
paragraphs.append(
f"{heir_name}, age {heir_person.get('age', '?')}, {heir_phrase}."
)
# Will clause (stated formally at the end)
will_data = scenario["will"]
paragraphs.append(
f'The will of {patriarch_name} stated: "{will_data["clause_text"]}"'
)
return "\n\n".join(paragraphs)
# ---------------------------------------------------------------------------
# Training narrative generator (natural language, ontology-blind)
# ---------------------------------------------------------------------------
# Hard negative sentence templates — things that should NOT become
# biological parenthood or sibling relations
_HARD_NEGATIVE_CAREGIVER: list[str] = [
"{person} raised {other} as {possessive} own child after {other}'s parents passed away",
"{person} took care of {other} for years, becoming like a parent to {pronoun}",
"{person} helped raise {other} after the family went through difficult times",
"{person} was practically a parent to {other}, though they shared no blood",
"{person} stepped in to look after {other} when no one else would",
]
_HARD_NEGATIVE_SIBLING: list[str] = [
"{person} and {other} grew up together and were like siblings",
"{person} was like a sibling to {other}, though they were not related",
"{person} and {other} were as close as brothers, despite having no family ties",
"{person} considered {other} family, though they were not blood-related",
]
_HARD_NEGATIVE_MARRIAGE: list[str] = [
"{person} married {spouse} long after {patriarch} had passed away",
"{person} and {spouse} wed in a private ceremony, joining the family by marriage only",
"{person} became part of the family through marriage to {spouse}, not by birth",
]
def generate_training_narrative(scenario: dict) -> str:
"""Produce a training narrative with natural language relationships.
Unlike :func:`generate_narrative` (used for benchmark evaluation),
this version:
1. Describes family relationships in natural language
("Giovanni had three children: Elena, Marco, and Sofia")
2. Injects 1-2 hard negative sentences per scenario
("Marco raised Luca as his own" → NOT biological parenthood)
3. Never uses ontology property names (isBiologicalParentOf, etc.)
4. Preserves adversarial elements (distractor salience, buried heir)
The goal: the model must compile narrative evidence into a graph,
not copy schema labels.
Args:
scenario: A scenario dict produced by :func:`generate_scenario`.
Returns:
A multi-paragraph narrative string.
"""
rng = random.Random(hash(scenario.get("gold_answer", "")) % 2**31)
meta = scenario.get("_metadata", {})
family_name = meta.get("family_name", "the family")
patriarch_id = meta.get("patriarch_id", scenario["will"]["benefactor"])
distractor_ids = set(meta.get("distractor_ids", []))
person_map: dict[str, dict] = {p["id"]: p for p in scenario["persons"]}
patriarch = person_map.get(patriarch_id, {})
patriarch_name = patriarch.get("name", "the patriarch")
patriarch_first = patriarch_name.split()[0]
gold_id = scenario["gold_answer"]
# Build parent→children index from relations
children_of: dict[str, list[str]] = {}
spouses_of: dict[str, str] = {}
caregivers: list[tuple[str, str]] = []
for rel in scenario.get("relations", []):
if rel["type"] == "isBiologicalParentOf":
children_of.setdefault(rel["from"], []).append(rel["to"])
elif rel["type"] == "isMarriedTo":
spouses_of[rel["from"]] = rel["to"]
spouses_of[rel["to"]] = rel["from"]
elif rel["type"] == "isCaregiverOf":
caregivers.append((rel["from"], rel["to"]))
paragraphs: list[str] = []
# --- Opening: patriarch and estate ---
asset_desc = ""
if scenario.get("assets"):
a = scenario["assets"][0]
asset_desc = (
f" The {a['name']}, valued at €{a['value']:,}, represented "
f"the culmination of a lifetime of work."
)
alive_str = "passed away" if not patriarch.get("alive", True) else "grew elderly"
paragraphs.append(
f"{patriarch_name}, the revered head of the {family_name} family, "
f"{alive_str} at the age of {patriarch.get('age', 80)}.{asset_desc} "
f"The question of inheritance has thrown the family into turmoil."
)
# --- Natural language family relationships ---
# Describe patriarch's children in natural language
patriarch_children = children_of.get(patriarch_id, [])
if patriarch_children:
child_names = []
for cid in patriarch_children:
cp = person_map.get(cid, {})
name = cp.get("name", cid).split()[0] # first name only
status = ""
if not cp.get("alive", True):
status = " (who later passed away)"
child_names.append(f"{name}{status}")
if len(child_names) == 1:
children_text = child_names[0]
elif len(child_names) == 2:
children_text = f"{child_names[0]} and {child_names[1]}"
else:
children_text = (
", ".join(child_names[:-1]) + f", and {child_names[-1]}"
)
n = len(child_names)
if n == 1:
children_verb = rng.choice([
f"{patriarch_first} had a single child",
f"{patriarch_first} had one child",
])
else:
children_verb = rng.choice([
f"{patriarch_first} had {n} children",
f"{patriarch_first} was the father of {n} children" if patriarch.get("gender", "M") == "M"
else f"{patriarch_first} was the mother of {n} children",
f"The {family_name} family included {n} children",
])
paragraphs.append(f"{children_verb}: {children_text}.")
# --- Describe grandchildren naturally (if they exist) ---
grandchild_descriptions = []
for cid in patriarch_children:
cp = person_map.get(cid, {})
gc_ids = children_of.get(cid, [])
if gc_ids:
parent_first = cp.get("name", "").split()[0]
parent_alive = cp.get("alive", True)
gc_parts = []
for gcid in gc_ids:
gcp = person_map.get(gcid, {})
gc_first = gcp.get("name", "").split()[0]
gc_age = gcp.get("age", "?")
gc_parts.append(f"{gc_first} (age {gc_age})")
if len(gc_parts) == 1:
gc_text = gc_parts[0]
verb = rng.choice(["had a child", "had one child"])
else:
gc_text = (
", ".join(gc_parts[:-1]) + f" and {gc_parts[-1]}"
)
verb = rng.choice([
f"had {len(gc_parts)} children",
f"went on to have {len(gc_parts)} children of their own",
])
# Use past tense for dead parents
if not parent_alive:
grandchild_descriptions.append(
f"The late {parent_first} {verb}: {gc_text}."
)
else:
grandchild_descriptions.append(
f"{parent_first} {verb}: {gc_text}."
)
if grandchild_descriptions:
paragraphs.append(" ".join(grandchild_descriptions))
# --- Distractor paragraphs (prominent, emotional, detailed) ---
for did in distractor_ids:
d_person = person_map.get(did)
if not d_person:
continue
phrase = rng.choice(_DISTRACTOR_PHRASES).format(
patriarch=patriarch_first,
family=family_name,
)
# Describe the distractor's relation in natural language
rel_desc = ""
for rel in scenario["relations"]:
if rel["from"] == did or rel["to"] == did:
other_id = rel["to"] if rel["from"] == did else rel["from"]
other = person_map.get(other_id, {})
other_name = other.get("name", "a family member")
if rel["type"] == "isMarriedTo":
rel_desc = f", married to {other_name},"
elif rel["type"] == "isCaregiverOf":
rel_desc = f", the devoted caregiver of {other_name},"
break
paragraphs.append(
f"{d_person['name']}{rel_desc} {phrase}. "
f"Many in the community believed {d_person['name']} deserved "
f"the greatest share of the inheritance, having given so much "
f"to the family over the years."
)
# --- Hard negative sentence (1-2 per scenario) ---
hard_negatives = []
non_blood = [
p for p in scenario["persons"]
if p["id"] in distractor_ids and p.get("alive", True)
]
family_members = [
p for p in scenario["persons"]
if p["id"] != patriarch_id
and p["id"] not in distractor_ids
and p["id"] != gold_id
and p.get("alive", True)
]
# Caregiver hard negative
if non_blood and family_members:
caregiver = rng.choice(non_blood)
charge = rng.choice(family_members)
possessive = "her" if caregiver.get("gender", "F") == "F" else "his"
pronoun = "her" if charge.get("gender", "F") == "F" else "him"
template = rng.choice(_HARD_NEGATIVE_CAREGIVER)
hard_negatives.append(template.format(
person=caregiver["name"],
other=charge["name"].split()[0],
possessive=possessive,
pronoun=pronoun,
))
# Sibling-like hard negative
if len(family_members) >= 2:
pair = rng.sample(family_members, 2)
template = rng.choice(_HARD_NEGATIVE_SIBLING)
hard_negatives.append(template.format(
person=pair[0]["name"].split()[0],
other=pair[1]["name"].split()[0],
))
# Marriage hard negative
if non_blood and family_members:
d = rng.choice(non_blood)
fm = rng.choice(family_members)
template = rng.choice(_HARD_NEGATIVE_MARRIAGE)
hard_negatives.append(template.format(
person=d["name"],
spouse=fm["name"].split()[0],
patriarch=patriarch_first,
))
# Add 1-2 hard negatives
rng.shuffle(hard_negatives)
for hn in hard_negatives[:rng.randint(1, 2)]:
paragraphs.append(hn + ".")
# --- Heir mention (buried, brief, underwhelming) ---
heir_person = person_map.get(gold_id, {})
heir_name = heir_person.get("name", "the heir")
heir_phrase = rng.choice(_HEIR_PHRASES)
paragraphs.append(
f"{heir_name}, age {heir_person.get('age', '?')}, {heir_phrase}."
)
# --- Will clause (stated formally at the end) ---
will_data = scenario["will"]
paragraphs.append(
f'The will of {patriarch_name} stated: "{will_data["clause_text"]}"'
)
return "\n\n".join(paragraphs)
# ---------------------------------------------------------------------------
# Question generator
# ---------------------------------------------------------------------------
def generate_question(scenario: dict) -> str:
"""Produce the inheritance question for the scenario.
Args:
scenario: A scenario dict.
Returns:
A question string.
"""
meta = scenario.get("_metadata", {})
patriarch_id = meta.get("patriarch_id", scenario["will"]["benefactor"])
person_map = {p["id"]: p for p in scenario["persons"]}
patriarch_name = person_map.get(patriarch_id, {}).get("name", "the deceased")
return (
f"Based on the will of {patriarch_name} and the family relationships "
f"described, who is the rightful heir to the estate? "
f"Provide only the full name of the person."
)
# ---------------------------------------------------------------------------
# Batch generator
# ---------------------------------------------------------------------------
def generate_batch(n: int, seed: int = 42) -> list[dict]:
"""Generate *n* scenarios with narratives and questions.
Each dict in the returned list contains:
- ``scenario``: the raw scenario dict
- ``narrative``: the semantically-primed text narrative
- ``question``: the inheritance question
- ``gold_answer``: the correct person's full name
Args:
n: Number of scenarios to generate.
seed: Base seed for reproducibility (each scenario uses seed+i).
Returns:
A list of scenario bundles.
"""
batch: list[dict] = []
for i in range(n):
scenario = generate_scenario(
depth=random.Random(seed + i).choice([2, 3]),
num_distractors=random.Random(seed + i).randint(1, 3),
seed=seed + i,
)
narrative = generate_narrative(scenario)
question = generate_question(scenario)
# Resolve gold answer to full name
person_map = {p["id"]: p for p in scenario["persons"]}
gold_name = person_map.get(
scenario["gold_answer"], {}
).get("name", scenario["gold_answer"])
batch.append({
"scenario": scenario,
"narrative": narrative,
"question": question,
"gold_answer": gold_name,
})
return batch