diff options
| author | Anonymous Authors <anonymous@invalid.example> | 2026-07-24 13:24:36 -0500 |
|---|---|---|
| committer | Anonymous Authors <anonymous@invalid.example> | 2026-07-24 13:24:36 -0500 |
| commit | db293f3606a97b3e417de27124858e134005acbd (patch) | |
| tree | 8efeedcd2033b82d1c90eb0cb84e134421ff1a8f /src/gap_pipeline/offline.py | |
Add minimal GAP reproduction package
Diffstat (limited to 'src/gap_pipeline/offline.py')
| -rw-r--r-- | src/gap_pipeline/offline.py | 279 |
1 files changed, 279 insertions, 0 deletions
diff --git a/src/gap_pipeline/offline.py b/src/gap_pipeline/offline.py new file mode 100644 index 0000000..2be8922 --- /dev/null +++ b/src/gap_pipeline/offline.py @@ -0,0 +1,279 @@ +"""No-API validation, released-data alignment, and run statistics.""" + +from __future__ import annotations + +import json +import re +from collections import Counter +from pathlib import Path +from typing import Any + +from .store import sha256_payload +from .surface import IDENTIFIER_RE, SURFACE_FAMILIES + + +REQUIRED_TOP_LEVEL = { + "index", + "question", + "solution", + "vars", + "params", + "sci_consts", + "variants", + "problem_type", +} + + +def load_dataset(dataset_dir: Path) -> dict[str, dict[str, Any]]: + records: dict[str, dict[str, Any]] = {} + for path in sorted(dataset_dir.glob("*.json")): + record = json.loads(path.read_text(encoding="utf-8")) + item_id = str(record.get("index", "")) + if not item_id: + raise ValueError(f"{path}: missing index") + if item_id in records: + raise ValueError(f"duplicate dataset ID {item_id}") + records[item_id] = record + return records + + +def normalize_latex_symbol(value: str) -> str: + """Normalize notation aliases used in the released rename maps. + + The public records sometimes contain both ``x_n`` and ``x_{n}``, or use a + different number of escaped backslashes between the symbol inventory and a + map key. These are representation aliases, not two mathematical symbols. + This normalization is deliberately used only for diagnostics; it never + rewrites released text. + """ + + normalized = re.sub(r"\\\\+", r"\\", str(value)) + normalized = re.sub(r"\s+", "", normalized) + previous = None + while previous != normalized: + previous = normalized + normalized = re.sub(r"_\{([^{}]+)\}", r"_\1", normalized) + return normalized + + +def validate_public_dataset(dataset_dir: Path) -> dict[str, Any]: + records = load_dataset(dataset_dir) + errors: list[str] = [] + warnings: list[str] = [] + family_counts: Counter[str] = Counter() + kernel_metadata_counts: Counter[str] = Counter() + potential_target_collision_count = 0 + unchanged_mapping_count = 0 + tex_target_count = 0 + alias_source_count = 0 + source_outside_inventory_count = 0 + + for item_id, record in records.items(): + missing = REQUIRED_TOP_LEVEL - set(record) + if missing: + errors.append(f"{item_id}: missing top-level fields {sorted(missing)}") + continue + if not record["question"].strip() or not record["solution"].strip(): + errors.append(f"{item_id}: empty canonical question or solution") + expected_symbols = set(record.get("vars", [])) | set(record.get("params", [])) + normalized_expected = { + normalize_latex_symbol(value) for value in expected_symbols + } + variants = record["variants"] + + for family in SURFACE_FAMILIES: + family_counts[family] += int(family in variants) + if family not in variants: + errors.append(f"{item_id}: missing surface family {family}") + continue + variant = variants[family] + missing_variant = {"map", "question", "solution"} - set(variant) + if missing_variant: + errors.append( + f"{item_id}/{family}: missing fields {sorted(missing_variant)}" + ) + continue + rename_map = variant["map"] + canonical_sources: dict[str, str] = {} + missing_sources: list[str] = [] + for source in rename_map: + normalized_source = normalize_latex_symbol(source) + canonical_sources[source] = normalized_source + if source not in expected_symbols and normalized_source in normalized_expected: + alias_source_count += 1 + elif normalized_source not in normalized_expected: + missing_sources.append(source) + source_outside_inventory_count += 1 + if missing_sources: + warnings.append( + f"{item_id}/{family}: map sources absent from normalized " + f"symbol inventory {sorted(missing_sources)}" + ) + + # Alias keys such as x_n and x_{n} are one source. A collision is + # counted only if two distinct normalized sources share a target. + target_to_sources: dict[str, set[str]] = {} + for source, replacement in rename_map.items(): + target_to_sources.setdefault( + normalize_latex_symbol(replacement), set() + ).add(canonical_sources[source]) + if normalize_latex_symbol(source) == normalize_latex_symbol( + replacement + ): + unchanged_mapping_count += 1 + if not IDENTIFIER_RE.fullmatch(str(replacement)): + tex_target_count += 1 + if not str(replacement).strip(): + errors.append(f"{item_id}/{family}: empty replacement target") + potential_target_collision_count += sum( + len(sources) - 1 + for sources in target_to_sources.values() + if len(sources) > 1 + ) + + if "kernel_variant" not in variants: + errors.append(f"{item_id}: missing kernel_variant") + else: + family_counts["kernel_variant"] += 1 + kernel = variants["kernel_variant"] + if not str(kernel.get("question", "")).strip(): + errors.append(f"{item_id}/kernel_variant: empty question") + if not str(kernel.get("solution", "")).strip(): + errors.append(f"{item_id}/kernel_variant: empty solution") + if "_meta" in kernel: + kernel_metadata_counts["_meta"] += 1 + if "metadata" in kernel: + kernel_metadata_counts["metadata"] += 1 + if "_replacement_note" in kernel: + kernel_metadata_counts["_replacement_note"] += 1 + return { + "dataset_dir": str(dataset_dir.resolve()), + "record_count": len(records), + "expected_record_count": 1051, + "record_count_matches": len(records) == 1051, + "family_counts": dict(family_counts), + "kernel_metadata_shapes": dict(kernel_metadata_counts), + "surface_map_checks": { + "potential_target_collision_count": potential_target_collision_count, + "unchanged_mapping_count": unchanged_mapping_count, + "tex_or_structured_target_count": tex_target_count, + "notation_alias_source_count": alias_source_count, + "source_outside_normalized_inventory_count": ( + source_outside_inventory_count + ), + "interpretation": ( + "TeX/structured targets, notation aliases, and potential " + "target collisions are descriptive diagnostics, not validation " + "errors. Strict rules apply only to newly generated maps." + ), + }, + "errors": errors, + "warnings": warnings, + "valid": not errors, + } + + +def normalize_text(value: str) -> str: + return re.sub(r"\s+", " ", value).strip() + + +def summarize_run(run_dir: Path) -> dict[str, Any]: + finals = sorted(run_dir.glob("items/*/final.json")) + counts: Counter[str] = Counter() + iterations: list[int] = [] + first_round_failed = 0 + repaired_accepts = 0 + cap_reached = 0 + accepted_ids: list[str] = [] + rejected_ids: list[str] = [] + + for path in finals: + payload = json.loads(path.read_text(encoding="utf-8")) + status = str(payload["status"]) + counts[status] += 1 + item_id = str(payload["item_id"]) + history = payload.get("iterations", []) + iterations.append(len(history)) + if history and not history[0].get("unanimous", False): + first_round_failed += 1 + if status == "accepted": + accepted_ids.append(item_id) + if any(row.get("repaired", False) for row in history): + repaired_accepts += 1 + else: + rejected_ids.append(item_id) + if len(history) >= 15: + cap_reached += 1 + + return { + "run_dir": str(run_dir.resolve()), + "candidate_count": len(finals), + "status_counts": dict(counts), + "failed_first_unanimous_vote": first_round_failed, + "accepted_after_repair": repaired_accepts, + "finally_rejected": counts["rejected"], + "reached_iteration_cap": cap_reached, + "iteration_min": min(iterations) if iterations else None, + "iteration_max": max(iterations) if iterations else None, + "iteration_mean": sum(iterations) / len(iterations) if iterations else None, + "accepted_ids": accepted_ids, + "rejected_ids": rejected_ids, + } + + +def align_run_to_release(run_dir: Path, dataset_dir: Path) -> dict[str, Any]: + release = load_dataset(dataset_dir) + rows = [] + for path in sorted(run_dir.glob("items/*/final.json")): + payload = json.loads(path.read_text(encoding="utf-8")) + if payload.get("status") != "accepted": + continue + item_id = str(payload["item_id"]) + candidate = payload["accepted_candidate"] + if item_id not in release: + rows.append({"item_id": item_id, "status": "missing_from_release"}) + continue + released_kernel = release[item_id]["variants"]["kernel_variant"] + question_exact = candidate["problem"] == released_kernel["question"] + solution_exact = candidate["proof"] == released_kernel["solution"] + question_normalized = normalize_text(candidate["problem"]) == normalize_text( + released_kernel["question"] + ) + solution_normalized = normalize_text(candidate["proof"]) == normalize_text( + released_kernel["solution"] + ) + rows.append( + { + "item_id": item_id, + "status": ( + "exact_match" + if question_exact and solution_exact + else "normalized_match" + if question_normalized and solution_normalized + else "mismatch" + ), + "question_exact": question_exact, + "solution_exact": solution_exact, + "question_normalized": question_normalized, + "solution_normalized": solution_normalized, + "candidate_sha256": sha256_payload( + { + "question": candidate["problem"], + "solution": candidate["proof"], + } + ), + "release_sha256": sha256_payload( + { + "question": released_kernel["question"], + "solution": released_kernel["solution"], + } + ), + } + ) + return { + "run_dir": str(run_dir.resolve()), + "dataset_dir": str(dataset_dir.resolve()), + "checked": len(rows), + "status_counts": dict(Counter(row["status"] for row in rows)), + "rows": rows, + } |
