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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ in the README).
## [Unreleased]

### Added
- **Dashboard context governor**: browsers cap live WebGL contexts per page
(~16 in Chrome) and LRU-evict the oldest on overflow, which permanently
blanked the earliest charts of a 20+/50-chart dashboard. The render client
now keeps itself inside a context budget (default 12,
`window.FASTCHARTS_CONTEXT_BUDGET` to override): at budget, the
least-recently-visible off-screen chart releases its own context
(`WEBGL_lose_context`, a controlled loss the existing restore machinery
undoes) and re-acquires when scrolled back into view — including canvas-swap
recovery for real browser evictions. Under the budget nothing releases, so
small pages are unaffected. Every decision is observable: `data-fc-ctx` on
the canvas reads live/released/lost. The dashboard benchmark now
settle-waits each scrolled chart (reporting per-visit recovery latency),
classifies governed releases vs evictions, adds a `governed` health tier,
and reports a `visible_stable_chart_ceiling`. Measured (Chrome/macOS): the
10/20/50 sweep goes from 16-of-50 permanently blank to 50-of-50 nonblank
when visited, recovery p95 ~8 ms, with 10-chart dashboards byte-identical
in behavior and heap/render times unchanged.
- **Stratified sampling in the native core** (ABI v10,
`fc_stratified_sample_mask` / `kernels.stratified_sample_mask`).
`lod.stratified_sample_keep_mask` — the category-aware mask behind
Expand Down
76 changes: 68 additions & 8 deletions benchmarks/bench_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,10 @@ def _probe_js() -> str:
const state = {lost: false};
view.canvas.addEventListener("webglcontextlost", () => {
state.lost = true;
contextEvents.push({id: payload.id, type: "lost", phase, at_ms: performance.now() - t0});
contextEvents.push({
id: payload.id, type: "lost", phase, at_ms: performance.now() - t0,
governed: view.canvas.dataset.fcCtx === "released",
});
});
view.canvas.addEventListener("webglcontextrestored", () => {
state.lost = false;
Expand Down Expand Up @@ -180,10 +183,24 @@ def _probe_js() -> str:
phase = "scroll";
const scrollStart = performance.now();
const scrollNonblankIds = [];
const scrollRecoveryMs = [];
// Context recovery is asynchronous (IntersectionObserver delivery ->
// restore/rebuild -> draw), so each visit settles until the chart paints
// or a hard deadline passes. The settle time IS the user-visible
// recovery latency for a chart scrolled back into view.
for (const slot of slots) {
slot.cell.scrollIntoView({block: "center", inline: "center"});
await new Promise((resolve) => setTimeout(resolve, 0));
if (nonblankPixels(slot) > 0) scrollNonblankIds.push(slot.id);
const arriveMs = performance.now();
let lit = nonblankPixels(slot) > 0;
while (!lit && performance.now() - arriveMs < 400) {
await new Promise((resolve) => requestAnimationFrame(resolve));
await new Promise((resolve) => setTimeout(resolve, 0));
lit = nonblankPixels(slot) > 0;
}
if (lit) {
scrollNonblankIds.push(slot.id);
scrollRecoveryMs.push(performance.now() - arriveMs);
}
}
window.scrollTo(0, 0);
await new Promise((resolve) => setTimeout(resolve, 0));
Expand All @@ -202,20 +219,38 @@ def _probe_js() -> str:
const heapAfter = performance.memory ? performance.memory.usedJSHeapSize : null;
const lostEvents = contextEvents.filter((event) => event.type === "lost");
const restoredEvents = contextEvents.filter((event) => event.type === "restored");
const governedLostEvents = lostEvents.filter((event) => event.governed === true);
const uniqueIds = (events) => Array.from(new Set(events.map((event) => event.id)));
const currentlyLostIds = slots
.filter((slot) => slot.view && contextLost(slot))
.map((slot) => slot.id);
const ctxState = (slot) =>
slot.view && slot.view.canvas && slot.view.canvas.dataset
? slot.view.canvas.dataset.fcCtx || null
: null;
// End-state split (§28): "released" is a governed, recoverable release;
// "lost" is a browser-side eviction the governor could not prevent.
const releasedChartIds = slots.filter((slot) => ctxState(slot) === "released").map((s) => s.id);
const evictedChartIds = slots.filter((slot) => ctxState(slot) === "lost").map((s) => s.id);
const createdCharts = slots.length - creationFailureIds.length;
const fullyNonblank =
createdCharts === slots.length &&
initialNonblankIds.length === slots.length &&
scrollNonblankIds.length === slots.length &&
lostEvents.length === 0 &&
currentlyLostIds.length === 0;
// "governed": above the context budget, but every chart was created,
// every context loss was a governed release, and every chart proved
// nonblank while visited — the dashboard is fully usable, off-screen
// charts just hold no context.
const governedHealth =
!fullyNonblank &&
createdCharts === slots.length &&
scrollNonblankIds.length === slots.length &&
governedLostEvents.length === lostEvents.length;
fcReport("FC_DASHBOARD", {
status: "ok",
render_status: fullyNonblank ? "complete" : "partial",
render_status: fullyNonblank ? "complete" : governedHealth ? "governed" : "partial",
fully_nonblank: fullyNonblank,
render_ms: renderMs,
navigation_ready_ms: navigationReadyMs,
Expand All @@ -235,6 +270,10 @@ def _probe_js() -> str:
scroll_nonblank_charts: scrollNonblankIds.length,
scroll_nonblank_chart_ids: scrollNonblankIds,
scroll_blank_chart_ids: complement(scrollNonblankIds),
scroll_recovery_p95_ms: fcPercentile(scrollRecoveryMs, 95),
governed_context_lost_events: governedLostEvents.length,
released_chart_ids: releasedChartIds,
evicted_chart_ids: evictedChartIds,
context_lost_events: lostEvents.length,
context_restored_events: restoredEvents.length,
context_lost_chart_ids: uniqueIds(lostEvents),
Expand Down Expand Up @@ -305,6 +344,10 @@ def run(*, chart_counts: list[int], chromium: str | None = None) -> dict[str, An
"scroll_nonblank_charts",
"scroll_nonblank_chart_ids",
"scroll_blank_chart_ids",
"scroll_recovery_p95_ms",
"governed_context_lost_events",
"released_chart_ids",
"evicted_chart_ids",
"context_lost_events",
"context_restored_events",
"context_lost_chart_ids",
Expand All @@ -323,6 +366,16 @@ def run(*, chart_counts: list[int], chromium: str | None = None) -> dict[str, An
for row in rows
if str(row.get("status", "")).startswith("ok") and row.get("fully_nonblank") is True
]
# A "governed" row is fully usable — every chart was created and proved
# nonblank while visited; off-screen charts hold no context by design —
# so it raises the *visible-stable* ceiling even though it is not
# loss-free.
visible_stable_counts = [
row["chart_count"]
for row in rows
if str(row.get("status", "")).startswith("ok")
and row.get("render_status") in {"complete", "governed"}
]
return {
"schema_version": SCHEMA_VERSION,
"kind": "dashboard-browser",
Expand All @@ -331,6 +384,9 @@ def run(*, chart_counts: list[int], chromium: str | None = None) -> dict[str, An
"tracked_categories": categories_for(DASHBOARD_CATEGORY_IDS),
"attempted_chart_counts": list(chart_counts),
"chart_count_ceiling": max(successful_counts) if successful_counts else None,
"visible_stable_chart_ceiling": (
max(visible_stable_counts) if visible_stable_counts else None
),
"rows": rows,
}

Expand Down Expand Up @@ -361,22 +417,25 @@ def to_markdown(report: dict[str, Any]) -> str:
"Tracked in this run: "
+ ", ".join(f"`{category['id']}`" for category in report["tracked_categories"]),
"",
f"Stable loss-free chart-count ceiling: `{report.get('chart_count_ceiling')}`.",
f"Stable loss-free chart-count ceiling: `{report.get('chart_count_ceiling')}`; "
f"visible-stable ceiling (complete or governed): "
f"`{report.get('visible_stable_chart_ceiling')}`.",
"",
"## Results",
"",
"| scenario | charts | prep | navigation ready | render | scroll pass | redraw submit p95 | active | JS heap | payload | html | initial/scroll nonblank | loss/restore | health |",
"|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|",
"| scenario | charts | prep | navigation ready | render | scroll pass | recovery p95 | redraw submit p95 | active | JS heap | payload | html | initial/scroll nonblank | loss(gov)/restore | health |",
"|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|",
]
for row in report["rows"]:
lines.append(
"| {scenario} | {count} | {prep} | {navigation} | {render} | {scroll} | {idle} | {active} | {heap} | {payload} | {html} | {nonblank}/{scroll_nonblank} | {lost}/{restored} | {health} |".format(
"| {scenario} | {count} | {prep} | {navigation} | {render} | {scroll} | {recovery} | {idle} | {active} | {heap} | {payload} | {html} | {nonblank}/{scroll_nonblank} | {lost}({governed})/{restored} | {health} |".format(
scenario=row["scenario"],
count=row["chart_count"],
prep=_fmt_ms(row.get("payload_prep_ms")),
navigation=_fmt_ms(row.get("navigation_ready_ms")),
render=_fmt_ms(row.get("render_ms")),
scroll=_fmt_ms(row.get("scroll_pass_ms")),
recovery=_fmt_ms(row.get("scroll_recovery_p95_ms")),
idle=_fmt_ms(row.get("steady_redraw_p95_ms")),
active=row.get("steady_redraw_active_charts", "—"),
heap=_fmt_bytes(row.get("js_heap_bytes")),
Expand All @@ -385,6 +444,7 @@ def to_markdown(report: dict[str, Any]) -> str:
nonblank=row.get("nonblank_charts", "—"),
scroll_nonblank=row.get("scroll_nonblank_charts", "—"),
lost=row.get("context_lost_events", "—"),
governed=row.get("governed_context_lost_events", "—"),
restored=row.get("context_restored_events", "—"),
health=row.get("render_status", row.get("status", "unknown")),
)
Expand Down
4 changes: 3 additions & 1 deletion docs/benchmark.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ also declares `tooltip_sample_count`, and eligible rows must report that exact
away fails verification.
`bench_dashboard.py` attempts 10/20/50 mixed dashboards and reports payload prep,
navigation readiness, JS heap, redraw-submission p95, per-chart context loss and
restore events, initial/scrolled nonblank IDs, and the largest stable loss-free
restore events (governed releases labeled separately from browser evictions),
initial/scrolled nonblank IDs, per-visit scroll recovery latency, and the
largest stable loss-free
count. Partial rows retain their timing and memory metrics. `bench_workflows.py` covers ingestion, streaming/pyramid
invalidation, and separate HTML/SVG/native-PNG/Chromium-PNG export rows. All three emit schema-versioned JSON with
environment metadata and benchmark category IDs.
Expand Down
10 changes: 10 additions & 0 deletions docs/production-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,16 @@ Not yet safe:
- Cross-browser/perceptual rendering conformance.
- Exact-marker interaction for every possible 100M-point zoom path.
- Production Reflex state integration as a first-class API.
- More than ~12 charts *simultaneously in view* holding live WebGL contexts.
Browsers cap live contexts per page (~16 in Chrome); the render client's
context governor keeps fastcharts inside a budget (default 12) by having
the least-recently-visible off-screen chart release its context and
re-acquire on scroll-into-view. Measured (`benchmarks/bench_dashboard.py`,
2026-07-09, Chrome/macOS): 10/20/50-chart dashboards are all fully usable —
every chart nonblank when visited, recovery p95 ~8 ms, heap sublinear
(28 MB at 50 charts) — but a layout keeping more than the budget visible
at once can still hit browser-side eviction, so do not claim unbounded
simultaneous live charts.

## Hardening Backlog

Expand Down
Loading
Loading