diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index eb1f0402..a6f70cf4 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 @@ -46,6 +46,17 @@ 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. ``scripts/reflex_ws_smoke.py`` pixel-probes every cell, so +the page carries browser render coverage, not only compile coverage. Run from ``examples/reflex``:: @@ -511,6 +522,30 @@ 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.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 + 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 +846,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 +890,288 @@ 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: + # 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, + border="1px solid var(--gray-5)", + border_radius="10px", + background="var(--gray-1)", + padding="0.75rem", + width="100%", + id=f"kind-{title}", + ) + + +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)) + 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 +1182,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 +1312,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 +1332,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/python/reflex_xy/__init__.py b/python/reflex_xy/__init__.py index 42426962..867631ad 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,12 @@ 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 +#: 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( { # marks @@ -137,6 +146,7 @@ def index() -> rx.Component: "hexbin", "contour", "heatmap", + "triangle_mesh", # annotations "vline", "hline", @@ -195,6 +205,7 @@ def index() -> rx.Component: "bar", "bar_chart", "box", + "box_chart", "callout", "chart", "clear_selection", @@ -202,8 +213,10 @@ def index() -> rx.Component: "column", "column_chart", "contour", + "contour_chart", "data", "ecdf", + "ecdf_chart", "error_band", "error_band_chart", "errorbar", @@ -211,7 +224,9 @@ def index() -> rx.Component: "export_config", "figure", "heatmap", + "heatmap_chart", "hexbin", + "hexbin_chart", "histogram", "histogram_chart", "hline", @@ -238,6 +253,7 @@ def index() -> rx.Component: "setup", "spring", "stairs", + "stairs_chart", "stem", "stem_chart", "step", @@ -248,7 +264,10 @@ def index() -> rx.Component: "threshold", "threshold_zone", "tooltip", + "triangle_mesh", + "triangle_mesh_chart", "violin", + "violin_chart", "vline", "x_axis", "x_band", @@ -421,6 +440,7 @@ def release(token: "str | FigureHandle") -> None: threshold, threshold_zone, tooltip, + triangle_mesh, violin, vline, x_axis, @@ -446,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 diff --git a/python/reflex_xy/data_vars.py b/python/reflex_xy/data_vars.py index 0ba1573b..446e8c1f 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 @@ -199,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 fed70504..f5b7a43f 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 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 @@ -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,17 @@ 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: 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), + _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 +475,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..a057a57a 100644 --- a/python/reflex_xy/plan.py +++ b/python/reflex_xy/plan.py @@ -5,12 +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 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; - 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 @@ -31,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", @@ -53,12 +63,6 @@ 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"}) - class PlanError(ValueError): """A chart plan could not be built, resolved, or bound.""" @@ -85,7 +89,10 @@ class _ProbeTable(Mapping): ``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. 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) -> None: @@ -103,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): @@ -118,6 +216,8 @@ 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): + 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 " @@ -180,29 +280,13 @@ def build_plan( 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. + # 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() - try: + with structural_probe(): 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 tailwind_classes = " ".join(probe_figure.dom_class_strings()) plan = ChartPlan( @@ -224,8 +308,18 @@ 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. 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 def plan_of(digest: str) -> Optional[ChartPlan]: 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/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/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/spec/design/reflex-component-api-implementation.md b/spec/design/reflex-component-api-implementation.md index 6b22abab..d5caad91 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,42 @@ 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, 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. +- 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 Reflex PR sites). diff --git a/spec/design/reflex-component-api-options.md b/spec/design/reflex-component-api-options.md index 76f21344..3f86bae7 100644 --- a/spec/design/reflex-component-api-options.md +++ b/spec/design/reflex-component-api-options.md @@ -903,3 +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, 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", + 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 efc1a1cb..2d9ba58e 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,30 @@ 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. 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. **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). @@ -567,7 +595,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 +604,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).** 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 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 @@ -1014,7 +1057,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..1eb0417e 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,177 @@ def test_zero_row_probe_fires_the_full_validation_gate(): build_plan("scatter_chart", (xy.scatter("x", "y", y_axis="y2"),), {}) +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"}), + ("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_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) 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 + 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_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_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_example_apps.py b/tests/test_example_apps.py index 1c70bd07..bae80626 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 (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 + assert len(columns["edges"]) == len(columns["counts"]) + 1 # --- retargeted browser smokes: import cleanly, pure helpers unit-tested ----- diff --git a/tests/test_validation_timing.py b/tests/test_validation_timing.py index d3c4f2e6..afcf5a1e 100644 --- a/tests/test_validation_timing.py +++ b/tests/test_validation_timing.py @@ -3,12 +3,17 @@ 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: +``.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: zero-row columns compile — 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. @@ -22,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 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 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)), @@ -42,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"), } @@ -53,6 +91,44 @@ def test_zero_row_construction_compiles(kind): assert figure is not None +@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(): """X2, the form the plan tier uses: string channels resolved against a chart-level table of zero-row columns."""