From ff5556554ca0ad0a22a75ddfbcebc61d402f76e4 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:02:31 +0500 Subject: [PATCH 1/6] feat(reflex): probe every mark kind with recorded synthetic columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data-bound plan tier refused the aggregating marks — box, violin, hexbin, contour, heatmap, stairs, ecdf — because their validators need at least one finite value and the Phase 3 decision judged a synthetic-row probe to be validating against made-up data. Answer that objection with discipline instead of exclusion. Each aggregating kind's channels bind fixed placeholder columns from a recorded table (plan._SYNTHETIC_CHANNELS): finite, positive, strictly increasing, one group for the grouped kinds, a square z grid with matching side coordinates, len+1 bin edges. The values satisfy every value-domain precondition a validator imposes, so a probe failure still indicts the structure and never the placeholders. Extending a kind means recording its minimal contract in that table — never a silent guess (§28 spirit). Landing with it: - Flat factories for the newly probed kinds plus triangle_mesh_chart (always zero-row-safe, just never wired). All 19 standalone mark kinds now have one. - validate_columns drops the shared-length requirement: a data var may carry mixed-length and 2-D columns (stairs edges, heatmap grids). Coupled-shape contracts stay with the mark validators at bind, where the errors name the mark and channels involved. - Module-level named callables in mark props (hexbin's reduce_C_function, np.mean by default) content-address as their import path; lambdas and closures have no stable one and are refused toward a module-level function or @reflex_xy.figure. Plan registration is last-write-wins so a hot reload replaces stale node objects behind an unchanged digest. Specs updated: the revision is recorded in the implementation doc's post-landing section, the options doc's decision record, and reflex-integration.md §3.6 "Kind coverage". --- python/reflex_xy/__init__.py | 28 ++- python/reflex_xy/data_vars.py | 15 +- python/reflex_xy/factories.py | 63 ++++++- python/reflex_xy/plan.py | 160 +++++++++++++----- .../reflex-component-api-implementation.md | 41 ++++- spec/design/reflex-component-api-options.md | 13 ++ spec/design/reflex-integration.md | 69 +++++--- tests/reflex_adapter/test_data_var.py | 15 +- tests/reflex_adapter/test_factories.py | 37 +++- tests/reflex_adapter/test_plan.py | 63 ++++++- tests/test_validation_timing.py | 53 +++++- 11 files changed, 457 insertions(+), 100 deletions(-) diff --git a/python/reflex_xy/__init__.py b/python/reflex_xy/__init__.py index 42426962..6ed4832d 100644 --- a/python/reflex_xy/__init__.py +++ b/python/reflex_xy/__init__.py @@ -73,15 +73,23 @@ def index() -> rx.Component: "chart": ".factories", "area_chart": ".factories", "bar_chart": ".factories", + "box_chart": ".factories", "column_chart": ".factories", + "contour_chart": ".factories", + "ecdf_chart": ".factories", "error_band_chart": ".factories", "errorbar_chart": ".factories", + "heatmap_chart": ".factories", + "hexbin_chart": ".factories", "histogram_chart": ".factories", "line_chart": ".factories", "scatter_chart": ".factories", "segments_chart": ".factories", + "stairs_chart": ".factories", "stem_chart": ".factories", "step_chart": ".factories", + "triangle_mesh_chart": ".factories", + "violin_chart": ".factories", "AsyncDataVar": ".data_vars", "DataVar": ".data_vars", "data": ".data_vars", @@ -111,11 +119,11 @@ def index() -> rx.Component: #: `xy.scatter`, so composed data-bound charts read uniformly #: (`reflex_xy.chart(reflex_xy.scatter("x", "y"), data=...)`) and a #: hallucinated constructor dies at import against this explicit map -#: instead of surviving to hydrate. Marks whose validators need data -#: (box, violin, hexbin, …) are still listed — the plan probe refuses them -#: with the recorded Phase 3 guidance rather than a misleading -#: AttributeError. Chart factories are deliberately absent: the flat -#: `*_chart` names above are reflex-native factories, not xy's. +#: instead of surviving to hydrate. Every standalone mark kind is plan- +#: compatible: the aggregating ones (box, violin, hexbin, …) probe with +#: the shaped synthetic columns recorded in `plan._SYNTHETIC_CHANNELS`. +#: Chart factories are deliberately absent: the flat `*_chart` names above +#: are reflex-native factories, not xy's. _XY_REEXPORTS = frozenset( { # marks @@ -137,6 +145,7 @@ def index() -> rx.Component: "hexbin", "contour", "heatmap", + "triangle_mesh", # annotations "vline", "hline", @@ -195,6 +204,7 @@ def index() -> rx.Component: "bar", "bar_chart", "box", + "box_chart", "callout", "chart", "clear_selection", @@ -202,8 +212,10 @@ def index() -> rx.Component: "column", "column_chart", "contour", + "contour_chart", "data", "ecdf", + "ecdf_chart", "error_band", "error_band_chart", "errorbar", @@ -211,7 +223,9 @@ def index() -> rx.Component: "export_config", "figure", "heatmap", + "heatmap_chart", "hexbin", + "hexbin_chart", "histogram", "histogram_chart", "hline", @@ -238,6 +252,7 @@ def index() -> rx.Component: "setup", "spring", "stairs", + "stairs_chart", "stem", "stem_chart", "step", @@ -248,7 +263,10 @@ def index() -> rx.Component: "threshold", "threshold_zone", "tooltip", + "triangle_mesh", + "triangle_mesh_chart", "violin", + "violin_chart", "vline", "x_axis", "x_band", diff --git a/python/reflex_xy/data_vars.py b/python/reflex_xy/data_vars.py index 0ba1573b..0a69bae1 100644 --- a/python/reflex_xy/data_vars.py +++ b/python/reflex_xy/data_vars.py @@ -53,16 +53,18 @@ def _deps(self, objclass: Any, obj: Any = None) -> dict[str, set[str]]: def validate_columns(columns: Any, *, source: str) -> dict[str, Any]: - """The only checks that need real data: a mapping of named, equal-length - array-like columns. Everything structural was validated at compile by the - plan's zero-row probe; dtype/shape details stay with figure compilation.""" + """The only check that needs real data: a mapping of named array-like + columns. Columns may differ in length and dimensionality — one var can + carry a scatter's rows next to a stairs mark's ``len+1`` edges or a + heatmap's 2-D grid. Coupled-length and shape contracts (x vs y, edges + vs values, z's 2-D-ness) belong to the mark validators when a plan + binds, where the errors name the mark and channels involved.""" if not isinstance(columns, Mapping): raise TypeError( f"{source} must return a mapping of column name -> values " f"(e.g. a TypedDict of arrays), got {type(columns).__name__}" ) validated: dict[str, Any] = {} - lengths: dict[str, int] = {} for key, values in columns.items(): if not isinstance(key, str): raise TypeError(f"{source} column names must be strings, got {key!r}") @@ -72,16 +74,13 @@ def validate_columns(columns: Any, *, source: str) -> dict[str, Any]: f"got {type(values).__name__}" ) try: - lengths[key] = len(values) + len(values) except TypeError as exc: raise TypeError( f"{source} column {key!r} must be an array-like with a length, " f"got {type(values).__name__}" ) from exc validated[key] = values - if len(set(lengths.values())) > 1: - detail = ", ".join(f"{key}={length}" for key, length in lengths.items()) - raise ValueError(f"{source} columns must share one length, got {detail}") return validated diff --git a/python/reflex_xy/factories.py b/python/reflex_xy/factories.py index fed70504..a7f1f660 100644 --- a/python/reflex_xy/factories.py +++ b/python/reflex_xy/factories.py @@ -22,8 +22,9 @@ height="460px", ) -Both compile the real xy tree at page evaluation, run the zero-row probe -(`plan.py` — the full mark/config validation gate, X1/X2), check channel +Both compile the real xy tree at page evaluation, run the plan probe +(`plan.py` — the full mark/config validation gate, X1/X2; zero-row +columns, shaped synthetic ones for the aggregating kinds), check channel names against the data var's TypedDict schema (R7), and mount the private component with a `plan` digest plus the `data` handle var. Marks and chrome are plain xy dataclass nodes — they never enter the Reflex tree @@ -73,16 +74,24 @@ __all__ = [ "area_chart", "bar_chart", + "box_chart", "chart", "column_chart", + "contour_chart", + "ecdf_chart", "error_band_chart", "errorbar_chart", + "heatmap_chart", + "hexbin_chart", "histogram_chart", "line_chart", "scatter_chart", "segments_chart", + "stairs_chart", "stem_chart", "step_chart", + "triangle_mesh_chart", + "violin_chart", ] #: Event triggers of the private XYChart component. Pinned against the @@ -227,6 +236,16 @@ def _flat_kind(chart_kind: str, mark_factory: Any) -> _FlatKind: _flat_kind("errorbar_chart", _xy.errorbar), _flat_kind("error_band_chart", _xy.error_band), _flat_kind("segments_chart", _xy.segments), + # Aggregating kinds: their channels probe with the shaped synthetic + # columns recorded in plan._SYNTHETIC_CHANNELS instead of zero rows. + _flat_kind("box_chart", _xy.box), + _flat_kind("violin_chart", _xy.violin), + _flat_kind("ecdf_chart", _xy.ecdf), + _flat_kind("hexbin_chart", _xy.hexbin), + _flat_kind("contour_chart", _xy.contour), + _flat_kind("heatmap_chart", _xy.heatmap), + _flat_kind("stairs_chart", _xy.stairs), + _flat_kind("triangle_mesh_chart", _xy.triangle_mesh), ) } @@ -455,6 +474,46 @@ def segments_chart(*, data: Any = None, **kwargs: Any) -> Any: return _flat_chart(FLAT_KINDS["segments_chart"], data, kwargs) +def box_chart(*, data: Any = None, **kwargs: Any) -> Any: + """A data-bound box plot (flat form; see module doc).""" + return _flat_chart(FLAT_KINDS["box_chart"], data, kwargs) + + +def violin_chart(*, data: Any = None, **kwargs: Any) -> Any: + """A data-bound violin plot (flat form; see module doc).""" + return _flat_chart(FLAT_KINDS["violin_chart"], data, kwargs) + + +def ecdf_chart(*, data: Any = None, **kwargs: Any) -> Any: + """A data-bound ECDF chart (flat form; see module doc).""" + return _flat_chart(FLAT_KINDS["ecdf_chart"], data, kwargs) + + +def hexbin_chart(*, data: Any = None, **kwargs: Any) -> Any: + """A data-bound hexbin chart (flat form; see module doc).""" + return _flat_chart(FLAT_KINDS["hexbin_chart"], data, kwargs) + + +def contour_chart(*, data: Any = None, **kwargs: Any) -> Any: + """A data-bound contour chart (flat form; see module doc).""" + return _flat_chart(FLAT_KINDS["contour_chart"], data, kwargs) + + +def heatmap_chart(*, data: Any = None, **kwargs: Any) -> Any: + """A data-bound heatmap (flat form; see module doc).""" + return _flat_chart(FLAT_KINDS["heatmap_chart"], data, kwargs) + + +def stairs_chart(*, data: Any = None, **kwargs: Any) -> Any: + """A data-bound stairs chart (flat form; see module doc).""" + return _flat_chart(FLAT_KINDS["stairs_chart"], data, kwargs) + + +def triangle_mesh_chart(*, data: Any = None, **kwargs: Any) -> Any: + """A data-bound triangle-mesh chart (flat form; see module doc).""" + return _flat_chart(FLAT_KINDS["triangle_mesh_chart"], data, kwargs) + + def chart(*sources: Any, data: Any = None, **kwargs: Any) -> Any: """Place a chart: composed xy nodes (data-bound), or the component tiers. diff --git a/python/reflex_xy/plan.py b/python/reflex_xy/plan.py index 28f54628..bd3cb2c4 100644 --- a/python/reflex_xy/plan.py +++ b/python/reflex_xy/plan.py @@ -5,10 +5,15 @@ node tree with **string channels only**, compiled once at page evaluation. - **Build** (factory call = page evaluation = Reflex compile): construct the - real xy tree, bind a zero-row placeholder column for every referenced - channel name, and call ``.figure()`` once — the full mark/config - validation gate (facts X1/X2, pinned in tests/test_validation_timing.py) - runs in milliseconds with no real data. The probe figure is discarded; + real xy tree, bind a placeholder column for every referenced channel + name, and call ``.figure()`` once — the full mark/config validation gate + (facts X1/X2, pinned in tests/test_validation_timing.py) runs in + milliseconds with no real data. Placeholders are zero-row except for the + channels of the aggregating kinds (box, violin, hexbin, contour, heatmap, + stairs, ecdf), whose validators need at least one finite value: those + bind tiny fixed synthetic columns shaped per channel + (:data:`_SYNTHETIC_CHANNELS`, the recorded table — spec: + reflex-integration.md "Kind coverage"). The probe figure is discarded; what is kept is the digest, the recorded column names, and (for live charts) the probe's Tailwind class inventory. - **Serialize**: nodes → canonical JSON (sorted keys, ``plan_version``) → @@ -53,11 +58,79 @@ PLAN_VERSION = 1 _DIGEST_CHARS = 20 # sha256 hex prefix; content address for a process-local map -#: Mark kinds whose figure-compile validators require at least one finite -#: value (they aggregate: quantiles, bins, meshes). The zero-row probe -#: cannot compile them, so they are excluded from the plan tier — the -#: Phase 3 decision recorded in reflex-component-api-implementation.md. -_NEEDS_DATA_MARKS = frozenset({"box", "violin", "hexbin", "contour", "heatmap", "stairs", "ecdf"}) +#: Synthetic probe columns. Aggregating kinds (quantiles, bins, meshes) +#: refuse zero rows, so their channels probe with tiny *fixed* placeholder +#: arrays instead — deterministic constants that satisfy every value-domain +#: precondition a validator imposes (finite, positive, strictly increasing, +#: log-scale-safe), so a probe failure still means the *structure* is +#: invalid, never the made-up values. Real-data shape contracts (actual +#: lengths, the user's z being 2-D) stay where they always were: at bind. +_PROBE_LEN = 8 +_PROBE_GRID_SIDE = 4 + + +def _spread() -> np.ndarray: + return np.linspace(1.0, float(_PROBE_LEN), _PROBE_LEN) + + +def _one_group() -> np.ndarray: + return np.ones(_PROBE_LEN, dtype=np.float64) + + +def _grid() -> np.ndarray: + side = _PROBE_GRID_SIDE + return np.linspace(1.0, float(side * side), side * side).reshape(side, side) + + +def _grid_side() -> np.ndarray: + return np.linspace(1.0, float(_PROBE_GRID_SIDE), _PROBE_GRID_SIDE) + + +def _bin_edges() -> np.ndarray: + return np.linspace(1.0, float(_PROBE_LEN + 1), _PROBE_LEN + 1) + + +#: The recorded shape table: mark kind -> {channel storage: builder}. Keys +#: name where the factory stores each column-capable channel on the Mark +#: node — its ``x``/``y`` fields or a ``props`` entry (``box(values=...)`` +#: lands on field ``x``; ``stairs(edges=...)`` on field ``y``; +#: ``heatmap(z=...)`` in ``props["z"]``). Kinds absent here probe zero-row. +#: Extending a kind means recording its minimal validator contract here — +#: never a silent guess (§28 spirit); pinned by tests/reflex_adapter/ +#: test_plan.py and tests/test_validation_timing.py. +_SYNTHETIC_CHANNELS: dict[str, dict[str, Any]] = { + "box": {"x": _spread, "props.x": _one_group, "props.group": _one_group}, + "violin": {"x": _spread, "props.x": _one_group, "props.group": _one_group}, + "hexbin": {"x": _spread, "y": _spread, "props.C": _spread}, + "contour": {"props.z": _grid, "x": _grid_side, "y": _grid_side}, + "heatmap": {"props.z": _grid, "x": _grid_side, "y": _grid_side}, + "stairs": {"x": _spread, "y": _bin_edges}, + "ecdf": {"x": _spread}, +} + + +def _probe_shapes(children: tuple[Component, ...]) -> dict[str, np.ndarray]: + """Synthetic columns for every aggregating-kind channel bound by name. + + Shared column names keep one shape (first mark wins) — the same column + genuinely is the same array at bind time. A column shared between an + aggregating channel and a zero-row mark's channel cannot probe (the + lengths disagree); the probe then fails with xy's ordinary length + message, which is also what real data would do in every case where the + aggregating channel's shape truly differs. + """ + shapes: dict[str, np.ndarray] = {} + for child in children: + if not isinstance(child, Mark): + continue + for location, build in _SYNTHETIC_CHANNELS.get(child.kind, {}).items(): + if location.startswith("props."): + name = child.props.get(location[len("props.") :]) + else: + name = getattr(child, location, None) + if isinstance(name, str): + shapes.setdefault(name, build()) + return shapes class PlanError(ValueError): @@ -80,20 +153,26 @@ class PlanBindError(PlanError): class _ProbeTable(Mapping): - """Zero-row placeholder table that records every column it resolves. + """Placeholder table that records every column it resolves. ``Chart.figure()`` resolves string channels through ``data[name]`` (the exact production code path), so the recorded names are *derived* from the real resolution logic — the plan's column list can never drift - from what binding will actually look up. + from what binding will actually look up. Columns are zero-row unless + ``shapes`` carries a synthetic array for the name (the aggregating + kinds' channels, from :data:`_SYNTHETIC_CHANNELS`). """ - def __init__(self) -> None: + def __init__(self, shapes: Optional[Mapping[str, np.ndarray]] = None) -> None: self.seen: list[str] = [] + self._shapes = dict(shapes or {}) def __getitem__(self, key: str) -> np.ndarray: if key not in self.seen: self.seen.append(key) + shaped = self._shapes.get(key) + if shaped is not None: + return shaped return np.empty(0, dtype=np.float64) def __iter__(self): # pragma: no cover - Mapping protocol completeness @@ -118,6 +197,21 @@ def _plain(value: Any, context: str) -> Any: return value.item() if isinstance(value, (str, int, float, bool)) or value is None: return value + if callable(value): + # A module-level named callable (hexbin's reduce_C_function is + # np.mean by default) has a stable import path — that path is its + # content address. Lambdas and closures don't, so they cannot keep + # digests faithful and are refused like any other opaque value. + module = getattr(value, "__module__", "") or "" + qualname = getattr(value, "__qualname__", "") or "" + if module and qualname and "<" not in module and "<" not in qualname: + return {"~callable": f"{module}.{qualname}"} + raise PlanError( + f"{context} holds a {type(value).__name__} without a stable " + "qualified name (a lambda or closure?), which cannot be " + "content-addressed into a chart plan. Use a module-level " + "function, or build the chart with @reflex_xy.figure." + ) raise PlanError( f"{context} holds a {type(value).__name__}, which cannot be part of a " "data-bound chart plan. Plans are data-free structure: bind columns " @@ -179,30 +273,12 @@ def build_plan( canonical = json.dumps(serialized, sort_keys=True, separators=(",", ":")) digest = hashlib.sha256(canonical.encode()).hexdigest()[:_DIGEST_CHARS] - # The compile-time validation gate: bind zero-row placeholders for every - # string channel and compile once. Errors surface here — at page - # evaluation — with the ordinary xy messages. - probe = _ProbeTable() - try: - probe_figure = Chart(kind, children, data=probe, **chart_props).figure() - except ValueError as exc: - needy = sorted( - { - child.kind - for child in children - if isinstance(child, Mark) and child.kind in _NEEDS_DATA_MARKS - } - ) - if needy: - raise PlanError( - f"{', '.join(needy)} marks aggregate their values, so their " - "validators need at least one row — the zero-row plan probe " - "cannot compile them. Data-bound charts exclude these kinds " - "(recorded in reflex-component-api-implementation.md, Phase 3 " - "decision); build the chart with @reflex_xy.figure, or pass " - "a concrete xy Chart to reflex_xy.chart() for the static tier." - ) from exc - raise + # The compile-time validation gate: bind placeholders for every string + # channel (zero-row, or the shaped synthetic columns for aggregating + # kinds) and compile once. Errors surface here — at page evaluation — + # with the ordinary xy messages. + probe = _ProbeTable(_probe_shapes(children)) + probe_figure = Chart(kind, children, data=probe, **chart_props).figure() tailwind_classes = " ".join(probe_figure.dom_class_strings()) plan = ChartPlan( @@ -224,8 +300,16 @@ def build_plan( def register_plan(plan: ChartPlan) -> ChartPlan: - """Idempotently register a plan under its digest; returns the canonical one.""" - return _PLANS.setdefault(plan.digest, plan) + """Register a plan under its digest; returns the registered one. + + Last write wins (idempotent for identical content — the digest is the + content address): after a hot reload re-evaluates the page, the fresh + node objects replace the stale ones, which matters for the one node + field a digest addresses by *name* rather than by value (module-level + callables, e.g. a hexbin ``reduce_C_function`` whose body was edited). + """ + _PLANS[plan.digest] = plan + return plan def plan_of(digest: str) -> Optional[ChartPlan]: diff --git a/spec/design/reflex-component-api-implementation.md b/spec/design/reflex-component-api-implementation.md index 6b22abab..a2663650 100644 --- a/spec/design/reflex-component-api-implementation.md +++ b/spec/design/reflex-component-api-implementation.md @@ -336,7 +336,8 @@ plan, all recorded in the options doc §8 decision record: - **Phase 0** — as written: `tests/reflex_adapter/test_framework_contracts.py` (R1/R7/R8) and `tests/test_validation_timing.py` (X1–X3, plus the exact - zero-row kind list the factories rely on). Verified against reflex 0.9.8. + zero-row and shaped-synthetic kind lists the factories rely on). + Verified against reflex 0.9.8. - **Phase 1** — as written, except the deprecation warning fires only for positional *live* sources; the positional static Chart/Figure form stays undeprecated (it is the only route for arbitrary Charts, e.g. facet @@ -351,12 +352,13 @@ plan, all recorded in the options doc §8 decision record: step, stem, column, errorbar, error_band, segments). **Decision point resolved:** aggregating marks (box, violin, hexbin, contour, heatmap, stairs, ecdf) and the data-taking composite factories (pie, radar, - wind_rose, sankey) are excluded from the plan tier — options (a)+(b): + wind_rose, sankey) were excluded from the plan tier — options (a)+(b): static tier or `@reflex_xy.figure` — because their validators need real - values and a synthetic-row probe would validate against made-up data; - the probe refuses them with an error naming both routes - (reflex-integration.md §3.6 "Kind coverage"). Plan format frozen: - `PLAN_VERSION = 1`, golden digest pinned in `test_plan.py`. + values and a synthetic-row probe would validate against made-up data. + **The aggregating-marks half of this decision was revised post-landing — + see "Post-landing revision (kind coverage)" below**; the composite + factories remain excluded. Plan format frozen: `PLAN_VERSION = 1`, + golden digest pinned in `test_plan.py`. - **Phase 4** — as written (`probe="build"`/`"figure"`/`False`, async off by default with explicit opt-in via `asyncio.run`); the session-dependence downgrade uses a source-text heuristic @@ -375,6 +377,33 @@ map is populated in every worker" a guarantee of the integration instead of an assumption about Reflex. Recorded in reflex-integration.md §3.6 and the options doc §8. +**Post-landing revision (kind coverage, 2026-08).** The Phase 3 exclusion +of the aggregating marks is lifted. The objection — synthetic-row probe +failures could depend on made-up values — is answered by discipline +instead of exclusion: each aggregating kind's channels probe with fixed, +recorded placeholder columns (`plan._SYNTHETIC_CHANNELS`; values finite, +positive, strictly increasing, one group for grouped kinds, a square z +grid with matching side coordinates, `len+1` bin edges), so a probe +failure indicts structure, never the synthetic values. Landed with it: + +- Flat factories for the newly probed kinds (`box_chart`, `violin_chart`, + `ecdf_chart`, `hexbin_chart`, `contour_chart`, `heatmap_chart`, + `stairs_chart`) plus `triangle_mesh_chart` (always zero-row-safe, just + never wired). +- `validate_columns` no longer requires one shared length: a data var may + carry mixed-length and 2-D columns (stairs edges, heatmap grids); + coupled-shape contracts live with the mark validators at bind. +- Module-level named callables in mark props (hexbin's + `reduce_C_function`) content-address as their import path; lambdas and + closures are refused. Plan registration is last-write-wins so a hot + reload replaces stale node objects behind an unchanged digest. +- Pinned by `tests/reflex_adapter/test_plan.py` (the shape table, plan + builds per kind, callable addressing), `test_factories.py` (the full + 19-kind flat table), and `tests/test_validation_timing.py` + (SHAPED_ROW_CHARTS — the xy-level half of the contract). Recorded in + reflex-integration.md §3.6 "Kind coverage (recorded decision, revised + 2026-08)". + Deferred items remain deferred and tracked (keyed dataset collections for `foreach`, per-mark `data=`, the core-xy metadata registry, both upstream Reflex PR sites). diff --git a/spec/design/reflex-component-api-options.md b/spec/design/reflex-component-api-options.md index 76f21344..6ff4f3b1 100644 --- a/spec/design/reflex-component-api-options.md +++ b/spec/design/reflex-component-api-options.md @@ -903,3 +903,16 @@ and small): unevaluated pages in its startup lifespan, making the plan-distribution property a guarantee of the integration rather than an observed Reflex behavior. +- **The aggregating-kind exclusion was revised (2026-08).** The plan tier + originally refused box, violin, hexbin, contour, heatmap, stairs, and + ecdf because their validators need at least one finite value and a + synthetic probe "would validate against made-up data". The revision + keeps the compile guarantee honest by *recording* the synthetic shapes + instead of refusing: fixed placeholder columns per (kind, channel) — + `plan._SYNTHETIC_CHANNELS`, pinned by tests at both the adapter and xy + levels — chosen to satisfy every value-domain precondition, so probe + failures still indict structure. Every standalone mark kind now has a + flat factory and composes; only the data-taking composite factories + (pie, radar, wind_rose, sankey) stay on the static/escape-hatch routes. + Details: reflex-integration.md §3.6 "Kind coverage" and the + implementation doc's post-landing revision record. diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index efc1a1cb..5ce8be92 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -488,9 +488,12 @@ the exact sibling of `@reflex_xy.figure`: a computed var whose value is a typed `DataHandle` wrapping `xyd1|||` (same grammar, charset, purity contract, pre-session short-circuit, underscore refusal, async dispatch, and `None`-releases semantics as figure vars). Evaluating -it validates the returned mapping — string keys, array-likes, one shared -length; the only checks that need real data — and publishes the **columns** -into the registry. The method's return annotation is the compile-time +it validates the returned mapping — string keys, array-likes; the only +check that needs real data — and publishes the **columns** into the +registry. Columns may differ in length and dimensionality (a stairs mark's +`len+1` edges or a heatmap's 2-D grid beside ordinary row columns): +coupled-length and shape contracts belong to the mark validators when a +plan binds, where the errors name the mark and channels involved. The method's return annotation is the compile-time schema channel (fact R7): a `TypedDict` parametrizes the handle (`DataHandle[CloudData]`), and the factories read the column names from the class-level var without executing anything; a plain `dict` annotation @@ -498,11 +501,13 @@ degrades to first-execution validation. **Plans** (`plan.py`). A chart factory call at page evaluation compiles its xy nodes (string channels only) into a `ChartPlan`: the real tree is built, -zero-row placeholder columns are bound for every referenced channel through +placeholder columns are bound for every referenced channel through the production resolution path (a recording table — the column list cannot drift from what binding will look up), and `.figure()` runs once — the full mark/config validation gate (X1/X2) at compile time, in milliseconds, with -no data ingestion (constraint 2). The canonical JSON of the tree +no data ingestion (constraint 2). Placeholders are zero-row except for the +aggregating kinds' channels, which bind the tiny shaped synthetic columns +recorded in `plan._SYNTHETIC_CHANNELS` (see "Kind coverage" below). The canonical JSON of the tree (`plan_version: 1`) is content-addressed into a sha256-prefix `digest` and registered in a process-local `{digest: plan}` map. Fact X4 ("page bodies run in every worker") turned out to hold only for processes that run the @@ -540,7 +545,14 @@ the check that catches it. Binding is the reverse: columns + plan → a **fresh** `Chart` (never reused — X3) → `.figure()`. Column-mismatch errors name both sides (*"plan binds column 'mag'; Dash.cloud produced {x, y}"*). Plans refuse concrete arrays, per-mark -`data=`, and `render=` components — data-free structure only. The probe +`data=`, and `render=` components — data-free structure only. One node +field is addressed by *name* rather than by value: a module-level named +callable (hexbin's `reduce_C_function`, `np.mean` by default) serializes +as its import path, which is a stable content address; lambdas and +closures have none and are refused toward a module-level function or the +escape hatch. Re-registration under a digest is last-write-wins, so a +hot-reloaded page replaces stale node objects (the body behind a callable +path may have changed). The probe figure also yields `dom_class_strings()`, so **live data-bound charts get automatic Tailwind discovery** (previously live sources needed the manual inventory). @@ -567,7 +579,7 @@ compensated for by a distance threshold); CSS goes through the explicit `style={...}` prop, which reaches the DOM unchanged. Errors this tier catches at `reflex run`: hallucinated factory names (import), unknown kwargs (partition), bad colormaps/enums/axis -refs (zero-row probe), unknown column names against a typed data var +refs (plan probe), unknown column names against a typed data var (schema channel), and the wrong var or a raw string in `data=` (typed prop, R1). @@ -576,20 +588,33 @@ binds immediately and routes to the §3.4 payload-asset path — same validation, same spec-aware bind errors, works under `reflex export`, never touches the registry. -**Kind coverage (recorded decision).** Flat factories exist for every mark -kind whose validators compile zero-row — scatter, line, histogram, bar, -area, step, stem, column, errorbar, error_band, segments — each derived -from the mark's signature, and the composed `reflex_xy.chart(*nodes, -data=...)` accepts any mix of those marks plus annotations and chrome. -Aggregating kinds whose validators require at least one finite value (box, -violin, hexbin, contour, heatmap, stairs, ecdf) and the data-taking -composite factories (pie, radar, wind_rose, sankey — eager numeric work at -call time) are **excluded from the plan tier**: the probe refuses them with -an error naming the two supported routes (`@reflex_xy.figure`, or a -concrete xy Chart on the static tier). Extending them would need -value-independent validation or a synthetic-row probe whose failures could -depend on made-up values — rejected as a silent decimation of the compile -guarantee (§28 spirit). +**Kind coverage (recorded decision, revised 2026-08).** Flat factories +exist for **every standalone mark kind**: scatter, line, histogram, bar, +area, step, stem, column, errorbar, error_band, segments, triangle_mesh +(zero-row probe) and box, violin, ecdf, hexbin, contour, heatmap, stairs +(shaped synthetic probe) — each derived from the mark's signature, and the +composed `reflex_xy.chart(*nodes, data=...)` accepts any mix of those +marks plus annotations and chrome. The aggregating kinds were previously +excluded on the argument that a synthetic-row probe's failures could +depend on made-up values; that objection is answered by discipline rather +than exclusion. Each such kind's channels probe with **fixed, recorded** +placeholder columns (`plan._SYNTHETIC_CHANNELS`, pinned by +`tests/reflex_adapter/test_plan.py` and, at the xy level, by +`tests/test_validation_timing.py`) whose values satisfy every value-domain +precondition a validator imposes: finite, positive, strictly increasing +(log-scale-safe); one group for grouped kinds; a square z grid with +matching side coordinates; `len+1` bin edges. A probe failure therefore +still indicts the chart's structure, never the synthetic values, and the +compile guarantee is unchanged in kind — config and channel structure at +compile, real-data shapes at bind, exactly where the zero-row probe +already left them (§28 spirit: the shapes are recorded, not guessed). +Known edge, recorded: a column shared between an aggregating channel and +a zero-row mark's channel in one composed chart cannot probe (the +placeholder lengths disagree); such charts take `@reflex_xy.figure`. The +data-taking composite factories (pie, radar, wind_rose, sankey — eager +numeric work at call time) remain outside the plan tier: they don't +produce data-free nodes, and keep the two recorded routes +(`@reflex_xy.figure`, or a concrete xy Chart on the static tier). **Column entries.** Published columns are registry entries in their own right, keyed by the data token: pure rebuildable caches of Reflex state @@ -1014,7 +1039,7 @@ python/reflex_xy/ the typed values chart state vars carry vars.py @reflex_xy.figure (FigureVar: builder-tracked deps) data_vars.py @reflex_xy.data (DataVar: columns in, handle out) - plan.py ChartPlan: zero-row probe, canonical digest, + plan.py ChartPlan: placeholder probe, canonical digest, process-local plan map, bind (§3.6) factories.py scatter/line/histogram/bar_chart flat factories + composed chart(*nodes): signature-derived kwarg diff --git a/tests/reflex_adapter/test_data_var.py b/tests/reflex_adapter/test_data_var.py index 451177fb..8a2864f9 100644 --- a/tests/reflex_adapter/test_data_var.py +++ b/tests/reflex_adapter/test_data_var.py @@ -150,7 +150,6 @@ def test_method_resolvable_from_class(client_token): ({"x": "not-values"}, "array-like"), ({"x": {"nested": 1}}, "array-like"), ({"x": 3.5}, "with a length"), - ({"x": [1.0, 2.0], "y": [1.0]}, "share one length"), ], ) def test_validate_columns_rejects_malformed(columns, match): @@ -158,6 +157,20 @@ def test_validate_columns_rejects_malformed(columns, match): validate_columns(columns, source="Demo.cloud") +def test_validate_columns_allows_mixed_lengths_and_dims(): + """One var may feed marks with different row counts — a stairs mark's + ``len+1`` edges, a heatmap's 2-D grid — beside ordinary columns. + Coupled-length contracts live with the mark validators at bind, where + the errors name the mark and channels involved.""" + columns = { + "x": np.arange(5.0), + "counts": np.arange(4.0), + "edges": np.arange(5.0), + "grid": np.arange(12.0).reshape(3, 4), + } + assert validate_columns(columns, source="Demo.cloud") == columns + + def test_republish_rebuilds_and_bumps_mounted_dependents(_fresh_registry, client_token): """The data-token -> digest index: a column republish re-binds every mounted plan into a fresh figure generation (broadcast by publish).""" diff --git a/tests/reflex_adapter/test_factories.py b/tests/reflex_adapter/test_factories.py index 87f26c73..8dd0c00f 100644 --- a/tests/reflex_adapter/test_factories.py +++ b/tests/reflex_adapter/test_factories.py @@ -54,15 +54,23 @@ def test_partition_table_is_the_public_contract(): assert aliases == { "area_chart": keyed, "bar_chart": {**keyed, "mark_width": "width"}, # stroke_width is native + "box_chart": {**base, "stroke_width": "width"}, "column_chart": {**keyed, "mark_width": "width"}, # stroke_width is native + "contour_chart": {**base, "stroke_width": "width"}, + "ecdf_chart": {**base, "stroke_width": "width"}, "error_band_chart": keyed, "errorbar_chart": {**keyed, "stroke_width": "width"}, + "heatmap_chart": base, + "hexbin_chart": base, "histogram_chart": base, "line_chart": {**keyed, "stroke_width": "width"}, "scatter_chart": keyed, "segments_chart": {**base, "stroke_width": "width"}, + "stairs_chart": {**base, "stroke_width": "width"}, "stem_chart": {**base, "stroke_width": "width"}, "step_chart": {**base, "stroke_width": "width"}, + "triangle_mesh_chart": base, + "violin_chart": {**base, "stroke_width": "width"}, } @@ -301,7 +309,8 @@ def test_curated_reexports_are_the_xy_constructors(): def test_every_flat_kind_compiles_a_plan(app_cwd): - """The signature-derived factories cover every zero-row-safe mark kind.""" + """The signature-derived factories cover every standalone mark kind — + zero-row-safe ones and the aggregating ones (shaped synthetic probe).""" from reflex_xy.factories import FLAT_KINDS calls = { @@ -316,6 +325,14 @@ def test_every_flat_kind_compiles_a_plan(app_cwd): "errorbar_chart": dict(x="x", y="y", yerr="mag"), "error_band_chart": dict(x="x", lower="y", upper="mag"), "segments_chart": dict(x0="x", y0="y", x1="x", y1="mag"), + "box_chart": dict(values="mag"), + "violin_chart": dict(values="mag"), + "ecdf_chart": dict(values="mag"), + "hexbin_chart": dict(x="x", y="y"), + "contour_chart": dict(z="mag"), + "heatmap_chart": dict(z="mag"), + "stairs_chart": dict(values="x", edges="y"), + "triangle_mesh_chart": dict(x0="x", y0="y", x1="mag", y1="x", x2="y", y2="mag"), } assert set(calls) == set(FLAT_KINDS) for kind, channels in calls.items(): @@ -323,10 +340,14 @@ def test_every_flat_kind_compiles_a_plan(app_cwd): assert "plan:" in str(comp), kind -def test_needs_data_marks_are_refused_with_guidance(app_cwd): - """The Phase 3 decision: aggregating marks whose validators need at - least one row are excluded from the plan tier, with the escape hatch - and static tier named in the error.""" - with pytest.raises(ValueError, match=r"box.*@reflex_xy\.figure"): - reflex_xy.chart(xy.box("mag"), data=FactoryDash.cloud) - assert not hasattr(reflex_xy, "box_chart") +def test_aggregating_marks_compile_through_the_shaped_probe(app_cwd): + """The revised Phase 3 decision: aggregating marks probe with the shaped + synthetic columns recorded in plan._SYNTHETIC_CHANNELS, so they are + first-class in the plan tier (flat and composed) — while their config + validation still fires at compile like every other kind's.""" + comp = reflex_xy.chart(xy.box("mag"), xy.ecdf("mag"), data=FactoryDash.cloud) + assert "plan:" in str(comp) + with pytest.raises(ValueError, match="colormap"): + reflex_xy.heatmap_chart(data=FactoryDash.cloud, z="mag", colormap="virids") + with pytest.raises(ValueError, match="unknown column 'reading'"): + reflex_xy.violin_chart(data=FactoryDash.cloud, values="reading") diff --git a/tests/reflex_adapter/test_plan.py b/tests/reflex_adapter/test_plan.py index 22bddae2..cd27a911 100644 --- a/tests/reflex_adapter/test_plan.py +++ b/tests/reflex_adapter/test_plan.py @@ -47,7 +47,7 @@ def test_digest_golden_pins_the_plan_format(): assert plan.digest == "b7d0b4245b686130e37d" -def test_zero_row_probe_fires_the_full_validation_gate(): +def test_probe_fires_the_full_validation_gate(): with pytest.raises(ValueError, match="colormap"): scatter_plan(colormap="virids") with pytest.raises(ValueError, match="symbol"): @@ -57,6 +57,67 @@ def test_zero_row_probe_fires_the_full_validation_gate(): build_plan("scatter_chart", (xy.scatter("x", "y", y_axis="y2"),), {}) +def test_shaped_probe_table_is_the_recorded_contract(): + """The synthetic-channel table (spec: reflex-integration.md "Kind + coverage") is a recorded decision, §28 spirit: extending a kind means + recording its validator's minimal shape here, never a silent guess. + Keys name the Mark storage each factory param lands on (x/y fields or + props entries).""" + from reflex_xy.plan import _SYNTHETIC_CHANNELS + + assert {kind: sorted(channels) for kind, channels in _SYNTHETIC_CHANNELS.items()} == { + "box": ["props.group", "props.x", "x"], + "violin": ["props.group", "props.x", "x"], + "hexbin": ["props.C", "x", "y"], + "contour": ["props.z", "x", "y"], + "heatmap": ["props.z", "x", "y"], + "stairs": ["x", "y"], + "ecdf": ["x"], + } + + +def test_aggregating_kinds_build_plans_with_shaped_probes(): + """Every aggregating kind compiles a plan (the old exclusion is gone), + with grouped/coordinate/weight channels recorded like any other.""" + cases = [ + ("box_chart", (xy.box("v", group="g"),), {"v", "g"}), + ("violin_chart", (xy.violin("v"),), {"v"}), + ("hexbin_chart", (xy.hexbin("a", "b", C="w"),), {"a", "b", "w"}), + ("contour_chart", (xy.contour("grid", x="xs", y="ys"),), {"grid", "xs", "ys"}), + ("heatmap_chart", (xy.heatmap("grid"),), {"grid"}), + ("stairs_chart", (xy.stairs("counts", "edges"),), {"counts", "edges"}), + ("ecdf_chart", (xy.ecdf("v"),), {"v"}), + ] + for kind, children, expected in cases: + plan = build_plan(kind, children, {}) + assert set(plan.columns) == expected, kind + + +def test_shaped_and_zero_row_marks_compose_and_share_columns(): + plan = build_plan("chart", (xy.histogram("v"), xy.ecdf("v")), {}) + assert plan.columns == ("v",) + + +def test_named_callables_digest_and_lambdas_are_refused(): + """hexbin's reduce_C_function default (np.mean) has a stable import + path — that path is its content address, so identical trees still agree + across workers. A lambda has no stable name and cannot keep digests + faithful; it is refused toward a module-level function or the hatch.""" + hexbin_plan = build_plan("hexbin_chart", (xy.hexbin("a", "b", C="w"),), {}) + again = build_plan("hexbin_chart", (xy.hexbin("a", "b", C="w"),), {}) + assert hexbin_plan.digest == again.digest + reduced = build_plan( + "hexbin_chart", (xy.hexbin("a", "b", C="w", reduce_C_function=np.median),), {} + ) + assert reduced.digest != hexbin_plan.digest + with pytest.raises(PlanError, match="stable qualified name"): + build_plan( + "hexbin_chart", + (xy.hexbin("a", "b", C="w", reduce_C_function=lambda values: values.max()),), + {}, + ) + + def test_plans_refuse_concrete_arrays(): with pytest.raises(PlanError, match="data-free"): build_plan("scatter_chart", (xy.scatter(np.array([1.0]), np.array([2.0])),), {}) diff --git a/tests/test_validation_timing.py b/tests/test_validation_timing.py index d3c4f2e6..037ed05a 100644 --- a/tests/test_validation_timing.py +++ b/tests/test_validation_timing.py @@ -2,13 +2,16 @@ The Reflex component API (spec/design/reflex-component-api-options.md §4, implementation plan in reflex-component-api-implementation.md) compiles -chart *plans* by binding zero-row placeholder columns and calling -``.figure()`` once at page evaluation. That only works while: +chart *plans* by binding placeholder columns — zero-row, or the tiny +shaped synthetic columns for aggregating kinds — and calling ``.figure()`` +once at page evaluation. That only works while: - X1: the tree is cheap to build without data; chrome nodes validate eagerly; mark config validates at ``.figure()``. -- X2: zero-row columns compile — the probe exercises the full validation - gate with no real data. +- X2: placeholder columns compile — zero-row for most kinds, and for the + aggregating kinds the recorded minimal shapes (SHAPED_ROW_CHARTS below, + mirroring reflex_xy.plan._SYNTHETIC_CHANNELS) — so the probe exercises + the full validation gate with no real data. - X3: ``Chart.figure()`` memoizes and is never invalidated — rebinding data means a fresh ``Chart``, never mutating one. @@ -25,11 +28,11 @@ EMPTY = np.empty(0, dtype=np.float64) -# Every mark kind the flat/composed Reflex factories cover with the zero-row -# probe (Phase 2 kinds first, then the Phase 3 signature-derived set). Kinds -# whose validators require at least one finite value (stairs, ecdf, box, -# violin, hexbin, heatmap, contour) are excluded from the plan/zero-row model -# by the Phase 3 decision recorded in the implementation plan. +# Every mark kind the flat/composed Reflex factories cover with the plain +# zero-row probe (Phase 2 kinds first, then the Phase 3 signature-derived +# set). Kinds whose validators require at least one finite value (stairs, +# ecdf, box, violin, hexbin, heatmap, contour) probe with the shaped +# synthetic columns pinned in SHAPED_ROW_CHARTS below instead. ZERO_ROW_CHARTS = { "scatter": lambda: xy.scatter_chart(xy.scatter(EMPTY, EMPTY)), "line": lambda: xy.line_chart(xy.line(EMPTY, EMPTY)), @@ -53,6 +56,38 @@ def test_zero_row_construction_compiles(kind): assert figure is not None +# The aggregating kinds' minimal validator contracts: deterministic, positive, +# finite, strictly increasing placeholder values (log-scale- and ratio-safe), +# one group for grouped kinds, a square grid with matching side coordinates, +# `len+1` bin edges. This is the xy-level half of the shaped-probe contract; +# reflex_xy.plan._SYNTHETIC_CHANNELS mirrors these shapes and +# tests/reflex_adapter/test_plan.py pins that table. +SPREAD = np.linspace(1.0, 8.0, 8) +ONE_GROUP = np.ones(8) +GRID = np.linspace(1.0, 16.0, 16).reshape(4, 4) +GRID_SIDE = np.linspace(1.0, 4.0, 4) +BIN_EDGES = np.linspace(1.0, 9.0, 9) + +SHAPED_ROW_CHARTS = { + "box": lambda: xy.box_chart(xy.box(SPREAD, group=ONE_GROUP)), + "violin": lambda: xy.violin_chart(xy.violin(SPREAD, group=ONE_GROUP)), + "hexbin": lambda: xy.hexbin_chart(xy.hexbin(SPREAD, SPREAD)), + "contour": lambda: xy.contour_chart(xy.contour(GRID, x=GRID_SIDE, y=GRID_SIDE)), + "heatmap": lambda: xy.heatmap_chart(xy.heatmap(GRID, x=GRID_SIDE, y=GRID_SIDE)), + "stairs": lambda: xy.stairs_chart(xy.stairs(SPREAD, BIN_EDGES)), + "ecdf": lambda: xy.ecdf_chart(xy.ecdf(SPREAD)), +} + + +@pytest.mark.parametrize("kind", sorted(SHAPED_ROW_CHARTS)) +def test_shaped_row_construction_compiles(kind): + """X2 for the aggregating kinds: the recorded minimal synthetic shapes + compile. If a validator starts demanding more (two groups, a bigger + grid), this fails first, named after the fact it broke.""" + figure = SHAPED_ROW_CHARTS[kind]().figure() + assert figure is not None + + def test_zero_row_columns_resolve_through_chart_data(): """X2, the form the plan tier uses: string channels resolved against a chart-level table of zero-row columns.""" From 1c747ace55887ec8ed328d28946b221843ca41fe Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:02:39 +0500 Subject: [PATCH 2/6] docs(reflex): demo every chart kind and a conditional data source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions to the demo app, both covered by test_example_apps.py: - A /kinds page rendering all 19 standalone mark kinds as data-bound flat factories fed by a single @reflex_xy.data var — mixed column lengths and a 2-D grid in one var, which the relaxed column validation now permits — next to the composite kinds (pie, radar, sankey, polar, polar bars, wind rose, facet) on the static tier, since those still take data directly. - §10 on the main page: data=rx.cond(...) picking between two data vars. The cond sits at the data-var level, so one fixed plan swaps between two column sets from state and both branches stay compile-checked against the shared schema. --- .../reflex/xy_reflex_demo/xy_reflex_demo.py | 353 ++++++++++++++++++ tests/test_example_apps.py | 8 + 2 files changed, 361 insertions(+) diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index eb1f0402..52bdfaee 100644 --- a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py +++ b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py @@ -46,6 +46,16 @@ one compile-validated plan, one mount per handle, column names checked inside the loop (fact R7), and both cond branches validated at ``reflex run``. +10. **Conditional data source.** ``data=rx.cond(Demo.ds == "full", + Demo.cloud, Demo.cond_summary)`` — the cond is at the *data var* level, + so one fixed plan swaps between two column sets from state; both + branches share the schema and stay compile-checked. + +A second page, ``/kinds``, renders **every chart kind**: all 19 standalone +mark kinds as data-bound flat factories fed by one ``@reflex_xy.data`` var +(mixed column lengths and a 2-D grid in a single var), and the composite +kinds (pie, radar, sankey, polar, polar bars, wind rose, facet) on the +static tier. Run from ``examples/reflex``:: @@ -511,6 +521,25 @@ def sensor_handles(self) -> list[DataHandle[SensorCols]]: def toggle_split(self): self.split = not self.split + # §10 conditional data source: data= is an rx.cond picking between two + # data vars. `cloud` (§1's 1M-point var) is the full branch; this var is + # the summary branch — binned means of the same cloud. + ds: str = "full" + + @reflex_xy.data + def cond_summary(self) -> CloudCols: + x, y, _ = _cloud(POINTS) + edges = np.linspace(x.min(), x.max(), 121) + idx = np.clip(np.digitize(x, edges) - 1, 0, 119) + counts = np.maximum(np.bincount(idx, minlength=120), 1) + mean_y = np.bincount(idx, weights=y, minlength=120) / counts + centers = (edges[:-1] + edges[1:]) / 2.0 + return {"x": centers, "y": mean_y, "mag": np.hypot(centers, mean_y)} + + @rx.event + def toggle_ds(self): + self.ds = "summary" if self.ds == "full" else "full" + @rx.event(background=True) async def stream(self): async with self: @@ -811,6 +840,39 @@ def cond_foreach_view() -> rx.Component: ) +# §10 wiring — one chart, two data sources: data= is an rx.cond that picks +# which @reflex_xy.data var feeds the fixed plan. Both branch vars share the +# CloudCols schema, so the column names stay compile-checked; toggling flips +# the handle client-side and the chart re-subscribes to the other columns. +def cond_data_view() -> rx.Component: + return rx.vstack( + reflex_xy.scatter_chart( + data=rx.cond(Demo.ds == "full", Demo.cloud, Demo.cond_summary), + x="x", + y="y", + color="mag", + colormap="viridis", + mark_opacity=0.75, + density=True, + title="data= is an rx.cond between two data vars", + height="300px", + id="cond-data", + ), + rx.hstack( + rx.button( + rx.cond(Demo.ds == "full", "switch to summary", "switch to full"), + on_click=Demo.toggle_ds, + id="ds-btn", + ), + kv("source", Demo.ds), + spacing="3", + align="center", + ), + width="100%", + spacing="2", + ) + + # §7 wiring — legend interactivity ships with the charts; no handlers needed def legend_view() -> rx.Component: return rx.grid( @@ -822,6 +884,285 @@ def legend_view() -> rx.Component: ) +# --- /kinds: every chart kind on one page ----------------------------------- + + +class KindCols(TypedDict): + """Schema of the /kinds data var: one var, every column every mark kind + needs — mixed lengths and a 2-D grid side by side (coupled-shape + contracts belong to each mark's validator at bind, not the var).""" + + x: np.ndarray + wave: np.ndarray + band_lo: np.ndarray + band_hi: np.ndarray + walk: np.ndarray + sx: np.ndarray + sy: np.ndarray + smag: np.ndarray + stem_x: np.ndarray + stem_y: np.ndarray + bar_x: np.ndarray + bar_y: np.ndarray + col_y: np.ndarray + hv: np.ndarray + ex: np.ndarray + ey: np.ndarray + err: np.ndarray + seg_x0: np.ndarray + seg_y0: np.ndarray + seg_x1: np.ndarray + seg_y1: np.ndarray + bv: np.ndarray + bg: np.ndarray + grid: np.ndarray + counts: np.ndarray + edges: np.ndarray + tx0: np.ndarray + ty0: np.ndarray + tx1: np.ndarray + ty1: np.ndarray + tx2: np.ndarray + ty2: np.ndarray + + +@lru_cache(maxsize=1) +def _kind_columns() -> dict[str, np.ndarray]: + rng = np.random.default_rng(29) + x = np.linspace(0.0, 12.0, 240) + wave = np.sin(x) * np.exp(-x / 9.0) + band = 0.25 + 0.12 * np.cos(x / 2.0) + sx = rng.normal(0.0, 1.0, 1200) + sy = sx * 0.5 + rng.normal(0.0, 0.6, 1200) + hv = rng.normal(0.0, 1.0, 2000) + ex = np.linspace(0.5, 11.5, 24) + bv = np.concatenate([rng.normal(mu, 0.6, 300) for mu in (0.0, 1.2, 2.6)]) + counts, edges = np.histogram(hv, bins=24) + gy, gx = np.mgrid[-2.2:2.2:40j, -3.0:3.0:48j] + grid = np.exp(-(gx**2 + gy**2)) - 0.6 * np.exp(-((gx - 1.2) ** 2 + (gy + 0.6) ** 2)) + spokes = np.linspace(0.0, 2.0 * np.pi, 36, endpoint=False) + radius = 1.0 + 0.3 * np.sin(spokes * 3.0) + step = 2.0 * np.pi / 36 + return { + "x": x, + "wave": wave, + "band_lo": wave - band, + "band_hi": wave + band, + "walk": np.cumsum(rng.normal(0.0, 0.25, 240)), + "sx": sx, + "sy": sy, + "smag": np.hypot(sx, sy), + "stem_x": x[::5], + "stem_y": wave[::5], + "bar_x": np.arange(1.0, 13.0), + "bar_y": rng.uniform(2.0, 9.0, 12), + "col_y": rng.uniform(2.0, 9.0, 12), + "hv": hv, + "ex": ex, + "ey": np.sin(ex / 2.0) * 3.0 + 5.0, + "err": rng.uniform(0.3, 0.9, 24), + "seg_x0": rng.uniform(0.0, 10.0, 36), + "seg_y0": rng.uniform(0.0, 10.0, 36), + "seg_x1": rng.uniform(0.0, 10.0, 36), + "seg_y1": rng.uniform(0.0, 10.0, 36), + "bv": bv, + "bg": np.repeat([1.0, 2.0, 3.0], 300), + "grid": grid, + "counts": counts.astype(np.float64), + "edges": edges, + "tx0": np.zeros(36), + "ty0": np.zeros(36), + "tx1": radius * np.cos(spokes), + "ty1": radius * np.sin(spokes), + "tx2": radius * np.cos(spokes + step), + "ty2": radius * np.sin(spokes + step), + } + + +class Kinds(rx.State): + """One data var feeds all 19 data-bound kind charts on /kinds.""" + + @reflex_xy.data + def table(self) -> KindCols: + return dict(_kind_columns()) + + +@lru_cache(maxsize=1) +def _composite_kind_charts() -> dict[str, xy.Chart]: + """The composite kinds — eager numeric work at call time, so they ride + the static tier as concrete xy Charts (reflex_xy.chart(chart)).""" + rng = np.random.default_rng(31) + angle = np.linspace(0.0, 360.0, 121) + gain = 6.0 + 4.0 * np.abs(np.cos(np.radians(angle))) ** 3 + fx = rng.normal(0.0, 1.0, 600) + fy = fx * 0.4 + rng.normal(0.0, 0.7, 600) + panel = np.repeat(["alpha", "beta", "gamma"], 200) + return { + "pie": xy.pie_chart(["Skyline", "Datawell", "Cloudpeak", "Union"], [27.0, 21.0, 13.0, 9.0]), + "radar": xy.radar_chart( + ["speed", "power", "range", "agility", "cost"], + xy.area([0.9, 0.7, 0.55, 0.85, 0.6], name="model A"), + xy.area([0.6, 0.8, 0.7, 0.5, 0.9], name="model B"), + xy.legend(), + ), + "sankey": xy.sankey_chart( + [ + ("Inflow", "Equities", 78000), + ("Inflow", "Bonds", 46000), + ("Equities", "Growth", 61000), + ("Equities", "Value", 17000), + ("Bonds", "Credit", 30000), + ("Bonds", "Rates", 16000), + ] + ), + "polar": xy.polar_chart( + xy.line(angle, gain, name="gain"), + xy.theta_axis(unit="degrees", zero="N", direction="clockwise"), + xy.r_axis(label="dBi"), + ), + "polar_bar": xy.polar_bar_chart( + xy.bar(np.arange(0.0, 360.0, 30.0), rng.uniform(2.0, 9.0, 12), width=24.0), + xy.theta_axis(unit="degrees", zero="N", direction="clockwise"), + ), + "wind_rose": xy.wind_rose( + rng.uniform(0.0, 360.0, 500), rng.rayleigh(4.0, 500), xy.legend() + ), + "facet": xy.facet_chart( + xy.scatter("fx", "fy", opacity=0.7), + by="panel", + data={"fx": fx, "fy": fy, "panel": panel}, + cols=3, + height=170, + ), + } + + +def _kind_cell(title: str, chart: rx.Component) -> rx.Component: + return rx.box( + rx.text(title, font_family="monospace", size="2", margin_bottom="0.4rem"), + chart, + border="1px solid var(--gray-5)", + border_radius="10px", + background="var(--gray-1)", + padding="0.75rem", + width="100%", + ) + + +def kinds() -> rx.Component: + """Every chart kind on one page: the data-bound tier for all standalone + marks (one plan each, columns from Kinds.table), then the composite + kinds on the static tier.""" + height = "230px" + bound: list[tuple[str, rx.Component]] = [ + ( + "scatter", + reflex_xy.scatter_chart(data=Kinds.table, x="sx", y="sy", color="smag", height=height), + ), + ("line", reflex_xy.line_chart(data=Kinds.table, x="x", y="wave", height=height)), + ( + "area", + reflex_xy.area_chart( + data=Kinds.table, x="x", y="band_hi", mark_opacity=0.6, height=height + ), + ), + ("step", reflex_xy.step_chart(data=Kinds.table, x="x", y="walk", height=height)), + ("stem", reflex_xy.stem_chart(data=Kinds.table, x="stem_x", y="stem_y", height=height)), + ("bar", reflex_xy.bar_chart(data=Kinds.table, x="bar_x", y="bar_y", height=height)), + ("column", reflex_xy.column_chart(data=Kinds.table, x="bar_x", y="col_y", height=height)), + ( + "histogram", + reflex_xy.histogram_chart(data=Kinds.table, values="hv", bins=40, height=height), + ), + ( + "errorbar", + reflex_xy.errorbar_chart(data=Kinds.table, x="ex", y="ey", yerr="err", height=height), + ), + ( + "error_band", + reflex_xy.error_band_chart( + data=Kinds.table, x="x", lower="band_lo", upper="band_hi", height=height + ), + ), + ( + "segments", + reflex_xy.segments_chart( + data=Kinds.table, x0="seg_x0", y0="seg_y0", x1="seg_x1", y1="seg_y1", height=height + ), + ), + ("box", reflex_xy.box_chart(data=Kinds.table, values="bv", group="bg", height=height)), + ( + "violin", + reflex_xy.violin_chart(data=Kinds.table, values="bv", group="bg", height=height), + ), + ("ecdf", reflex_xy.ecdf_chart(data=Kinds.table, values="hv", height=height)), + ("hexbin", reflex_xy.hexbin_chart(data=Kinds.table, x="sx", y="sy", height=height)), + ("heatmap", reflex_xy.heatmap_chart(data=Kinds.table, z="grid", height=height)), + ( + "contour", + reflex_xy.contour_chart(data=Kinds.table, z="grid", filled=True, height=height), + ), + ( + "stairs", + reflex_xy.stairs_chart(data=Kinds.table, values="counts", edges="edges", height=height), + ), + ( + "triangle_mesh", + reflex_xy.triangle_mesh_chart( + data=Kinds.table, + x0="tx0", + y0="ty0", + x1="tx1", + y1="ty1", + x2="tx2", + y2="ty2", + height=height, + ), + ), + ] + composites = [ + (name, reflex_xy.chart(chart, height=height, id=f"kind-{name}")) + for name, chart in _composite_kind_charts().items() + ] + return rx.container( + rx.vstack( + rx.heading("Every chart kind", size="8"), + rx.text( + "19 standalone mark kinds, data-bound: one @reflex_xy.data var " + "supplies every column (mixed lengths and a 2-D grid in one " + "var); each chart is a compile-validated plan.", + color_scheme="gray", + size="3", + ), + rx.grid( + *[_kind_cell(name, chart) for name, chart in bound], + columns="3", + gap="1rem", + width="100%", + ), + rx.heading("Composite kinds — static tier", size="6", margin_top="1rem"), + rx.text( + "pie, radar, sankey, polar, polar bars, wind rose, and a facet " + "grid do eager numeric work at build time, so they ride as " + "concrete xy charts.", + color_scheme="gray", + size="3", + ), + rx.grid( + *[_kind_cell(name, chart) for name, chart in composites], + columns="3", + gap="1rem", + width="100%", + ), + rx.link("← the linking showcase", href="/"), + spacing="5", + width="100%", + ), + size="4", + padding_y="28px", + ) + + def index() -> rx.Component: return rx.container( rx.vstack( @@ -832,6 +1173,7 @@ def index() -> rx.Component: color_scheme="gray", size="3", ), + rx.link("all chart kinds on one page →", href="/kinds"), section( "1 · The flagship, data-bound", "A 1M-point drillable scatter declared in the page: composed xy " @@ -961,6 +1303,16 @@ def index() -> rx.Component: Demo.sensor_alpha, Demo.sensor_handles, Demo.toggle_split, cond_foreach_view ), ), + section( + "10 · Conditional data source", + "One chart, two @reflex_xy.data vars: data= is an rx.cond " + "that picks the source from state — the full 1M-point cloud " + "or its 120-bin summary. The plan is fixed and both branches " + "share the schema (column names stay compile-checked); the " + "toggle flips the handle and the chart re-subscribes.", + cond_data_view(), + code_accordion(Demo.cond_summary, Demo.toggle_ds, cond_data_view), + ), spacing="5", width="100%", ), @@ -971,3 +1323,4 @@ def index() -> rx.Component: app = rx.App() app.add_page(index, title="XY Reflex showcase") +app.add_page(kinds, route="/kinds", title="XY chart kinds") diff --git a/tests/test_example_apps.py b/tests/test_example_apps.py index 1c70bd07..f8a1cf49 100644 --- a/tests/test_example_apps.py +++ b/tests/test_example_apps.py @@ -150,6 +150,7 @@ def test_reflex_app_shows_every_linking_method_and_event() -> None: "reflex_xy.scatter_chart(", # flat data-bound factory (§8) "rx.cond(Demo.split", # conditional chart rendering (§9) "rx.foreach(", # chart-per-handle rendering (§9) + "data=rx.cond(", # conditional data source under one fixed plan (§10) "list[DataHandle[SensorCols]]", # the typed handle collection (R7) "@reflex_xy.figure", # escape hatch: chart structure from state (§2) "reflex_xy.chart(", # the component / composed factory @@ -208,6 +209,13 @@ def test_reflex_app_introspection_and_composition(tmp_path, monkeypatch) -> None assert module.DRILLDOWN.token.startswith("xyin-") assert module.DRILLDOWN_POINTS == 50000 assert module.index() is not None + # The /kinds page: all 19 data-bound kind plans compile (shaped synthetic + # probes included), the composite kinds build on the static tier, and one + # data var legally carries mixed-length plus 2-D columns. + assert module.kinds() is not None + columns = module._kind_columns() + assert columns["grid"].ndim == 2 + assert len(columns["edges"]) == len(columns["counts"]) + 1 # --- retargeted browser smokes: import cleanly, pure helpers unit-tested ----- From 8047468245ec6d65de2fa939ef5611d1426ff8ac Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 6 Aug 2026 18:37:29 +0500 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20structural-probe=20build=20mode=20?= =?UTF-8?q?=E2=80=94=20config=20validates,=20aggregation=20never=20runs=20?= =?UTF-8?q?empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xy.structural_probe() (spec/api/chart-kind-contract.md 'Structural probe'): while active, a mark whose data channels are all empty validates its configuration — enums, bounds, colormaps, range/level shapes — and contributes no traces, instead of refusing zero rows or aggregating. This is the core seam compile gates need to validate chart structure with no data and no invented data; non-empty channels behave identically in and out of the mode. Each aggregating mark (stairs, ecdf, histogram, box, violin, hexbin, contour, heatmap) orders config validation before its data work and gates its zero-row refusal on the mode; _split_by_positions handles the empty grouping case. Pinned three ways in test_validation_timing.py: every kind compiles empty under the probe, still refuses empty normally, and still raises config errors under the probe. --- python/xy/__init__.py | 3 + python/xy/_figure.py | 25 +++++++ python/xy/components.py | 3 +- python/xy/marks.py | 129 +++++++++++++++++++++++--------- spec/api/chart-kind-contract.md | 27 ++++++- tests/test_check_typing.py | 2 +- tests/test_validation_timing.py | 121 ++++++++++++++++++++---------- 7 files changed, 231 insertions(+), 79 deletions(-) diff --git a/python/xy/__init__.py b/python/xy/__init__.py index 6ac39be4..38ffbe3c 100644 --- a/python/xy/__init__.py +++ b/python/xy/__init__.py @@ -109,6 +109,7 @@ "segments_chart": ".components", "step": ".components", "step_chart": ".components", + "structural_probe": ".components", "stairs": ".components", "stairs_chart": ".components", "stem": ".components", @@ -219,6 +220,7 @@ "stem_chart", "step", "step_chart", + "structural_probe", "text", "theme", "theta_axis", @@ -361,6 +363,7 @@ def __dir__() -> list[str]: stem_chart, step, step_chart, + structural_probe, text, theme, theta_axis, diff --git a/python/xy/_figure.py b/python/xy/_figure.py index 4c0f92cc..500f2e95 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -8,9 +8,11 @@ from __future__ import annotations +import contextlib import math import warnings from collections.abc import Mapping, Sequence +from contextvars import ContextVar from os import PathLike from typing import Any, Optional, TypeAlias, Union @@ -49,6 +51,26 @@ # there (clear the selection), so absence needs its own marker. _STATE_UNSET: Any = object() +#: Structural-probe mode. While active, a mark whose data channels are all +#: empty validates its *configuration* (enums, bounds, colormaps, range +#: shapes) and contributes no traces, instead of refusing zero rows. This is +#: the core validation seam compile gates build on (the Reflex plan probe: +#: spec/design/reflex-integration.md §3.6 "Kind coverage") — structure is +#: checkable with no data bound, and data-dependent aggregation never runs +#: on invented values. Non-empty data behaves identically in and out of +#: probe mode. +_STRUCTURAL_PROBE: ContextVar[bool] = ContextVar("xy_structural_probe", default=False) + + +@contextlib.contextmanager +def structural_probe(): + """Build figures in structural-probe mode (see ``_STRUCTURAL_PROBE``).""" + token = _STRUCTURAL_PROBE.set(True) + try: + yield + finally: + _STRUCTURAL_PROBE.reset(token) + class Selection: """The payload handed to an `on_select` callback. Holds the selected @@ -108,6 +130,9 @@ def __init__( # size (§28). height="100%" needs a parent with a defined height (the # usual CSS contract); otherwise the chart falls back to its 120px # min-height. + # Captured at construction so every mark applied to this figure agrees + # on the mode regardless of where the applier call happens. + self._structural_probe = _STRUCTURAL_PROBE.get() self.width = self._pixel_dimension(width, "width") self.height = self._pixel_dimension(height, "height") # padding: override the auto plot margins (top, right, bottom, left) in diff --git a/python/xy/components.py b/python/xy/components.py index a5ec5b9c..6e9e3af1 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -45,7 +45,7 @@ import numpy as np from . import _validate, channels, export, plugins, styles -from ._figure import Figure, Selection +from ._figure import Figure, Selection, structural_probe from ._typing import ArrayLike, ColorLike, Scalar, TableLike from .dom import CHART_DOM_SLOTS, validate_dom_slots @@ -136,6 +136,7 @@ "stem_chart", "step", "step_chart", + "structural_probe", "text", "theme", "theta_axis", diff --git a/python/xy/marks.py b/python/xy/marks.py index 72b4252e..69da4a32 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -841,6 +841,10 @@ def _split_by_positions( without the O(n·k) rescan. NaN positions keep the mask semantics: NaN never compares equal, so a NaN key carries an empty group. """ + if len(vals) == 0: + # np.split of nothing still yields one empty chunk, which would + # desynchronize groups (1) from unique positions (0). + return [], positions[:0] unique, inverse = np.unique(positions, return_inverse=True) order = np.argsort(inverse, kind="stable") bounds = np.searchsorted(inverse[order], np.arange(1, len(unique))) @@ -1600,6 +1604,13 @@ def stairs( raise ValueError("stairs where must be 'pre', 'post', or 'mid'") vals = self._as_1d_float(values, "stairs values") if len(vals) == 0: + if self._structural_probe: + # Structural probe: config validated above; the values/edges + # coupling (len+1, strictly increasing) is a real-data contract + # and is checked when data binds. No trace is contributed. + if edges is not None: + self._as_1d_float(edges, "stairs edges") + return self raise ValueError("stairs values must contain at least one value") if edges is None: edge_values = np.arange(len(vals) + 1, dtype=np.float64) @@ -1644,17 +1655,21 @@ def ecdf( bounded approximation for very large distributions using the native histogram kernel. """ - vals = self._as_1d_float(values, "ecdf values") - vals = vals[np.isfinite(vals)] + # Config before data: the bins contract is structural and must hold (and + # be reported) whether or not any values arrived yet. + if bins is not None and ( + isinstance(bins, (bool, np.bool_)) + or not isinstance(bins, (int, np.integer)) + or int(bins) <= 0 + ): + raise ValueError("ecdf bins must be a positive integer or None") + vals_all = self._as_1d_float(values, "ecdf values") + vals = vals_all[np.isfinite(vals_all)] if len(vals) == 0: + if self._structural_probe and len(vals_all) == 0: + return self # structural probe: config checked, no trace raise ValueError("ecdf values must contain at least one finite value") if bins is not None: - if ( - isinstance(bins, (bool, np.bool_)) - or not isinstance(bins, (int, np.integer)) - or int(bins) <= 0 - ): - raise ValueError("ecdf bins must be a positive integer or None") lo, hi = self._auto_domain(kernels.min_max(vals)) counts, edges = kernels.histogram_uniform(vals, lo, hi, int(bins), density=False) keep = counts > 0 @@ -2000,6 +2015,15 @@ def histogram( density = self._bool_param(density, "histogram density") cumulative = self._bool_param(cumulative, "histogram cumulative") vals = self._as_1d_float(values, "histogram values") + if self._structural_probe and len(vals) == 0: + # Structural probe: validate the config the aggregation below would + # otherwise carry, then contribute no trace — binning is data work + # and empty probe input must never be able to fail it. + if isinstance(bins, (int, np.integer)) and not isinstance(bins, bool) and int(bins) <= 0: + raise ValueError("histogram bins must be positive") + if range is not None: + self._finite_increasing_pair(range, "histogram range") + return self if density and not np.isfinite(vals).any(): raise ValueError("histogram density requires at least one finite value") if isinstance(bins, (int, np.integer)) and not isinstance(bins, bool): @@ -2177,6 +2201,8 @@ def box( stats = [_distribution_stats(g) for g in groups] finite_stats = [s for s in stats if np.isfinite(s[0])] if not finite_stats: + if self._structural_probe and not any(len(g) for g in groups): + return self # structural probe: config checked, no trace raise ValueError("box values must contain at least one finite group") checkpoint = self._checkpoint() try: @@ -2372,6 +2398,8 @@ def violin( rect_y0.append(center - half_width) rect_y1.append(center + half_width) if not rect_x0: + if self._structural_probe and not any(len(g) for g in groups): + return self # structural probe: config checked, no trace raise ValueError("violin values must contain at least one finite group") checkpoint = self._checkpoint() try: @@ -2438,6 +2466,21 @@ def hexbin( name = self._optional_text(name, "hexbin name") opacity = self._opacity(opacity, "hexbin opacity") colormap = channels.resolve_colormap(colormap) + # Config before data (the structural probe relies on this ordering: the + # range's shape, mincnt's sign, and gridsize above are structure — where + # the invented-free probe must still fail loudly — while which points + # fall inside the range is data work that only runs on real rows). + if range is not None: + if len(range) != 2: + raise ValueError("hexbin range must be ((x0, x1), (y0, y1))") + xr = self._finite_increasing_pair(range[0], "hexbin x range") + yr = self._finite_increasing_pair(range[1], "hexbin y range") + # Matplotlib displays zero-count cells when C is absent and mincnt is not + # specified, producing the full rectangular honeycomb. Reducer hexbins + # cannot reduce an empty group and therefore default to one observation. + threshold = (0 if C is None else 1) if mincnt is None else int(mincnt) + if threshold < 0: + raise ValueError("hexbin mincnt must be nonnegative") # Canonicalize WITHOUT ingesting: only occupied bin centers ship, so the # raw points must not stay resident in the figure's column store. x_all, _x_kind, _x_copies = columns._canonicalize(x) @@ -2456,23 +2499,14 @@ def hexbin( if c_all is not None: finite &= np.isfinite(c_all) if not np.any(finite): + if self._structural_probe and n_points == 0: + return self # structural probe: config checked, no aggregation raise ValueError("hexbin x and y must contain at least one finite pair") xv, yv = x_all[finite], y_all[finite] cv = None if c_all is None else c_all[finite] if range is None: xr = self._auto_domain(kernels.min_max(xv)) yr = self._auto_domain(kernels.min_max(yv)) - else: - if len(range) != 2: - raise ValueError("hexbin range must be ((x0, x1), (y0, y1))") - xr = self._finite_increasing_pair(range[0], "hexbin x range") - yr = self._finite_increasing_pair(range[1], "hexbin y range") - # Matplotlib displays zero-count cells when C is absent and mincnt is not - # specified, producing the full rectangular honeycomb. Reducer hexbins - # cannot reduce an empty group and therefore default to one observation. - threshold = (0 if cv is None else 1) if mincnt is None else int(mincnt) - if threshold < 0: - raise ValueError("hexbin mincnt must be nonnegative") # Matplotlib's hex lattice is the union of an integer grid and a half-cell # offset grid. Assign each point to the nearer center in the hex metric; # rectangular binning plus staggered display centers leaves overlaps and @@ -2712,6 +2746,27 @@ def clip( ) +def _validated_contour_levels( + self: "Figure", levels: Union[int, ArrayLike] +) -> Union[int, np.ndarray]: + """The config half of contour level resolution: bounds and finiteness. + + Returns the validated level count for the int form (whose concrete + values derive from the data's domain later), or the sorted explicit + level values. Shared by the normal build and the structural-probe gate + so the contract cannot fork. + """ + if isinstance(levels, (int, np.integer)) and not isinstance(levels, (bool, np.bool_)): + n_levels = int(levels) + if n_levels <= 0 or n_levels > 256: + raise ValueError("contour levels must be between 1 and 256") + return n_levels + level_values = self._as_1d_float(levels, "contour levels") + if len(level_values) == 0 or len(level_values) > 256 or not np.all(np.isfinite(level_values)): + raise ValueError("contour levels must contain 1 to 256 finite values") + return np.sort(level_values) + + def contour( self: "Figure", z: ArrayLike, @@ -2740,7 +2795,16 @@ def contour( color = css.get("color", color) width = css.get("width", width) opacity = css.get("opacity", opacity) + # Config before data (single-source: _validated_contour_levels is shared + # with the structural-probe gate below). + levels_config = _validated_contour_levels(self, levels) + colormap = channels.resolve_colormap(colormap) + name = self._optional_text(name, "contour name") + if extend not in ("neither", "min", "max", "both"): + raise ValueError("contour extend must be 'neither', 'min', 'max', or 'both'") arr = self._as_float_array(z, "contour z") + if self._structural_probe and arr.size == 0: + return self # structural probe: config checked, no marching if arr.ndim != 2 or min(arr.shape) < 2: raise ValueError( f"contour z must be a 2-D matrix with at least 2 rows/columns, got {arr.shape}" @@ -2751,30 +2815,16 @@ def contour( finite = arr[np.isfinite(arr)] if len(finite) == 0: raise ValueError("contour z must contain at least one finite value") - if isinstance(levels, (int, np.integer)) and not isinstance(levels, (bool, np.bool_)): - n_levels = int(levels) - if n_levels <= 0 or n_levels > 256: - raise ValueError("contour levels must be between 1 and 256") + if isinstance(levels_config, int): lo, hi = self._auto_domain(kernels.min_max(finite)) - level_values = np.linspace(lo, hi, n_levels + 2, dtype=np.float64)[1:-1] + level_values = np.linspace(lo, hi, levels_config + 2, dtype=np.float64)[1:-1] else: - level_values = self._as_1d_float(levels, "contour levels") - if ( - len(level_values) == 0 - or len(level_values) > 256 - or not np.all(np.isfinite(level_values)) - ): - raise ValueError("contour levels must contain 1 to 256 finite values") - level_values = np.sort(level_values) + level_values = levels_config work = (rows - 1) * (cols - 1) * len(level_values) if work > MAX_CONTOUR_WORK: raise ValueError( f"contour grid x levels exceeds the bounded work budget ({MAX_CONTOUR_WORK:,})" ) - colormap = channels.resolve_colormap(colormap) - name = self._optional_text(name, "contour name") - if extend not in ("neither", "min", "max", "both"): - raise ValueError("contour extend must be 'neither', 'min', 'max', or 'both'") extend_min = filled and extend in ("min", "both") extend_max = filled and extend in ("max", "both") color_table: Optional[np.ndarray] @@ -3142,6 +3192,13 @@ def heatmap( if hasattr(z, "to_numpy"): z = z.to_numpy() arr = np.asarray(z) + if self._structural_probe and arr.size == 0: + # Structural probe: validate the config the build below would carry + # (z's 2-D-ness is a real-data shape contract, checked at bind). + channels.resolve_colormap(colormap) + if domain is not None: + self._finite_increasing_pair(domain, "heatmap domain") + return self truecolor = arr.ndim == 3 and arr.shape[-1] in (3, 4) if not truecolor and arr.ndim != 2: raise ValueError(f"heatmap z must be 2-D or RGB(A), got shape {arr.shape}") diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index 31ee6978..dd70038a 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -367,6 +367,29 @@ usually wrong). Each has an explicit trigger: viewport message the kernel answers per trace, and bump PROTOCOL once, instead of accreting a message type per tier. +## Structural probe (compile-gate contract) + +`xy.structural_probe()` is a context manager under which figures build in +**structural-probe mode** (`Figure._structural_probe`): a mark whose data +channels are all empty must validate its *configuration* — enums, numeric +bounds, colormaps, range/level shapes — and then return without appending +traces, instead of refusing zero rows or aggregating. Compile gates (the +Reflex plan probe, reflex-integration.md §3.6) rely on this to validate +chart structure with **no data and no invented data**: a probe failure +always indicts structure, and data-dependent work (binning, quantiles, +marching, range filtering) never runs at page evaluation. Non-empty +channels behave identically in and out of probe mode, and outside the +context the aggregating validators keep their at-least-one-value +contracts. Real-data shape couplings (x/y lengths, `edges = len+1`, z +2-D-ness) are checked when data binds, not by the probe. + +Mark-author obligation: every aggregating builder orders config validation +before its data work and gates its zero-row refusal on +`self._structural_probe` (see `stairs`/`ecdf`/`histogram`/`box`/`violin`/ +`hexbin`/`contour`/`heatmap` in `marks.py`). Pinned by +`tests/test_validation_timing.py` (compiles empty under probe, still +refuses empty normally, still raises config errors under probe). + ## Checklist for a new kind 1. Internal `Figure.(...)` builder (`marks.py`) + `_emit_` (kernel @@ -377,6 +400,8 @@ usually wrong). Each has an explicit trigger: 4. Tests: payload shape + tier decision (pytest); a render probe in `scripts/render_smoke_nonumpy.py` asserting it lights pixels. 5. If aggregating: an aggregate kernel (native Rust core) and wire - it through the `lod` framework rather than a bespoke path. + it through the `lod` framework rather than a bespoke path — and honor + the structural-probe contract above (config first, probe-gated + zero-row early-out, entries in `tests/test_validation_timing.py`). 6. Roadmap and contract docs: record the kind as implemented and note any compatibility-depth follow-ups. diff --git a/tests/test_check_typing.py b/tests/test_check_typing.py index 55ff617c..0f2fa079 100644 --- a/tests/test_check_typing.py +++ b/tests/test_check_typing.py @@ -92,7 +92,7 @@ def test_canonical_public_names_come_from_source_exports(tmp_path: Path) -> None def test_canonical_public_names_match_the_current_root_contract() -> None: names = check_typing._canonical_public_names() - assert len(names) == 102 + assert len(names) == 103 # +structural_probe (the compile-gate build mode) assert names == sorted(xy.__all__) diff --git a/tests/test_validation_timing.py b/tests/test_validation_timing.py index 037ed05a..afcf5a1e 100644 --- a/tests/test_validation_timing.py +++ b/tests/test_validation_timing.py @@ -2,16 +2,18 @@ The Reflex component API (spec/design/reflex-component-api-options.md §4, implementation plan in reflex-component-api-implementation.md) compiles -chart *plans* by binding placeholder columns — zero-row, or the tiny -shaped synthetic columns for aggregating kinds — and calling ``.figure()`` -once at page evaluation. That only works while: +chart *plans* by binding zero-row placeholder columns and calling +``.figure()`` once at page evaluation, under the core's +``xy.components.structural_probe()`` mode. That only works while: - X1: the tree is cheap to build without data; chrome nodes validate eagerly; mark config validates at ``.figure()``. -- X2: placeholder columns compile — zero-row for most kinds, and for the - aggregating kinds the recorded minimal shapes (SHAPED_ROW_CHARTS below, - mirroring reflex_xy.plan._SYNTHETIC_CHANNELS) — so the probe exercises - the full validation gate with no real data. +- X2: zero-row columns compile for **every** mark kind — directly for the + non-aggregating kinds, and under ``structural_probe()`` for the + aggregating kinds, whose marks then validate configuration and skip + aggregation instead of refusing empty input. No synthetic data exists + anywhere in the probe: a probe failure indicts structure, never + invented values. - X3: ``Chart.figure()`` memoizes and is never invalidated — rebinding data means a fresh ``Chart``, never mutating one. @@ -25,14 +27,12 @@ import pytest import xy +from xy.components import structural_probe EMPTY = np.empty(0, dtype=np.float64) # Every mark kind the flat/composed Reflex factories cover with the plain -# zero-row probe (Phase 2 kinds first, then the Phase 3 signature-derived -# set). Kinds whose validators require at least one finite value (stairs, -# ecdf, box, violin, hexbin, heatmap, contour) probe with the shaped -# synthetic columns pinned in SHAPED_ROW_CHARTS below instead. +# zero-row build (no probe mode needed: their validators accept empty). ZERO_ROW_CHARTS = { "scatter": lambda: xy.scatter_chart(xy.scatter(EMPTY, EMPTY)), "line": lambda: xy.line_chart(xy.line(EMPTY, EMPTY)), @@ -45,6 +45,41 @@ "errorbar": lambda: xy.errorbar_chart(xy.errorbar(EMPTY, EMPTY, yerr=EMPTY)), "error_band": lambda: xy.error_band_chart(xy.error_band(EMPTY, EMPTY, upper=EMPTY)), "segments": lambda: xy.segments_chart(xy.segments(EMPTY, EMPTY, x1=EMPTY, y1=EMPTY)), + "triangle_mesh": lambda: xy.triangle_mesh_chart( + xy.triangle_mesh(EMPTY, EMPTY, x1=EMPTY, y1=EMPTY, x2=EMPTY, y2=EMPTY) + ), +} + +# The aggregating kinds refuse zero rows in a normal build (their validators +# need at least one finite value) but compile zero-row under the structural +# probe: config validates, aggregation is skipped, no trace is contributed. +AGGREGATING_CHARTS = { + "box": lambda: xy.box_chart(xy.box(EMPTY, group=EMPTY)), + "violin": lambda: xy.violin_chart(xy.violin(EMPTY)), + "hexbin": lambda: xy.hexbin_chart(xy.hexbin(EMPTY, EMPTY)), + "contour": lambda: xy.contour_chart(xy.contour(EMPTY, x=EMPTY, y=EMPTY)), + "heatmap": lambda: xy.heatmap_chart(xy.heatmap(EMPTY, x=EMPTY, y=EMPTY)), + "stairs": lambda: xy.stairs_chart(xy.stairs(EMPTY, EMPTY)), + "ecdf": lambda: xy.ecdf_chart(xy.ecdf(EMPTY)), + "histogram_density": lambda: xy.histogram_chart(xy.histogram(EMPTY, density=True)), +} + +# Config errors the structural probe must still raise with empty channels: +# no data does not mean no validation. One representative per kind. +AGGREGATING_CONFIG_ERRORS = { + "box": (lambda: xy.box_chart(xy.box(EMPTY, orientation="diagonal")), "orientation"), + "violin": (lambda: xy.violin_chart(xy.violin(EMPTY, bins=2)), "bins"), + "hexbin": (lambda: xy.hexbin_chart(xy.hexbin(EMPTY, EMPTY, gridsize=0)), "gridsize"), + "hexbin_range": ( + lambda: xy.hexbin_chart(xy.hexbin(EMPTY, EMPTY, range=(0.0, 1.0))), + "range", + ), + "hexbin_mincnt": (lambda: xy.hexbin_chart(xy.hexbin(EMPTY, EMPTY, mincnt=-1)), "mincnt"), + "contour": (lambda: xy.contour_chart(xy.contour(EMPTY, levels=0)), "levels"), + "heatmap": (lambda: xy.heatmap_chart(xy.heatmap(EMPTY, colormap="virids")), "colormap"), + "stairs": (lambda: xy.stairs_chart(xy.stairs(EMPTY, where="diagonal")), "where"), + "ecdf": (lambda: xy.ecdf_chart(xy.ecdf(EMPTY, bins=-1)), "bins"), + "histogram": (lambda: xy.histogram_chart(xy.histogram(EMPTY, bins=-1)), "bins"), } @@ -56,36 +91,42 @@ def test_zero_row_construction_compiles(kind): assert figure is not None -# The aggregating kinds' minimal validator contracts: deterministic, positive, -# finite, strictly increasing placeholder values (log-scale- and ratio-safe), -# one group for grouped kinds, a square grid with matching side coordinates, -# `len+1` bin edges. This is the xy-level half of the shaped-probe contract; -# reflex_xy.plan._SYNTHETIC_CHANNELS mirrors these shapes and -# tests/reflex_adapter/test_plan.py pins that table. -SPREAD = np.linspace(1.0, 8.0, 8) -ONE_GROUP = np.ones(8) -GRID = np.linspace(1.0, 16.0, 16).reshape(4, 4) -GRID_SIDE = np.linspace(1.0, 4.0, 4) -BIN_EDGES = np.linspace(1.0, 9.0, 9) - -SHAPED_ROW_CHARTS = { - "box": lambda: xy.box_chart(xy.box(SPREAD, group=ONE_GROUP)), - "violin": lambda: xy.violin_chart(xy.violin(SPREAD, group=ONE_GROUP)), - "hexbin": lambda: xy.hexbin_chart(xy.hexbin(SPREAD, SPREAD)), - "contour": lambda: xy.contour_chart(xy.contour(GRID, x=GRID_SIDE, y=GRID_SIDE)), - "heatmap": lambda: xy.heatmap_chart(xy.heatmap(GRID, x=GRID_SIDE, y=GRID_SIDE)), - "stairs": lambda: xy.stairs_chart(xy.stairs(SPREAD, BIN_EDGES)), - "ecdf": lambda: xy.ecdf_chart(xy.ecdf(SPREAD)), -} - - -@pytest.mark.parametrize("kind", sorted(SHAPED_ROW_CHARTS)) -def test_shaped_row_construction_compiles(kind): - """X2 for the aggregating kinds: the recorded minimal synthetic shapes - compile. If a validator starts demanding more (two groups, a bigger - grid), this fails first, named after the fact it broke.""" - figure = SHAPED_ROW_CHARTS[kind]().figure() +@pytest.mark.parametrize("kind", sorted(AGGREGATING_CHARTS)) +def test_aggregating_kinds_compile_zero_row_under_structural_probe(kind): + """X2 for the aggregating kinds: under structural_probe() an all-empty + mark validates config and contributes no trace instead of refusing.""" + with structural_probe(): + figure = AGGREGATING_CHARTS[kind]().figure() assert figure is not None + assert figure.traces == [] + + +@pytest.mark.parametrize("kind", sorted(AGGREGATING_CHARTS)) +def test_aggregating_kinds_still_refuse_zero_rows_normally(kind): + """Probe mode never leaks: outside structural_probe() the aggregating + validators keep their at-least-one-value contract.""" + with pytest.raises(ValueError): + AGGREGATING_CHARTS[kind]().figure() + + +@pytest.mark.parametrize("case", sorted(AGGREGATING_CONFIG_ERRORS)) +def test_structural_probe_still_raises_config_errors(case): + """No synthetic data does not mean no validation: configuration errors + surface in probe mode exactly as they do with real data.""" + build, match = AGGREGATING_CONFIG_ERRORS[case] + with structural_probe(), pytest.raises((ValueError, TypeError), match=match): + build().figure() + + +def test_structural_probe_does_not_change_real_data_builds(): + """Probe mode is a zero-row affordance only: non-empty channels build + identical figures in and out of it.""" + values = np.linspace(1.0, 8.0, 8) + with structural_probe(): + probed = xy.ecdf_chart(xy.ecdf(values)).figure() + normal = xy.ecdf_chart(xy.ecdf(values)).figure() + assert len(probed.traces) == len(normal.traces) == 1 + assert probed.traces[0].n_points == normal.traces[0].n_points def test_zero_row_columns_resolve_through_chart_data(): From e07b8ec302497c50d88ee02996e41114ddb71f99 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 6 Aug 2026 18:37:43 +0500 Subject: [PATCH 4/6] fix(reflex): zero-row structural plan probe; content-addressed callables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review hard-blocked the synthetic-column probe as structurally unsound: a column shared between an aggregating channel and a zero-row channel falsely failed on invented lengths (stairs edges len 9 vs scatter's 0), valid hexbin range=/mincnt= configurations falsely failed on invented values, and large gridsize ran real aggregation at page evaluation. - build_plan now compiles every kind zero-row under the core's new structural_probe() mode; _SYNTHETIC_CHANNELS and the shape table are gone. Config errors still fail reflex run; data-dependent outcomes and real-data shape couplings move (back) to bind. Repro pins: shared-column composition and hexbin range/mincnt/gridsize configs. - Plan callables are content-addressed, not name-addressed: import path + code fingerprint (bytecode/names/nested code/defaults) for pure-Python functions — editing a reducer body changes the digest, so rolling deployments resync instead of diverging; import path + distribution version for C-level callables that resolve back to themselves; bound methods, lambdas, closures, and partials refused. - @reflex_xy.data docstring drops the stale equal-length claim. - /kinds gains browser render coverage: reflex_ws_smoke.py step 7 navigates there and pixel-probes all 26 kind cells (19 data-bound + 7 static composites), each addressable as kind-. --- .../reflex/xy_reflex_demo/xy_reflex_demo.py | 10 +- python/reflex_xy/__init__.py | 5 +- python/reflex_xy/data_vars.py | 9 +- python/reflex_xy/factories.py | 11 +- python/reflex_xy/plan.py | 246 +++++++++--------- scripts/reflex_ws_smoke.py | 67 +++++ .../reflex-component-api-implementation.md | 55 ++-- spec/design/reflex-component-api-options.md | 26 +- spec/design/reflex-integration.md | 78 +++--- tests/reflex_adapter/test_plan.py | 162 ++++++++++-- tests/test_example_apps.py | 6 +- 11 files changed, 446 insertions(+), 229 deletions(-) diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index 52bdfaee..5328f68f 100644 --- a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py +++ b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py @@ -1,6 +1,6 @@ """XY Reflex showcase: ways to link chart data into a Reflex app. -One page of nine sections; each has a "Code" accordion showing its own source +One page of ten sections; each has a "Code" accordion showing its own source via `inspect.getsource`. Charts use the data-bound component API — structure declared in the page, compiled to a validated plan at ``reflex run``, columns supplied by ``@reflex_xy.data`` state methods — except where a section @@ -55,7 +55,8 @@ mark kinds as data-bound flat factories fed by one ``@reflex_xy.data`` var (mixed column lengths and a 2-D grid in a single var), and the composite kinds (pie, radar, sankey, polar, polar bars, wind rose, facet) on the -static tier. +static tier. ``scripts/reflex_ws_smoke.py`` pixel-probes every cell, so +the page carries browser render coverage, not only compile coverage. Run from ``examples/reflex``:: @@ -1038,6 +1039,8 @@ def _composite_kind_charts() -> dict[str, xy.Chart]: def _kind_cell(title: str, chart: rx.Component) -> rx.Component: + # id on the cell (not the chart): scripts/reflex_ws_smoke.py locates each + # cell by `kind-` and ink-probes the canvas inside it. return rx.box( rx.text(title, font_family="monospace", size="2", margin_bottom="0.4rem"), chart, @@ -1046,6 +1049,7 @@ def _kind_cell(title: str, chart: rx.Component) -> rx.Component: background="var(--gray-1)", padding="0.75rem", width="100%", + id=f"kind-{title}", ) @@ -1121,7 +1125,7 @@ def kinds() -> rx.Component: ), ] composites = [ - (name, reflex_xy.chart(chart, height=height, id=f"kind-{name}")) + (name, reflex_xy.chart(chart, height=height)) for name, chart in _composite_kind_charts().items() ] return rx.container( diff --git a/python/reflex_xy/__init__.py b/python/reflex_xy/__init__.py index 6ed4832d..b1261424 100644 --- a/python/reflex_xy/__init__.py +++ b/python/reflex_xy/__init__.py @@ -120,8 +120,9 @@ def index() -> rx.Component: #: (`reflex_xy.chart(reflex_xy.scatter("x", "y"), data=...)`) and a #: hallucinated constructor dies at import against this explicit map #: instead of surviving to hydrate. Every standalone mark kind is plan- -#: compatible: the aggregating ones (box, violin, hexbin, …) probe with -#: the shaped synthetic columns recorded in `plan._SYNTHETIC_CHANNELS`. +#: compatible: the aggregating ones (box, violin, hexbin, …) probe +#: zero-row under the core's structural_probe() mode — config validates, +#: aggregation never runs on invented values. #: Chart factories are deliberately absent: the flat `*_chart` names above #: are reflex-native factories, not xy's. _XY_REEXPORTS = frozenset( diff --git a/python/reflex_xy/data_vars.py b/python/reflex_xy/data_vars.py index 0a69bae1..446e8c1f 100644 --- a/python/reflex_xy/data_vars.py +++ b/python/reflex_xy/data_vars.py @@ -198,9 +198,12 @@ def cloud(self) -> CloudData: # in the page: # reflex_xy.scatter_chart(data=Dash.cloud, x="x", y="y", color="mag") - The method must return a mapping of column name -> equal-length - array-likes, or ``None`` for "no data right now" (which releases the - registered columns and yields the empty handle). ``async def`` methods + The method must return a mapping of column name -> array-likes — mixed + lengths and dimensionalities are fine (a scatter's rows can sit next to + a stairs mark's ``len+1`` edges or a heatmap's 2-D grid; coupled-shape + contracts belong to the mark validators when a plan binds) — or + ``None`` for "no data right now" (which releases the registered columns + and yields the empty handle). ``async def`` methods become ``AsyncDataVar``s (same dispatch rule as ``rx.var``); keyword arguments pass through to reflex's computed var (``deps=``, ``interval=``, ...). diff --git a/python/reflex_xy/factories.py b/python/reflex_xy/factories.py index a7f1f660..f5b7a43f 100644 --- a/python/reflex_xy/factories.py +++ b/python/reflex_xy/factories.py @@ -22,9 +22,9 @@ height="460px", ) -Both compile the real xy tree at page evaluation, run the plan probe -(`plan.py` — the full mark/config validation gate, X1/X2; zero-row -columns, shaped synthetic ones for the aggregating kinds), check channel +Both compile the real xy tree at page evaluation, run the zero-row plan +probe (`plan.py` — the mark/config validation gate, X1/X2, under the +core's `structural_probe()` mode: no synthetic data), check channel names against the data var's TypedDict schema (R7), and mount the private component with a `plan` digest plus the `data` handle var. Marks and chrome are plain xy dataclass nodes — they never enter the Reflex tree @@ -236,8 +236,9 @@ def _flat_kind(chart_kind: str, mark_factory: Any) -> _FlatKind: _flat_kind("errorbar_chart", _xy.errorbar), _flat_kind("error_band_chart", _xy.error_band), _flat_kind("segments_chart", _xy.segments), - # Aggregating kinds: their channels probe with the shaped synthetic - # columns recorded in plan._SYNTHETIC_CHANNELS instead of zero rows. + # Aggregating kinds: zero-row like everything else — under the + # core's structural_probe() mode they validate config and skip + # aggregation, so no synthetic data is ever invented for them. _flat_kind("box_chart", _xy.box), _flat_kind("violin_chart", _xy.violin), _flat_kind("ecdf_chart", _xy.ecdf), diff --git a/python/reflex_xy/plan.py b/python/reflex_xy/plan.py index bd3cb2c4..a057a57a 100644 --- a/python/reflex_xy/plan.py +++ b/python/reflex_xy/plan.py @@ -5,17 +5,19 @@ node tree with **string channels only**, compiled once at page evaluation. - **Build** (factory call = page evaluation = Reflex compile): construct the - real xy tree, bind a placeholder column for every referenced channel - name, and call ``.figure()`` once — the full mark/config validation gate - (facts X1/X2, pinned in tests/test_validation_timing.py) runs in - milliseconds with no real data. Placeholders are zero-row except for the - channels of the aggregating kinds (box, violin, hexbin, contour, heatmap, - stairs, ecdf), whose validators need at least one finite value: those - bind tiny fixed synthetic columns shaped per channel - (:data:`_SYNTHETIC_CHANNELS`, the recorded table — spec: - reflex-integration.md "Kind coverage"). The probe figure is discarded; - what is kept is the digest, the recorded column names, and (for live - charts) the probe's Tailwind class inventory. + real xy tree, bind a **zero-row** placeholder column for every referenced + channel name, and call ``.figure()`` once under the core's + ``structural_probe()`` mode — the mark/config validation gate (facts + X1/X2, pinned in tests/test_validation_timing.py) runs in milliseconds + with no real data and **no invented data**: in probe mode a mark whose + channels are all empty validates its configuration (enums, bounds, + colormaps, range shapes) and skips aggregation, so a probe failure always + indicts the chart's structure, and no value-dependent check (hexbin + range/mincnt filtering, contour marching, quantiles) can fire on + placeholder values or allocate at page evaluation. Real-data shape + contracts (coupled lengths, z's 2-D-ness) stay at bind. The probe figure + is discarded; what is kept is the digest, the recorded column names, and + (for live charts) the probe's Tailwind class inventory. - **Serialize**: nodes → canonical JSON (sorted keys, ``plan_version``) → sha256 prefix = ``digest``. The digest is a *content address*: every worker that evaluates the page derives the same digest and holds the plan @@ -36,13 +38,16 @@ import copy import dataclasses import hashlib +import importlib import json +import sys +import types from collections.abc import Mapping from typing import Any, Optional import numpy as np -from xy.components import Chart, Component, Mark +from xy.components import Chart, Component, Mark, structural_probe __all__ = [ "PLAN_VERSION", @@ -58,80 +63,6 @@ PLAN_VERSION = 1 _DIGEST_CHARS = 20 # sha256 hex prefix; content address for a process-local map -#: Synthetic probe columns. Aggregating kinds (quantiles, bins, meshes) -#: refuse zero rows, so their channels probe with tiny *fixed* placeholder -#: arrays instead — deterministic constants that satisfy every value-domain -#: precondition a validator imposes (finite, positive, strictly increasing, -#: log-scale-safe), so a probe failure still means the *structure* is -#: invalid, never the made-up values. Real-data shape contracts (actual -#: lengths, the user's z being 2-D) stay where they always were: at bind. -_PROBE_LEN = 8 -_PROBE_GRID_SIDE = 4 - - -def _spread() -> np.ndarray: - return np.linspace(1.0, float(_PROBE_LEN), _PROBE_LEN) - - -def _one_group() -> np.ndarray: - return np.ones(_PROBE_LEN, dtype=np.float64) - - -def _grid() -> np.ndarray: - side = _PROBE_GRID_SIDE - return np.linspace(1.0, float(side * side), side * side).reshape(side, side) - - -def _grid_side() -> np.ndarray: - return np.linspace(1.0, float(_PROBE_GRID_SIDE), _PROBE_GRID_SIDE) - - -def _bin_edges() -> np.ndarray: - return np.linspace(1.0, float(_PROBE_LEN + 1), _PROBE_LEN + 1) - - -#: The recorded shape table: mark kind -> {channel storage: builder}. Keys -#: name where the factory stores each column-capable channel on the Mark -#: node — its ``x``/``y`` fields or a ``props`` entry (``box(values=...)`` -#: lands on field ``x``; ``stairs(edges=...)`` on field ``y``; -#: ``heatmap(z=...)`` in ``props["z"]``). Kinds absent here probe zero-row. -#: Extending a kind means recording its minimal validator contract here — -#: never a silent guess (§28 spirit); pinned by tests/reflex_adapter/ -#: test_plan.py and tests/test_validation_timing.py. -_SYNTHETIC_CHANNELS: dict[str, dict[str, Any]] = { - "box": {"x": _spread, "props.x": _one_group, "props.group": _one_group}, - "violin": {"x": _spread, "props.x": _one_group, "props.group": _one_group}, - "hexbin": {"x": _spread, "y": _spread, "props.C": _spread}, - "contour": {"props.z": _grid, "x": _grid_side, "y": _grid_side}, - "heatmap": {"props.z": _grid, "x": _grid_side, "y": _grid_side}, - "stairs": {"x": _spread, "y": _bin_edges}, - "ecdf": {"x": _spread}, -} - - -def _probe_shapes(children: tuple[Component, ...]) -> dict[str, np.ndarray]: - """Synthetic columns for every aggregating-kind channel bound by name. - - Shared column names keep one shape (first mark wins) — the same column - genuinely is the same array at bind time. A column shared between an - aggregating channel and a zero-row mark's channel cannot probe (the - lengths disagree); the probe then fails with xy's ordinary length - message, which is also what real data would do in every case where the - aggregating channel's shape truly differs. - """ - shapes: dict[str, np.ndarray] = {} - for child in children: - if not isinstance(child, Mark): - continue - for location, build in _SYNTHETIC_CHANNELS.get(child.kind, {}).items(): - if location.startswith("props."): - name = child.props.get(location[len("props.") :]) - else: - name = getattr(child, location, None) - if isinstance(name, str): - shapes.setdefault(name, build()) - return shapes - class PlanError(ValueError): """A chart plan could not be built, resolved, or bound.""" @@ -153,26 +84,23 @@ class PlanBindError(PlanError): class _ProbeTable(Mapping): - """Placeholder table that records every column it resolves. + """Zero-row placeholder table that records every column it resolves. ``Chart.figure()`` resolves string channels through ``data[name]`` (the exact production code path), so the recorded names are *derived* from the real resolution logic — the plan's column list can never drift - from what binding will actually look up. Columns are zero-row unless - ``shapes`` carries a synthetic array for the name (the aggregating - kinds' channels, from :data:`_SYNTHETIC_CHANNELS`). + from what binding will actually look up. Every column is empty: under + ``structural_probe()`` the marks validate configuration against empty + channels and never aggregate, so no synthetic values exist for a + validator to (falsely) accept or reject. """ - def __init__(self, shapes: Optional[Mapping[str, np.ndarray]] = None) -> None: + def __init__(self) -> None: self.seen: list[str] = [] - self._shapes = dict(shapes or {}) def __getitem__(self, key: str) -> np.ndarray: if key not in self.seen: self.seen.append(key) - shaped = self._shapes.get(key) - if shaped is not None: - return shaped return np.empty(0, dtype=np.float64) def __iter__(self): # pragma: no cover - Mapping protocol completeness @@ -182,6 +110,97 @@ def __len__(self) -> int: # pragma: no cover - Mapping protocol completeness return len(self.seen) +def _code_fingerprint(fn: Any) -> str: + """Content hash of a pure-Python function's behavior. + + A qualified name is *identity*, not *content*: hashing only the import + path would keep the digest stable while the function body changes (a + rolling deployment then executes two behaviors behind one address). The + fingerprint covers the bytecode, referenced names, nested code objects, + and default values. It is deterministic across processes for one + interpreter version; across differing interpreter versions digests + diverge and stale clients resync — fail-safe, never silently wrong. + """ + h = hashlib.sha256() + + def feed(code: types.CodeType) -> None: + h.update(code.co_code) + h.update(",".join(code.co_names).encode()) + h.update(",".join(code.co_varnames).encode()) + for const in code.co_consts: + if isinstance(const, types.CodeType): + feed(const) + elif isinstance(const, frozenset): + # frozenset repr order follows per-process string hashing; + # sort for a process-independent byte stream. + h.update(",".join(sorted(map(repr, const))).encode()) + else: + h.update(repr(const).encode()) + + feed(fn.__code__) + h.update(repr(getattr(fn, "__defaults__", None)).encode()) + h.update(repr(getattr(fn, "__kwdefaults__", None)).encode()) + return h.hexdigest()[:16] + + +def _resolve_qualname(module: str, qualname: str) -> Any: + """The object ``module.qualname`` names right now, or None.""" + try: + target: Any = importlib.import_module(module) + for part in qualname.split("."): + target = getattr(target, part) + except Exception: # noqa: BLE001 - resolution failure means "not addressable" + return None + return target + + +def _callable_address(value: Any, context: str) -> dict[str, str]: + """Serialize one plan callable as a true content address (fail closed). + + Bound methods are refused outright: the instance state behind + ``__self__`` has no content address, so two differently configured + instances would collide on one digest and last-write-wins registration + would silently swap behavior. Pure-Python functions carry a code + fingerprint beside their import path; C-level callables (ufuncs, + builtins) must resolve back to the same object by name and carry their + distribution's version, which is what pins their behavior. + """ + if getattr(value, "__self__", None) is not None: + raise PlanError( + f"{context} holds a bound method ({value!r}). The instance state " + "behind it has no content address, so differently configured " + "instances would collide on one plan digest. Use a module-level " + "function, or build the chart with @reflex_xy.figure." + ) + module = getattr(value, "__module__", "") or "" + qualname = getattr(value, "__qualname__", "") or "" + if not module or not qualname or "<" in module or "<" in qualname: + raise PlanError( + f"{context} holds a {type(value).__name__} without a stable " + "qualified name (a lambda, closure, or partial?), which cannot " + "be content-addressed into a chart plan. Use a module-level " + "function, or build the chart with @reflex_xy.figure." + ) + code = getattr(value, "__code__", None) + if code is not None: + if code.co_freevars: + raise PlanError( + f"{context} holds a closure ({module}.{qualname}), whose " + "captured variables have no content address. Use a module-" + "level function, or build the chart with @reflex_xy.figure." + ) + return {"~callable": f"{module}.{qualname}", "code": _code_fingerprint(value)} + if _resolve_qualname(module, qualname) is not value: + raise PlanError( + f"{context} holds {value!r}, whose qualified name " + f"{module}.{qualname!r} does not resolve back to it — the name " + "cannot address this callable across workers. Use a module-level " + "function, or build the chart with @reflex_xy.figure." + ) + root = sys.modules.get(module.split(".", 1)[0]) + return {"~callable": f"{module}.{qualname}", "dist": str(getattr(root, "__version__", ""))} + + def _plain(value: Any, context: str) -> Any: """Canonical JSON-able copy of one plan node field (fail closed).""" if dataclasses.is_dataclass(value) and not isinstance(value, type): @@ -198,20 +217,7 @@ def _plain(value: Any, context: str) -> Any: if isinstance(value, (str, int, float, bool)) or value is None: return value if callable(value): - # A module-level named callable (hexbin's reduce_C_function is - # np.mean by default) has a stable import path — that path is its - # content address. Lambdas and closures don't, so they cannot keep - # digests faithful and are refused like any other opaque value. - module = getattr(value, "__module__", "") or "" - qualname = getattr(value, "__qualname__", "") or "" - if module and qualname and "<" not in module and "<" not in qualname: - return {"~callable": f"{module}.{qualname}"} - raise PlanError( - f"{context} holds a {type(value).__name__} without a stable " - "qualified name (a lambda or closure?), which cannot be " - "content-addressed into a chart plan. Use a module-level " - "function, or build the chart with @reflex_xy.figure." - ) + return _callable_address(value, context) raise PlanError( f"{context} holds a {type(value).__name__}, which cannot be part of a " "data-bound chart plan. Plans are data-free structure: bind columns " @@ -273,12 +279,14 @@ def build_plan( canonical = json.dumps(serialized, sort_keys=True, separators=(",", ":")) digest = hashlib.sha256(canonical.encode()).hexdigest()[:_DIGEST_CHARS] - # The compile-time validation gate: bind placeholders for every string - # channel (zero-row, or the shaped synthetic columns for aggregating - # kinds) and compile once. Errors surface here — at page evaluation — - # with the ordinary xy messages. - probe = _ProbeTable(_probe_shapes(children)) - probe_figure = Chart(kind, children, data=probe, **chart_props).figure() + # The compile-time validation gate: bind zero-row placeholders for every + # string channel and compile once under the core's structural-probe + # mode — configuration validates, aggregation never runs on invented + # values. Errors surface here — at page evaluation — with the ordinary + # xy messages. + probe = _ProbeTable() + with structural_probe(): + probe_figure = Chart(kind, children, data=probe, **chart_props).figure() tailwind_classes = " ".join(probe_figure.dom_class_strings()) plan = ChartPlan( @@ -304,9 +312,11 @@ def register_plan(plan: ChartPlan) -> ChartPlan: Last write wins (idempotent for identical content — the digest is the content address): after a hot reload re-evaluates the page, the fresh - node objects replace the stale ones, which matters for the one node - field a digest addresses by *name* rather than by value (module-level - callables, e.g. a hexbin ``reduce_C_function`` whose body was edited). + node objects replace the stale ones. An edited pure-Python callable + changes the digest itself (its code fingerprint is part of the + serialization), so behavior can never swap silently behind a stable + address; C-level callables are pinned by import path + distribution + version instead. """ _PLANS[plan.digest] = plan return plan diff --git a/scripts/reflex_ws_smoke.py b/scripts/reflex_ws_smoke.py index c230a660..29275929 100644 --- a/scripts/reflex_ws_smoke.py +++ b/scripts/reflex_ws_smoke.py @@ -17,6 +17,10 @@ 5. Composition: toggling the §9 cond swaps the composed board for `rx.foreach` small multiples over a `list[DataHandle]` var — new plan charts mount and subscribe live. +6. Kind coverage: the /kinds page mounts and paints **every chart kind** + (19 data-bound plan charts + 7 static composites), pixel-probed per + `kind-` cell — the compile guarantee is backed by a render + guarantee. Usage: python3 scripts/reflex_ws_smoke.py [--frontend http://localhost:3100] @@ -385,6 +389,69 @@ def main() -> None: views_after = probe.eval("window.__xy_views.size") print(f"cond/foreach: {views_before} -> {views_after} mounted views after split toggle") + # 7) /kinds: every chart kind renders in the browser — 19 data-bound + # plan charts plus the 7 static composites. Each `kind-` + # cell must hold a canvas that paints real ink (a compile-only + # guarantee is not a render guarantee). + probe._call("Page.navigate", {"url": args.frontend.rstrip("/") + "/kinds"}) + probe.wait_for( + "window.__xy_views && window.__xy_views.size >= 26", + timeout_s=120.0, + label="mounted /kinds chart views", + ) + time.sleep(2.0) # let late payloads paint before the pixel probe + kinds_png = probe.screenshot() + kind_names = [ + "scatter", + "line", + "area", + "step", + "stem", + "bar", + "column", + "histogram", + "errorbar", + "error_band", + "segments", + "box", + "violin", + "ecdf", + "hexbin", + "heatmap", + "contour", + "stairs", + "triangle_mesh", + "pie", + "radar", + "sankey", + "polar", + "polar_bar", + "wind_rose", + "facet", + ] + blank: list[str] = [] + for name in kind_names: + rect_js = ( + f"(() => {{ const cell = document.getElementById('kind-{name}');" + " if (!cell) return null;" + " const c = cell.querySelector('canvas');" + " if (!c) return null;" + " const b = c.getBoundingClientRect();" + " return {x: b.x + window.scrollX, y: b.y + window.scrollY," + " w: b.width, h: b.height}; })()" + ) + canvas_rect = probe.eval(rect_js) + if not canvas_rect or not canvas_rect["w"] or not canvas_rect["h"]: + failures.append(f"/kinds {name}: no mounted canvas") + continue + frac = ink_fraction(kinds_png, canvas_rect, 1.0) + if frac < 0.003: + blank.append(f"{name} ({frac:.2%})") + if blank: + failures.append("/kinds charts look blank: " + ", ".join(blank)) + else: + print(f"/kinds: all {len(kind_names)} kind cells painted") + if args.screenshot: Path(args.screenshot).write_bytes(probe.screenshot()) print(f"saved {args.screenshot}") diff --git a/spec/design/reflex-component-api-implementation.md b/spec/design/reflex-component-api-implementation.md index a2663650..d5caad91 100644 --- a/spec/design/reflex-component-api-implementation.md +++ b/spec/design/reflex-component-api-implementation.md @@ -377,32 +377,41 @@ map is populated in every worker" a guarantee of the integration instead of an assumption about Reflex. Recorded in reflex-integration.md §3.6 and the options doc §8. -**Post-landing revision (kind coverage, 2026-08).** The Phase 3 exclusion -of the aggregating marks is lifted. The objection — synthetic-row probe -failures could depend on made-up values — is answered by discipline -instead of exclusion: each aggregating kind's channels probe with fixed, -recorded placeholder columns (`plan._SYNTHETIC_CHANNELS`; values finite, -positive, strictly increasing, one group for grouped kinds, a square z -grid with matching side coordinates, `len+1` bin edges), so a probe -failure indicts structure, never the synthetic values. Landed with it: - -- Flat factories for the newly probed kinds (`box_chart`, `violin_chart`, - `ecdf_chart`, `hexbin_chart`, `contour_chart`, `heatmap_chart`, - `stairs_chart`) plus `triangle_mesh_chart` (always zero-row-safe, just - never wired). +**Post-landing revision (kind coverage, 2026-08, revised again after +review).** The Phase 3 exclusion of the aggregating marks is lifted. The +first lift probed them with fixed synthetic placeholder columns +(`plan._SYNTHETIC_CHANNELS`); review proved synthetic data structurally +unsound — a column shared between an aggregating channel and a zero-row +channel falsely failed on invented lengths, valid hexbin `range=`/ +`mincnt=` configurations falsely failed on invented values, and large +`gridsize` ran real aggregation at page evaluation. The final design +replaces synthetic data with a **core structural-validation seam**: +`xy.structural_probe()` (spec/api/chart-kind-contract.md "Structural +probe"), under which an all-empty mark validates configuration and skips +aggregation. Every kind probes zero-row. Landed with it: + +- Flat factories for all 19 standalone kinds (`box_chart`, + `violin_chart`, `ecdf_chart`, `hexbin_chart`, `contour_chart`, + `heatmap_chart`, `stairs_chart`, `triangle_mesh_chart` beside the + eleven zero-row-safe kinds). - `validate_columns` no longer requires one shared length: a data var may carry mixed-length and 2-D columns (stairs edges, heatmap grids); coupled-shape contracts live with the mark validators at bind. -- Module-level named callables in mark props (hexbin's - `reduce_C_function`) content-address as their import path; lambdas and - closures are refused. Plan registration is last-write-wins so a hot - reload replaces stale node objects behind an unchanged digest. -- Pinned by `tests/reflex_adapter/test_plan.py` (the shape table, plan - builds per kind, callable addressing), `test_factories.py` (the full - 19-kind flat table), and `tests/test_validation_timing.py` - (SHAPED_ROW_CHARTS — the xy-level half of the contract). Recorded in - reflex-integration.md §3.6 "Kind coverage (recorded decision, revised - 2026-08)". +- Plan callables (hexbin's `reduce_C_function`) are content-addressed: + import path + code fingerprint for pure-Python functions (an edited + body changes the digest — rolling deployments fail safe to resync, + never to divergent behavior), import path + distribution version for + C-level callables that resolve back to themselves. Bound methods, + lambdas, closures, and partials are refused. Plan registration is + last-write-wins so a hot reload replaces stale node objects. +- Pinned by `tests/reflex_adapter/test_plan.py` (zero-row plans per kind, + the review's shared-column and hexbin-config repros, callable + addressing incl. bound-method refusal and body-sensitive digests), + `test_factories.py` (the full 19-kind flat table), and + `tests/test_validation_timing.py` (the xy half: every kind compiles + empty under the probe, still refuses empty normally, still raises + config errors under the probe). Recorded in reflex-integration.md §3.6 + "Kind coverage" and spec/api/chart-kind-contract.md. Deferred items remain deferred and tracked (keyed dataset collections for `foreach`, per-mark `data=`, the core-xy metadata registry, both upstream diff --git a/spec/design/reflex-component-api-options.md b/spec/design/reflex-component-api-options.md index 6ff4f3b1..3f86bae7 100644 --- a/spec/design/reflex-component-api-options.md +++ b/spec/design/reflex-component-api-options.md @@ -903,16 +903,20 @@ and small): unevaluated pages in its startup lifespan, making the plan-distribution property a guarantee of the integration rather than an observed Reflex behavior. -- **The aggregating-kind exclusion was revised (2026-08).** The plan tier - originally refused box, violin, hexbin, contour, heatmap, stairs, and - ecdf because their validators need at least one finite value and a - synthetic probe "would validate against made-up data". The revision - keeps the compile guarantee honest by *recording* the synthetic shapes - instead of refusing: fixed placeholder columns per (kind, channel) — - `plan._SYNTHETIC_CHANNELS`, pinned by tests at both the adapter and xy - levels — chosen to satisfy every value-domain precondition, so probe - failures still indict structure. Every standalone mark kind now has a - flat factory and composes; only the data-taking composite factories +- **The aggregating-kind exclusion was revised (2026-08, twice).** The + plan tier originally refused box, violin, hexbin, contour, heatmap, + stairs, and ecdf because their validators need at least one finite + value and a synthetic probe "would validate against made-up data". A + first revision recorded fixed synthetic shapes per (kind, channel); + review proved the original objection right after all — shared columns + and value-dependent config (hexbin `range=`/`mincnt=`) failed on the + invented values, and large grids aggregated at page evaluation. The + final design moves the fix into the core instead of the data: + `xy.structural_probe()` mode, under which an all-empty mark validates + configuration and skips aggregation, so every kind probes zero-row with + no synthetic data at all. Every standalone mark kind now has a flat + factory and composes; only the data-taking composite factories (pie, radar, wind_rose, sankey) stay on the static/escape-hatch routes. - Details: reflex-integration.md §3.6 "Kind coverage" and the + Details: reflex-integration.md §3.6 "Kind coverage", + spec/api/chart-kind-contract.md "Structural probe", and the implementation doc's post-landing revision record. diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index 5ce8be92..9b5a4a93 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -545,14 +545,20 @@ the check that catches it. Binding is the reverse: columns + plan → a **fresh** `Chart` (never reused — X3) → `.figure()`. Column-mismatch errors name both sides (*"plan binds column 'mag'; Dash.cloud produced {x, y}"*). Plans refuse concrete arrays, per-mark -`data=`, and `render=` components — data-free structure only. One node -field is addressed by *name* rather than by value: a module-level named -callable (hexbin's `reduce_C_function`, `np.mean` by default) serializes -as its import path, which is a stable content address; lambdas and -closures have none and are refused toward a module-level function or the -escape hatch. Re-registration under a digest is last-write-wins, so a -hot-reloaded page replaces stale node objects (the body behind a callable -path may have changed). The probe +`data=`, and `render=` components — data-free structure only. Callables +in mark props (hexbin's `reduce_C_function`, `np.mean` by default) are +**content-addressed, not name-addressed**: a pure-Python function +serializes as its import path *plus a code fingerprint* (bytecode, names, +nested code, defaults), so editing a reducer's body changes the digest — +two workers on either side of a rolling deployment can never execute +different behavior behind one address (mismatched digests resync +instead). C-level callables (ufuncs, builtins) must resolve back to the +same object by qualified name and carry their distribution's version. +Bound methods are refused outright (instance state has no content +address; two differently configured instances would collide on one +digest), as are lambdas, closures, and partials. Re-registration under a +digest is last-write-wins, so a hot-reloaded page replaces stale node +objects. The probe figure also yields `dom_class_strings()`, so **live data-bound charts get automatic Tailwind discovery** (previously live sources needed the manual inventory). @@ -588,33 +594,35 @@ binds immediately and routes to the §3.4 payload-asset path — same validation, same spec-aware bind errors, works under `reflex export`, never touches the registry. -**Kind coverage (recorded decision, revised 2026-08).** Flat factories -exist for **every standalone mark kind**: scatter, line, histogram, bar, -area, step, stem, column, errorbar, error_band, segments, triangle_mesh -(zero-row probe) and box, violin, ecdf, hexbin, contour, heatmap, stairs -(shaped synthetic probe) — each derived from the mark's signature, and the -composed `reflex_xy.chart(*nodes, data=...)` accepts any mix of those -marks plus annotations and chrome. The aggregating kinds were previously -excluded on the argument that a synthetic-row probe's failures could -depend on made-up values; that objection is answered by discipline rather -than exclusion. Each such kind's channels probe with **fixed, recorded** -placeholder columns (`plan._SYNTHETIC_CHANNELS`, pinned by -`tests/reflex_adapter/test_plan.py` and, at the xy level, by -`tests/test_validation_timing.py`) whose values satisfy every value-domain -precondition a validator imposes: finite, positive, strictly increasing -(log-scale-safe); one group for grouped kinds; a square z grid with -matching side coordinates; `len+1` bin edges. A probe failure therefore -still indicts the chart's structure, never the synthetic values, and the -compile guarantee is unchanged in kind — config and channel structure at -compile, real-data shapes at bind, exactly where the zero-row probe -already left them (§28 spirit: the shapes are recorded, not guessed). -Known edge, recorded: a column shared between an aggregating channel and -a zero-row mark's channel in one composed chart cannot probe (the -placeholder lengths disagree); such charts take `@reflex_xy.figure`. The -data-taking composite factories (pie, radar, wind_rose, sankey — eager -numeric work at call time) remain outside the plan tier: they don't -produce data-free nodes, and keep the two recorded routes -(`@reflex_xy.figure`, or a concrete xy Chart on the static tier). +**Kind coverage (recorded decision, revised 2026-08 twice).** Flat +factories exist for **every standalone mark kind**: scatter, line, +histogram, bar, area, step, stem, column, errorbar, error_band, segments, +triangle_mesh, box, violin, ecdf, hexbin, contour, heatmap, stairs — each +derived from the mark's signature, and the composed +`reflex_xy.chart(*nodes, data=...)` accepts any mix of those marks plus +annotations and chrome. The aggregating kinds were first excluded (their +validators refuse zero rows), then briefly probed with recorded synthetic +placeholder columns; review showed synthetic data is **not structurally +sound**: a column shared between an aggregating channel and a zero-row +channel falsely failed on invented lengths, a valid hexbin `range=` or +`mincnt=` falsely failed on invented values, and a large `gridsize` ran +real aggregation at page evaluation. The resolution is a core validation +seam instead of cleverer data: the probe compiles under +`xy.structural_probe()` (spec/api/chart-kind-contract.md "Structural +probe"), where a mark with all-empty channels validates configuration and +skips aggregation. Every kind probes **zero-row**; there is no synthetic +data anywhere; configuration errors (bad enums/bounds/colormaps/range +shapes) still fail `reflex run`; data-dependent outcomes (range +filtering, mincnt, quantiles, marching) and real-data shape couplings +(`edges = len+1`, z 2-D-ness) are computed and checked at bind. Pinned by +`tests/test_validation_timing.py` (the xy half: every kind compiles empty +under the probe, still refuses empty normally, still raises config +errors) and `tests/reflex_adapter/test_plan.py` (the plan half, including +the shared-column and hexbin-config repros from review). The data-taking +composite factories (pie, radar, wind_rose, sankey — eager numeric work +at call time) remain outside the plan tier: they don't produce data-free +nodes, and keep the two recorded routes (`@reflex_xy.figure`, or a +concrete xy Chart on the static tier). **Column entries.** Published columns are registry entries in their own right, keyed by the data token: pure rebuildable caches of Reflex state diff --git a/tests/reflex_adapter/test_plan.py b/tests/reflex_adapter/test_plan.py index cd27a911..1eb0417e 100644 --- a/tests/reflex_adapter/test_plan.py +++ b/tests/reflex_adapter/test_plan.py @@ -57,28 +57,12 @@ def test_probe_fires_the_full_validation_gate(): build_plan("scatter_chart", (xy.scatter("x", "y", y_axis="y2"),), {}) -def test_shaped_probe_table_is_the_recorded_contract(): - """The synthetic-channel table (spec: reflex-integration.md "Kind - coverage") is a recorded decision, §28 spirit: extending a kind means - recording its validator's minimal shape here, never a silent guess. - Keys name the Mark storage each factory param lands on (x/y fields or - props entries).""" - from reflex_xy.plan import _SYNTHETIC_CHANNELS - - assert {kind: sorted(channels) for kind, channels in _SYNTHETIC_CHANNELS.items()} == { - "box": ["props.group", "props.x", "x"], - "violin": ["props.group", "props.x", "x"], - "hexbin": ["props.C", "x", "y"], - "contour": ["props.z", "x", "y"], - "heatmap": ["props.z", "x", "y"], - "stairs": ["x", "y"], - "ecdf": ["x"], - } - - -def test_aggregating_kinds_build_plans_with_shaped_probes(): - """Every aggregating kind compiles a plan (the old exclusion is gone), - with grouped/coordinate/weight channels recorded like any other.""" +def test_aggregating_kinds_build_zero_row_plans(): + """Every aggregating kind compiles a plan zero-row (the old exclusion is + gone, and so are the synthetic columns that replaced it): under the + core's structural_probe() mode the marks validate config and skip + aggregation, with grouped/coordinate/weight channels recorded like any + other.""" cases = [ ("box_chart", (xy.box("v", group="g"),), {"v", "g"}), ("violin_chart", (xy.violin("v"),), {"v"}), @@ -93,16 +77,78 @@ def test_aggregating_kinds_build_plans_with_shaped_probes(): assert set(plan.columns) == expected, kind +def test_structural_probe_still_fails_bad_aggregating_config(): + """No synthetic data does not mean no validation: configuration errors + of the aggregating kinds fail the zero-row probe exactly like scatter's + bad colormap does.""" + cases = [ + ("box orientation", (xy.box("v", orientation="diagonal"),)), + ("violin bins", (xy.violin("v", bins=2),)), + ("hexbin gridsize", (xy.hexbin("a", "b", gridsize=0),)), + ("hexbin range", (xy.hexbin("a", "b", range=(0.0, 0.5)),)), + ("hexbin mincnt", (xy.hexbin("a", "b", mincnt=-1),)), + ("contour levels", (xy.contour("grid", levels=0),)), + ("contour extend", (xy.contour("grid", extend="sideways"),)), + ("heatmap colormap", (xy.heatmap("grid", colormap="virids"),)), + ("stairs where", (xy.stairs("counts", where="diagonal"),)), + ("ecdf bins", (xy.ecdf("v", bins=-1),)), + ] + for label, children in cases: + with pytest.raises(ValueError, match=label.split(" ")[-1]): + build_plan("chart", children, {}) + + +def test_shared_columns_between_aggregating_and_zero_row_marks_probe(): + """The review's repro: stairs (values len k, edges len k+1) composed + with a scatter that reads the same 'edges' column. Synthetic per-name + shapes made the scatter probe see lengths 9 and 0; the all-empty + structural probe has no lengths to disagree about, and the real mixed- + length data binds.""" + plan = build_plan( + "chart", + (xy.stairs("counts", "edges"), xy.scatter("edges", "other")), + {}, + ) + assert set(plan.columns) == {"counts", "edges", "other"} + fig = plan.bind( + { + "counts": np.arange(8.0), + "edges": np.arange(9.0), + "other": np.arange(9.0) * 2.0, + } + ).figure() + assert len(fig.traces) == 2 + + +def test_hexbin_value_dependent_configs_probe_without_aggregating(): + """The review's other repro class: a range that excludes any invented + points, a mincnt that filters them, or a maximal gridsize must not fail + (or allocate) at page evaluation — those are data outcomes, computed + only when real data binds.""" + for kwargs in ( + {"range": ((0.0, 0.5), (0.0, 0.5))}, + {"mincnt": 5}, + {"gridsize": 2048}, + ): + plan = build_plan("hexbin_chart", (xy.hexbin("a", "b", **kwargs),), {}) + assert set(plan.columns) == {"a", "b"} + # and the range case renders with real in-range data + plan = build_plan("hexbin_chart", (xy.hexbin("a", "b", range=((0.0, 0.5), (0.0, 0.5))),), {}) + fig = plan.bind({"a": [0.1, 0.2, 0.3], "b": [0.1, 0.2, 0.3]}).figure() + assert fig.traces[0].kind == "hexbin" + + def test_shaped_and_zero_row_marks_compose_and_share_columns(): plan = build_plan("chart", (xy.histogram("v"), xy.ecdf("v")), {}) assert plan.columns == ("v",) def test_named_callables_digest_and_lambdas_are_refused(): - """hexbin's reduce_C_function default (np.mean) has a stable import - path — that path is its content address, so identical trees still agree - across workers. A lambda has no stable name and cannot keep digests - faithful; it is refused toward a module-level function or the hatch.""" + """hexbin's reduce_C_function default (np.mean) content-addresses as its + import path plus a code fingerprint, so identical trees agree across + workers and different reducers disagree. A lambda has no stable name + and cannot keep digests faithful; it is refused toward a module-level + function or the hatch.""" hexbin_plan = build_plan("hexbin_chart", (xy.hexbin("a", "b", C="w"),), {}) again = build_plan("hexbin_chart", (xy.hexbin("a", "b", C="w"),), {}) assert hexbin_plan.digest == again.digest @@ -118,6 +164,70 @@ def test_named_callables_digest_and_lambdas_are_refused(): ) +def test_bound_methods_are_refused_as_plan_callables(): + """The review's repro: two bound reducers with different instance state + used to serialize identically (module.qualname) — last-write-wins then + made the first chart execute the second reducer. Instance state has no + content address, so bound methods are refused outright.""" + + class Quantile: + def __init__(self, q: float) -> None: + self.q = q + + def reduce(self, values: np.ndarray) -> float: + return float(np.quantile(values, self.q)) + + with pytest.raises(PlanError, match="bound method"): + build_plan( + "hexbin_chart", + (xy.hexbin("a", "b", C="w", reduce_C_function=Quantile(0.5).reduce),), + {}, + ) + + +def test_callable_digest_follows_the_body_not_only_the_name(): + """A qualified name is identity, not content: editing a module-level + reducer's body must change the digest (a rolling deployment otherwise + executes two behaviors behind one address).""" + import sys + import types + + def module_reducer(body: str): + module = types.ModuleType("plan_test_reducers") + sys.modules["plan_test_reducers"] = module + exec( # noqa: S102 - building a same-qualname function pair for the pin + f"import numpy as np\ndef reduce(values):\n return {body}\n", + module.__dict__, + ) + return module.__dict__["reduce"] + + first = module_reducer("float(np.mean(values))") + digest_one = build_plan( + "hexbin_chart", (xy.hexbin("a", "b", C="w", reduce_C_function=first),), {} + ).digest + second = module_reducer("float(np.max(values))") # same module.qualname + digest_two = build_plan( + "hexbin_chart", (xy.hexbin("a", "b", C="w", reduce_C_function=second),), {} + ).digest + assert digest_one != digest_two + sys.modules.pop("plan_test_reducers", None) + + +def test_closures_are_refused_as_plan_callables(): + def make_reducer(q: float): + def reduce(values: np.ndarray) -> float: + return float(np.quantile(values, q)) + + return reduce + + with pytest.raises(PlanError, match="stable qualified name"): + build_plan( + "hexbin_chart", + (xy.hexbin("a", "b", C="w", reduce_C_function=make_reducer(0.5)),), + {}, + ) + + def test_plans_refuse_concrete_arrays(): with pytest.raises(PlanError, match="data-free"): build_plan("scatter_chart", (xy.scatter(np.array([1.0]), np.array([2.0])),), {}) diff --git a/tests/test_example_apps.py b/tests/test_example_apps.py index f8a1cf49..bae80626 100644 --- a/tests/test_example_apps.py +++ b/tests/test_example_apps.py @@ -209,9 +209,9 @@ def test_reflex_app_introspection_and_composition(tmp_path, monkeypatch) -> None assert module.DRILLDOWN.token.startswith("xyin-") assert module.DRILLDOWN_POINTS == 50000 assert module.index() is not None - # The /kinds page: all 19 data-bound kind plans compile (shaped synthetic - # probes included), the composite kinds build on the static tier, and one - # data var legally carries mixed-length plus 2-D columns. + # The /kinds page: all 19 data-bound kind plans compile (zero-row under + # the structural probe), the composite kinds build on the static tier, + # and one data var legally carries mixed-length plus 2-D columns. assert module.kinds() is not None columns = module._kind_columns() assert columns["grid"].ndim == 2 From 2c2e916c91598c16b5a90fd59e6d54c09cb896cf Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Thu, 6 Aug 2026 22:06:59 +0000 Subject: [PATCH 5/6] fix(demo): omit empty bins from the binned-mean summary; record the fingerprint boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cond_summary floor-clamped bin counts to dodge division by zero, so an empty bin would render a mean of exactly 0 — a false origin point at that x-centre. Empty bins are now dropped (columns simply shorten); with the demo's 1M-point seed the output is unchanged, the promise is now unconditional. reflex-integration.md §3.6 records the callable-fingerprint boundary explicitly: the digest addresses code, never process state — mutable module globals in a reducer are the same purity contract every server-side builder tier carries, and are not content-addressable. --- examples/reflex/xy_reflex_demo/xy_reflex_demo.py | 9 +++++++-- spec/design/reflex-integration.md | 12 +++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index 5328f68f..a6f70cf4 100644 --- a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py +++ b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py @@ -532,9 +532,14 @@ def cond_summary(self) -> CloudCols: x, y, _ = _cloud(POINTS) edges = np.linspace(x.min(), x.max(), 121) idx = np.clip(np.digitize(x, edges) - 1, 0, 119) - counts = np.maximum(np.bincount(idx, minlength=120), 1) - mean_y = np.bincount(idx, weights=y, minlength=120) / counts + counts = np.bincount(idx, minlength=120) + sums = np.bincount(idx, weights=y, minlength=120) centers = (edges[:-1] + edges[1:]) / 2.0 + # A bin with no points has no mean — drop it rather than plotting a + # false zero at its centre (the columns just get shorter). + filled = counts > 0 + mean_y = sums[filled] / counts[filled] + centers = centers[filled] return {"x": centers, "y": mean_y, "mag": np.hypot(centers, mean_y)} @rx.event diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index 9b5a4a93..2d9ba58e 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -558,7 +558,17 @@ Bound methods are refused outright (instance state has no content address; two differently configured instances would collide on one digest), as are lambdas, closures, and partials. Re-registration under a digest is last-write-wins, so a hot-reloaded page replaces stale node -objects. The probe +objects. **Recorded boundary:** the fingerprint addresses *code*, not +process state. A module-level function that reads a mutable module global +executes whatever that global holds in the serving worker — exactly like +an `@reflex_xy.figure` builder or `@reflex_xy.data` method that does the +same, and no more addressable: hashing referenced global *values* would +have to snapshot an unbounded, mutable object graph at registration and +still be stale by bind time, and refusing functions that reference any +global name would refuse every NumPy-using reducer (`np` is a global +reference). The declared state carriers — closures, partials, bound +methods — are refused; residual global-state impurity is the same +purity contract every server-side builder tier already carries (§3.1). The probe figure also yields `dom_class_strings()`, so **live data-bound charts get automatic Tailwind discovery** (previously live sources needed the manual inventory). From ce04ec4f1fece91d9f864ed356b100c6e9f69815 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Fri, 7 Aug 2026 01:26:11 +0000 Subject: [PATCH 6/6] fix(reflex): statically declare the newly exported kinds The public-surface pin added in stack/5 (`test_public_surface.py`) requires every name in `reflex_xy.__all__` to carry a static declaration alongside its `__getattr__` route, so consumers get real signatures instead of `Any`. This layer's eight new flat factories (box, contour, ecdf, heatmap, hexbin, stairs, triangle_mesh, violin) and the newly curated `triangle_mesh` node re-export were added to `_EXPORTS`/`_XY_REEXPORTS` only, so they typed as missing. Restate them in the `TYPE_CHECKING` block. --- python/reflex_xy/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/python/reflex_xy/__init__.py b/python/reflex_xy/__init__.py index b1261424..867631ad 100644 --- a/python/reflex_xy/__init__.py +++ b/python/reflex_xy/__init__.py @@ -440,6 +440,7 @@ def release(token: "str | FigureHandle") -> None: threshold, threshold_zone, tooltip, + triangle_mesh, violin, vline, x_axis, @@ -465,16 +466,24 @@ def release(token: "str | FigureHandle") -> None: from .factories import ( area_chart, bar_chart, + box_chart, chart, column_chart, + contour_chart, + ecdf_chart, error_band_chart, errorbar_chart, + heatmap_chart, + hexbin_chart, histogram_chart, line_chart, scatter_chart, segments_chart, + stairs_chart, stem_chart, step_chart, + triangle_mesh_chart, + violin_chart, ) from .handles import DataHandle, FigureHandle from .namespace import XY_NAMESPACE, XYNamespace