diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a1bf6c..4119dfe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ in the README). ## [Unreleased] ### Added +- **Plotly attribute coverage is now a number** (§24): + `scripts/plotly_schema_coverage.py` ingests Plotly's schema and classifies + every attribute `supported | mapped-with-difference | unsupported` by a + committed rule table, writing `spec/api/plotly-coverage.md`. Two corrections + to the plan it implements: no published Plotly wheel ships `plot-schema.json` + (the equivalent is `validators/_validators.json`, and the real surface is + 9,472 leaf attributes rather than ~3,000), and there is no Plotly shim to warn + — so "supported" means XY can express the same visual outcome, not that a shim + accepts the key. Scoped to the trace types XY implements plus `layout`: 344 + supported and 126 mapped-with-difference of 3,387. - A **committed customization comparison** against Plotly, Vega-Lite, Bokeh, and Matplotlib (`spec/api/customization-vs-alternatives.md`), with the same discipline as the benchmark harness: pinned competitor versions, a named diff --git a/scripts/plotly_schema_coverage.py b/scripts/plotly_schema_coverage.py new file mode 100644 index 00000000..93d01e97 --- /dev/null +++ b/scripts/plotly_schema_coverage.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Classify Plotly's attribute schema against what XY can express (§24). + +§24 says compat should be "a measured number", and names Plotly's +`plot-schema.json` as the thing to ingest. Two facts about the world have +changed since that was written, and this script is written to both of them: + +1. **No published Plotly wheel ships `plot-schema.json`.** Verified against + 4.14.3, 5.24.1, and 6.9.0 — the file is a plotly.js artifact and the Python + package generates validator classes from it instead. The equivalent, and a + better shape for this job, is `plotly/validators/_validators.json`: one flat + entry per dotted attribute path with its validator class. +2. **XY has no Plotly shim.** The shipped shim is `xy.pyplot`, which is + Matplotlib-flavored. So "supported" here cannot mean "the shim accepts the + attribute" — there is nothing to accept it. It means *XY can express the + same visual outcome*, and the rule that says so is committed below where it + can be argued with. + +Classification is rule-driven, not hand-enumerated: 9,472 attributes cannot be +audited one at a time, and a table that claims otherwise would be fiction. Each +rule names the XY surface that answers the attribute, so an unfamiliar reader +can check any row by following the pointer. Attributes no rule claims land in +`unsupported/unclassified`, which is the honest bucket — it is reported, never +folded into a friendlier one. + + uv run --extra bench python scripts/plotly_schema_coverage.py --check + uv run --extra bench python scripts/plotly_schema_coverage.py --write +""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import sys +from collections import Counter +from pathlib import Path +from typing import NamedTuple + +ROOT = Path(__file__).resolve().parents[1] +REPORT = ROOT / "spec" / "api" / "plotly-coverage.md" + +SUPPORTED = "supported" +MAPPED = "mapped-with-difference" +UNSUPPORTED = "unsupported" + +#: Trace types XY implements, mapped to the XY mark that answers them. Every +#: other Plotly trace type is `unsupported/no-such-trace` — a truthful bucket, +#: and by far the largest one. +TRACE_EQUIVALENTS: dict[str, str] = { + "scatter": "xy.scatter / xy.line", + "scattergl": "xy.scatter", + "bar": "xy.bar / xy.column", + "histogram": "xy.histogram", + "histogram2d": "xy.heatmap", + "heatmap": "xy.heatmap", + "box": "xy.box", + "violin": "xy.violin", + "contour": "xy.contour", + "mesh3d": "", # deliberately absent: 3-D is out of v1 +} + +#: Roots that are not trace types at all. +META_ROOTS = frozenset({"data", "frame", "frames"}) + + +class Rule(NamedTuple): + """One committed claim about a family of Plotly attributes.""" + + pattern: str + verdict: str + surface: str + note: str + + +#: Order matters: the first matching rule wins, so specific patterns precede +#: general ones. `*` matches within a path segment and across segments alike +#: (fnmatch semantics), which is what makes `*.marker.symbol` reach both +#: `scatter.marker.symbol` and `scatter.selected.marker.symbol`. +RULES: tuple[Rule, ...] = ( + # --- paint and geometry XY draws exactly ------------------------------- + Rule("*.marker.symbol", SUPPORTED, "style={'marker-shape': ...}", "17 shared shapes"), + Rule("*.marker.color", SUPPORTED, "color= / style={'fill': ...}", ""), + Rule("*.marker.opacity", SUPPORTED, "style={'fill-opacity': ...}", ""), + Rule("*.marker.size", SUPPORTED, "size=", ""), + Rule("*.marker.line.color", SUPPORTED, "style={'stroke': ...}", ""), + Rule("*.marker.line.width", SUPPORTED, "style={'stroke-width': ...}", ""), + Rule("*.line.dash", SUPPORTED, "style={'stroke-dasharray': ...}", "named presets and px lists"), + Rule("*.line.color", SUPPORTED, "style={'stroke': ...}", ""), + Rule("*.line.width", SUPPORTED, "style={'stroke-width': ...}", ""), + Rule("*.line.shape", MAPPED, "curve=", "XY has linear and monotone-cubic, not all six"), + Rule("*.opacity", SUPPORTED, "style={'opacity': ...}", ""), + Rule("*.name", SUPPORTED, "name=", ""), + Rule("*.visible", MAPPED, "compose the chart without the mark", "no per-trace toggle prop"), + Rule("*.colorscale", SUPPORTED, "colormap=", "custom ramps accepted"), + Rule("*.showscale", SUPPORTED, "xy.colorbar()", ""), + Rule("*.cmin", SUPPORTED, "color_domain=", ""), + Rule("*.cmax", SUPPORTED, "color_domain=", ""), + # --- layout XY answers -------------------------------------------------- + Rule("layout.xaxis.type", SUPPORTED, "xy.x_axis(scale=...)", ""), + Rule("layout.yaxis.type", SUPPORTED, "xy.y_axis(scale=...)", ""), + Rule("layout.*axis.title.text", SUPPORTED, "xy.x_axis(label=...)", ""), + Rule("layout.*axis.range", SUPPORTED, "xy.x_axis(domain=...)", ""), + Rule("layout.*axis.showgrid", SUPPORTED, "xy.x_axis(grid=...)", ""), + Rule("layout.*axis.gridcolor", SUPPORTED, "style={'grid-color': ...}", ""), + Rule("layout.*axis.gridwidth", SUPPORTED, "style={'grid-width': ...}", ""), + Rule("layout.*axis.showline", SUPPORTED, "xy.x_axis(line=...)", ""), + Rule("layout.*axis.linecolor", SUPPORTED, "style={'axis-color': ...}", ""), + Rule("layout.*axis.ticklen", SUPPORTED, "style={'tick-length': ...}", ""), + Rule("layout.*axis.tickcolor", SUPPORTED, "style={'tick-color': ...}", ""), + Rule("layout.*axis.ticks", SUPPORTED, "style={'tick-direction': ...}", ""), + Rule("layout.*axis.showticklabels", SUPPORTED, "xy.x_axis(text=...)", ""), + Rule("layout.title.text", SUPPORTED, "title=", ""), + Rule("layout.showlegend", SUPPORTED, "xy.legend()", ""), + Rule("layout.legend.*", MAPPED, "xy.legend(...) + slot styling", "different option names"), + Rule("layout.colorway", SUPPORTED, "xy.theme(palette=[...])", ""), + Rule("layout.template", MAPPED, "xy.theme(...)", "not a serializable template format"), + Rule("layout.paper_bgcolor", SUPPORTED, "style={'--chart-bg': ...}", ""), + Rule("layout.plot_bgcolor", SUPPORTED, "style={'--chart-plot-bg': ...}", ""), + Rule("layout.width", SUPPORTED, "width=", ""), + Rule("layout.height", SUPPORTED, "height=", ""), + Rule("layout.margin.*", SUPPORTED, "padding=", ""), + Rule("layout.annotations.*", MAPPED, "xy.text() / xy.callout()", "different geometry model"), + Rule("layout.shapes.*", MAPPED, "xy.rule() / xy.band()", "no arbitrary path shapes"), + Rule("layout.hovermode", MAPPED, "xy.tooltip(...)", ""), + Rule("layout.dragmode", MAPPED, "xy.modebar(...) / interaction options", ""), + # --- deliberate exclusions, named as such ------------------------------- + Rule("layout.scene*", UNSUPPORTED, "", "3-D is explicitly out of v1"), + Rule("layout.geo*", UNSUPPORTED, "", "geo is explicitly out of v1"), + Rule("layout.map*", UNSUPPORTED, "", "mapbox/map is explicitly out of v1"), + Rule("layout.polar*", UNSUPPORTED, "", "polar axes are not implemented"), + Rule("layout.ternary*", UNSUPPORTED, "", "ternary axes are not implemented"), + Rule("layout.smith*", UNSUPPORTED, "", "smith axes are not implemented"), + Rule("layout.transition*", MAPPED, "xy.animation(...)", "different easing vocabulary"), + Rule("layout.updatemenus*", UNSUPPORTED, "", "no in-chart widget layer"), + Rule("layout.sliders*", UNSUPPORTED, "", "no in-chart widget layer"), + Rule("*.hovertemplate", MAPPED, "xy.tooltip(...)", "not a mini-language"), + Rule("*.customdata", MAPPED, "tooltip sources", ""), +) + + +def load_schema() -> tuple[dict[str, dict], str]: + try: + import plotly + except ModuleNotFoundError: # pragma: no cover - depends on the extra + raise SystemExit( + "plotly is not installed; run with `uv run --extra bench` " + "(the pinned version is in benchmarks/requirements-ci.in)" + ) from None + path = Path(plotly.__file__).parent / "validators" / "_validators.json" + if not path.exists(): # pragma: no cover - depends on the plotly release + raise SystemExit( + f"plotly {plotly.__version__} has no {path.name}; see this script's docstring" + ) + return json.loads(path.read_text(encoding="utf-8")), plotly.__version__ + + +def attribute_paths(schema: dict[str, dict]) -> list[str]: + """Leaf attributes only, minus the `*src` column-reference companions. + + A compound node is a namespace, not a knob, and `marker.colorsrc` is not a + second styling decision beside `marker.color` — counting either would pad + the denominator in XY's favour, which is the wrong direction to be wrong in. + """ + skip = {"CompoundValidator", "CompoundArrayValidator", "SrcValidator"} + return sorted(path for path, node in schema.items() if node["superclass"] not in skip) + + +def classify(path: str) -> tuple[str, str, str]: + """-> (verdict, xy surface, reason). First matching rule wins.""" + root = path.split(".", 1)[0] + if root not in META_ROOTS and root != "layout": + equivalent = TRACE_EQUIVALENTS.get(root) + if equivalent is None: + return UNSUPPORTED, "", f"no such trace: XY does not implement {root!r}" + if not equivalent: + return UNSUPPORTED, "", f"{root!r} is explicitly out of v1" + for rule in RULES: + if fnmatch.fnmatch(path, rule.pattern): + return rule.verdict, rule.surface, rule.note + return UNSUPPORTED, "", "unclassified" + + +def build_report(schema: dict[str, dict], version: str) -> tuple[str, dict]: + paths = attribute_paths(schema) + rows = [(path, *classify(path)) for path in paths] + verdicts = Counter(verdict for _, verdict, _, _ in rows) + reasons = Counter(reason for _, verdict, _, reason in rows if verdict == UNSUPPORTED) + + implemented = sorted(k for k, v in TRACE_EQUIVALENTS.items() if v) + in_scope = [r for r in rows if r[0].split(".", 1)[0] in {*implemented, "layout"}] + in_scope_verdicts = Counter(verdict for _, verdict, _, _ in in_scope) + + summary = { + "plotly_version": version, + "attributes": len(paths), + "verdicts": dict(verdicts), + "in_scope_attributes": len(in_scope), + "in_scope_verdicts": dict(in_scope_verdicts), + "unsupported_reasons": dict(reasons.most_common()), + "implemented_trace_types": implemented, + } + + def pct(n: int, d: int) -> str: + return f"{100.0 * n / d:.1f}%" if d else "n/a" + + lines = [ + "# Plotly attribute coverage", + "", + "", + "", + f"Measured against **plotly {version}**, " + f"`plotly/validators/_validators.json`, {len(paths):,} leaf attributes " + "(compound namespaces and `*src` column-reference companions excluded).", + "", + "## Whole schema", + "", + "| verdict | attributes | share |", + "|---|---|---|", + ] + for verdict in (SUPPORTED, MAPPED, UNSUPPORTED): + count = verdicts.get(verdict, 0) + lines.append(f"| {verdict} | {count:,} | {pct(count, len(paths))} |") + lines += [ + "", + "That denominator is dominated by trace types XY does not implement, so", + "the whole-schema share is a poor headline number. The scoped one below", + "is the number worth quoting, and it must be quoted *with* its scope.", + "", + "## Scoped to the trace types XY implements, plus `layout`", + "", + f"Trace types: {', '.join(f'`{name}`' for name in implemented)}. " + f"{len(in_scope):,} attributes.", + "", + "| verdict | attributes | share |", + "|---|---|---|", + ] + for verdict in (SUPPORTED, MAPPED, UNSUPPORTED): + count = in_scope_verdicts.get(verdict, 0) + lines.append(f"| {verdict} | {count:,} | {pct(count, len(in_scope))} |") + lines += [ + "", + "## Why attributes are unsupported", + "", + "| reason | attributes |", + "|---|---|", + ] + for reason, count in reasons.most_common(15): + lines.append(f"| {reason} | {count:,} |") + lines += [ + "", + "`unclassified` is the honest residue: attributes no committed rule in", + "`scripts/plotly_schema_coverage.py` claims. It is reported rather than", + "folded into a friendlier bucket, and shrinking it is how this table", + "improves.", + "", + "## Reproducing", + "", + "```bash", + "uv run --extra bench python scripts/plotly_schema_coverage.py --check", + "```", + "", + "`--check` fails if this file is stale. The rules it applies are in the", + "`RULES` table of that script, one line per claim, so any row here can be", + "traced to the claim that produced it.", + "", + ] + return "\n".join(lines), summary + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="regenerate the committed report") + parser.add_argument( + "--check", action="store_true", help="fail if the committed report is stale" + ) + parser.add_argument("--json", type=Path, help="also write the summary as JSON") + args = parser.parse_args(argv) + + schema, version = load_schema() + report, summary = build_report(schema, version) + + if args.json: + args.json.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + if args.write: + REPORT.parent.mkdir(parents=True, exist_ok=True) + REPORT.write_text(report, encoding="utf-8") + print(f"wrote {REPORT.relative_to(ROOT)}") + return 0 + if args.check: + current = REPORT.read_text(encoding="utf-8") if REPORT.exists() else "" + if current != report: + print( + f"{REPORT.relative_to(ROOT)} is stale; " + "run scripts/plotly_schema_coverage.py --write", + file=sys.stderr, + ) + return 1 + print(f"{REPORT.relative_to(ROOT)} is current") + return 0 + print(json.dumps(summary, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_sdist.py b/scripts/verify_sdist.py index e0fdb2ab..0963c67d 100644 --- a/scripts/verify_sdist.py +++ b/scripts/verify_sdist.py @@ -106,6 +106,7 @@ "scripts/check_public_api.py", "scripts/check_claim_guardrails.py", "scripts/gen_capability_matrix.py", + "scripts/plotly_schema_coverage.py", "scripts/check_python_floor.py", "scripts/check_regressions.py", "scripts/bench_dashboard.py", diff --git a/spec/README.md b/spec/README.md index cb5a6ce0..aca1f4c7 100644 --- a/spec/README.md +++ b/spec/README.md @@ -34,6 +34,10 @@ The public surface: what callers can build, style, export, and interact with. the committed comparison of customization and extensibility against Plotly, Vega-Lite, Bokeh, and Matplotlib, with its method, its pinned versions, and the rows XY loses. Pinned by `tests/test_customization_comparison.py`. +- [`plotly-coverage.md`](api/plotly-coverage.md) — **generated**: every Plotly + attribute classified `supported | mapped-with-difference | unsupported` (§24). + Regenerate with `scripts/plotly_schema_coverage.py --write` (needs the `bench` + extra). - [`export.md`](api/export.md) — how a figure becomes bytes: one entry point across five image formats, deterministic engine choice, browser-free default. - [`interaction.md`](api/interaction.md) — the authority on which browser diff --git a/spec/api/plotly-coverage.md b/spec/api/plotly-coverage.md new file mode 100644 index 00000000..a4d1fa6f --- /dev/null +++ b/spec/api/plotly-coverage.md @@ -0,0 +1,62 @@ +# Plotly attribute coverage + + + +Measured against **plotly 6.9.0**, `plotly/validators/_validators.json`, 9,472 leaf attributes (compound namespaces and `*src` column-reference companions excluded). + +## Whole schema + +| verdict | attributes | share | +|---|---|---| +| supported | 345 | 3.6% | +| mapped-with-difference | 126 | 1.3% | +| unsupported | 9,001 | 95.0% | + +That denominator is dominated by trace types XY does not implement, so +the whole-schema share is a poor headline number. The scoped one below +is the number worth quoting, and it must be quoted *with* its scope. + +## Scoped to the trace types XY implements, plus `layout` + +Trace types: `bar`, `box`, `contour`, `heatmap`, `histogram`, `histogram2d`, `scatter`, `scattergl`, `violin`. 3,387 attributes. + +| verdict | attributes | share | +|---|---|---| +| supported | 344 | 10.2% | +| mapped-with-difference | 126 | 3.7% | +| unsupported | 2,917 | 86.1% | + +## Why attributes are unsupported + +| reason | attributes | +|---|---| +| unclassified | 2,190 | +| no such trace: XY does not implement 'scatter3d' | 290 | +| 3-D is explicitly out of v1 | 281 | +| no such trace: XY does not implement 'carpet' | 200 | +| no such trace: XY does not implement 'treemap' | 198 | +| no such trace: XY does not implement 'funnel' | 197 | +| no such trace: XY does not implement 'icicle' | 192 | +| no such trace: XY does not implement 'scatterpolar' | 187 | +| no such trace: XY does not implement 'scattercarpet' | 184 | +| no such trace: XY does not implement 'scatterternary' | 184 | +| no such trace: XY does not implement 'surface' | 183 | +| no such trace: XY does not implement 'histogram2dcontour' | 182 | +| no such trace: XY does not implement 'scattersmith' | 182 | +| no such trace: XY does not implement 'scattergeo' | 180 | +| no such trace: XY does not implement 'sunburst' | 177 | + +`unclassified` is the honest residue: attributes no committed rule in +`scripts/plotly_schema_coverage.py` claims. It is reported rather than +folded into a friendlier bucket, and shrinking it is how this table +improves. + +## Reproducing + +```bash +uv run --extra bench python scripts/plotly_schema_coverage.py --check +``` + +`--check` fails if this file is stale. The rules it applies are in the +`RULES` table of that script, one line per claim, so any row here can be +traced to the claim that produced it. diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 2e0e225b..3d2d93cd 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -650,7 +650,7 @@ missing entirely.* | 10 | Autorange is an O(n) full scan per update | Moderate | Chunk zone maps make it O(chunks) (§22) | | 11 | No VRAM budget/eviction; no device-loss recovery | Moderate | Byte-budgeted caches; rebuildable GPU state (§6, §18) | | 12 | No bundle-size budget — a fat WASM blob forfeits a real Plotly pain point (3.5 MB+) | Moderate | Feature-gated modules + CI size budget (§23) | -| 13 | Compat shim scope unquantified (~3,000 Plotly schema attributes) | Moderate | Generated conformance suite + explicit degradation contract (§24) | +| 13 | Compat shim scope unquantified (~3,000 Plotly schema attributes) | Moderate | **Quantified**: 9,472 leaf attributes, classified by committed rules (§24, `spec/api/plotly-coverage.md`). The estimate was 3x low. | | 14 | Benchmarks measured throughput but not interaction latency; "60fps" undefined | Minor | Latency budgets + p99 framing added (§17, §12) | | 15 | No extensibility story (Plotly has custom traces) | Minor | **Shipped v0**: composition mark plugins, `xy.register_mark` (§24). Custom shaders still deferred. | @@ -902,6 +902,29 @@ partial-bundle pain). Neither exists today. `supported | mapped-with-difference | unsupported`, the shim **warns loudly** on unsupported attributes (never silently drops), and the docs publish the coverage table per release. "Drop-in for the common 80%" becomes checkable, not vibes. + + **Shipped, with two amendments the original wording did not anticipate** + (`scripts/plotly_schema_coverage.py` → `spec/api/plotly-coverage.md`): + + 1. *No published Plotly wheel ships `plot-schema.json`.* Verified against + 4.14.3, 5.24.1, and 6.9.0 — it is a plotly.js artifact, and the Python + package generates validator classes from it instead. The equivalent, and a + better shape for this job, is `plotly/validators/_validators.json`: one flat + entry per dotted path. **9,472 leaf attributes** once compound namespaces + and `*src` column-reference companions come out — three times the estimate. + 2. *There is no Plotly shim to warn.* The shipped shim is `xy.pyplot`, which is + Matplotlib-flavored. So "supported" cannot mean "the shim accepts the + attribute"; it means XY can express the same visual outcome, decided by a + committed rule table rather than by hand — 9,472 attributes cannot be + audited one at a time, and a table claiming otherwise would be fiction. + + The number, scoped to the trace types XY implements plus `layout`: **344 + supported, 126 mapped-with-difference of 3,387**. Whole-schema it is 345 and + 126 of 9,472, a denominator dominated by trace types XY does not implement. + Both are published, because either one quoted alone misleads in a different + direction. Attributes no rule claims land in `unsupported/unclassified` — + currently 2,190 — which is reported rather than folded somewhere friendlier; + shrinking it is how the table improves. - **Custom traces without forking:** a registered *mark plugin* provides (a) a calc function over columns → columns (runs in the worker, gets zone maps), (b) either a composition of built-in GPU primitives (instanced marks, density diff --git a/tests/test_plotly_schema_coverage.py b/tests/test_plotly_schema_coverage.py new file mode 100644 index 00000000..52d78fd7 --- /dev/null +++ b/tests/test_plotly_schema_coverage.py @@ -0,0 +1,99 @@ +"""The Plotly coverage number has to be reproducible, and honest about its tail. + +§24 asks for compat to be "a measured number". A measured number that anyone +can inflate by widening a rule without noticing is not one, so these tests hold +the properties that make the number mean something: it is regenerated rather +than hand-written, its `unclassified` residue is reported rather than absorbed, +and the classifier stays deterministic. + +The suite skips when plotly is absent — it lives in the `bench` extra, and the +committed report is what a reader without it consults. +""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "plotly_schema_coverage.py" +REPORT = ROOT / "spec" / "api" / "plotly-coverage.md" + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("plotly") is None, + reason="plotly is in the bench extra; the committed report stands in without it", +) + + +def _module(): + spec = importlib.util.spec_from_file_location("plotly_schema_coverage", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_the_committed_report_is_regenerated_not_hand_edited() -> None: + result = subprocess.run( + [sys.executable, str(SCRIPT), "--check"], capture_output=True, text=True + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_src_companions_and_namespaces_are_out_of_the_denominator() -> None: + # `marker.colorsrc` is not a second styling decision beside `marker.color`, + # and a compound node is a namespace rather than a knob. Counting either + # would pad the denominator in XY's favour. + module = _module() + schema, _ = module.load_schema() + paths = module.attribute_paths(schema) + assert not any(path.endswith("src") for path in paths) + assert all( + schema[path]["superclass"] not in {"CompoundValidator", "CompoundArrayValidator"} + for path in paths + ) + + +def test_unclassified_is_reported_rather_than_absorbed() -> None: + # The residue is large and shrinking it is the work. Folding it into a + # friendlier bucket would make the table look better and mean less. + module = _module() + schema, version = module.load_schema() + _, summary = module.build_report(schema, version) + assert summary["unsupported_reasons"]["unclassified"] > 0 + assert "unclassified" in REPORT.read_text(encoding="utf-8") + + +def test_every_verdict_is_one_of_the_three_section_24_names() -> None: + module = _module() + schema, version = module.load_schema() + _, summary = module.build_report(schema, version) + assert set(summary["verdicts"]) <= { + module.SUPPORTED, + module.MAPPED, + module.UNSUPPORTED, + } + + +def test_classification_is_deterministic_and_first_rule_wins() -> None: + module = _module() + # A path matched by a specific rule must not fall through to a general one. + verdict, surface, _ = module.classify("scatter.marker.symbol") + assert verdict == module.SUPPORTED and "marker-shape" in surface + # A trace XY does not implement is unsupported regardless of the attribute. + verdict, _, reason = module.classify("scatter3d.marker.symbol") + assert verdict == module.UNSUPPORTED and "no such trace" in reason + + +def test_the_scoped_number_is_reported_next_to_the_whole_schema_one() -> None: + # Quoting 3.6% without its scope understates; quoting 10.2% without its + # scope overstates. The report has to carry both. + text = REPORT.read_text(encoding="utf-8") + assert "## Whole schema" in text + assert "## Scoped to the trace types XY implements" in text + assert "must be quoted *with* its scope" in text