"""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