"""Predeclare a broad source-grounded holdout, without reading retrieval outputs. All profiles have already been exposed in v1-v3. This is a NEW QUERY-GROUP holdout over the same corpus, explicitly not an unseen-person experiment. Historic cases remain DEV; paraphrases of the same logical task stay together. """ from __future__ import annotations import argparse import copy import json import random import re from collections import Counter from pathlib import Path from .benchmark_v2 import BASE, academic_binding, academics, audit_dataset, fingerprint, independent_pool, make_case, validate_case, write_json from .source_oracle import load_source_oracle, norm SEED = 20261025 QUOTAS = { 'shared_keyword_pair': 40, 'same_academic_year_pair': 40, 'same_start_year_pair': 32, 'person_title_year': 24, 'person_keyword_title': 32, 'document_author_category_multi_hop': 24, 'person_multiple_categories': 24, 'candidate_set_aggregation': 32, 'conjunctive_disambiguation': 40, 'negative_lookup': 32, } def logical_group(case: dict) -> str: """Normalize task/subjects/constraints; never make paraphrases independent.""" graph = case['graph']; category = case['category'] names = sorted(norm(row['value']) for row in graph['start_entities'] if row['type'] == 'person') bindings = graph.get('expected_bindings', []) keywords = sorted({norm(row['keyword']) for row in bindings if row.get('keyword')}) years = sorted({int(value) for value in re.findall(r'\b20\d{2}\b', case['question'])}) # A shared-year query has no requested year; its complete answer is derived. selectors = {'keywords': keywords, 'requested_years': years} if category == 'document_author_category_multi_hop': selectors['categories'] = sorted({norm(row['category']) for row in bindings if row.get('category')}) if graph.get('expected_no_answer'): selectors['negative_constraints'] = { key: norm(value) if isinstance(value, str) else value for key, value in graph.get('negative_constraints', {}).items()} return fingerprint({'category': category, 'subjects': names, 'selectors': selectors}) def paraphrase(case: dict, variant: int) -> str: names = [e['value'] for e in case['graph']['start_entities']] category = case['category']; rows = case['graph']['expected_bindings'] year = next(iter(re.findall(r'\b20\d{2}\b', case['question'])), '') tag = next((r['keyword'] for r in rows if r.get('keyword')), '') if variant == 0 or category == 'negative_lookup': return case['question'] if category == 'shared_keyword_pair': return (f'Majú osoby {names[0]} a {names[1]} spoločné kľúčové slovo {tag}?' if variant == 1 else f'Pre osoby {names[0]} a {names[1]} over rovnaké kľúčové slovo {tag}.') if category == 'same_academic_year_pair': return (f'Ktoré spoločné roky prác majú osoby {names[0]} a {names[1]}?' if variant == 1 else f'Porovnaj rovnaké roky práce pri osobách {names[0]} a {names[1]}.') if category == 'same_start_year_pair': return f'Aký spoločný rok začiatku štúdia majú osoby {names[0]} a {names[1]}?' if category == 'person_multiple_categories': return f'Aké všetky kategórie majú dokumenty osoby {names[0]}?' if category == 'document_author_category_multi_hop': return f'Ktorý autor dokumentu je pri osobe {names[0]} spojený s kategóriou {rows[0]["category"]}?' if category == 'person_title_year': # Preserve an explicit project selector, if present in the source query. label = 'diplomového projektu' if 'diplomoveho projektu' in norm(case['question']) else 'práce' return f'Uveď názov {label} osoby {names[0]} a rok práce pre rok {year}.' if category == 'person_keyword_title': return f'Uveď názov práce osoby {names[0]} v roku {year} s kľúčovým slovom {tag}.' if category == 'candidate_set_aggregation': return f'Z kandidátov {", ".join(names)} vyber osoby s prácou v roku {year}. Aký je ich počet?' if category == 'conjunctive_disambiguation': return f'Ktorá z osôb {names[0]} a {names[1]} spĺňa kľúčové slovo {tag} a rok práce {year}?' return case['question'] def build_pool(oracle: dict) -> dict[str, list[dict]]: names = sorted(oracle['people'], key=norm) pool = independent_pool(oracle, names) negatives = [] # Realistic absent combinations as well as unknown entities. Positive cases # make up 90% of TEST; no easy-negative inflation of the headline score. for index, name in enumerate(names): other_tags = sorted({tag for p in oracle['people'].values() for tag in p['tags'] if norm(tag) not in {norm(t) for t in oracle['people'][name]['tags']}}, key=norm) if other_tags: tag = other_tags[index % len(other_tags)] negatives.append(make_case('', 'test', 'negative_lookup', f'Aký názov práce má osoba {name} s kľúčovým slovom {tag}?', [name], oracle, [], no_answer=True, reason='Requested literal tag is absent from this source document family.', negative_constraints={'keyword': tag})) observed = {r['year'] for r in academics(oracle, name)} absent_year = next(y for y in range(2018, 2027) if y not in observed) negatives.append(make_case('', 'test', 'negative_lookup', f'Aký názov práce má osoba {name} v roku {absent_year}?', [name], oracle, [], no_answer=True, reason='Requested academic year is absent from the original profile.', negative_constraints={'year': absent_year})) for first, last in [('Jozef', 'Neznámy Overovací'), ('Petra', 'Neznáma Overovacia'), ('Juraj', 'Nevyskytujúci Skúšobný'), ('Lucia', 'Chýbajúca Vzorová'), ('Adam', 'Nedostupný Kontrolný'), ('Eva', 'Neprítomná Kontrolná')]: name = f'{first} {last}' negatives.append(make_case('', 'test', 'negative_lookup', f'Aký názov práce má osoba {name}?', [name], oracle, [], no_answer=True, reason='Full name absent from all original profiles.', negative_constraints={'unknown_person': name})) pool['negative_lookup'] = negatives return pool def refresh_titles(case: dict, oracle: dict) -> dict: """Adjudicate title gold from raw sources before looking at TEST predictions.""" if case['category'] not in {'person_title_year', 'person_keyword_title'} or not case['should_answer']: return copy.deepcopy(case) name = case['graph']['start_entities'][0]['value'] years = [int(v) for v in re.findall(r'\b20\d{2}\b', case['question'])] tag = next((b.get('keyword') for b in case['graph']['expected_bindings'] if b.get('keyword')), None) text = norm(case['question']) kind = next((kind for phrase, kind in [('diplomoveho projektu', 'diploma_project'), ('diplomovej prace', 'diploma_thesis'), ('bakalarskej prace', 'bachelor_thesis')] if phrase in text), None) records = [r for r in academics(oracle, name) if r.get('title') and (not years or r['year'] in years) and (kind is None or r['academic_type'] == kind)] bindings = [academic_binding(name, r, title=True, keyword=tag) for r in records] return make_case(case['id'], case['split'], case['category'], case['question'], [name], oracle, bindings, no_answer=not bindings, reason='No explicit title for the requested academic record in original Markdown.', negative_constraints={'title_required': True, **({'year': years[-1]} if years else {}), **({'keyword': tag} if tag else {}), **({'academic_type': kind} if kind else {})}) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', type=Path, default=BASE / 'graphrag_questions_v4.json') parser.add_argument('--artifacts', type=Path, default=BASE / 'results/v4_preparation') parser.add_argument('--seed', type=int, default=SEED) parser.add_argument('--extra-exposed', type=Path, action='append', default=[]) args = parser.parse_args() if args.output.exists() or args.artifacts.exists(): parser.error('Refusing to overwrite a declared benchmark or its preparation artifacts.') oracle = load_source_oracle() historic = json.loads((BASE / 'graphrag_questions_v3.json').read_text()) for path in args.extra_exposed: historic.extend(json.loads(path.read_text())) old_questions = {norm(q['question']) for q in historic} exposed = {logical_group(q) for q in historic} adjudications = [] for index, original in enumerate(historic): corrected = refresh_titles(original, oracle) if corrected['graph']['expected_bindings'] != original['graph']['expected_bindings']: adjudications.append({'id': original['id'], 'split': 'dev', 'old_bindings': original['graph']['expected_bindings'], 'new_bindings': corrected['graph']['expected_bindings'], 'source_evidence': corrected['source_evidence']}) historic[index] = corrected exposed.update(logical_group(q) for q in historic) dev = [] for index, original in enumerate(historic): case = copy.deepcopy(original); case['split'] = 'dev' case['id'] = f'regression-{index + 1:04d}' case['graph']['partition_group'] = logical_group(case) dev.append(case) rng = random.Random(args.seed); holdout = []; capacities = {} for category, cases in build_pool(oracle).items(): unique = {logical_group(c): c for c in cases if logical_group(c) not in exposed and norm(c['question']) not in old_questions} candidates = sorted(unique.values(), key=lambda q: logical_group(q)); rng.shuffle(candidates) quota = QUOTAS[category]; capacities[category] = len(candidates) if len(candidates) < quota: raise RuntimeError(f'Insufficient unseen logical groups for {category}: {len(candidates)} < {quota}') # Random selection is committed before running either split. Never # select or remove a TEST question based on a retrieval outcome. for index, case in enumerate(candidates[:quota]): case = refresh_titles(case, oracle); case['split'] = 'test' case['id'] = f'wide-{len(holdout) + 1:03d}' case['question'] = paraphrase(case, index % 3) case['graph']['partition_group'] = logical_group(case) holdout.append(case) for index, case in enumerate(candidates[quota:quota + min(12, max(0, len(candidates) - quota))]): case = refresh_titles(case, oracle); case['split'] = 'dev' case['id'] = f'wide-dev-{len(dev) + 1:04d}' case['question'] = paraphrase(case, index % 3) case['graph']['partition_group'] = logical_group(case) dev.append(case) questions = dev + holdout problems = {q['id']: validate_case(q, oracle) for q in questions if validate_case(q, oracle)} if problems: raise RuntimeError(f'Source oracle rejected cases: {problems}') groups = {split: {q['graph']['partition_group'] for q in questions if q['split'] == split} for split in ['dev', 'test']} assert not groups['dev'] & groups['test'] assert not {norm(q['question']) for q in dev} & {norm(q['question']) for q in holdout} write_json(args.output, questions) audit = audit_dataset(questions, oracle, args.output) audit['partition_policy'] = {'method': 'disjoint logical query groups', 'shared_source_profiles': True, 'group_function': 'benchmark_v4.logical_group', 'seed': args.seed, 'previously_exposed_groups': sorted(exposed), 'limitations': ['Known source corpus, persons, vocabulary and template families.', 'Fresh questions/fact combinations; not an unseen-person or LLM-answer benchmark.']} write_json(args.artifacts / 'source_audit.json', audit) write_json(args.artifacts / 'source_adjudications.json', { 'reason': 'Raw-source review before any new TEST predictions; preserve historic datasets unchanged.', 'questions': adjudications}) write_json(args.artifacts / 'partition.json', { **audit['partition_policy'], 'dataset_sha256': audit['dataset_sha256'], 'dev_count': len(dev), 'test_count': len(holdout), 'available_unseen_groups': capacities, 'test_categories': dict(Counter(q['category'] for q in holdout)), 'test_positive_count': sum(q['should_answer'] for q in holdout), 'test_negative_count': sum(not q['should_answer'] for q in holdout), 'dev_groups': sorted(groups['dev']), 'test_groups': sorted(groups['test']), 'test_profile_count': len({p for q in holdout for p in q['graph']['source_profiles']}), 'source_corpus': oracle['source_corpus']}) print(json.dumps({'dev': len(dev), 'test': len(holdout), 'source_issues': 0, 'available_unseen_groups': capacities}, ensure_ascii=False, indent=2)) if __name__ == '__main__': main()