File size: 8,770 Bytes
8d3cfa6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
#!/usr/bin/env python3
"""Benchmark-scale hard/soft agreement audit on all public MATH solutions.

The paper-specific Atomic Dependency Graph annotations and learned factuality
scorer are not public.  This audit therefore makes a deliberately scoped proxy:
each gold solution is split into reasoning steps, the steps form a chain DAG,
and a deterministic structural risk score is assigned to each step.  The DCF
prediction relaxation itself follows the equations audited for Theorem 3.2.
"""

from __future__ import annotations

import argparse
import csv
import hashlib
import json
import math
import re
from pathlib import Path

import numpy as np
import pyarrow.parquet as pq


DATASET = "https://huggingface.co/datasets/qwedsacf/competition_math"
DATASET_REVISION = "e839825f9ec5c6cfa585c654a59610969ec13993"
ALPHAS = tuple(round(i / 100, 2) for i in range(1, 11))
THRESHOLDS = tuple(round(i / 20, 2) for i in range(1, 20))
TEMPERATURE = 0.01
ANCESTOR_WEIGHT = 1.0


def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def split_steps(solution: str) -> list[str]:
    text = re.sub(r"\s+", " ", solution).strip()
    parts = [part.strip() for part in re.split(r"(?<=[.!?])\s+|(?=\\boxed)|(?=\\Rightarrow)", text)]
    parts = [part for part in parts if len(part) >= 8]
    if not parts:
        parts = [text or "empty"]
    # Bound pathological TeX fragments while retaining full benchmark coverage.
    if len(parts) > 24:
        parts = parts[:23] + [" ".join(parts[23:])]
    return parts


def structural_risk(step: str, row_index: int, step_index: int) -> float:
    """Deterministic, non-learned proxy risk in [0.05, 0.95]."""
    tokens = re.findall(r"[A-Za-z0-9]+|\\[A-Za-z]+", step)
    equation_density = min(1.0, (step.count("=") + step.count("\\")) / 12.0)
    length_term = min(1.0, len(tokens) / 80.0)
    conclusion_bonus = 1.0 if "boxed" in step or "therefore" in step.lower() else 0.0
    digest = hashlib.blake2b(
        f"{row_index}:{step_index}:{step}".encode(), digest_size=8
    ).digest()
    jitter = int.from_bytes(digest, "big") / (2**64 - 1)
    score = 0.10 + 0.42 * length_term + 0.28 * equation_density - 0.10 * conclusion_bonus + 0.20 * jitter
    return min(0.95, max(0.05, score))


def ancestors_for_chain(nodes: int) -> list[list[int]]:
    return [list(range(node)) for node in range(nodes)]


def log_sigmoid(value: float) -> float:
    if value >= 0:
        return -math.log1p(math.exp(-value))
    return value - math.log1p(math.exp(value))


def hard_prediction(risks: list[float], ancestors: list[list[int]], tau_alpha: float) -> list[int]:
    tau_star = max(tau for tau in THRESHOLDS if tau < tau_alpha)
    return [
        int(risks[node] <= tau_star and all(risks[parent] <= tau_star for parent in ancestors[node]))
        for node in range(len(risks))
    ]


