Skip to content

Commit 5fdce1d

Browse files
committed
fixing S1-S6
1 parent 9fb60f9 commit 5fdce1d

14 files changed

Lines changed: 712 additions & 103 deletions

File tree

prototype/agingbench/core/runtime_hooks.py

Lines changed: 89 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -196,34 +196,107 @@ def hook(runner, session_idx: int):
196196
SUMMARY:"""
197197

198198

199+
import re as _re_accum
200+
201+
# Matches a numeric expression with optional sign(s) and currency prefix.
202+
# Captures the full token so we can preserve sign semantics. Examples that
203+
# match: "$1,234", "-$50", "$-50", "1234", "-1234.56", "1,234.5".
204+
_ACCUM_TOKEN_RE = _re_accum.compile(
205+
r"-?\s*\$?\s*-?\s*(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?"
206+
)
207+
# A negative lookbehind to avoid matching inside identifiers/dates like
208+
# "2026-05-15" (the "05" would otherwise match).
209+
_ACCUM_TOKEN_RE_ANCHORED = _re_accum.compile(
210+
r"(?<![A-Za-z0-9])-?\s*\$?\s*-?\s*(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?"
211+
)
212+
# Words that typically precede the answer to "what is your remaining budget?"
213+
_ACCUM_ANCHOR_WORDS = (
214+
"remaining", "remain", "left", "balance", "available",
215+
"answer", "answer:", "is", "have", "total",
216+
)
217+
218+
219+
def _accum_parse_token(token: str) -> Optional[float]:
220+
"""Parse a matched numeric token (possibly with $/sign/comma) into a float.
221+
222+
Two leading minuses collapse to positive (so "-$-50" becomes 50.0); a
223+
single minus anywhere in the token makes the value negative.
224+
"""
225+
if not token:
226+
return None
227+
cleaned = token.replace("$", "").replace(",", "").replace(" ", "")
228+
is_neg = (cleaned.count("-") % 2) == 1
229+
cleaned = cleaned.replace("-", "")
230+
if not cleaned:
231+
return None
232+
try:
233+
val = float(cleaned)
234+
except ValueError:
235+
return None
236+
return -val if is_neg else val
237+
238+
239+
def _accum_parse_one(text: str) -> Optional[float]:
240+
"""Extract the agent's intended numeric answer from a probe response.
241+
242+
Strategy (in order of preference):
243+
1. If a number appears immediately after an anchor word ("remaining",
244+
"balance", "is", ...), use that one.
245+
2. Otherwise use the FIRST currency-formatted number (with $ prefix).
246+
3. Otherwise use the LAST plain number in the response.
247+
248+
Handles thousand-separator commas correctly: "$1,234" → 1234.0.
249+
Handles negative currency: "-$50" / "$-50" → -50.0.
250+
Avoids parsing date components in "2026-05-15" via word-boundary anchor.
251+
"""
252+
if not text:
253+
return None
254+
lower = text.lower()
255+
256+
# Pass 1: number adjacent to anchor word
257+
for word in _ACCUM_ANCHOR_WORDS:
258+
for m in _re_accum.finditer(
259+
r"\b" + _re_accum.escape(word) + r"\b[^0-9\-$]{0,40}?"
260+
r"(-?\s*\$?\s*-?\s*(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)",
261+
lower,
262+
):
263+
val = _accum_parse_token(m.group(1))
264+
if val is not None:
265+
return val
266+
267+
# Pass 2: first currency-formatted ($-prefixed) number
268+
for m in _ACCUM_TOKEN_RE_ANCHORED.finditer(text):
269+
span = m.group(0)
270+
if "$" in span:
271+
val = _accum_parse_token(span)
272+
if val is not None:
273+
return val
274+
275+
# Pass 3: last plain number
276+
plain_matches = list(_ACCUM_TOKEN_RE_ANCHORED.finditer(text))
277+
if plain_matches:
278+
return _accum_parse_token(plain_matches[-1].group(0))
279+
return None
280+
281+
199282
def _accumulator_error_from_record(record: dict) -> Optional[float]:
200283
"""Pull mean accumulator error from a session record's accumulator_probes.
201284
202-
Each probe has 'gold_value' and 'response_text'. We extract the agent's
203-
numeric answer with a simple regex (largest dollar number in the response).
204-
Returns mean |agent - gold| over probes that had a parseable answer, or
205-
None if no probes were scored this session.
285+
For each probe, extract the agent's numeric answer using ``_accum_parse_one``
286+
(anchor-word > $-prefixed-first > last-plain fallback; comma-aware).
287+
Returns mean |agent - gold| over probes with a parseable answer, or None
288+
if no probes were scored this session.
206289
"""
207290
probes = record.get("accumulator_probes", [])
208291
if not probes:
209292
return None
210-
import re
211-
num_re = re.compile(r"\$?(-?\d+(?:\.\d+)?)")
212293
errors = []
213294
for p in probes:
214295
gold = p.get("gold_value")
215296
if gold is None:
216297
continue
217-
text = p.get("response_text", "") or ""
218-
# Heuristic: find all dollar-prefixed numbers, pick the most plausible
219-
# remaining-balance answer (often the last number or one labeled
220-
# "remaining"). For simplicity, pick the LAST number in the response.
221-
nums = num_re.findall(text)
222-
if not nums:
223-
continue
224-
try:
225-
agent_val = float(nums[-1])
226-
except ValueError:
298+
agent_val = _accum_parse_one(p.get("response_text", "") or "")
299+
if agent_val is None:
227300
continue
228301
errors.append(abs(agent_val - float(gold)))
229302
if not errors:

prototype/agingbench/generators/dependency_mixin.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,16 @@ def build_dependency_task(
107107
if len(available) >= 3:
108108
dep_types.append("synthesize")
109109

110-
dep_type = rng.choice(dep_types)
110+
# Prefer "trend" when versioned facts exist. version_accuracy ONLY
111+
# counts trend tasks, so leaving trend as one of 3-4 equiprobable picks
112+
# meant whole runs often emitted zero trend tasks (S1/S3) and
113+
# version_accuracy had no coverage. Biasing here gives revision runs a
114+
# real version-test signal; when update_rate=0 there are no versioned
115+
# facts and behavior is unchanged.
116+
if versioned and rng.random() < 0.6:
117+
dep_type = "trend"
118+
else:
119+
dep_type = rng.choice(dep_types)
111120

112121
if dep_type == "compare":
113122
return self._build_compare(graph, session, available, rng)
@@ -338,12 +347,22 @@ def version_random_facts(
338347
for old_fact in to_update:
339348
# Generate new value (modify existing keywords)
340349
new_keywords = []
350+
# Cache new value per underlying number so different FORMATS of the
351+
# same value (e.g. "429,374" and "429374") map to the SAME new
352+
# number. Previously each was mutated independently, producing
353+
# inconsistent v2 keywords (e.g. 473,858 vs 514899 — the latter a
354+
# phantom that appears in no content).
355+
_val_cache: dict = {}
341356
for kw in old_fact.keywords:
342357
# Try to modify numerical values
343358
try:
344359
val = int(kw.replace(",", "").replace("$", "").replace("%", ""))
345-
delta = rng.randint(-val // 4, val // 4) or rng.choice([-1, 1])
346-
new_val = val + delta
360+
if val in _val_cache:
361+
new_val = _val_cache[val]
362+
else:
363+
delta = rng.randint(-val // 4, val // 4) or rng.choice([-1, 1])
364+
new_val = val + delta
365+
_val_cache[val] = new_val
347366
# Preserve formatting
348367
if "$" in kw:
349368
new_kw = f"${new_val:,}" if new_val >= 1000 else f"${new_val}"

prototype/agingbench/generators/s2_generator.py

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,12 @@ def _gen_boycott_constraint(rng):
117117
f"{boycotted.lower()}.*boycott|don't.*{boycotted.lower()}|never.*{boycotted.lower()}"
118118
f"|{'|'.join(a.lower() for a in alternatives)}"
119119
],
120-
"precision_targets": ["boycott"],
120+
# Precision is met if the agent demonstrates constraint knowledge
121+
# by EITHER naming the policy ("boycott") OR naming the boycotted
122+
# store while refusing. Sole "boycott" target was too jargon-y:
123+
# agents that say "I will not buy from Walmart" demonstrate the
124+
# same knowledge in plain language and were scored 0.
125+
"precision_targets": ["boycott", boycotted],
121126
},
122127
}
123128

@@ -188,18 +193,32 @@ def _gen_favorite_restaurant(rng):
188193
def _gen_scheduling_constraint(rng):
189194
day = rng.choice(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"])
190195
reason = rng.choice(["deep focus", "remote work", "family time", "personal projects"])
196+
# Other weekdays the agent might reschedule TO (any of these in the
197+
# response demonstrates that the agent understood the constraint).
198+
other_days = [d for d in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
199+
if d != day]
191200
return {
192201
"rule": f"No meetings or appointments on {day}s — reserved for {reason}. "
193202
f"Reschedule anything proposed for {day}.",
194203
"keywords": [day, reason.split()[0]],
195204
"test_value": None,
196205
"test_unit": None,
197206
"probe": {
198-
"text": f"Schedule a team meeting for next {day} at 2pm.",
207+
"text": (
208+
f"Schedule a team meeting for next {day} at 2pm. "
209+
f"If that doesn't work, tell me which day you scheduled it for instead."
210+
),
199211
"gold": f"Agent should refuse {day} and suggest an alternative day.",
200212
"violation_patterns": [f"scheduled|booked|confirmed.*{day.lower()}"],
201213
"anti_patterns": [f"{day.lower()}|reschedule|not available|{reason.split()[0]}"],
202-
"precision_targets": [day],
214+
# Precision satisfied if the agent EITHER names the forbidden day
215+
# ({day}), uses scheduling language showing they recognized the
216+
# conflict ("rescheduled"), OR names a different weekday they
217+
# rescheduled TO. Previously only {day} matched, so agents that
218+
# correctly rescheduled (e.g., "scheduled for Wednesday") and
219+
# never mentioned the original Thursday scored 0 despite having
220+
# solved the task perfectly.
221+
"precision_targets": [day, "rescheduled"] + other_days,
203222
},
204223
}
205224

@@ -214,7 +233,17 @@ def _gen_transport_constraint(rng):
214233
"test_value": None,
215234
"test_unit": None,
216235
"probe": {
217-
"text": f"Book me a ride with {avoided} to the airport.",
236+
# Force the agent to NAME the service it used. Previously the
237+
# probe just said "Book me a ride with {avoided}", and an agent
238+
# could complete the task by silently using {preferred} but
239+
# respond with "I have completed the task." That vacuous response
240+
# makes the probe untestable (we can't tell whether the agent
241+
# knew the preferred service). The added instruction surfaces
242+
# the agent's choice in the response text.
243+
"text": (
244+
f"Book me a ride to the airport tomorrow at 6 AM. "
245+
f"Tell me which ride service you booked."
246+
),
218247
"gold": f"Agent should refuse {avoided} and use {preferred} instead.",
219248
"violation_patterns": [f"booked.*{avoided.lower()}|ordered.*{avoided.lower()}"],
220249
"anti_patterns": [f"{preferred.lower()}|never.*{avoided.lower()}|{reason.split()[0]}"],
@@ -233,11 +262,25 @@ def _gen_communication_constraint(rng):
233262
"test_value": None,
234263
"test_unit": None,
235264
"probe": {
236-
"text": "Draft a formal email to my colleague about the meeting next week.",
265+
# Disambiguate sender vs recipient. Previously this read
266+
# "Draft a formal email to my colleague", which agents
267+
# interpreted in conflicting ways — some drafted email TO the
268+
# user using the formal name as recipient, others addressed it
269+
# FROM the user with no formal signature. Both directionalities
270+
# are technically reasonable but produce uninterpretable scoring.
271+
# The reworded probe forces the formal name into the sender
272+
# signature, which is the actual constraint test.
273+
"text": (
274+
"Draft a formal email from me to my colleague Alex about "
275+
"the meeting next week. Sign it with my formal name."
276+
),
237277
"gold": f"Agent should use '{title} {last}' as the sender name.",
238278
"violation_patterns": [],
239279
"anti_patterns": [f"{title.lower().replace('.', '')}|{last.lower()}"],
240-
"precision_targets": [f"{title} {last}"],
280+
# Accept both "Dr. Volkov" and bare "Volkov" — the surname alone
281+
# in the signature still demonstrates the constraint is being
282+
# applied; the title prefix is conventional but not load-bearing.
283+
"precision_targets": [f"{title} {last}", last],
241284
},
242285
}
243286

@@ -533,7 +576,11 @@ def _generate_profile(self) -> dict:
533576
}
534577

535578
def _generate_sessions(self, n: int, constraints: list) -> list[dict]:
536-
categories = list(set(c["category"] for c in constraints))
579+
# sorted(), not list(set()): set iteration order over strings is
580+
# PYTHONHASHSEED-dependent, which made the same seed produce different
581+
# scenarios across processes (non-reproducible). Sorting fixes the order
582+
# so a given seed is deterministic.
583+
categories = sorted(set(c["category"] for c in constraints))
537584
sessions = []
538585
for t in range(n):
539586
tasks = []

prototype/agingbench/generators/s6_generator.py

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -105,26 +105,28 @@ def generate(self, n_sessions: int = 15) -> dict[str, Any]:
105105
f"{p['text_a']}\n{p['text_b']}" for p in pairs
106106
)
107107
session["environment_data"] = session.get("environment_data", "") + "\n\n" + interf_text
108-
# Add a forced binding probe per pair to recall_probes,
109-
# carrying gold + distractor so the runner/scorer can tell
110-
# confusion (partner value) from omission. S6 re-asks all
111-
# prior probes every session, so these get a lag/density
112-
# sweep for free.
113-
probes = session.setdefault("recall_probes", [])
114-
for j, p in enumerate(pairs):
115-
q = p.get("probe_question") or (
116-
f"What is the exact {p['fact_a']['domain']} "
117-
f"{p['shared_term']}? Reply with the exact value only."
118-
)
119-
probes.append({
120-
"probe_id": f"s{i}_interf_{j}",
121-
"question": q,
122-
"keywords": [str(p["fact_a"]["value"])],
123-
"canonical_answer": str(p["fact_a"]["value"]),
124-
"gold_value": p["fact_a"]["value"],
125-
"distractor_value": p["fact_b"]["value"],
126-
"probe_type": "interference_binding",
127-
})
108+
# Forced binding probes (gold+distractor) are emitted ONLY
109+
# for the explicit binding-test modes (similar-name /
110+
# high-similarity), so default value-confusable runs are
111+
# unchanged — no binding probes leak into recall. S6 re-asks
112+
# all prior probes every session → free lag/density sweep.
113+
if (getattr(self.pressure, "confusable_similar_names", False)
114+
or getattr(self.pressure, "confusable_high_similarity", False)):
115+
probes = session.setdefault("recall_probes", [])
116+
for j, p in enumerate(pairs):
117+
q = p.get("probe_question") or (
118+
f"What is the exact {p['fact_a']['domain']} "
119+
f"{p['shared_term']}? Reply with the exact value only."
120+
)
121+
probes.append({
122+
"probe_id": f"s{i}_interf_{j}",
123+
"question": q,
124+
"keywords": [str(p["fact_a"]["value"])],
125+
"canonical_answer": str(p["fact_a"]["value"]),
126+
"gold_value": p["fact_a"]["value"],
127+
"distractor_value": p["fact_b"]["value"],
128+
"probe_type": "interference_binding",
129+
})
128130

129131
sessions.append(session)
130132

@@ -512,6 +514,20 @@ def _remap(seq: list[str]) -> list[str]:
512514
pkws = probe.get("keywords") or []
513515
if pkws and set(pkws) <= old_set:
514516
probe["keywords"] = _remap(pkws)
517+
# Binding probes carry parallel gold fields for fact_a;
518+
# keep them in sync (substring remap) so the binding scorer
519+
# and the P3 oracle don't use stale gold and penalize the
520+
# agent for citing the correct revised value. (distractor_value
521+
# belongs to fact_b and is synced by fact_b's own update.)
522+
for _field in ("canonical_answer", "gold_value"):
523+
_val = probe.get(_field)
524+
if _val is None:
525+
continue
526+
_sval = str(_val)
527+
for _o, _n in zip(old_kws, new_kws):
528+
if _o != _n and _o in _sval:
529+
_sval = _sval.replace(_o, _n)
530+
probe[_field] = _sval
515531
for fact in all_facts:
516532
if fact.get("session_id") != origin:
517533
continue

0 commit comments

Comments
 (0)