|
| 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 | + ) |
0 commit comments