def soft_prediction(risks: list[float], ancestors: list[list[int]], tau_alpha: float) -> tuple[list[int], float]:
    # Theorem 3.2 schedule: a=1, b=3, beta=T^-a, tau_z=T^(ab).
    beta = 1.0 / TEMPERATURE
    tau_z = TEMPERATURE**3
    log_weights = [
        beta * tau + log_sigmoid((tau_alpha - tau - math.sqrt(tau_z)) / tau_z)
        for tau in THRESHOLDS
    ]
    maximum = max(log_weights)
    weights = np.exp(np.asarray(log_weights) - maximum)
    weights /= weights.sum()
    memberships = []
    for tau in THRESHOLDS:
        probabilities = [
            1.0 / (1.0 + math.exp(max(-700.0, min(700.0, (risk - tau) / TEMPERATURE))))
            for risk in risks
        ]
        coherent = []
        for node, parent_nodes in enumerate(ancestors):
            indices = [node] + parent_nodes
            node_weights = [1.0] + [ANCESTOR_WEIGHT] * len(parent_nodes)
            denominator = sum(node_weights)
            coherent.append(
                math.exp(
                    sum(
                        weight * math.log(max(probabilities[index], 1e-300))
                        for index, weight in zip(indices, node_weights)
                    )
                    / denominator
                )
            )
        memberships.append(coherent)
    mixed = weights @ np.asarray(memberships)
    return [int(value >= 0.5) for value in mixed], float(weights.max())


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--data", type=Path, default=Path("competition_math.parquet"))
    parser.add_argument("--output", type=Path, default=Path("outputs/math_agreement_v1"))
    args = parser.parse_args()
    args.output.mkdir(parents=True, exist_ok=True)

    table = pq.read_table(args.data, columns=["solution", "level", "type"])
    records = table.to_pylist()
    examples = []
    all_risks = []
    step_counts = []
    for row_index, record in enumerate(records):
        steps = split_steps(record["solution"])
        risks = [structural_risk(step, row_index, step_index) for step_index, step in enumerate(steps)]
        examples.append((risks, ancestors_for_chain(len(risks)), record["level"], record["type"]))
        all_risks.extend(risks)
        step_counts.append(len(steps))

    output_rows = []
    for alpha in ALPHAS:
        # Quantile calibration is performed once over all public solution-step risks.
        tau_alpha = float(np.quantile(np.asarray(all_risks), 1.0 - alpha, method="higher"))
        # Ensure the prediction gate has at least one feasible threshold and is
        # not exactly on a grid point, as in the theorem's separated regime.
        tau_alpha = min(0.999, max(0.051, tau_alpha + 1e-7))
        exact_matches = 0
        node_matches = 0
        node_total = 0
        hard_retained = 0
        soft_retained = 0
        minimum_selected_mass = 1.0
        for risks, ancestors, _, _ in examples:
            hard = hard_prediction(risks, ancestors, tau_alpha)
            soft, selected_mass = soft_prediction(risks, ancestors, tau_alpha)
            exact_matches += int(hard == soft)
            node_matches += sum(int(left == right) for left, right in zip(hard, soft))
            node_total += len(hard)
            hard_retained += sum(hard)
            soft_retained += sum(soft)
            minimum_selected_mass = min(minimum_selected_mass, selected_mass)
        output_rows.append(
            {
                "alpha": alpha,
                "calibrated_tau_alpha": tau_alpha,
                "examples": len(examples),
                "claim_nodes": node_total,
                "exact_set_matches": exact_matches,
                "exact_set_agreement_percent": 100.0 * exact_matches / len(examples),
                "node_matches": node_matches,
                "node_agreement_percent": 100.0 * node_matches / node_total,
                "hard_retained_nodes": hard_retained,
                "soft_retained_nodes": soft_retained,
                "minimum_selected_threshold_mass": minimum_selected_mass,
            }
        )

    with (args.output / "agreement_by_alpha.csv").open("w", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=list(output_rows[0]))
        writer.writeheader()
        writer.writerows(output_rows)
    report = {
        "claim": "DCF soft/hard agreement across alpha=0.01..0.10",
        "scope": "benchmark-scale proxy; not the unreleased paper scorer/ADGs",
        "dataset": DATASET,
        "dataset_revision": DATASET_REVISION,
        "dataset_file_sha256": sha256(args.data),
        "examples": len(examples),
        "solution_claim_nodes": sum(step_counts),
        "step_count": {
            "minimum": min(step_counts),
            "median": float(np.median(step_counts)),
            "maximum": max(step_counts),
        },
        "graph_construction": "sentence/TeX-step split; sequential chain with transitive ancestors",
        "risk_proxy": "deterministic structural features plus pinned BLAKE2 jitter; not learned factuality",
        "threshold_grid": list(THRESHOLDS),
        "temperature": TEMPERATURE,
        "ancestor_weight": ANCESTOR_WEIGHT,
        "rows": output_rows,
        "minimum_exact_set_agreement_percent": min(row["exact_set_agreement_percent"] for row in output_rows),
        "maximum_exact_set_agreement_percent": max(row["exact_set_agreement_percent"] for row in output_rows),
        "minimum_node_agreement_percent": min(row["node_agreement_percent"] for row in output_rows),
        "maximum_node_agreement_percent": max(row["node_agreement_percent"] for row in output_rows),
    }
    (args.output / "summary.json").write_text(json.dumps(report, indent=2) + "\n")
    print(json.dumps(report, indent=2))


if __name__ == "__main__":
    main()