SeaWolf-AI commited on
Commit
d209e4f
·
verified ·
1 Parent(s): dbb7b0f

Add decision core: autojev/sft_numeric.py

Browse files
Files changed (1) hide show
  1. autojev/sft_numeric.py +276 -0
autojev/sft_numeric.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Executable numeric labels, with expression structures reserved by split."""
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from dataclasses import dataclass
5
+ from fractions import Fraction
6
+ import random
7
+
8
+ from autojev.types import Content, Example, JSONValue
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Expression:
13
+ op: str
14
+ value: Fraction = Fraction(0)
15
+ left: "Expression | None" = None
16
+ right: "Expression | None" = None
17
+
18
+ def evaluate(self) -> Fraction:
19
+ if self.op == "number":
20
+ return self.value
21
+ if self.left is None or self.right is None:
22
+ raise ValueError("An operation needs two operands")
23
+ a, b = self.left.evaluate(), self.right.evaluate()
24
+ if self.op == "+":
25
+ return a + b
26
+ if self.op == "-":
27
+ return a - b
28
+ if self.op == "*":
29
+ return a * b
30
+ if self.op == "/":
31
+ return a / b
32
+ raise ValueError(self.op)
33
+
34
+ def render(self) -> str:
35
+ if self.op == "number":
36
+ return str(self.value) if self.value >= 0 else f"({self.value})"
37
+ if self.left is None or self.right is None:
38
+ raise ValueError("An operation needs two operands")
39
+ return f"({self.left.render()} {self.op} {self.right.render()})"
40
+
41
+ def json(self) -> dict[str, JSONValue]:
42
+ if self.op == "number":
43
+ return {"op": self.op, "value": str(self.value)}
44
+ if self.left is None or self.right is None:
45
+ raise ValueError("An operation needs two operands")
46
+ return {"op": self.op, "left": self.left.json(), "right": self.right.json()}
47
+
48
+
49
+ def number(value: Fraction | int) -> Expression:
50
+ return Expression("number", Fraction(value))
51
+
52
+
53
+ def operation(op: str, a: Expression, b: Expression) -> Expression:
54
+ return Expression(op, left=a, right=b)
55
+
56
+
57
+ def composed(value: Fraction, rng: random.Random, split: str, variant: int) -> Expression:
58
+ """Build an exact value without sharing expression-tree templates across splits."""
59
+ a, b = Fraction(rng.randint(2, 41)), Fraction(rng.randint(2, 23))
60
+ n, op = number, operation
61
+ forms: tuple[Callable[[], Expression], ...]
62
+ if split == "train":
63
+ forms = (
64
+ lambda: op("+", n(value - a), n(a)),
65
+ lambda: op("-", n(value + a), n(a)),
66
+ lambda: op("*", n(value / a), n(a)),
67
+ lambda: op("/", n(value * a), n(a)),
68
+ )
69
+ elif split == "calibration":
70
+ forms = (
71
+ lambda: op("-", op("+", n(value + b - a), n(a)), n(b)),
72
+ lambda: op("/", op("-", n(value * b + a), n(a)), n(b)),
73
+ lambda: op("+", op("*", n((value - b) / a), n(a)), n(b)),
74
+ )
75
+ elif split == "confirmation":
76
+ forms = (
77
+ lambda: op("*", op("+", n(value / b - a), n(a)), n(b)),
78
+ lambda: op("-", op("*", n((value + b) / a), n(a)), n(b)),
79
+ lambda: op("+", op("-", n(value + a - 1 / b), n(a)), op("/", n(1), n(b))),
80
+ )
81
+ else:
82
+ raise ValueError(f"Unknown split: {split}")
83
+ expression = forms[variant % len(forms)]()
84
+ if expression.evaluate() != value:
85
+ raise ValueError("Expression construction changed its value")
86
+ return expression
87
+
88
+
89
+ WORDS = {
90
+ "EN": {
91
+ "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.",
92
+ "first": "First quantity", "second": "Second quantity", "record": "record", "unit": "unit",
93
+ "missing": "not recorded; may be any rational value", "conversion": "Explicit conversion",
94
+ "other": "Separate records; none changes the two queried records", "context": "Operations context",
95
+ "question": "Which relationship is established for the first quantity relative to the second? If multiple relationships remain possible, choose unknown.",
96
+ "less": "The first quantity is smaller.", "equal": "The quantities are equal.",
97
+ "greater": "The first quantity is larger.", "unknown": "The relationship is not determined.",
98
+ "percent": "Percentage computation: the expression gives the base amount; multiply by the stated percentage / 100.",
99
+ "rate": "Rate computation: the expression gives the total amount; divide by the stated duration in hours.",
100
+ "duration": "duration in hours", "percentage": "percentage", "calculation": "calculation",
101
+ "archive": "An additional archived measurement, independent of the queried quantities", "speaker": "Operator",
102
+ },
103
+ "DE": {
104
+ "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.",
105
+ "first": "Erste Größe", "second": "Zweite Größe", "record": "Eintrag", "unit": "Einheit",
106
+ "missing": "nicht erfasst; jeder rationale Wert ist möglich", "conversion": "Explizite Umrechnung",
107
+ "other": "Separate Einträge; keiner verändert die beiden abgefragten Einträge", "context": "Betriebskontext",
108
+ "question": "Welche Beziehung der ersten zur zweiten Größe ist belegt? Wenn mehrere Beziehungen möglich bleiben, wähle unknown.",
109
+ "less": "Die erste Größe ist kleiner.", "equal": "Die Größen sind gleich.",
110
+ "greater": "Die erste Größe ist größer.", "unknown": "Die Beziehung ist nicht bestimmt.",
111
+ "percent": "Prozentrechnung: Der Ausdruck ergibt den Grundwert; multipliziere ihn mit dem angegebenen Prozentsatz / 100.",
112
+ "rate": "Ratenberechnung: Der Ausdruck ergibt die Gesamtmenge; dividiere sie durch die angegebene Dauer in Stunden.",
113
+ "duration": "Dauer in Stunden", "percentage": "Prozentsatz", "calculation": "Berechnung",
114
+ "archive": "Eine zusätzliche archivierte Messung, unabhängig von den abgefragten Größen", "speaker": "Sachbearbeiter",
115
+ },
116
+ "ES": {
117
+ "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.",
118
+ "first": "Primera cantidad", "second": "Segunda cantidad", "record": "registro", "unit": "unidad",
119
+ "missing": "no registrada; puede tener cualquier valor racional", "conversion": "Conversión explícita",
120
+ "other": "Registros separados; ninguno modifica los dos registros consultados", "context": "Contexto operativo",
121
+ "question": "¿Qué relación queda establecida entre la primera cantidad y la segunda? Si siguen siendo posibles varias relaciones, elige unknown.",
122
+ "less": "La primera cantidad es menor.", "equal": "Las cantidades son iguales.",
123
+ "greater": "La primera cantidad es mayor.", "unknown": "No se puede determinar la relación.",
124
+ "percent": "Cálculo porcentual: la expresión indica la cantidad base; multiplícala por el porcentaje indicado / 100.",
125
+ "rate": "Cálculo de tasa: la expresión indica la cantidad total; divídela por la duración indicada en horas.",
126
+ "duration": "duración en horas", "percentage": "porcentaje", "calculation": "cálculo",
127
+ "archive": "Una medición archivada adicional, independiente de las cantidades consultadas", "speaker": "Operador",
128
+ },
129
+ "FR": {
130
+ "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.",
131
+ "first": "Première quantité", "second": "Deuxième quantité", "record": "relevé", "unit": "unité",
132
+ "missing": "non enregistrée ; toute valeur rationnelle est possible", "conversion": "Conversion explicite",
133
+ "other": "Relevés distincts ; aucun ne modifie les deux relevés demandés", "context": "Contexte opérationnel",
134
+ "question": "Quelle relation est établie entre la première quantité et la deuxième ? Si plusieurs relations restent possibles, choisissez unknown.",
135
+ "less": "La première quantité est inférieure.", "equal": "Les quantités sont égales.",
136
+ "greater": "La première quantité est supérieure.", "unknown": "La relation ne peut pas être déterminée.",
137
+ "percent": "Calcul de pourcentage : l'expression donne la quantité de base ; multipliez-la par le pourcentage indiqué / 100.",
138
+ "rate": "Calcul de débit : l'expression donne la quantité totale ; divisez-la par la durée indiquée en heures.",
139
+ "duration": "durée en heures", "percentage": "pourcentage", "calculation": "calcul",
140
+ "archive": "Une mesure archivée supplémentaire, indépendante des quantités demandées", "speaker": "Opérateur",
141
+ },
142
+ }
143
+
144
+
145
+ def compare(a: tuple[Fraction, Fraction] | None, b: tuple[Fraction, Fraction] | None) -> str:
146
+ if a is None or b is None:
147
+ return "unknown"
148
+ if a[1] < b[0]:
149
+ return "less"
150
+ if a[0] > b[1]:
151
+ return "greater"
152
+ if a[0] == a[1] == b[0] == b[1]:
153
+ return "equal"
154
+ return "unknown"
155
+
156
+
157
+ def numeric_example(slot: Mapping[str, JSONValue]) -> Example:
158
+ """Return a machine-checkable decision. Only the rendered state enters the model."""
159
+ seed, split, phenomenon = int(str(slot["seed"])), str(slot["split"]), str(slot["phenomenon"])
160
+ rng = random.Random(seed)
161
+ words = WORDS[str(slot["language"])]
162
+ desired = str(slot["desired_label"])
163
+ denominator = rng.choice((2, 3, 4, 5, 8, 10)) if phenomenon == "fractions" else 1
164
+ center = Fraction(rng.randint(100, 5000), denominator)
165
+ if phenomenon == "signed_values":
166
+ center *= rng.choice((-1, 1))
167
+ gap = Fraction(rng.randint(1, 17), denominator)
168
+ proposed_width = Fraction(rng.randint(1, 9), denominator)
169
+ labels = ("less", "equal", "greater", "unknown")
170
+ first_label = labels[(labels.index(desired) - int(str(slot["variant"]))) % 4]
171
+ pair = (first_label, labels[(labels.index(first_label) + 1) % 4])
172
+ width = proposed_width if phenomenon == "bounds" and "equal" not in pair else Fraction(0)
173
+ a = (center - width, center + width)
174
+ # Keep a family counterfactual local: the second record's decisive amount or availability changes.
175
+ offset = {"less": 2 * width + gap, "equal": Fraction(0), "greater": -2 * width - gap,
176
+ "unknown": Fraction(0)}[desired]
177
+ optional_width = rng.choice((Fraction(0), Fraction(0), gap / 2))
178
+ b_width = max(width, optional_width) if desired != "equal" else Fraction(0)
179
+ if desired == "unknown":
180
+ b_width = max(width, gap)
181
+ b: tuple[Fraction, Fraction] | None = (center + offset - b_width, center + offset + b_width)
182
+ if phenomenon == "missing_information" and desired == "unknown":
183
+ b = None
184
+ label = compare(a, b)
185
+ if label != desired:
186
+ raise ValueError(f"Requested {desired}; computed {label}")
187
+ shape = rng.randrange(12)
188
+ template = f"{split}:{phenomenon}:{shape % (4 if split == 'train' else 3)}"
189
+ units = rng.choice((("m", "cm", Fraction(100)), ("kg", "g", Fraction(1000)),
190
+ ("h", "min", Fraction(60)), ("L", "mL", Fraction(1000))))
191
+ quantity_units = (units[0], units[1]) if phenomenon == "unit_conversion" else (words["unit"], words["unit"])
192
+ factors = (Fraction(1), 1 / units[2]) if phenomenon == "unit_conversion" else (Fraction(1), Fraction(1))
193
+ records: list[str] = []
194
+ specs: list[JSONValue] = []
195
+ record_ids = (f"R{rng.randint(10000, 49999)}", f"R{rng.randint(50000, 99999)}")
196
+ for index, interval in enumerate((a, b)):
197
+ record_rng = random.Random(seed + 100 + index)
198
+ prefix = f"{record_ids[index]} · {words['unit']}: {quantity_units[index]}"
199
+ if interval is None:
200
+ records.append(f"{prefix} · {words['calculation']}: {words['missing']}")
201
+ specs.append({"id": record_ids[index], "unit": quantity_units[index], "parameter": None,
202
+ "bounds": None, "factor": str(factors[index])})
203
+ continue
204
+ parameter = record_rng.randint(2, 19) if phenomenon == "rates" else record_rng.choice((5, 10, 15, 20, 25, 40, 60, 75))
205
+ expressions: list[Expression] = []
206
+ for endpoint in interval:
207
+ if expressions and interval[0] == interval[1]:
208
+ expressions.append(expressions[0])
209
+ continue
210
+ value = endpoint / factors[index]
211
+ if phenomenon == "rates":
212
+ value *= parameter
213
+ elif phenomenon == "percentages":
214
+ value *= Fraction(100, parameter)
215
+ expression = composed(value, record_rng, split, shape)
216
+ expressions.append(expression)
217
+ rendered = expressions[0].render() if interval[0] == interval[1] else f"[{expressions[0].render()}, {expressions[1].render()}]"
218
+ description = f"{prefix} · {words['calculation']}: {rendered}"
219
+ if phenomenon in ("rates", "percentages"):
220
+ description += f" · {words['duration' if phenomenon == 'rates' else 'percentage']}: {parameter}"
221
+ records.append(description)
222
+ multiplier = Fraction(1, parameter) if phenomenon == "rates" else Fraction(parameter, 100) if phenomenon == "percentages" else Fraction(1)
223
+ calculated = tuple(expr.evaluate() * multiplier * factors[index] for expr in expressions)
224
+ if calculated != interval:
225
+ raise ValueError("Rendered expression, conversion and reference interval differ")
226
+ specs.append({"id": record_ids[index], "unit": quantity_units[index],
227
+ "parameter": parameter if phenomenon in ("rates", "percentages") else None,
228
+ "expressions": [expr.json() for expr in expressions],
229
+ "factor": str(factors[index]), "multiplier": str(multiplier),
230
+ "bounds": [str(value) for value in calculated]})
231
+ # Numeric long contexts exercise distraction, not repeated narrative padding.
232
+ length = str(slot["length"])
233
+ auxiliary: list[str] = []
234
+ rng = random.Random(seed + 200)
235
+ for index in range({"short": 0, "medium": 4, "long": 20}[length]):
236
+ identifier = f"S{rng.randint(100000, 999999)}-{index}"
237
+ left, right = rng.randint(2, 7000), rng.randint(2, 7000)
238
+ auxiliary.append(f"{identifier}: {words['archive']}; {words['calculation']}: {left} + {right}; {words['unit']}: {words['unit']}.")
239
+ rng.shuffle(records)
240
+ all_records = records + auxiliary
241
+ rng.shuffle(all_records)
242
+ header = f"{words['intro']}\n{words['context']}: {slot['domain']}.\n{words['first']}: {record_ids[0]}; {words['second']}: {record_ids[1]}."
243
+ if phenomenon == "unit_conversion":
244
+ header += f"\n{words['conversion']}: 1 {units[0]} = {units[2]} {units[1]}."
245
+ if phenomenon in ("percentages", "rates"):
246
+ header += "\n" + words["percent" if phenomenon == "percentages" else "rate"]
247
+ style = str(slot["style"])
248
+ if style == "table":
249
+ body = "\n".join("| " + item.replace(" · ", " | ") + " |" for item in all_records)
250
+ elif style == "dialogue":
251
+ body = "\n".join(f"{words['speaker']} {1 + i % 2}: {item}" for i, item in enumerate(all_records))
252
+ elif style == "bullet_list":
253
+ body = "\n".join("- " + item for item in all_records)
254
+ elif style == "form":
255
+ body = "\n".join(f"{words['record']} {i + 1}: {item}" for i, item in enumerate(all_records))
256
+ elif style == "report":
257
+ body = "\n\n".join(all_records)
258
+ elif style == "message":
259
+ body = "; ".join(all_records)
260
+ else:
261
+ raise ValueError(style)
262
+ options = slot["option_labels"]
263
+ if not isinstance(options, list) or set(map(str, options)) != {"less", "equal", "greater", "unknown"}:
264
+ raise ValueError("Numeric criteria must contain the four relation labels")
265
+ criteria: dict[str, Content | None] = {str(key): words[str(key)] for key in options}
266
+ source: dict[str, JSONValue] = dict(slot)
267
+ source.update({"generator": "python-executable-numeric", "template_id": template,
268
+ "numeric_spec": {"records": specs, "first": record_ids[0], "second": record_ids[1],
269
+ "phenomenon": phenomenon, "unit_conversion": [units[0], units[1], str(units[2])] if phenomenon == "unit_conversion" else None},
270
+ "reference_calculation": {"a": [str(value) for value in a],
271
+ "b": [str(value) for value in b] if b is not None else None,
272
+ "label": label}})
273
+ return {"id": str(slot["id"]), "family": str(slot["family"]), "suite": "sft2-numeric",
274
+ "state": header + "\n" + (words["other"] + ":\n" if auxiliary else "") + body,
275
+ "question": {"type": "choice", "instructions": words["question"], "criteria": criteria},
276
+ "label": label, "target": label, "source": source}