summaryrefslogtreecommitdiff
path: root/src/gap_pipeline/surface.py
blob: 7de4e9065e6169274f0066bbbafb23260a6c1fa0 (plain)
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
213
214
215
216
217
"""Surface-renaming generation, application, and deterministic validation."""

from __future__ import annotations

import asyncio
import hashlib
import re
from dataclasses import dataclass
from typing import Literal

from .clients import JsonLLM
from .models import CanonicalItem, ModelCallRecord
from .prompts import SURFACE_NAME_SYSTEM, surface_name_user
from .store import RunStore


SurfaceFamily = Literal[
    "descriptive_long",
    "descriptive_long_confusing",
    "descriptive_long_misleading",
    "garbled_string",
]

SURFACE_FAMILIES: tuple[SurfaceFamily, ...] = (
    "descriptive_long",
    "descriptive_long_confusing",
    "descriptive_long_misleading",
    "garbled_string",
)

IDENTIFIER_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]{2,63}$")
MATH_SPAN_RE = re.compile(
    r"(?P<dollar>\${1,2}.*?\${1,2})"
    r"|(?P<paren>\\\(.*?\\\))"
    r"|(?P<bracket>\\\[.*?\\\])"
    r"|(?P<env>\\begin\{(?P<envname>[A-Za-z*]+)\}.*?\\end\{(?P=envname)\})",
    flags=re.DOTALL,
)


@dataclass(frozen=True)
class SurfaceVariant:
    family: SurfaceFamily
    rename_map: dict[str, str]
    problem: str
    solution: str

    def as_release_payload(self) -> dict[str, object]:
        return {
            "map": dict(self.rename_map),
            "question": self.problem,
            "solution": self.solution,
        }


def validate_rename_map(
    rename_map: dict[str, str],
    *,
    existing_identifiers: list[str],
    scientific_constants: list[str],
) -> None:
    if not rename_map:
        raise ValueError("surface rename map cannot be empty")
    if len(rename_map.values()) != len(set(rename_map.values())):
        raise ValueError("surface replacements must be one-to-one")
    originals = set(existing_identifiers) | set(scientific_constants)
    for old, new in rename_map.items():
        if old not in existing_identifiers:
            raise ValueError(f"rename source {old!r} is not in the symbol inventory")
        if new in originals:
            raise ValueError(f"replacement {new!r} collides with an existing identifier")
        if not IDENTIFIER_RE.fullmatch(new):
            raise ValueError(f"invalid replacement identifier {new!r}")
        if new.startswith("\\"):
            raise ValueError("replacement may not be a LaTeX command")


def _replace_in_span(span: str, rename_map: dict[str, str]) -> str:
    result = span
    for old in sorted(rename_map, key=lambda value: (-len(value), value)):
        # ASCII identifier boundaries avoid r -> radius changing \sqrt or prose.
        pattern = re.compile(
            rf"(?<![A-Za-z0-9]){re.escape(old)}(?![A-Za-z0-9])"
        )
        result = pattern.sub(lambda _: rename_map[old], result)
    return result


def apply_rename_map(text: str, rename_map: dict[str, str]) -> str:
    """Rename identifiers only inside explicit LaTeX math spans.

    This deliberately avoids the old prototype's ``a`` -> identifier mutation
    in ordinary English prose. PutnamGAP source records use explicit math
    delimiters for mathematical identifiers.
    """

    output: list[str] = []
    cursor = 0
    for match in MATH_SPAN_RE.finditer(text):
        output.append(text[cursor : match.start()])
        output.append(_replace_in_span(match.group(0), rename_map))
        cursor = match.end()
    output.append(text[cursor:])
    return "".join(output)


def create_surface_variant(
    item: CanonicalItem,
    family: SurfaceFamily,
    rename_map: dict[str, str],
) -> SurfaceVariant:
    existing = item.variables + item.parameters
    validate_rename_map(
        rename_map,
        existing_identifiers=existing,
        scientific_constants=item.scientific_constants,
    )
    return SurfaceVariant(
        family=family,
        rename_map=dict(rename_map),
        problem=apply_rename_map(item.problem, rename_map),
        solution=apply_rename_map(item.solution, rename_map),
    )


class SurfacePipeline:
    def __init__(self, proposer: JsonLLM, store: RunStore) -> None:
        self.proposer = proposer
        self.store = store

    async def propose_map(
        self,
        item: CanonicalItem,
        family: SurfaceFamily,
    ) -> dict[str, str]:
        symbols = [(value, "free variable") for value in item.variables] + [
            (value, "fixed parameter") for value in item.parameters
        ]
        forbidden = list(
            dict.fromkeys(
                item.variables + item.parameters + item.scientific_constants
            )
        )

        async def propose(symbol: str, role: str) -> tuple[str, str]:
            request_id = f"{item.item_id}.surface.{family}.{symbol}"
            user_prompt = surface_name_user(
                symbol=symbol,
                role=role,
                family=family,
                context=(item.problem + "\n" + item.solution)[:12_000],
                forbidden=forbidden,
            )
            response = await self.proposer.generate_json(
                system_prompt=SURFACE_NAME_SYSTEM,
                user_prompt=user_prompt,
                request_id=request_id,
            )
            self.store.write_call(
                ModelCallRecord(
                    request_id=request_id,
                    model=response.model,
                    system_prompt=SURFACE_NAME_SYSTEM,
                    user_prompt=user_prompt,
                    response_data=response.data,
                    raw_text=response.raw_text,
                    provider_response_id=response.response_id,
                    usage=response.usage,
                )
            )
            replacement = str(response.data["replacement"])
            return symbol, replacement

        proposals = await asyncio.gather(
            *(propose(symbol, role) for symbol, role in symbols)
        )
        rename_map = dict(proposals)
        validate_rename_map(
            rename_map,
            existing_identifiers=item.variables + item.parameters,
            scientific_constants=item.scientific_constants,
        )
        self.store.write_stage(
            f"surface_{family}_map",
            {"rename_map": rename_map},
            request_id=None,
        )
        return rename_map

    async def run_family(
        self,
        item: CanonicalItem,
        family: SurfaceFamily,
    ) -> SurfaceVariant:
        rename_map = await self.propose_map(item, family)
        variant = create_surface_variant(item, family, rename_map)
        self.store.write_stage(
            f"surface_{family}_variant",
            variant.as_release_payload(),
            request_id=None,
        )
        return variant

    async def run_all(self, item: CanonicalItem) -> dict[SurfaceFamily, SurfaceVariant]:
        # Run families sequentially so the immutable call log stays easy to
        # inspect; symbol proposals within each family remain concurrent.
        output: dict[SurfaceFamily, SurfaceVariant] = {}
        for family in SURFACE_FAMILIES:
            output[family] = await self.run_family(item, family)
        return output


def deterministic_garbled_name(item_id: str, symbol: str, length: int = 12) -> str:
    """Stable offline GS name for smoke tests; production follows the proposer."""

    digest = hashlib.sha256(f"{item_id}:{symbol}".encode()).hexdigest()
    return "v" + digest[: max(3, length - 1)]