fix: improve schema validation error relevance - #4090
Conversation
|
Thanks for the PR! Could you instruct your agent to first fix your review environment so the uv toolchain and pytest are available locally? Also can you update the PR description? Currently it feels more like feedback of your code generator to you than an invitation for a review. Maybe something like this as template?: <!-- Title: fix: <short imperative> -->
Fix for #<issue>
# Problem
One or two sentences: what breaks, and why the current code does it.
# Reproduce
```python
# minimal runnable Altair snippet
Current output:
<actual error message>
With this PR:
<new error message>
Proposed solution
What changed and why, in prose or a short list.
Please write in first person, and explain briefly your trail of thought (make your uncertainty visible).Also a reminder to try we generalize to the varies reported cases and not have a hotfix for a single usecase. I'm also a bit confused on the Draft7Validator tests added, these seem more appropriate for jsonschema itself? Please use the vegalite jsonschema itself where necessary. Thanks for your work!🦾🙌 |
|
Thanks, this was helpful. I fixed my local setup first and can now run the full I also rewrote the PR description around the actual problem, included runnable examples with the before/after errors, and explained why I kept the branch selection conservative. You were right about the synthetic tests too. I replaced the broad |
|
Thanks for the ongoing work. I'm still on mobile, so I can't check the details but looking to the code diff I don't think we need to add another schema-resolution layer for this, my gut feeling says we can use existing functions that are available for this, including usage of Please expect some scrutiny, since we have aimed to simplify these routines previously, see for reference #2771 and other mentioned issues/PR in there. Some crumbs from my LLM assistant: Instead of resolving pointers would it be possible to thread the registry/resolver that Thanks again for the ongoing effort. We will get there🤗 |
|
Thanks for the guidance — I've reworked the reference resolution to reuse the registry, as suggested.
On CI: the current failures are pre-existing network flakes in Happy to iterate further if the branch-selection heuristics need tightening. |
| required_values_match = not _branch_has_required_value_error( | ||
| error.context, branch_index, required_names | ||
| ) | ||
| if required_values_match and _required_values_identify_branch( |
There was a problem hiding this comment.
I think that this branch-selection heuristic can hide the actual enum error. A required property is not necessarily an unambiguous discriminator. In particular, a generic fallback branch may accept the property's type while a more specialized branch restricts it with enum or const. If another property identifies the specialized branch, selecting the generic branch produces a misleading additionalProperties error.
Take this example:
import altair as alt
slider = alt.param(
name="threshold",
value=5,
bind={
"input": "ragne", # Intentional typo: should be "range"
"min": 0,
"max": 10,
},
)
chart = (
alt.Chart({"values": [{"x": 1}]})
.mark_point()
.encode(x="x:Q")
.add_params(slider)
)On this PR, the result is:
`VariableParameter` has no parameters named 'max', 'min'
That is misleading because min and max are valid properties of a range binding. They also provide evidence that the intended branch is BindRange. The actual error is the misspelled value "ragne".
On main, the result correctly identifies the invalid input value (although the error message could still be improved):
'ragne' is an invalid value for `input`. Valid values are one of ['radio', 'select'].
'checkbox' was expected
'range' was expected
The same behavior can be demonstrated with a reduced schema:
from altair.utils.schemapi import validate_jsonschema
schema = {
"anyOf": [
{
"type": "object",
"required": ["kind"],
"properties": {
"kind": {"enum": ["allowed"]},
"payload": {"type": "integer"},
},
"additionalProperties": False,
},
{
"type": "object",
"required": ["kind"],
"properties": {
"kind": {"type": "string"},
},
"additionalProperties": False,
},
]
}
error = validate_jsonschema(
{"kind": "typo", "payload": 1},
schema,
raise_error=False,
)
assert error is not None
print(error.message)This PR reports:
Additional properties are not allowed ('payload' was unexpected)
main reports the more actionable error:
'typo' is not one of ['allowed']
Could we adjust the heuristic so that an enum/const mismatch does not automatically make a specialized branch irrelevant when other properties identify that branch? Please also add the real binding example as a regression test and mirror the fix in tools/schemapi/schemapi.py.
generated with codex
There was a problem hiding this comment.
Thanks for the review. I've addressed all three points: the heuristic now keeps a specialized branch when its only mismatch is a const/enum on a required property (so the BindRange / 'allowed' enum errors surface instead of the misleading additionalProperties / unknown-parameters ones), the real binding example is added as a regression test, and the fix is mirrored in tools/schemapi/schemapi.py. Both examples now produce the expected messages, and the full suite is green. Re-requesting review — happy to iterate further.
There was a problem hiding this comment.
Thanks @dk3yyyy, it seems like the fix migh overcorrect by no longer treating const/enum mismatches as branch-exclusion evidence in _branch_excludes_required_value (altair/utils/schemapi.py:556). This can retain a branch that is clearly contradicted by a valid discriminator.
For example, with branches distinguishing kind="integer" from kind="string":
from altair.utils.schemapi import SchemaBase, SchemaValidationError
schema = {
"anyOf": [
{
"type": "object",
"required": ["kind"],
"properties": {
"kind": {"const": "integer"},
"payload": {"type": "integer"},
},
"additionalProperties": False,
},
{
"type": "object",
"required": ["kind"],
"properties": {
"kind": {"const": "string"},
"payload": {"type": "string"},
},
"additionalProperties": False,
},
]
}
class Example(SchemaBase):
_schema = schema
try:
Example(
kind="integer",
payload="bad", # Invalid: the "integer" branch requires an integer.
).to_dict()
except SchemaValidationError as error:
print(error)
At commit c886518, this prints:
Multiple errors were found.
Error 1: 'bad' is an invalid value for `payload`. Valid values are of type `int`.
Error 2: 'integer' is an invalid value for `kind`.
'string' was expected
The second error should not appear. kind="integer" positively identifies the first branch, so the only relevant error is:
'bad' is an invalid value for payload. Valid values are of type int.
The parent commit reports only that relevant error.
Also note that The new regression test fails mypy at tests/utils/test_schemapi.py:1046:
Argument "bind" to "param" has incompatible type "dict[str, object]";
expected "Binding | UndefinedType"
There was a problem hiding this comment.
Thanks — I've refined the heuristic so a const/enum mismatch is only treated as ambiguous when the value satisfies no branch's discriminator; when it matches another branch's const/enum, the contradicted branch is still excluded. The overcorrection example now reports only the payload error, the original enum-typo and binding cases are unchanged, and the mypy issue in the test is fixed with a scoped # type: ignore[arg-type] (mirroring the existing test pattern). Full suite, ruff, and mypy are green.
A required property is not an unambiguous union discriminator: a generic fallback branch may accept the property's type while a specialized branch restricts it with enum/const. The branch-selection heuristic treated such a mismatch as evidence that the specialized branch was irrelevant, so the generic branch's misleading additionalProperties error was reported instead of the actual enum/const error. Only discard a branch when it cannot accept the instance on its own merits, and do not count a pure const/enum mismatch as exclusion. Adds the real binding example as a regression test and mirrors the fix in tools/schemapi/schemapi.py.
A const/enum mismatch is only ambiguous when the value does not satisfy any branch's discriminator. When the value matches another branch's const/enum it unambiguously identifies that branch, so the mismatch must still exclude this one. Without this, the previous fix overcorrected and leaked spurious discriminator errors (e.g. 'integer' is an invalid value for 'kind') alongside the relevant payload error. Also fixes the mypy error in the binding regression test and adds a regression test for the overcorrection case.
…chema-error-relevance
|
While reviewing last evening, I think I found something that may account for half of this issue. In our Lines 957 to 973 in 1db4156 with a docstring saying: "Never includes The other thing, I think this PR's current approach of reconstructing the relevant branch after the fact is a bit risky, since it has to guess. Altair already has the object's own type available at that point, so we can re-validate against that directly instead. My branch with applied changes is here: main...fix/3752-validate-against-live-object-schema (or code.diff) Because I can reuse the existing object the introduced new LOC is much lower. The only thing I haven't get to is this example from original post (and is working correctly in this PR), is this example alt.selection_interval(
resolve="global",
value={"x": ["Europe"], "y": [10, 20]},
)Technically speaking, while still super relevant to be fixed, I think it is a bit of a different issue than #3752, since its because Its also defined in the doctsring of this altair/altair/utils/schemapi.py Lines 330 to 340 in fde4b47 This is done under the assumption that more specific json paths give more helpful error messages to the user. All errors can be retrieved from this code: from altair.utils.schemapi import (
_get_errors_from_spec,
_get_leaves_of_error_tree,
_group_errors_by_json_path,
)
from altair.vegalite.v6.schema.core import SelectionParameter
spec = {
"name": "__TEMP__",
"select": {"type": "interval", "resolve": "global"},
"value": {"x": ["Europe"], "y": [10, 20]},
}
errors = _get_errors_from_spec(spec, SelectionParameter._schema, rootschema=SelectionParameter._rootschema)
leaves = _get_leaves_of_error_tree(errors)
grouped = _group_errors_by_json_path(leaves)
grouped{
'$.value': [
<ValidationError: "{'x': ['Europe'], 'y': [10, 20]} is not of type 'number', 'string', 'boolean', 'null'">,
<ValidationError: "Additional properties are not allowed ('x', 'y' were unexpected)">,
<ValidationError: "{'x': ['Europe'], 'y': [10, 20]} is not of type 'array'">
],
'$.value.x[0]': [
<ValidationError: "'Europe' is not of type 'boolean'">,
<ValidationError: "'Europe' is not of type 'number'">,
<ValidationError: "'Europe' is not of type 'object'">
],
'$.value.x': [
<ValidationError: "['Europe'] is too short">,
<ValidationError: "['Europe'] is too short">,
<ValidationError: "['Europe'] is too short">,
<ValidationError: "['Europe'] is too short">
]}I'm wondering what you think @dk3yyyy here |
…bjects infer_encoding_types/_wrap_in_channel now route a datum= kwarg to its *Datum channel class instead of the generic field class, matching how value= is wrapped in its *Value class. SchemaValidationError additionally re-validates the failing value against the live object's own schema when such an object exists and its schema is not itself a union. This uses the object's known type instead of guessing, per the suggestion in the review, while branch selection remains the fallback for raw-dict values without a live object (e.g. Binding).
The prior run failed only on flaky external dataset downloads (URLError: Connection reset by peer) and a polars BufferError under xdist; the identical Tests workflow passed on the previous commit.
fde4b47 refactored _get_errors_from_spec/_get_leaves_of_error_tree to drop the referencing Registry plumbing and _resolve_references no longer accepts a registry. Restore our union-branch selection and live-object narrowing on top of that refactor: - _get_leaves_of_error_tree keeps upstream's signature and takes the root schema only to resolve union branch schemas - _get_relevant_errors/_union_branch_schemas resolve branches via the refactored _resolve_references (no registry threading) - keep the hardened _resolve_references reference handling - keep multi-parameter additionalProperties messages and maxItems/minItems fallback exclusion
|
Thanks for the detailed look — you were right that guessing branches is the fragile part. I've switched to your approach: The only thing I kept from the previous approach is a fallback for cases where there is no live object at the error path, like a raw dict passed to On the CI is green on the current head. Happy to adjust anything else if you have suggestions. |
|
@joelostblom just a heads-up that both points from the last round are addressed on the current head:
The branch is also rebased on the latest |
|
Heads up: not forgotten this PR and so now and then I'm reading this thread and the code again. But my feeling is that it has become a bit messy, since we are piling fixes. Maybe good idea if we can get a overview of the different Altair examples discussed and discovered in this thread that we feel can improve in its error message justification. So it's easier to create a few follow up issues if these don't touch the same heuristic as the original linked issue aimed to be fixed. |
Fix for #3752
Problem
I found that Altair's error-selection heuristic could choose the wrong branch of a Vega-Lite
anyOfunion. In positional channels this could reportvalueitself as unexpected instead of reporting the options that are invalid for the value branch.Reproduce
Current output:
With this PR:
The same underlying heuristic could also hide an array-size error. For example:
With this PR, the error points to the actual constraint:
Proposed solution
I changed union-error selection so a branch is preferred only when root-level required properties and their discriminator values reliably identify it. I resolve local Vega-Lite schema references against the effective validation root before making that decision, including Altair's internal
urn:vega-lite-schemaform on currentjsonschema. If multiple branches can accept the same properties, or a reference cannot be resolved locally, I keep the competing errors instead of guessing.Local JSON Pointer resolution handles URI-fragment percent decoding, strict
~0/~1escapes, chained references, and cycle detection. Malformed, external, cyclic, or otherwise unresolved references remain unknown rather than becoming branch-selection evidence.I also compare JSON paths by path segments, prioritize
minItemsandmaxItemsconstraints over nested item errors, and preserve all unexpected parameters while respectingpatternProperties.My main uncertainty was how aggressively to choose a branch when the schema is ambiguous. I chose the conservative option: only narrow the error tree with positive evidence and otherwise retain the available branch errors. This is intended to generalize across Vega-Lite's referenced unions rather than special-case
YValue.Tests
I replaced the synthetic union-schema matrix with regressions that exercise Altair's shipped Vega-Lite schema:
YValuecases reported inSchemaValidationErrorrelevance heuristic is flawed #3752, including multiple invalid parameters;minItemscase reported inSchemaValidationErrorrelevance heuristic is flawed #3752;Bindingunion, including itsconst,enum, and general input branches;schema/rootschemavalidation and strict local JSON Pointer handling;patternProperties;minItems/maxItemsprecedence.Local validation:
uvandpytestare available in the review environment;uv run task testpasses: 1,943 main tests and 2 serial tests, plus Ruff, formatting, MyPy, and Ty;jsonschema==3.0.2;altair/utils/schemapi.pyis synchronized withtools/schemapi/schemapi.pyand regeneration is idempotent.Closes #3752