Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,8 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
# it by happily compiling the core!) — strip every cargo from PATH so the
# GitHub runners preinstall Rust (which would defeat this job's purpose
# by happily compiling the core!) — strip every cargo from PATH so the
# build genuinely exercises the no-toolchain path.
- name: Remove preinstalled Rust
run: |
Expand Down
13 changes: 11 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,9 @@ jobs:
with:
node-version: "22"
# The sdist carries the committed JS bundle; it builds the Rust core on the
# user's machine if they install from sdist and have a toolchain, else it
# falls back to NumPy (hatch_build.py). Wheels are the normal path.
# user's machine if they install from sdist and have a toolchain. There is
# no fallback: without a toolchain the compute layer raises a clear
# ImportError on first use (hatch_build.py). Wheels are the normal path.
- name: Verify committed JS bundles are fresh
run: node js/build.mjs --check
- name: Build sdist
Expand Down Expand Up @@ -232,6 +233,14 @@ jobs:
permissions:
id-token: write # PyPI trusted publishing (OIDC) — no API token stored
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2
# The artifact verifiers check everything inside the wheels/sdist, but
# only this gate stops a mismatched tag from publishing whatever version
# sits in pyproject.toml: the tag must equal v<project.version> and
# CHANGELOG.md must carry a dated entry for it.
- name: Release version gate (tag == pyproject == CHANGELOG)
if: github.event_name == 'push'
run: python3 scripts/check_release_version.py
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: dist
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# charts-exp / fastcharts
# reviz / fastcharts

A high-performance charting engine. The authoritative design is
`docs/design-dossier.md` — **read the relevant § before changing behavior**;
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ chart-type contribution walkthrough — lives at
Quick start:

```bash
git clone https://github.com/Alek99/charts-exp.git
cd charts-exp
git clone https://github.com/reflex-dev/reviz.git
cd reviz
uv venv && uv pip install -e ".[dev]"
make check # fast gate
make check-full # full production gate (needs Rust + Node 18+)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<p align="center">
<a href="https://github.com/reflex-dev/reviz/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/reflex-dev/reviz/actions/workflows/ci.yml/badge.svg"></a>
<a href="https://app.codspeed.io/Alek99/charts-exp?utm_source=badge"><img alt="CodSpeed" src="https://img.shields.io/endpoint?url=https://codspeed.io/badge.json"></a>
<a href="https://app.codspeed.io/reflex-dev/reviz?utm_source=badge"><img alt="CodSpeed" src="https://img.shields.io/endpoint?url=https://codspeed.io/badge.json"></a>
<a href="pyproject.toml"><img alt="Python 3.11+" src="https://img.shields.io/badge/python-3.11%2B-3776ab?logo=python&logoColor=white"></a>
<a href="src/"><img alt="Rust native core" src="https://img.shields.io/badge/Rust-native%20core-b7410e?logo=rust&logoColor=white"></a>
<a href="LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/license-Apache--2.0-blue"></a>
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ fixes.
## Reporting a vulnerability

Please **do not** open a public issue for security reports. Instead, use
[GitHub private vulnerability reporting](https://github.com/Alek99/charts-exp/security/advisories/new)
[GitHub private vulnerability reporting](https://github.com/reflex-dev/reviz/security/advisories/new)
on this repository. Reports are acknowledged within a week.

## Scope notes for triage
Expand Down
6 changes: 3 additions & 3 deletions docs/benchmark.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,9 +266,9 @@ PYTHONPATH=python .venv/bin/python benchmarks/bench_2d_charts.py \
--profile standard --ttfr --ttfr-max-work-units 50000
```

Environment note: TTFR probes require launching local headless Chrome; under the
Codex sandbox the probe returns `None`, so local TTFR runs need browser-launch
permission.
Environment note: TTFR probes require launching local headless Chrome; in a
sandboxed environment without browser-launch permission the probe returns
`None`.

