Contract: Tool Argument Matchers¶
Feature: 032-multi-turn-test-cases
Consumers: src/holodeck/lib/test_runner/tool_arg_matcher.py, executor turn-assertion path, config loader.
Defines the exact semantics of the three matcher kinds and the call-selection rule. This is the authoritative reference for SC-006 acceptance pairs.
1. Call-selection (per tool name)¶
Within a single turn, for each ExpectedTool(name=T, args=A, count=C):
- Collect all
tool_invocationson the turn whose name containsTas a case-sensitive substring (preserves the existing legacy contract ofvalidate_tool_calls). Preserve the order they were invoked. - Define "matching call" as one where every asserted arg key
kinAhas a valuev_actualsuch thatmatch(A[k], v_actual) == True. Unasserted keys on the actual call are ignored (FR-012, A4). - If ≥
Cmatching calls exist, the assertion passes (defaultC = 1, A5). Otherwise it fails. - First-match-wins is used only to populate
arg_match_details.matched_call_indexin the report; selection itself is order-independent (FR-011 scenario 6).
If A is None (name-only assertion), step 2 collapses to "any call with a matching name".
2. Literal matcher¶
match(expected=LiteralMatcher(v), actual):
def match_literal(v, actual):
# Numeric int/float equivalence (A7)
if isinstance(v, (int, float)) and isinstance(actual, (int, float)) and not isinstance(v, bool) and not isinstance(actual, bool):
return float(v) == float(actual)
# Bool strict
if isinstance(v, bool) or isinstance(actual, bool):
return type(v) == type(actual) and v == actual
# None
if v is None or actual is None:
return v is None and actual is None
# Strings / lists / dicts: deep equality, no normalization
return v == actual
No string normalization (case, whitespace, punctuation) on literal. No separator stripping. That's the fuzzy matcher's job.
3. Fuzzy matcher¶
match(expected=FuzzyMatcher(pattern), actual) — case-insensitive, whitespace-normalized, numeric-aware, separator-tolerant.
def match_fuzzy(pattern, actual):
s_expected = _normalize(pattern)
s_actual = _normalize(str(actual))
# Try numeric equivalence first
n_e = _try_parse_number(s_expected)
n_a = _try_parse_number(s_actual)
if n_e is not None and n_a is not None:
return math.isclose(n_e, n_a, rel_tol=0.0, abs_tol=1e-9)
return s_expected == s_actual
Where:
def _normalize(s: str) -> str:
# lowercase, collapse runs of whitespace, strip thousands separators
s = s.strip().lower()
s = re.sub(r"[,_\u202f]", "", s) # comma, underscore, narrow NBSP
s = re.sub(r"\s+", " ", s)
return s
def _try_parse_number(s: str) -> float | None:
# handle trailing percent
pct = False
if s.endswith("%"):
s = s[:-1].strip()
pct = True
try:
v = float(s)
return v / 100 if pct else v
except ValueError:
return None
Consequences (verified in SC-006 matrix):
- fuzzy("206588") matches 206588, 206588.0, "206588", "206,588", "206 588", " 206588 ".
- fuzzy("14.14%") matches 0.1414 (percent parsing).
- fuzzy("YES") matches "yes", " yes ".
- fuzzy("206588") does not match 206000 (no distance tolerance).
4. Regex matcher¶
match(expected=RegexMatcher(compiled), actual):
def match_regex(compiled, actual):
return compiled.fullmatch(str(actual)) is not None
- Always compared against
str(actual)(FR-014). fullmatchsemantics — equivalent to^…$anchoring without requiring the user to write them.- Pattern compile failures are caught at load time (FR-025), never here.
5. Missing vs extra args¶
| Situation | Outcome |
|---|---|
| Asserted arg key absent on actual call | Turn fails; arg_match_details.unmatched_reason = "arg '<k>' missing". |
| Unasserted arg key present on actual call | Ignored (A4). Never causes failure. |
| Actual call has no arguments and no args asserted | Passes name-only assertion. |
Asserted arg has matcher but actual value is None |
Match is kind-specific: literal requires expected None; fuzzy compares "none"; regex compares "None" (Python str(None)). |
6. Count semantics¶
- Default
count = 1(A5).count >= 1enforced at load time. - Turn passes when ≥
countcalls in the turn satisfy both the name substring match and the arg matchers simultaneously (FR-011). - Calls across different turns never contribute to a single turn's count.
7. Acceptance matrix (SC-006, 20+ pairs)¶
| # | Expected matcher | Actual arg value | Expected result |
|---|---|---|---|
| 1 | a: 206588 |
206588 |
PASS |
| 2 | a: 206588 |
206588.0 |
PASS (int↔float) |
| 3 | a: 206588 |
206000 |
FAIL |
| 4 | a: 206588.0 |
206588 |
PASS |
| 5 | a: {fuzzy: "206588"} |
"206,588" |
PASS (separator tol.) |
| 6 | a: {fuzzy: "206588"} |
"206 588" |
PASS (whitespace tol.) |
| 7 | a: {fuzzy: "206588"} |
206588.0 |
PASS |
| 8 | a: {fuzzy: "206588"} |
205000 |
FAIL |
| 9 | a: {fuzzy: "14.14%"} |
0.1414 |
PASS (percent) |
| 10 | a: {fuzzy: "YES"} |
"yes" |
PASS (case) |
| 11 | a: {fuzzy: "YES"} |
" yes " |
PASS (whitespace trim) |
| 12 | a: {regex: "^2009.*$"} |
"2009 cash flow" |
PASS (anchored fullmatch) |
| 13 | a: {regex: "^2009.*$"} |
"report 2009 cash flow" |
FAIL (not anchored to start) |
| 14 | a: {regex: "^181001(\\.0+)?$"} |
"181001.0" |
PASS |
| 15 | a: {regex: "^181001(\\.0+)?$"} |
"181001.5" |
FAIL |
| 16 | a: 206588 (asserted) |
arg a missing |
FAIL (missing) |
| 17 | a: 206588 (asserted), extra b: "x" |
actual {a: 206588, b: "x", c: "y"} |
PASS (extras ignored) |
| 18 | a: true |
True |
PASS (bool) |
| 19 | a: true |
1 |
FAIL (bool≠int) |
| 20 | a: [1, 2] |
[1, 2] |
PASS (list eq) |
| 21 | a: [1, 2] |
[1.0, 2.0] |
PASS (element numeric eq) |
| 22 | a: {mode: "fast"} |
{mode: "fast"} |
PASS (dict eq) |
| 23 | multi-call, a: {fuzzy: "206588"} |
turn has two subtract calls; second matches |
PASS (any-call wins, FR-011.6) |