Skip to content

Commit 38b958f

Browse files
bokelleyclaude
andcommitted
feat(validation): oneOf near-miss validator hints + issues[].hint on every VALIDATION_ERROR
When a discriminated-union (`oneOf`) shape fails validation because the caller used the wrong key as the discriminator (the v3 reference-seller `pricing_options` regression: `{"type": "cpm", ...}` instead of `{"pricing_model": "cpm", ...}`), an additive `hint` field on the `VALIDATION_ERROR` issue names the closest matching variant and the wrong / expected discriminator keys: Looks like you may have meant the 'cpm' variant. Use 'pricing_model' instead of 'type' as the discriminator. The hint is best-effort: when no clear winner exists across variants, no hint is emitted (silent is better than misleading). Clients that ignore the new field behave exactly as before — `hint` is only added to the wire envelope's `issues[i]` dict when populated. Heuristic (in `adcp.validation.oneof_hints.compute_oneof_hint`): 1. Walk to the schema's `oneOf` keyword via the failing issue's `absolute_schema_path`. 2. Detect the discriminator field — a property pinned by `const` in at least two variants. No discriminator -> no hint. 3. Score each variant by `(const_match, required_present, total_present)`: the strongest signal is whether any value in the payload matches a variant's discriminator `const` (the wrong-key case); shape match is the tiebreaker. Tie at the top -> no hint. 4. Identify the wrong key in the payload — first key whose value matches the best variant's `const`, or first key not declared by any variant's `properties`. Plumbed through `validate_request` / `validate_response` (which now forward the validator's resolved schema + payload to `_format_error`) and surfaced via the existing `_issue_to_wire` helper used by both `SchemaValidationError.details` and `build_adcp_validation_error_payload`. Closes #460. Refs #452. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4ba19fc commit 38b958f

4 files changed

Lines changed: 822 additions & 23 deletions

File tree

src/adcp/validation/oneof_hints.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""Heuristic ``hint`` strings for ``oneOf`` near-miss validation failures.
2+
3+
When a payload fails a discriminated-union (``oneOf``) shape because the
4+
caller used the wrong key as the discriminator (the v3 ref seller
5+
``pricing_options`` regression: ``{"type": "cpm", ...}`` instead of
6+
``{"pricing_model": "cpm", ...}``), the standard jsonschema diagnostic is
7+
``"<value> is not valid under any of the given schemas"`` — accurate but
8+
unactionable for an LLM client.
9+
10+
This module computes an additive ``hint`` string that names the closest
11+
matching variant and the wrong / expected discriminator keys:
12+
13+
Looks like you may have meant the 'cpm' variant. Use 'pricing_model'
14+
instead of 'type' as the discriminator.
15+
16+
The hint is best-effort: if no clear winner exists across variants, no
17+
hint is emitted (silent is better than misleading).
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from typing import Any
23+
24+
25+
def _navigate(schema: Any, path_segments: list[Any]) -> Any | None:
26+
"""Walk ``schema`` along ``path_segments`` (jsonschema absolute_schema_path).
27+
28+
Returns the sub-schema at the path or ``None`` if any segment misses.
29+
"""
30+
node: Any = schema
31+
for seg in path_segments:
32+
if isinstance(node, dict):
33+
if seg in node:
34+
node = node[seg]
35+
continue
36+
# jsonschema sometimes emits int-as-string segments
37+
if isinstance(seg, int) and str(seg) in node:
38+
node = node[str(seg)]
39+
continue
40+
return None
41+
if isinstance(node, list):
42+
try:
43+
node = node[int(seg)]
44+
continue
45+
except (ValueError, IndexError, TypeError):
46+
return None
47+
return None
48+
return node
49+
50+
51+
def _navigate_input(payload: Any, path_segments: list[Any]) -> Any | None:
52+
"""Walk the request/response payload along an instance path."""
53+
node: Any = payload
54+
for seg in path_segments:
55+
if isinstance(node, dict):
56+
if seg in node:
57+
node = node[seg]
58+
continue
59+
return None
60+
if isinstance(node, list):
61+
try:
62+
node = node[int(seg)]
63+
continue
64+
except (ValueError, IndexError, TypeError):
65+
return None
66+
return None
67+
return node
68+
69+
70+
def _detect_discriminator(variants: list[dict[str, Any]]) -> str | None:
71+
"""Identify the discriminator field across ``oneOf`` variants.
72+
73+
A field qualifies when at least two variants pin it to a literal
74+
``const``. Ties broken by the field with the most variants pinning
75+
it; further ties broken by lexical order so the result is stable.
76+
77+
The ``count >= 2`` floor distinguishes a real discriminator (a key
78+
that genuinely partitions the union) from incidental ``const``
79+
pinning on a single variant. A union with only one variant pinning
80+
a field is not discriminated by that field — applying near-miss
81+
heuristics there would just guess.
82+
"""
83+
counts: dict[str, int] = {}
84+
for variant in variants:
85+
if not isinstance(variant, dict):
86+
continue
87+
props = variant.get("properties")
88+
if not isinstance(props, dict):
89+
continue
90+
for field_name, field_schema in props.items():
91+
if isinstance(field_schema, dict) and "const" in field_schema:
92+
counts[field_name] = counts.get(field_name, 0) + 1
93+
if not counts:
94+
return None
95+
# Pick the field pinned by the most variants (>=2 to be a real discriminator).
96+
best = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
97+
field_name, count = best[0]
98+
if count < 2:
99+
return None
100+
return field_name
101+
102+
103+
def _variant_const_value(variant: dict[str, Any], field: str) -> Any | None:
104+
props = variant.get("properties")
105+
if not isinstance(props, dict):
106+
return None
107+
field_schema = props.get(field)
108+
if not isinstance(field_schema, dict):
109+
return None
110+
return field_schema.get("const")
111+
112+
113+
def _score_variant(
114+
variant: dict[str, Any],
115+
value: dict[str, Any],
116+
discriminator: str | None = None,
117+
) -> tuple[int, int, int, str | None]:
118+
"""Score how close ``value`` is to a ``oneOf`` variant.
119+
120+
Returns ``(const_match, required_present, total_present, seen_key)`` where:
121+
122+
* ``const_match`` — 1 when the variant's discriminator ``const``
123+
value appears as the value of some top-level key in the payload
124+
*other than* the expected discriminator. Strongest signal: the
125+
caller picked this variant by value but used the wrong key
126+
(the v3 ref-seller ``pricing_options`` regression).
127+
* ``required_present`` — count of the variant's ``required`` fields
128+
present in ``value``. The variant the caller most nearly hit by
129+
shape.
130+
* ``total_present`` — count of the variant's declared ``properties``
131+
present in ``value``. Tiebreaker.
132+
* ``seen_key`` — the top-level key that carried the matching
133+
``const`` value, if any. Recorded so the hint can name the exact
134+
key the caller misused rather than guessing later.
135+
136+
The exact-pairing requirement (``key != discriminator AND
137+
val == const_value``) replaces a membership scan against
138+
``value.values()``. The looser scan would mark a const_match when
139+
an unrelated field happened to carry the same scalar (e.g., a
140+
variant pinning ``"type": "object"`` matching a payload's
141+
``"label": "object"``), producing a misleading hint.
142+
"""
143+
required = variant.get("required") or []
144+
if not isinstance(required, list):
145+
required = []
146+
required_present = sum(1 for r in required if isinstance(r, str) and r in value)
147+
148+
properties = variant.get("properties") or {}
149+
if not isinstance(properties, dict):
150+
properties = {}
151+
total_present = sum(1 for p in properties if p in value)
152+
153+
const_match = 0
154+
seen_key: str | None = None
155+
if discriminator is not None:
156+
const_value = _variant_const_value(variant, discriminator)
157+
if const_value is not None:
158+
for key, val in value.items():
159+
if key == discriminator:
160+
continue
161+
if val == const_value:
162+
const_match = 1
163+
seen_key = key
164+
break
165+
166+
return const_match, required_present, total_present, seen_key
167+
168+
169+
def _fallback_seen_key(
170+
value: dict[str, Any],
171+
expected_discriminator: str,
172+
variants: list[dict[str, Any]],
173+
) -> str | None:
174+
"""Pick a likely "wrong discriminator" key when no const_match was found.
175+
176+
Used only when the variant's score did not record a ``seen_key``
177+
via exact (key, val) pairing — i.e., the caller didn't carry the
178+
expected variant's ``const`` value at all. In that case we fall
179+
back to the first top-level key that isn't declared by any variant
180+
(an extraneous key, plausibly the caller's misnamed discriminator).
181+
"""
182+
declared: set[str] = set()
183+
for variant in variants:
184+
if not isinstance(variant, dict):
185+
continue
186+
props = variant.get("properties")
187+
if isinstance(props, dict):
188+
declared.update(props.keys())
189+
190+
for key in value:
191+
if key == expected_discriminator:
192+
continue
193+
if key not in declared:
194+
return key
195+
196+
return None
197+
198+
199+
def compute_oneof_hint(
200+
schema: dict[str, Any],
201+
schema_path_segments: list[Any],
202+
instance_path_segments: list[Any],
203+
payload: Any,
204+
) -> str | None:
205+
"""Compute a near-miss hint for an ``oneOf`` failure.
206+
207+
Args:
208+
schema: The compiled validator's root schema (refs already inlined).
209+
schema_path_segments: ``absolute_schema_path`` from the validation
210+
error — points at the ``oneOf`` keyword.
211+
instance_path_segments: ``absolute_path`` from the validation
212+
error — points at the offending value in the payload.
213+
payload: The full request/response payload that failed validation.
214+
215+
Returns the hint string, or ``None`` if the heuristic can't pick a
216+
clear winner (no detectable discriminator, no clear best variant,
217+
or no obvious wrong-discriminator key).
218+
"""
219+
if not schema_path_segments or schema_path_segments[-1] != "oneOf":
220+
return None
221+
222+
parent = _navigate(schema, list(schema_path_segments[:-1]))
223+
if not isinstance(parent, dict):
224+
return None
225+
variants_raw = parent.get("oneOf")
226+
if not isinstance(variants_raw, list) or len(variants_raw) < 2:
227+
return None
228+
variants: list[dict[str, Any]] = [v for v in variants_raw if isinstance(v, dict)]
229+
if len(variants) < 2:
230+
return None
231+
232+
value = _navigate_input(payload, list(instance_path_segments))
233+
if not isinstance(value, dict):
234+
return None
235+
236+
discriminator = _detect_discriminator(variants)
237+
if discriminator is None:
238+
return None
239+
240+
# Skip the hint when the caller already used the right discriminator
241+
# — they merely picked a value that doesn't match any variant. The
242+
# default "value not in allowed enum" message is more accurate there.
243+
if discriminator in value:
244+
return None
245+
246+
scored = [(_score_variant(v, value, discriminator), idx, v) for idx, v in enumerate(variants)]
247+
# Sort by const_match (strongest), then required_present, then total_present.
248+
# seen_key (index 3 of the score tuple) is metadata, not a ranking signal.
249+
scored.sort(key=lambda s: (-s[0][0], -s[0][1], -s[0][2], s[1]))
250+
251+
best_score, _, best_variant = scored[0]
252+
if len(scored) > 1:
253+
runner_up = scored[1][0]
254+
# Compare the ranking signals only — ignore seen_key.
255+
if best_score[:3] == runner_up[:3]:
256+
# No clear winner; silent rather than misleading.
257+
return None
258+
259+
if best_score[:3] == (0, 0, 0):
260+
return None
261+
262+
expected_const = _variant_const_value(best_variant, discriminator)
263+
if expected_const is None:
264+
return None
265+
266+
# Prefer the seen_key recorded during scoring (exact key/value match
267+
# against the winning variant's const). Fall back to "extraneous
268+
# top-level key" only when no const-carrying key was found.
269+
seen_key = best_score[3] or _fallback_seen_key(value, discriminator, variants)
270+
if seen_key is None:
271+
return None
272+
273+
return (
274+
f"Looks like you may have meant the {expected_const!r} variant. "
275+
f"Use {discriminator!r} instead of {seen_key!r} as the discriminator."
276+
)

src/adcp/validation/schema_errors.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
from dataclasses import dataclass
77
from typing import Any
88

9-
from adcp.validation.schema_validator import SchemaValidationError, ValidationIssue
9+
from adcp.validation.schema_validator import (
10+
SchemaValidationError,
11+
ValidationIssue,
12+
_issue_to_wire,
13+
)
1014

1115

1216
@dataclass(frozen=True)
@@ -72,15 +76,7 @@ def build_adcp_validation_error_payload(
7276
"details": {
7377
"tool": tool,
7478
"side": side,
75-
"issues": [
76-
{
77-
"pointer": i.pointer,
78-
"message": i.message,
79-
"keyword": i.keyword,
80-
"schema_path": i.schema_path,
81-
}
82-
for i in issues
83-
],
79+
"issues": [_issue_to_wire(i) for i in issues],
8480
},
8581
}
8682
if first is not None and first.pointer:

0 commit comments

Comments
 (0)