Seaborn rows render through matplotlib/Agg PNG. For those static raster rows,
TTFR is treated as total chart-to-pixels time because the image already exists
Expand Down
6 changes: 3 additions & 3 deletions examples/demo.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"metadata": {},
"outputs": [],
"source": [
"n = 10_000_00000\n",
"n = 10_000_000\n",
"t = np.datetime64(\"2015-01-01\") + np.arange(n).astype(\"timedelta64[s]\")\n",
"y = np.cumsum(rng.normal(size=n))\n",
"y[n // 2] += 80 # a single-sample spike M4 must preserve"
Expand Down Expand Up @@ -146,13 +146,13 @@
"metadata": {},
"outputs": [],
"source": [
"big = 30_000_0000\n",
"big = 30_000_000\n",
"bx = rng.normal(0, 1, big)\n",
"by = bx * 0.7 + rng.normal(0, 0.5, big)\n",
"\n",
"chart = fc.scatter_chart(\n",
" fc.scatter(x=bx, y=by, colormap=\"magma\"),\n",
" title=\"300M points (density)\",\n",
" title=\"30M points (density)\",\n",
" width=950,\n",
" height=420,\n",
")\n",
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ dependencies = [
"anywidget>=0.9",
]

[project.urls]
Repository = "https://github.com/reflex-dev/reviz"
Issues = "https://github.com/reflex-dev/reviz/issues"
Changelog = "https://github.com/reflex-dev/reviz/blob/main/CHANGELOG.md"
Security = "https://github.com/reflex-dev/reviz/blob/main/SECURITY.md"

[project.optional-dependencies]
dev = [
"pytest>=8",
Expand Down
23 changes: 17 additions & 6 deletions python/fastcharts/_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@

ABI_VERSION = 10

# Rust reports invalid arguments (and, via the ffi_guard panic shield, any
# internal panic) by returning `usize::MAX` from size-returning entry points.
# `usize` is `c_size_t`, whose width is platform-dependent — 32 bits on
# armv7/win32/wasm32 — so the sentinel must be derived from ctypes. Comparing
# against 2**64-1 would never match on 32-bit targets and an error return
# would be sliced as data.
_USIZE_MAX = ctypes.c_size_t(-1).value

_F64_P = ctypes.POINTER(ctypes.c_double)
_F32_P = ctypes.POINTER(ctypes.c_float)
_U64_P = ctypes.POINTER(ctypes.c_uint64)
Expand Down Expand Up @@ -368,9 +376,12 @@ def zone_maps(
_ptr_f64(sums),
_ptr_f64(sum_sqs),
)
if written == np.iinfo(np.uint64).max:
if written == _USIZE_MAX:
raise ValueError("invalid zone_maps arguments")
assert written == n_chunks
if written != n_chunks:
raise RuntimeError(
f"fastcharts native zone_maps wrote {written} chunks, expected {n_chunks}"
)
return mins, maxs, counts, nulls, sums, sum_sqs


Expand Down Expand Up @@ -416,7 +427,7 @@ def m4_indices(
n_buckets,
out.ctypes.data_as(_U32_P),
)
if written == np.iinfo(np.uint64).max: # usize::MAX sentinel
if written == _USIZE_MAX:
raise ValueError("invalid m4 arguments")
return out[:written].copy()

Expand Down Expand Up @@ -500,7 +511,7 @@ def bin_2d_indices(
grid.ctypes.data_as(_F32_P),
idx.ctypes.data_as(_U32_P),
)
if written == np.iinfo(np.uint64).max:
if written == _USIZE_MAX:
raise ValueError("invalid bin_2d_indices arguments")
return grid, idx[:written].copy()

Expand Down Expand Up @@ -538,7 +549,7 @@ def histogram_uniform(
int(density),
_ptr_f64(counts),
)
if written == np.iinfo(np.uint64).max:
if written == _USIZE_MAX:
raise ValueError("invalid histogram arguments")
edges = np.linspace(lo, hi, n_bins + 1, dtype=np.float64)
return counts, edges
Expand Down Expand Up @@ -598,7 +609,7 @@ def range_indices(
hi_y,
out.ctypes.data_as(_U32_P),
)
if written == np.iinfo(np.uint64).max:
if written == _USIZE_MAX:
raise ValueError("invalid range_indices arguments")
return out[:written].copy()

Expand Down
3 changes: 3 additions & 0 deletions python/fastcharts/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -1633,11 +1633,14 @@ def to_png(

def memory_report(self) -> dict[str, Any]:
"""§27: every byte class itemized; if it isn't in the report it isn't real."""
from . import interaction # method-local: no load-time cycle

spec, blob = self.build_payload()
report = self.store.memory_report()
report["transport_bytes_first_paint"] = len(blob)
n_total = sum(t.n_points for t in self.traces) or 1
report["transport_bytes_per_point"] = len(blob) / n_total
report["pyramid_bytes"] = interaction.pyramid_report_bytes(self)
report["backend"] = kernels.BACKEND
return report

Expand Down
48 changes: 42 additions & 6 deletions python/fastcharts/interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from __future__ import annotations

import operator
import weakref
from typing import TYPE_CHECKING, Any, Optional

import numpy as np
Expand Down Expand Up @@ -222,8 +223,46 @@ def _ensure_pyramid(t) -> int | None:
# (bin_2d's window is half-open).
x1 += (x1 - x0) * 1e-9
y1 += (y1 - y0) * 1e-9
t._pyr_handle = kernels.pyramid_build(t.x.values, t.y.values, x0, x1, y0, y1, PYRAMID_BASE_DIM)
return t._pyr_handle or None
handle = kernels.pyramid_build(t.x.values, t.y.values, x0, x1, y0, y1, PYRAMID_BASE_DIM)
t._pyr_handle = handle
if handle:
# §27: the pyramid is native-side memory owned by this trace. Tie its
# lifetime to the Trace object so a discarded Figure (the notebook
# cell-re-run pattern) frees it instead of leaking it in the
# process-lifetime registry.
t._pyr_finalizer = weakref.finalize(t, kernels.pyramid_free, handle)
return handle or None


def _free_pyramid(t) -> None:
"""Free the trace's pyramid now and disarm its GC finalizer.

Resets the handle to None ("never tried") so the next far-out view
rebuilds lazily; safe to call when no pyramid was ever built.
"""
fin = getattr(t, "_pyr_finalizer", None)
if fin is not None:
fin() # runs pyramid_free exactly once; later GC becomes a no-op
t._pyr_finalizer = None
elif getattr(t, "_pyr_handle", None):
kernels.pyramid_free(t._pyr_handle)
t._pyr_handle = None


def _pyramid_resident_bytes(base_dim: int = PYRAMID_BASE_DIM) -> int:
"""Exact native bytes of one count pyramid: u32 levels from base_dim²
halving per side down to 1² (mirrors tiles.rs level construction)."""
total, dim = 0, base_dim
while True:
total += dim * dim * 4
if dim == 1:
return total
dim >>= 1


def pyramid_report_bytes(fig) -> int:
"""§27 memory-report line: native bytes held by live trace pyramids."""
return sum(_pyramid_resident_bytes() for t in fig.traces if getattr(t, "_pyr_handle", 0))


def _encode_log_u8(grid: np.ndarray, gmax: float) -> bytes:
Expand Down Expand Up @@ -577,10 +616,7 @@ def _channel_tail(ch: Any, values: Any, name: str) -> Optional[np.ndarray]:
if size_tail is not None:
t.size_ch.values = np.concatenate([t.size_ch.values, size_tail])

handle = getattr(t, "_pyr_handle", None)
if handle:
kernels.pyramid_free(handle)
t._pyr_handle = None # lazily rebuilt; n grew so "not applicable" may flip
_free_pyramid(t) # lazily rebuilt; n grew so "not applicable" may flip
lod.exit_drill(t)

spec, blob = fig.build_payload()
Expand Down
16 changes: 16 additions & 0 deletions scripts/check_claim_guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
DEFAULT_DOCS = (
"pyproject.toml",
"README.md",
"SECURITY.md",
"CONTRIBUTING.md",
"docs/api-examples.md",
"docs/benchmark.md",
"docs/chart-roadmap.md",
Expand Down Expand Up @@ -56,6 +58,10 @@
r"\b\d+(?:\.\d+)?\s*x\s+(?:faster|slower|smaller|larger|less|more)\b",
re.IGNORECASE,
)
# The project has lived under earlier names (charts-exp, personal forks).
# Public docs must point at the canonical repository — a stale URL here means
# security reports, clones, and badges land on the wrong repo.
STALE_REPO_RE = re.compile(r"github\.com/Alek99|app\.codspeed\.io/Alek99|charts-exp", re.IGNORECASE)

POLICY_WORDS = re.compile(
r"\b(do not|don't|must|should|guardrail|policy|claim|goal|planned|target|"
Expand Down Expand Up @@ -111,6 +117,16 @@ def _findings_for_file(path: Path) -> list[Finding]:
findings: list[Finding] = []
for index, line in enumerate(lines):
window = _line_window(lines, index)
if STALE_REPO_RE.search(line):
# No policy-context exemption: a stale repo identity is always wrong.
findings.append(
Finding(
path,
index + 1,
"stale repository identity; use github.com/reflex-dev/reviz",
line,
)
)
if BROAD_SUPERLATIVE_RE.search(line) and not _is_policy_or_negative_context(window):
findings.append(
Finding(
Expand Down
78 changes: 78 additions & 0 deletions scripts/check_release_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Release gate: the pushed tag, pyproject version, and CHANGELOG must agree.

The wheel/sdist verifiers check everything *inside* the artifacts, but nothing
else stops `git tag v0.2.0` from publishing whatever version happens to sit in
pyproject.toml under a mismatched tag. This runs first in the publish job on
tag pushes: the tag must equal `v` + the pyproject version, and CHANGELOG.md
must carry a dated entry for that version (an "unreleased" heading fails —
date it as part of cutting the release).
"""

from __future__ import annotations

import argparse
import os
import re
import sys
import tomllib
from pathlib import Path
from typing import Optional

ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PYPROJECT = ROOT / "pyproject.toml"
DEFAULT_CHANGELOG = ROOT / "CHANGELOG.md"


def check_release(tag: str, pyproject: Path, changelog: Path) -> list[str]:
errors: list[str] = []
try:
version = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["version"]
except (OSError, tomllib.TOMLDecodeError, KeyError) as exc:
return [f"cannot read project version from {pyproject}: {exc}"]
if tag != f"v{version}":
errors.append(
f"tag {tag!r} does not match pyproject version {version!r} (expected 'v{version}')"
)
try:
text = changelog.read_text(encoding="utf-8")
except OSError as exc:
errors.append(f"cannot read {changelog}: {exc}")
return errors
# Keep-a-Changelog heading with a real date (this repo separates with an
# em dash; accept a plain hyphen too): `## [X.Y.Z] — 2026-07-09`.
dated = re.compile(rf"^## \[{re.escape(version)}\] [—-] \d{{4}}-\d{{2}}-\d{{2}}\s*$", re.M)
if not dated.search(text):
errors.append(
f"CHANGELOG.md has no dated '## [{version}]' entry — date the "
"release section before tagging"
)
return errors


def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--tag",
default=os.environ.get("GITHUB_REF_NAME", ""),
help="release tag (defaults to $GITHUB_REF_NAME)",
)
parser.add_argument("--pyproject", type=Path, default=DEFAULT_PYPROJECT)
parser.add_argument("--changelog", type=Path, default=DEFAULT_CHANGELOG)
args = parser.parse_args(argv)

if not args.tag:
print("release version gate: no tag provided (--tag or GITHUB_REF_NAME)", file=sys.stderr)
return 1
errors = check_release(args.tag, args.pyproject, args.changelog)
if errors:
print("release version gate failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
return 1
print(f"release version gate OK: {args.tag} matches pyproject and CHANGELOG")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading