File size: 17,016 Bytes
d209e4f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
"""Executable numeric labels, with expression structures reserved by split."""

from collections.abc import Callable, Mapping
from dataclasses import dataclass
from fractions import Fraction
import random

from autojev.types import Content, Example, JSONValue


@dataclass(frozen=True)
class Expression:
    op: str
    value: Fraction = Fraction(0)
    left: "Expression | None" = None
    right: "Expression | None" = None

    def evaluate(self) -> Fraction:
        if self.op == "number":
            return self.value
        if self.left is None or self.right is None:
            raise ValueError("An operation needs two operands")
        a, b = self.left.evaluate(), self.right.evaluate()
        if self.op == "+":
            return a + b
        if self.op == "-":
            return a - b
        if self.op == "*":
            return a * b
        if self.op == "/":
            return a / b
        raise ValueError(self.op)

    def render(self) -> str:
        if self.op == "number":
            return str(self.value) if self.value >= 0 else f"({self.value})"
        if self.left is None or self.right is None:
            raise ValueError("An operation needs two operands")
        return f"({self.left.render()} {self.op} {self.right.render()})"

    def json(self) -> dict[str, JSONValue]:
        if self.op == "number":
            return {"op": self.op, "value": str(self.value)}
        if self.left is None or self.right is None:
            raise ValueError("An operation needs two operands")
        return {"op": self.op, "left": self.left.json(), "right": self.right.json()}


def number(value: Fraction | int) -> Expression:
    return Expression("number", Fraction(value))


def operation(op: str, a: Expression, b: Expression) -> Expression:
    return Expression(op, left=a, right=b)


def composed(value: Fraction, rng: random.Random, split: str, variant: int) -> Expression:
    """Build an exact value without sharing expression-tree templates across splits."""
    a, b = Fraction(rng.randint(2, 41)), Fraction(rng.randint(2, 23))
    n, op = number, operation
    forms: tuple[Callable[[], Expression], ...]
    if split == "train":
        forms = (
            lambda: op("+", n(value - a), n(a)),
            lambda: op("-", n(value + a), n(a)),
            lambda: op("*", n(value / a), n(a)),
            lambda: op("/", n(value * a), n(a)),
        )
    elif split == "calibration":
        forms = (
            lambda: op("-", op("+", n(value + b - a), n(a)), n(b)),
            lambda: op("/", op("-", n(value * b + a), n(a)), n(b)),
            lambda: op("+", op("*", n((value - b) / a), n(a)), n(b)),
        )
    elif split == "confirmation":
        forms = (
            lambda: op("*", op("+", n(value / b - a), n(a)), n(b)),
            lambda: op("-", op("*", n((value + b) / a), n(a)), n(b)),
            lambda: op("+", op("-", n(value + a - 1 / b), n(a)), op("/", n(1), n(b))),
        )
    else:
        raise ValueError(f"Unknown split: {split}")
    expression = forms[variant % len(forms)]()
    if expression.evaluate() != value:
        raise ValueError("Expression construction changed its value")
    return expression


WORDS = {
    "EN": {
        "intro": "Compare the specified first and second quantities in this operations record. Use exact arithmetic; a/b is a fraction. Square brackets are inclusive bounds. No unstated relationship between records is implied.",
        "first": "First quantity", "second": "Second quantity", "record": "record", "unit": "unit",
        "missing": "not recorded; may be any rational value", "conversion": "Explicit conversion",
        "other": "Separate records; none changes the two queried records", "context": "Operations context",
        "question": "Which relationship is established for the first quantity relative to the second? If multiple relationships remain possible, choose unknown.",
        "less": "The first quantity is smaller.", "equal": "The quantities are equal.",
        "greater": "The first quantity is larger.", "unknown": "The relationship is not determined.",
        "percent": "Percentage computation: the expression gives the base amount; multiply by the stated percentage / 100.",
        "rate": "Rate computation: the expression gives the total amount; divide by the stated duration in hours.",
        "duration": "duration in hours", "percentage": "percentage", "calculation": "calculation",
        "archive": "An additional archived measurement, independent of the queried quantities", "speaker": "Operator",
    },
    "DE": {
        "intro": "Vergleiche die angegebene erste und zweite Größe in diesem Betriebsprotokoll. Rechne exakt; a/b ist ein Bruch. Eckige Klammern geben inklusive Grenzen an. Zwischen Einträgen bestehen keine unausgesprochenen Beziehungen.",
        "first": "Erste Größe", "second": "Zweite Größe", "record": "Eintrag", "unit": "Einheit",
        "missing": "nicht erfasst; jeder rationale Wert ist möglich", "conversion": "Explizite Umrechnung",
        "other": "Separate Einträge; keiner verändert die beiden abgefragten Einträge", "context": "Betriebskontext",
        "question": "Welche Beziehung der ersten zur zweiten Größe ist belegt? Wenn mehrere Beziehungen möglich bleiben, wähle unknown.",
        "less": "Die erste Größe ist kleiner.", "equal": "Die Größen sind gleich.",
        "greater": "Die erste Größe ist größer.", "unknown": "Die Beziehung ist nicht bestimmt.",
        "percent": "Prozentrechnung: Der Ausdruck ergibt den Grundwert; multipliziere ihn mit dem angegebenen Prozentsatz / 100.",
        "rate": "Ratenberechnung: Der Ausdruck ergibt die Gesamtmenge; dividiere sie durch die angegebene Dauer in Stunden.",
        "duration": "Dauer in Stunden", "percentage": "Prozentsatz", "calculation": "Berechnung",
        "archive": "Eine zusätzliche archivierte Messung, unabhängig von den abgefragten Größen", "speaker": "Sachbearbeiter",
    },
    "ES": {
        "intro": "Compara la primera y la segunda cantidad indicadas en este registro operativo. Calcula exactamente; a/b es una fracción. Los corchetes indican límites inclusivos. No se supone ninguna relación no indicada entre registros.",
        "first": "Primera cantidad", "second": "Segunda cantidad", "record": "registro", "unit": "unidad",
        "missing": "no registrada; puede tener cualquier valor racional", "conversion": "Conversión explícita",
        "other": "Registros separados; ninguno modifica los dos registros consultados", "context": "Contexto operativo",
        "question": "¿Qué relación queda establecida entre la primera cantidad y la segunda? Si siguen siendo posibles varias relaciones, elige unknown.",
        "less": "La primera cantidad es menor.", "equal": "Las cantidades son iguales.",
        "greater": "La primera cantidad es mayor.", "unknown": "No se puede determinar la relación.",
        "percent": "Cálculo porcentual: la expresión indica la cantidad base; multiplícala por el porcentaje indicado / 100.",
        "rate": "Cálculo de tasa: la expresión indica la cantidad total; divídela por la duración indicada en horas.",
        "duration": "duración en horas", "percentage": "porcentaje", "calculation": "cálculo",
        "archive": "Una medición archivada adicional, independiente de las cantidades consultadas", "speaker": "Operador",
    },
    "FR": {
        "intro": "Comparez la première et la deuxième quantité indiquées dans ce relevé opérationnel. Calculez exactement ; a/b est une fraction. Les crochets désignent des bornes inclusives. Aucune relation non précisée entre les relevés n'est supposée.",
        "first": "Première quantité", "second": "Deuxième quantité", "record": "relevé", "unit": "unité",
        "missing": "non enregistrée ; toute valeur rationnelle est possible", "conversion": "Conversion explicite",
        "other": "Relevés distincts ; aucun ne modifie les deux relevés demandés", "context": "Contexte opérationnel",
        "question": "Quelle relation est établie entre la première quantité et la deuxième ? Si plusieurs relations restent possibles, choisissez unknown.",
        "less": "La première quantité est inférieure.", "equal": "Les quantités sont égales.",
        "greater": "La première quantité est supérieure.", "unknown": "La relation ne peut pas être déterminée.",
        "percent": "Calcul de pourcentage : l'expression donne la quantité de base ; multipliez-la par le pourcentage indiqué / 100.",
        "rate": "Calcul de débit : l'expression donne la quantité totale ; divisez-la par la durée indiquée en heures.",
        "duration": "durée en heures", "percentage": "pourcentage", "calculation": "calcul",
        "archive": "Une mesure archivée supplémentaire, indépendante des quantités demandées", "speaker": "Opérateur",
    },
}


def compare(a: tuple[Fraction, Fraction] | None, b: tuple[Fraction, Fraction] | None) -> str:
    if a is None or b is None:
        return "unknown"
    if a[1] < b[0]:
        return "less"
    if a[0] > b[1]:
        return "greater"
    if a[0] == a[1] == b[0] == b[1]:
        return "equal"
    return "unknown"


def numeric_example(slot: Mapping[str, JSONValue]) -> Example:
    """Return a machine-checkable decision. Only the rendered state enters the model."""
    seed, split, phenomenon = int(str(slot["seed"])), str(slot["split"]), str(slot["phenomenon"])
    rng = random.Random(seed)
    words = WORDS[str(slot["language"])]
    desired = str(slot["desired_label"])
    denominator = rng.choice((2, 3, 4, 5, 8, 10)) if phenomenon == "fractions" else 1
    center = Fraction(rng.randint(100, 5000), denominator)
    if phenomenon == "signed_values":
        center *= rng.choice((-1, 1))
    gap = Fraction(rng.randint(1, 17), denominator)
    proposed_width = Fraction(rng.randint(1, 9), denominator)
    labels = ("less", "equal", "greater", "unknown")
    first_label = labels[(labels.index(desired) - int(str(slot["variant"]))) % 4]
    pair = (first_label, labels[(labels.index(first_label) + 1) % 4])
    width = proposed_width if phenomenon == "bounds" and "equal" not in pair else Fraction(0)
    a = (center - width, center + width)
    # Keep a family counterfactual local: the second record's decisive amount or availability changes.
    offset = {"less": 2 * width + gap, "equal": Fraction(0), "greater": -2 * width - gap,
              "unknown": Fraction(0)}[desired]
    optional_width = rng.choice((Fraction(0), Fraction(0), gap / 2))
    b_width = max(width, optional_width) if desired != "equal" else Fraction(0)
    if desired == "unknown":
        b_width = max(width, gap)
    b: tuple[Fraction, Fraction] | None = (center + offset - b_width, center + offset + b_width)
    if phenomenon == "missing_information" and desired == "unknown":
        b = None
    label = compare(a, b)
    if label != desired:
        raise ValueError(f"Requested {desired}; computed {label}")
    shape = rng.randrange(12)
    template = f"{split}:{phenomenon}:{shape % (4 if split == 'train' else 3)}"
    units = rng.choice((("m", "cm", Fraction(100)), ("kg", "g", Fraction(1000)),
                        ("h", "min", Fraction(60)), ("L", "mL", Fraction(1000))))
    quantity_units = (units[0], units[1]) if phenomenon == "unit_conversion" else (words["unit"], words["unit"])
    factors = (Fraction(1), 1 / units[2]) if phenomenon == "unit_conversion" else (Fraction(1), Fraction(1))
    records: list[str] = []
    specs: list[JSONValue] = []
    record_ids = (f"R{rng.randint(10000, 49999)}", f"R{rng.randint(50000, 99999)}")
    for index, interval in enumerate((a, b)):
        record_rng = random.Random(seed + 100 + index)
        prefix = f"{record_ids[index]} · {words['unit']}: {quantity_units[index]}"
        if interval is None:
            records.append(f"{prefix} · {words['calculation']}: {words['missing']}")
            specs.append({"id": record_ids[index], "unit": quantity_units[index], "parameter": None,
                          "bounds": None, "factor": str(factors[index])})
            continue
        parameter = record_rng.randint(2, 19) if phenomenon == "rates" else record_rng.choice((5, 10, 15, 20, 25, 40, 60, 75))
        expressions: list[Expression] = []
        for endpoint in interval:
            if expressions and interval[0] == interval[1]:
                expressions.append(expressions[0])
                continue
            value = endpoint / factors[index]
            if phenomenon == "rates":
                value *= parameter
            elif phenomenon == "percentages":
                value *= Fraction(100, parameter)
            expression = composed(value, record_rng, split, shape)
            expressions.append(expression)
        rendered = expressions[0].render() if interval[0] == interval[1] else f"[{expressions[0].render()}, {expressions[1].render()}]"
        description = f"{prefix} · {words['calculation']}: {rendered}"
        if phenomenon in ("rates", "percentages"):
            description += f" · {words['duration' if phenomenon == 'rates' else 'percentage']}: {parameter}"
        records.append(description)
        multiplier = Fraction(1, parameter) if phenomenon == "rates" else Fraction(parameter, 100) if phenomenon == "percentages" else Fraction(1)
        calculated = tuple(expr.evaluate() * multiplier * factors[index] for expr in expressions)
        if calculated != interval:
            raise ValueError("Rendered expression, conversion and reference interval differ")
        specs.append({"id": record_ids[index], "unit": quantity_units[index],
                      "parameter": parameter if phenomenon in ("rates", "percentages") else None,
                      "expressions": [expr.json() for expr in expressions],
                      "factor": str(factors[index]), "multiplier": str(multiplier),
                      "bounds": [str(value) for value in calculated]})
    # Numeric long contexts exercise distraction, not repeated narrative padding.
    length = str(slot["length"])
    auxiliary: list[str] = []
    rng = random.Random(seed + 200)
    for index in range({"short": 0, "medium": 4, "long": 20}[length]):
        identifier = f"S{rng.randint(100000, 999999)}-{index}"
        left, right = rng.randint(2, 7000), rng.randint(2, 7000)
        auxiliary.append(f"{identifier}: {words['archive']}; {words['calculation']}: {left} + {right}; {words['unit']}: {words['unit']}.")
    rng.shuffle(records)
    all_records = records + auxiliary
    rng.shuffle(all_records)
    header = f"{words['intro']}\n{words['context']}: {slot['domain']}.\n{words['first']}: {record_ids[0]}; {words['second']}: {record_ids[1]}."
    if phenomenon == "unit_conversion":
        header += f"\n{words['conversion']}: 1 {units[0]} = {units[2]} {units[1]}."
    if phenomenon in ("percentages", "rates"):
        header += "\n" + words["percent" if phenomenon == "percentages" else "rate"]
    style = str(slot["style"])
    if style == "table":
        body = "\n".join("| " + item.replace(" · ", " | ") + " |" for item in all_records)
    elif style == "dialogue":
        body = "\n".join(f"{words['speaker']} {1 + i % 2}: {item}" for i, item in enumerate(all_records))
    elif style == "bullet_list":
        body = "\n".join("- " + item for item in all_records)
    elif style == "form":
        body = "\n".join(f"{words['record']} {i + 1}: {item}" for i, item in enumerate(all_records))
    elif style == "report":
        body = "\n\n".join(all_records)
    elif style == "message":
        body = "; ".join(all_records)
    else:
        raise ValueError(style)
    options = slot["option_labels"]
    if not isinstance(options, list) or set(map(str, options)) != {"less", "equal", "greater", "unknown"}:
        raise ValueError("Numeric criteria must contain the four relation labels")
    criteria: dict[str, Content | None] = {str(key): words[str(key)] for key in options}
    source: dict[str, JSONValue] = dict(slot)
    source.update({"generator": "python-executable-numeric", "template_id": template,
                   "numeric_spec": {"records": specs, "first": record_ids[0], "second": record_ids[1],
                                    "phenomenon": phenomenon, "unit_conversion": [units[0], units[1], str(units[2])] if phenomenon == "unit_conversion" else None},
                   "reference_calculation": {"a": [str(value) for value in a],
                                             "b": [str(value) for value in b] if b is not None else None,
                                             "label": label}})
    return {"id": str(slot["id"]), "family": str(slot["family"]), "suite": "sft2-numeric",
            "state": header + "\n" + (words["other"] + ":\n" if auxiliary else "") + body,
            "question": {"type": "choice", "instructions": words["question"], "criteria": criteria},
            "label": label, "target": label, "source": source}