Spaces:
Running on Zero
Running on Zero
| """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 | |