-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_validation.py
More file actions
262 lines (211 loc) · 11.4 KB
/
Copy pathtest_validation.py
File metadata and controls
262 lines (211 loc) · 11.4 KB
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
"""test_validation.py — offline unit tests for the S9 validate guardrail.
No network calls. Three layers:
1. scenario.validate callable — does it recompute correctly from tool results?
2. _validation_nudge_message — does it name components without stating the sum?
3. agent.run(validate=True) integration — fake run where the model submits wrong then right.
Run: uv run test_validation.py
"""
from __future__ import annotations
import json
# ---------------------------------------------------------------------------
# 1. scenario.validate: the ORDER_SCENARIO recompute-from-evidence function
# ---------------------------------------------------------------------------
def _make_tool_msg(content: dict, tool_call_id: str = "c1") -> dict:
return {"role": "tool", "tool_call_id": tool_call_id, "content": json.dumps(content)}
def test_validate_correct_submission():
from scenario import ORDER_SCENARIO
msgs = [
_make_tool_msg({"customer": "Acme Co.", "item_total_usd": 140, "ship_zone": "WEST"}),
_make_tool_msg({"zone": "WEST", "rate_usd": 18}, tool_call_id="c2"),
]
consistent, evidence = ORDER_SCENARIO.validate(msgs, 158)
assert consistent, "158 should be consistent with 140+18"
assert evidence is None, "consistent submissions return evidence=None"
def test_validate_wrong_submission_missing_shipping():
from scenario import ORDER_SCENARIO
msgs = [
_make_tool_msg({"customer": "Acme Co.", "item_total_usd": 140, "ship_zone": "WEST"}),
_make_tool_msg({"zone": "WEST", "rate_usd": 18}, tool_call_id="c2"),
]
consistent, evidence = ORDER_SCENARIO.validate(msgs, 140)
assert not consistent, "140 is wrong when retrieved 140+18=158"
assert evidence is not None, "inconsistent submissions return evidence dict"
assert evidence["item_total_usd"] == 140
assert evidence["rate_usd"] == 18
assert evidence["expected"] == 158
def test_validate_partial_retrieval_no_rate():
"""If the model only retrieved the order but not the rate, can't recompute — accept."""
from scenario import ORDER_SCENARIO
msgs = [
_make_tool_msg({"customer": "Acme Co.", "item_total_usd": 140, "ship_zone": "WEST"}),
]
consistent, evidence = ORDER_SCENARIO.validate(msgs, 99999)
assert consistent, "missing rate -> can't recompute -> accept (avoid false negative)"
assert evidence is None
def test_validate_no_retrieval_at_all():
from scenario import ORDER_SCENARIO
msgs = [{"role": "user", "content": "What is the total?"}]
consistent, evidence = ORDER_SCENARIO.validate(msgs, 0)
assert consistent, "no tool results -> can't recompute -> accept"
assert evidence is None
def test_validate_never_reads_ground_truth():
"""Validate function must only use tool result messages, NOT scenario.ground_truth."""
from scenario import ORDER_SCENARIO, GROUND_TRUTH
# Feed bogus retrieved data (wrong record values) — validator should recompute from THESE,
# not from GROUND_TRUTH. So submitting 60 (50+10) is "consistent with wrong evidence."
msgs = [
_make_tool_msg({"item_total_usd": 50, "ship_zone": "EAST"}),
_make_tool_msg({"zone": "EAST", "rate_usd": 10}, tool_call_id="c2"),
]
consistent, evidence = ORDER_SCENARIO.validate(msgs, 60)
assert consistent, "60 is consistent with wrong retrieved data (50+10) — validator doesn't check against ground truth"
assert evidence is None
# But 50 (item total only, shipping forgotten) IS inconsistent with THAT wrong data.
consistent2, evidence2 = ORDER_SCENARIO.validate(msgs, 50)
assert not consistent2
assert evidence2 is not None
assert evidence2["expected"] == 60 # recomputed from wrong-record data, not 158
def test_validate_with_validation_tool_result_in_messages():
"""The re-prompt appends a submit_answer tool result — it must not confuse the validator."""
from scenario import ORDER_SCENARIO
msgs = [
_make_tool_msg({"customer": "Acme Co.", "item_total_usd": 140, "ship_zone": "WEST"}),
_make_tool_msg({"zone": "WEST", "rate_usd": 18}, tool_call_id="c2"),
# The validation re-prompt appends a synthetic tool result for submit_answer
{"role": "tool", "tool_call_id": "c3",
"content": "Answer 140 received but does not match retrieved data."},
]
consistent, evidence = ORDER_SCENARIO.validate(msgs, 140)
assert not consistent, "140 is still wrong; synthetic submit_answer tool result must not pollute evidence"
# ---------------------------------------------------------------------------
# 2. _validation_nudge_message: names components, NOT the sum
# ---------------------------------------------------------------------------
def test_nudge_names_components_not_sum():
from agent import _validation_nudge_message
evidence = {"item_total_usd": 140, "rate_usd": 18, "expected": 158}
msg = _validation_nudge_message(140, evidence)
assert "140" in msg, "re-prompt must mention the submitted value"
assert "18" in msg, "re-prompt must mention the shipping rate component"
# 158 (the sum) must NOT appear — the model must still do the addition itself.
assert "158" not in msg, "re-prompt must NOT state the computed sum (D22 bright line)"
assert "submit_answer" in msg.lower() or "resubmit" in msg.lower() or "call" in msg.lower()
def test_nudge_message_without_evidence():
"""validate returns evidence=None only when consistent; this path shouldn't be called,
but the nudge message should still not crash with a generic evidence dict."""
from agent import _validation_nudge_message
evidence = {"item_total_usd": 75, "rate_usd": 12, "expected": 87}
msg = _validation_nudge_message(75, evidence)
assert "75" in msg
assert "12" in msg
assert "87" not in msg
# ---------------------------------------------------------------------------
# 3. agent.run integration: validate=True fires on wrong, accepts correct
#
# We drive agent.run() with a stubbed `chat` whose responses are plain
# types.SimpleNamespace objects (NOT MagicMock: `MagicMock(name="submit_answer")`
# silently swallows the `name` kwarg, so `.name` returns a child mock instead of
# the string — which breaks both the `== final_tool` check and JSON serialization).
# ---------------------------------------------------------------------------
from types import SimpleNamespace as _NS
def _call(cid, fname, args_str):
"""A stub tool_call: `.id`, `.function.name`, `.function.arguments`."""
return _NS(id=cid, function=_NS(name=fname, arguments=args_str))
def _resp(tool_calls, *, content=None, finish_reason="tool_calls", usage=None):
"""A stub chat response shaped like the OpenAI SDK object agent.run() reads."""
msg = _NS(content=content, tool_calls=tool_calls)
return _NS(choices=[_NS(message=msg, finish_reason=finish_reason)], usage=usage)
def test_validate_toggle_off_accepts_wrong_answer():
"""With validate=False (default), a wrong submission (140) is accepted and graded False."""
from unittest.mock import patch
from agent import run
from scenario import ORDER_SCENARIO
# Model submits 140 (wrong) immediately; validate is off so it's accepted as final.
resp = _resp([_call("c99", "submit_answer", '{"value": 140}')])
with patch("agent.chat", return_value=resp):
result = run(ORDER_SCENARIO, validate=False, out_path="/dev/null")
assert result["stop"] == "submitted"
assert result["submitted"] == 140
assert result["correct"] is False
assert result["validations"] == 0
def test_validate_toggle_on_reprompts_wrong_then_accepts_correct():
"""With validate=True, a wrong submission triggers a re-prompt; the corrected one is accepted."""
from unittest.mock import patch
from agent import run
from scenario import ORDER_SCENARIO
# Turn 0: get_order · Turn 1: get_ship_rate · Turn 2: submit 140 (wrong -> re-prompt) ·
# Turn 3: submit 158 (correct -> accepted).
side_effects = [
_resp([_call("c1", "get_order", '{"order_id": "ORD-204"}')]),
_resp([_call("c2", "get_ship_rate", '{"zone": "WEST"}')]),
_resp([_call("c3", "submit_answer", '{"value": 140}')]),
_resp([_call("c4", "submit_answer", '{"value": 158}')]),
]
with patch("agent.chat", side_effect=side_effects):
result = run(ORDER_SCENARIO, validate=True, max_steps=8, out_path="/dev/null")
assert result["stop"] == "submitted"
assert result["submitted"] == 158
assert result["correct"] is True
assert result["validations"] == 1, "exactly one validation re-prompt should have fired"
def test_validate_accepts_correct_without_repromotion():
"""With validate=True, a correct first submission (158) is accepted without any re-prompt."""
from unittest.mock import patch
from agent import run
from scenario import ORDER_SCENARIO
side_effects = [
_resp([_call("c1", "get_order", '{"order_id": "ORD-204"}')]),
_resp([_call("c2", "get_ship_rate", '{"zone": "WEST"}')]),
_resp([_call("c3", "submit_answer", '{"value": 158}')]),
]
with patch("agent.chat", side_effect=side_effects):
result = run(ORDER_SCENARIO, validate=True, max_steps=8, out_path="/dev/null")
assert result["stop"] == "submitted"
assert result["submitted"] == 158
assert result["correct"] is True
assert result["validations"] == 0
def test_validate_accepts_when_evidence_incomplete():
"""If only the order is retrieved (no rate yet), validate can't recompute — accepts the answer."""
from unittest.mock import patch
from agent import run
from scenario import ORDER_SCENARIO
side_effects = [
_resp([_call("c1", "get_order", '{"order_id": "ORD-204"}')]),
# Skip get_ship_rate — submit without the rate lookup.
_resp([_call("c2", "submit_answer", '{"value": 140}')]),
]
with patch("agent.chat", side_effect=side_effects):
result = run(ORDER_SCENARIO, validate=True, max_steps=8, out_path="/dev/null")
# Missing rate evidence -> can't recompute -> no re-prompt -> accepted (but wrong per oracle).
assert result["stop"] == "submitted"
assert result["validations"] == 0, "no re-prompt when evidence is incomplete"
assert result["correct"] is False # oracle still catches it
# ---------------------------------------------------------------------------
# runner
# ---------------------------------------------------------------------------
def _run_all():
tests = [
test_validate_correct_submission,
test_validate_wrong_submission_missing_shipping,
test_validate_partial_retrieval_no_rate,
test_validate_no_retrieval_at_all,
test_validate_never_reads_ground_truth,
test_validate_with_validation_tool_result_in_messages,
test_nudge_names_components_not_sum,
test_nudge_message_without_evidence,
test_validate_toggle_off_accepts_wrong_answer,
test_validate_toggle_on_reprompts_wrong_then_accepts_correct,
test_validate_accepts_correct_without_repromotion,
test_validate_accepts_when_evidence_incomplete,
]
passed = failed = 0
for t in tests:
try:
t()
print(f" PASS {t.__name__}")
passed += 1
except Exception as exc:
print(f" FAIL {t.__name__}: {exc}")
failed += 1
print(f"\n{passed + failed} tests — {passed} passed, {failed} failed")
return failed
if __name__ == "__main__":
raise SystemExit(_run_all())