From 8c123357ee243530c346c7957a0aaa65bf90749c Mon Sep 17 00:00:00 2001 From: youo he Date: Wed, 21 Oct 2026 16:53:45 +0000 Subject: [PATCH 01/11] Correct renderer release references after editor integration --- README.md | 3 ++- docs/HOSTS.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 894514f..7b9e03c 100644 --- a/README.md +++ b/README.md @@ -432,7 +432,8 @@ rates are reported separately; they are not monitor FPS measurements. The native extension uses the published **figgy-renderer 0.18.1** and **figgy-model 0.12.1** crates from crates.io, pinned in `Cargo.lock`. The packaged browser kernel is built from their upstream release commit -`3a0a6a3` with a recorded web-binding patch for non-borrowing GPU completion. +`f3f569c4c0e32d3d0682632444b6fe7ce84cba8d` with a recorded web-binding patch +for non-borrowing GPU completion and current annotation hit geometry. Asset provenance checks pair the registry versions and checksums with that source revision and verify the binding patch hash. Rust owns chart layout, rendering, picking and drag policies; this repository adds the NumPy-facing API, diff --git a/docs/HOSTS.md b/docs/HOSTS.md index 1b88c9f..8bcc865 100644 --- a/docs/HOSTS.md +++ b/docs/HOSTS.md @@ -2,7 +2,8 @@ The figgyplot 0.3.0 source uses the published `figgy-renderer 0.18.1` and `figgy-model 0.12.1` crates from crates.io. `Cargo.lock` pins their versions -and checksums; packaged WASM uses their release commit `3a0a6a3`. +and checksums; packaged WASM uses their release commit +`f3f569c4c0e32d3d0682632444b6fe7ce84cba8d`. Provenance checks preserve this source pairing; see [publishing](PUBLISHING.md). Chart layout, styles, data rendering, hit maps and drag policies remain renderer APIs. No renderer source is From ff950ecd76bb852eca77666c24a5b35f4de7a82e Mon Sep 17 00:00:00 2001 From: youo he Date: Wed, 21 Oct 2026 18:51:57 +0000 Subject: [PATCH 02/11] Add opt-in durable chart review sessions and agent adapter --- README.md | 7 + docs/REVIEW_MODE.md | 136 ++++ docs/REVIEW_MODE_VALIDATION.md | 56 ++ docs/images/review-mode-browser.png | Bin 0 -> 49493 bytes examples/review_workflow.py | 31 + pyproject.toml | 2 +- python/figgyplot/_review_diff.py | 121 ++++ python/figgyplot/_review_runner.py | 30 + python/figgyplot/_review_store.py | 150 ++++ python/figgyplot/editor.css | 6 + python/figgyplot/notebook.py | 5 +- .../figgyplot/review-feedback-v1.schema.json | 257 +++++++ python/figgyplot/review.py | 638 ++++++++++++++++++ .../skills/figgyplot-review/SKILL.md | 46 ++ python/figgyplot/webgpu.js | 36 +- python/figgyplot/widget.py | 22 +- src/bounded_host.rs | 1 + src/desktop.rs | 45 ++ src/editor.rs | 83 +++ src/lib.rs | 7 + src/radial_editor.rs | 1 + tests/jupyter_harness.py | 11 +- tests/test_interaction_parity.py | 146 ++++ tests/test_review.py | 370 ++++++++++ 24 files changed, 2199 insertions(+), 8 deletions(-) create mode 100644 docs/REVIEW_MODE.md create mode 100644 docs/REVIEW_MODE_VALIDATION.md create mode 100644 docs/images/review-mode-browser.png create mode 100644 examples/review_workflow.py create mode 100644 python/figgyplot/_review_diff.py create mode 100644 python/figgyplot/_review_runner.py create mode 100644 python/figgyplot/_review_store.py create mode 100644 python/figgyplot/review-feedback-v1.schema.json create mode 100644 python/figgyplot/review.py create mode 100644 python/figgyplot/skills/figgyplot-review/SKILL.md create mode 100644 tests/test_review.py diff --git a/README.md b/README.md index 7b9e03c..6b23626 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,13 @@ in [Editor commands and history](docs/EDITOR_COMMANDS.md). ## Edit once, reuse across datasets +For an optional multi-Figure agent review workflow, use +[`figgyplot.review.Session`](docs/REVIEW_MODE.md) and the +[review controller example](examples/review_workflow.py). Review comments are +separate from chart annotations. Approval checkpoints exact SSOT edits and binary +source data before closing the view; the complete next round is published after +source execution and rendering validation. The ordinary editor needs no session. + Run the example shown in the GIF: ```bash diff --git a/docs/REVIEW_MODE.md b/docs/REVIEW_MODE.md new file mode 100644 index 0000000..7b28651 --- /dev/null +++ b/docs/REVIEW_MODE.md @@ -0,0 +1,136 @@ +# Review sessions (protocol 1) + +Status: unreleased implementation for #18, pending API review. Use a wheel built +from this branch; the previously published figgyplot 0.3.0 does not include it. +See the [validation record](REVIEW_MODE_VALIDATION.md) for measured test coverage. + +Issue #18 adds an explicit `figgyplot.review.Session` lifecycle. Ordinary +`Figure`, `show`, notebook editing and Copy Python do not create review files, +start agents or require an LLM runtime. + +## API and lifecycle + +```python +from figgyplot.review import Session + +session = Session(source={"entrypoint": "plots.py", "function": "build", "arguments": {}}) +fig = session.figure(id="signal") +fig.axes[0].plot([0, 1], [1, 3], id="measurement") +session.present() # native windows; use session.widget(fig) in a notebook +session.run_events() # handles review requests while pumping native windows +``` + +Native and notebook review panels contain a dedicated **Review comments** input, +**Request changes**, and **Accept**. Chart text/annotations stay chart data. +Approval flushes input, checks the displayed revision, publishes durable feedback, +then closes the approved view. Failed publication keeps the view editable with +the comment intact. Closing a window manually leaves its disposition pending. +`session.submit(figure_id, disposition, comments=..., expected_revision=...)` +is the synchronous native/headless interface; `await session.submit_async(...)` +first acknowledges notebook edits. `skip` is an explicit programmatic disposition. +`session.start()` seals a complete set for headless use without opening views. +`session.comment(figure_id, text, target=...)` appends a targeted API comment. +The editable UI comment has its own channel; changing or clearing it preserves +API comments and their targets. Both channels are included in feedback. +Runtime revision counters are scoped to recorded Figure instance IDs. Recovery +creates a new instance; baseline and approved instance/revision pairs therefore +remain explicit. Persistent hashes and stable IDs identify the compared states. +The session round number, rather than a process-local Figure counter, drives +successor publication and cleanup. + +Figure IDs use the existing document identity namespace. Choose a Figure ID +different from its panel IDs (the default panel is `main`). +Review mode supports one active interactive view per Figure in a round. A second +native window is rejected; duplicate notebook views must be closed before +approval, so another view's pending input cannot be discarded. Native PNG +fallback editors must also be closed before review approval. Ordinary manual +Figures retain their existing multiple-view behavior. + +All required Figures form one sealed review set. Only when all have an explicit +disposition can `session.events()` emit the batch's stable work ID and manifest +location. Repeated polling returns the same pending work. Accepting every Figure +without edits/comments finishes the round without requesting code changes. +Source writes belong to one agent/staging workspace, outside the UI event loop. + +`Session.open(cache_dir)` resumes a session. `restore(figure_id)` reconstructs a +Figure from the last complete settings and binary payloads. It does not depend +on the original Python process, GPU resources, notebook transfer buffers or an +unbounded undo journal. `cancel()` preserves the last recoverable round. + +`regenerate(work_id, superseded=...)` loads the recorded source entry point, +calls its `build(session, **arguments)` function, validates every candidate by +rendering, and publishes a complete new round. Build/render failure preserves +the pending work and previous checkpoint. The source hash must stay unchanged +during validation. Stable Figure IDs must remain present unless explicitly +superseded. Human property changes must be reproduced by source, or explicitly +superseded with a reason. Merely acknowledging an operation is insufficient. +The next complete set is shown only after publication. Agent integration can +use the CLI and filesystem protocol without importing private Python objects. +Source imports, recorded working directory and rendering execute in an isolated +Python worker process, with a configurable timeout. They cannot change the +controller's working directory, module state or UI event loop. + +## Feedback and persistence + +The versioned session manifest carries session/round/work IDs, source entry point, +function, arguments and digest, per-Figure schema/renderer versions, baseline and +approved revisions, full baseline/approved settings, canonical persistent hashes, +exact edit operations, separately entered comments, and binary references. +Runtime hover/selection is removed using the same Rust projection as edit history. +Operations use JSON Pointer paths, before/after existence and values, and stable +identity selectors. Object creation, removal and ordering are explicit operations; +there is no guessed gesture delta. The baseline and approved snapshots remain +available to interpret derived layout/link changes. + +Numeric input is cached at upload time as content-addressed NumPy binary files +(`allow_pickle=False`), with dtype, shape, byte size and SHA-256. Matrix rows and +vector/mapping columns retain their source IDs; bounded-family values reside in +the complete settings document. Settings-only revisions reuse immutable files. +No permanent native CPU array copy or GPU readback is introduced. + +Create sessions in a dedicated empty directory, or omit `cache_dir` to allocate +one. `Session.open` requires an existing manifest. Cache creation rejects +nonempty foreign directories before creating files or running cleanup. + +Writers and readers hold a process/file lock. Payloads are flushed before a +temporary manifest is flushed and atomically replaced; the directory is flushed +where supported. Only the published manifest defines a complete revision. +Interrupted writes cannot replace the previous complete state. Garbage collection +runs after commits and on reopen/access. It retains the newest complete round, +unfinished work and every referenced baseline/data file. Higher complete rounds +allow reclamation of superseded completed rounds in the same session lineage. +Readers and active writers are protected by the same lock. Cleanup never depends +on an agent acknowledgement or compares unrelated Figure revision counters. + +The filesystem adapter is local and agent-neutral. A disconnected agent resumes +by polling `python -m figgyplot.review events CACHE`; raw arrays never enter the +feedback text. `feedback CACHE WORK` reads structured feedback. Before editing +source, `claim CACHE WORK --worker ID` obtains a renewable writer lease; +`regenerate CACHE WORK --claim-token TOKEN` validates updated source and publishes +its next round. Expired/stale leases cannot publish; duplicate completion returns +the existing successor. `status CACHE` reports pending Figures and rerun errors. +The bundled companion skill documents protocol/version discovery and this loop. +Jupyter controllers use `await session.watch()` to display published successors; +native controllers use `run_events(on_status=...)` to observe progress while +their approved windows are closed. + +The [bundled skill](../python/figgyplot/skills/figgyplot-review/SKILL.md) is also +available through `python -m figgyplot.review skill`. Agent hosts use their +existing terminal/tool runner to poll events while a human reviews the figures. +The adapter does not require a vendor model SDK. A disconnected agent resumes +the same durable work ID instead of relying on an in-memory notification. + +Run adapter commands on the rendering host through the agent's execution tool. +Remote agents need an explicit execution bridge with access to the cache, +recorded source paths and Python environment. A manifest location alone does not +connect a remote chat service. The validation worker needs a native GPU or a +software GPU runtime, including for notebook-built candidates; the notebook +controller itself continues to use its GPU-free document model. + +The cache preserves registered renderer columns and canonical bounded values. +For histograms and boxes these include counts/edges or computed statistics; +original samples, external computation inputs and random seeds belong to the +source builder's reproducible inputs/arguments. The checkpoint restores the +chart without inferring those computations from its rendered result. + +![One of two validated next-round Figures in the live browser review controller](images/review-mode-browser.png) diff --git a/docs/REVIEW_MODE_VALIDATION.md b/docs/REVIEW_MODE_VALIDATION.md new file mode 100644 index 0000000..ac3125b --- /dev/null +++ b/docs/REVIEW_MODE_VALIDATION.md @@ -0,0 +1,56 @@ +# Review mode validation + +This records local validation of the unreleased #18 implementation. Required +GitHub checks still run on the draft PR's exact commit. + +## Final wheel + +- CPython 3.12.14, NumPy 2.5.4, Linux, Mesa software Vulkan, Chromium 154. +- Wheel SHA-256: `93e14460b0430002f65d284b11dd99532864c52bc2ec6006f8fd215ff16103b2`. +- Every packaged Python, JavaScript, CSS, schema and skill file matches source. +- **46 review tests passed** in 134.33 seconds: 37 core cases and nine real native + or Jupyter input cases. UI tests ran with Xvfb/Openbox at 1600 x 1000 and the + required interaction/timing diagnostics enabled. +- **Four existing editor/layout probes passed** in 72.51 seconds, including + resize at two scales, multiline text formatting and inactive overlay editing. +- **117 CI preflight tests passed**. Cargo format/check, JavaScript syntax, + installed-package checks and editor-control inventory passed. + +Run the review cases with the installed wheel and the normal CI UI environment: + +```sh +python -m pytest -vv tests/test_review.py tests/test_interaction_parity.py -k review +``` + +The review cases compare the actual accepted baseline and approved settings, +then restore from the manifest and binary files. They cover all chart families +on both backends, unchanged binary reuse, corruption, stale revisions/instances, +writer leases, disconnect, duplicate delivery, concurrent comments, failed and +timed-out source/render workers, explicit edit supersession and completed-round +cleanup. Native and browser UI cases submit through real controls, retain pending +annotations and comments on storage failure, retry approval, and display every +Figure in the validated next round. + +Additional regressions first failed against the preceding wheel, then passed: + +- UI comments no longer erase separately targeted API comments. +- A close observer may read session state after a post-rename durability retry; + approval does not close views while holding the manifest file lock. +- Cache creation cannot claim a nonempty foreign directory, and opening a missing + cache creates no files. +- Native review buttons remain inside the surface with long comments. The final + input check pastes 40 actual lines and verifies the complete stored text. +- Duplicate notebook views cannot approve while another view has pending input; + duplicate native windows and reopening approved native Figures are rejected. + +## Earlier full-suite run + +Before those final review-specific fixes and six added regressions, one complete +UI-enabled invocation passed **633 tests in 2460.86 seconds**. The final wheel was +then rebuilt and the 46 review cases, four existing layout probes and preflight +checks above were rerun. This is not a claim that the final wheel completed a +second full-suite invocation locally. The draft PR's CI supplies that check. + +No thresholds, gesture assertions, pixel tolerances or CI time limits were relaxed. +The notebook harness now accepts an explicit expected initial view count and +requires every such view to reach Ready within the existing startup budget. diff --git a/docs/images/review-mode-browser.png b/docs/images/review-mode-browser.png new file mode 100644 index 0000000000000000000000000000000000000000..3656a5d1603f0bf0b4af350b384845b61177be88 GIT binary patch literal 49493 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%0A!4HG$!Sfh9Ij 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 F`5&rFunzzL literal 0 HcmV?d00001 diff --git a/examples/review_workflow.py b/examples/review_workflow.py new file mode 100644 index 0000000..e101bad --- /dev/null +++ b/examples/review_workflow.py @@ -0,0 +1,31 @@ +"""Native review controller; an external agent polls the printed cache path.""" +import argparse +from pathlib import Path + +import numpy as np +from figgyplot.review import Session + + +def build(session): + x=np.linspace(0,10,101) + density=session.figure(id='density') + density.axes[0].plot(x,np.exp(-x/3),id='density-values',linewidth=2.5) + density.axes[0].set_title('Density profile') + field=session.figure(id='field-plot') + field.axes[0].imshow(np.sin(x[:20,None])*np.cos(x[None,:20]),id='field-values') + field.axes[0].set_title('Spatial distribution') + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cache',help='Resume an existing session cache') + args=parser.parse_args() + session=Session.open(args.cache) if args.cache else Session(source={'entrypoint':str(Path(__file__).resolve())}) + with session: + if not args.cache:build(session) + print('Review cache:',session.cache_dir,flush=True) + print('Agent command: python -m figgyplot.review events '+str(session.cache_dir),flush=True) + session.present();session.run_events(on_status=lambda status:print(status,flush=True)) + + +if __name__=='__main__':main() diff --git a/pyproject.toml b/pyproject.toml index 59b9d51..cf08eae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ test = ["pytest>=8", "pillow>=10", "anywidget>=0.9,<1"] [tool.maturin] python-source = "python" module-name = "figgyplot._native" -include = ["python/figgyplot/*.js", "python/figgyplot/*.css", "python/figgyplot/licenses/*.txt", "python/figgyplot/assets/*", "THIRD_PARTY_NOTICES.md", { path = "docs/*.md", format = "sdist" }, { path = "scripts/build-wasm.sh", format = "sdist" }, { path = "scripts/wasm-host.patch", format = "sdist" }, { path = "scripts/check_assets.py", format = "sdist" }, { path = "scripts/record_registry_provenance.py", format = "sdist" }, { path = "scripts/repro_upstream_colorbar/Cargo.toml", format = "sdist" }, { path = "scripts/repro_upstream_colorbar/README.md", format = "sdist" }, { path = "scripts/repro_upstream_colorbar/src/*.rs", format = "sdist" }] +include = ["python/figgyplot/review-feedback-v1.schema.json", "python/figgyplot/skills/figgyplot-review/SKILL.md", "python/figgyplot/*.js", "python/figgyplot/*.css", "python/figgyplot/licenses/*.txt", "python/figgyplot/assets/*", "THIRD_PARTY_NOTICES.md", { path = "docs/*.md", format = "sdist" }, { path = "scripts/build-wasm.sh", format = "sdist" }, { path = "scripts/wasm-host.patch", format = "sdist" }, { path = "scripts/check_assets.py", format = "sdist" }, { path = "scripts/record_registry_provenance.py", format = "sdist" }, { path = "scripts/repro_upstream_colorbar/Cargo.toml", format = "sdist" }, { path = "scripts/repro_upstream_colorbar/README.md", format = "sdist" }, { path = "scripts/repro_upstream_colorbar/src/*.rs", format = "sdist" }] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/python/figgyplot/_review_diff.py b/python/figgyplot/_review_diff.py new file mode 100644 index 0000000..653f6c8 --- /dev/null +++ b/python/figgyplot/_review_diff.py @@ -0,0 +1,121 @@ +"""Exact persistent changes with identity selectors for source reconciliation.""" +import copy +import json + +from ._review_store import digest + + +def persistent(state): + from ._native import persistent_settings_json + return json.loads(persistent_settings_json(json.dumps(state, allow_nan=False))) + + +def escape(value): + return str(value).replace('~', '~0').replace('/', '~1') + + +def _selector(path, item, index, document): + if not isinstance(item, dict): + return None + for key in ('series_id', 'id'): + if isinstance(item.get(key), str): + return {'key': key, 'value': item[key]} + if path == '/config/subplots': + return {'panel': document['identity']['panels'][index+1]['id']} + if path.endswith('/overlays') and path.startswith('/config'): + pieces = path.split('/') + owner = int(pieces[3])+1 if len(pieces)>3 and pieces[2]=='subplots' else 0 + return {'overlay': document['identity']['panels'][owner]['overlays'][index]} + return None + + +def resolve(document, path, selectors): + parts = path.split('/')[1:] + value = document + actual = [] + for encoded in parts: + key = encoded.replace('~1', '/').replace('~0', '~') + if isinstance(value, list): + selection = selectors.get('/'+'/'.join(actual)) + if selection: + if 'key' in selection: + key = next(i for i,v in enumerate(value) if v.get(selection['key']) == selection['value']) + elif 'panel' in selection: + key = next(i-1 for i,v in enumerate(document['identity']['panels']) if v['id']==selection['panel']) + else: + owner = int(actual[2])+1 if len(actual)>2 and actual[1]=='subplots' else 0 + key = document['identity']['panels'][owner]['overlays'].index(selection['overlay']) + else: + key = int(key) + value = value[key] + actual.append(escape(key)) + return value + + +def changes(before, after): + before, after = persistent(before), persistent(after) + result = [] + + def emit(op, path, a, b, selectors, **extra): + change = dict(op=op, path=path, before_exists=op!='add', after_exists=op!='remove', + before=copy.deepcopy(a), after=copy.deepcopy(b), selectors=copy.deepcopy(selectors), **extra) + change['id'] = digest(change) + change['object_id'] = 'figure/'+after['identity']['figure'] + for selector in selectors.values(): + change['object_id'] += '/'+str(selector.get('value', selector.get('panel', selector.get('overlay')))) + result.append(change) + + def walk(a, b, path, selectors): + if a == b: + return + if isinstance(a, dict) and isinstance(b, dict): + for key in sorted(set(a)|set(b)): + child = path+'/'+escape(key) + if key not in a: emit('add',child,None,b[key],selectors) + elif key not in b: emit('remove',child,a[key],None,selectors) + else: walk(a[key],b[key],child,selectors) + elif isinstance(a, list) and isinstance(b, list): + ka = [_selector(path,v,i,before) for i,v in enumerate(a)] + kb = [_selector(path,v,i,after) for i,v in enumerate(b)] + if (a or b) and all(v is not None for v in ka+kb) and len({json.dumps(v,sort_keys=True) for v in ka})==len(ka) and len({json.dumps(v,sort_keys=True) for v in kb})==len(kb): + current, keys = copy.deepcopy(a), ka[:] + for i in range(len(keys)-1,-1,-1): + if keys[i] not in kb: + emit('remove',path+'/'+str(i),current[i],None,dict(selectors, **{path:keys[i]})) + keys.pop(i);current.pop(i) + for i,key in enumerate(kb): + selected = dict(selectors, **{path:key}) + if key not in keys: + emit('add',path+'/'+str(i),None,b[i],selected) + keys.insert(i,key);current.insert(i,copy.deepcopy(b[i])) + else: + old = keys.index(key) + if old != i: + emit('move',path+'/'+str(i),current[old],current[old],selected, from_path=path+'/'+str(old)) + keys.insert(i,keys.pop(old));current.insert(i,current.pop(old)) + walk(current[i],b[i],path+'/'+str(i),selected) + elif len(a)==len(b): + for i,(old,new) in enumerate(zip(a,b)):walk(old,new,path+'/'+str(i),selectors) + else: + emit('replace',path,a,b,selectors) + else: + emit('replace',path,a,b,selectors) + walk(before,after,'',{}) + return result + + +def preserved(change, document): + try: + value = resolve(persistent(document), change['path'], change['selectors']) + except (KeyError, IndexError, StopIteration, ValueError, TypeError): + return not change['after_exists'] + if not change['after_exists']: + return False + if change['op'] == 'move': + # Selection resolves the item; order is checked against the new index. + parent, index = change['path'].rsplit('/',1) + try: + return resolve(persistent(document),parent,change['selectors'])[int(index)] == value + except (KeyError, IndexError, ValueError, TypeError): + return False + return value == change['after'] diff --git a/python/figgyplot/_review_runner.py b/python/figgyplot/_review_runner.py new file mode 100644 index 0000000..15f98ee --- /dev/null +++ b/python/figgyplot/_review_runner.py @@ -0,0 +1,30 @@ +"""Isolated review builder: source imports/cwd and GPU work stay out of the UI.""" +import hashlib +import os +from pathlib import Path +import runpy +import sys + +from .review import Session + + +def build(cache): + with Session.open(cache) as session: + session._validation=True + source=session.snapshot()['rounds'][-1]['source'] + os.chdir(source['cwd']) + sys.path.insert(0,str(Path(source['entrypoint']).parent)) + namespace=runpy.run_path(source['entrypoint'],run_name='__figgy_review__') + namespace[source['function']](session,**source['arguments']) + session.start() + for fid in list(session._figures): + fig=session._figures[fid] + if fig.backend!='native':fig=session.restore(fid,backend='native') + png=fig.to_png() + with session._store.locked(): + state=session._store.read() + state['rounds'][-1]['documents'][fid]['validated_png_sha256']=hashlib.sha256(png).hexdigest() + session._store.write(state) + + +if __name__=='__main__':build(sys.argv[1]) diff --git a/python/figgyplot/_review_store.py b/python/figgyplot/_review_store.py new file mode 100644 index 0000000..5f15421 --- /dev/null +++ b/python/figgyplot/_review_store.py @@ -0,0 +1,150 @@ +"""Crash-safe review manifests and immutable numeric payloads.""" +from contextlib import contextmanager +import hashlib +import io +import json +import os +from pathlib import Path +import re +import shutil +import tempfile +import threading +import time + +import numpy as np + + +def encoded(value): + return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False).encode('utf-8') + + +def digest(value): + return hashlib.sha256(encoded(value)).hexdigest() + + +def _sync_directory(path): + if hasattr(os, 'O_DIRECTORY'): + fd = os.open(str(path), os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def atomic_write(path, data): + fd, temporary = tempfile.mkstemp(prefix='.writing-', dir=str(path.parent)) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + _sync_directory(path.parent) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +class Store: + @staticmethod + def references(doc): + for key in ('columns','baseline_columns','bounded_data','baseline_bounded_data'): + yield from doc.get(key,{}).values() + + def __init__(self, path): + self.path = Path(path).resolve() + self.path.mkdir(parents=True, exist_ok=True) + (self.path/'data').mkdir(exist_ok=True) + self.mutex = threading.RLock() + self._seen_generation = None + + @contextmanager + def locked(self): + with self.mutex, (self.path/'.lock').open('a+b') as stream: + if os.name == 'nt': + import msvcrt + stream.seek(0) + if not stream.read(1): + stream.write(b'0'); stream.flush() + stream.seek(0) + msvcrt.locking(stream.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + fcntl.flock(stream, fcntl.LOCK_EX) + try: + yield + finally: + if os.name == 'nt': + stream.seek(0) + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(stream, fcntl.LOCK_UN) + + def read(self): + state = json.loads((self.path/'manifest.json').read_text(encoding='utf-8')) + if state.get('protocol') != 1: + raise ValueError('Unsupported review protocol; expected version 1') + if state['generation'] != self._seen_generation: + # Also complete durability after a writer lost its fsync response + # following rename. Every controller confirms a newly observed head. + _sync_directory(self.path/'data') + _sync_directory(self.path) + self._seen_generation=state['generation'] + return state + + def write(self, state): + state['generation'] += 1 + _sync_directory(self.path/'data') + atomic_write(self.path/'manifest.json', encoded(state)) + self._seen_generation=state['generation'] + + def put(self, array): + array = np.ascontiguousarray(array) + if array.dtype.kind not in 'fiu' or array.ndim != 1: + raise ValueError('Review columns must be one-dimensional numeric arrays') + array = array.astype(array.dtype.newbyteorder('<'), copy=False) + stream = io.BytesIO() + np.save(stream, array, allow_pickle=False) + data = stream.getvalue() + sha = hashlib.sha256(data).hexdigest() + path = self.path/'data'/(sha+'.npy') + if not path.exists(): + atomic_write(path, data) + elif hashlib.sha256(path.read_bytes()).hexdigest() != sha: + raise ValueError('Corrupt existing review data: '+sha) + return dict(file='data/'+path.name, sha256=sha, bytes=len(data), dtype=array.dtype.str, shape=list(array.shape)) + + def get(self, reference): + sha = reference['sha256'] + if not isinstance(sha, str) or not re.fullmatch('[0-9a-f]{64}', sha) or reference['file'] != 'data/'+sha+'.npy': + raise ValueError('Invalid review data reference') + data = (self.path/reference['file']).read_bytes() + if len(data) != reference['bytes'] or hashlib.sha256(data).hexdigest() != sha: + raise ValueError('Review data integrity check failed: '+sha) + array = np.load(io.BytesIO(data), allow_pickle=False) + if array.dtype.str != reference['dtype'] or list(array.shape) != reference['shape']: + raise ValueError('Review data dtype/shape differs from manifest') + return array + + def collect(self, state): + # Unfinished work owns its baseline and approval; retain it until a + # complete successor is committed. Each retained round is self-contained. + rounds = state['rounds'] + latest = max((r['number'] for r in rounds), default=0) + retained = [r for r in rounds if r['number'] == latest or r['status'] not in ('completed', 'cancelled')] + if len(retained) != len(rounds): + state['rounds'] = retained + self.write(state) + live = {ref['file'] for r in retained for doc in r['documents'].values() for ref in self.references(doc)} + for path in (self.path/'data').iterdir(): + if path.name.startswith('.writing-') or ('data/'+path.name not in live and re.fullmatch('[0-9a-f]{64}\\.npy', path.name)): + path.unlink() + for path in self.path.glob('.writing-*'): + path.unlink() + active={r.get('claim',{}).get('token') for r in retained + if r['status']=='ready' and r.get('claim',{}).get('expires',0)>time.time()} + for path in self.path.glob('candidate-*'): + match=re.fullmatch('candidate-([0-9a-f]{32})-.+',path.name) + if match and match[1] not in active and path.is_dir(): + try:shutil.rmtree(path) + except OSError:pass # Retry on access if an expired writer still has files open. diff --git a/python/figgyplot/editor.css b/python/figgyplot/editor.css index 9a3f268..065096b 100644 --- a/python/figgyplot/editor.css +++ b/python/figgyplot/editor.css @@ -93,3 +93,9 @@ .figgy-choice-swatch{display:inline-block;flex:0 0 18px;height:3px} .figgy-webgpu .figgy-choice-preview{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:4} .figgy-switch-chart{position:absolute;z-index:5;transform:translate(calc(-100% - 6px),6px);font:12px system-ui,sans-serif;border:1px solid #9bb8dc;border-radius:5px;padding:4px 7px;background:#fff;color:#234d82} +.figgy-review {border-top:1px solid #d2d8e1;padding:12px 16px;background:#f5f8fc;} +.figgy-review[hidden] {display:none;} +.figgy-review label {display:block;} +.figgy-review textarea {display:block;box-sizing:border-box;width:100%;margin:6px 0;resize:vertical;font:inherit;} +.figgy-review button {margin-right:8px;} +.figgy-review [data-review-status] {font-size:13px;} diff --git a/python/figgyplot/notebook.py b/python/figgyplot/notebook.py index 17a5ed4..b569d4c 100644 --- a/python/figgyplot/notebook.py +++ b/python/figgyplot/notebook.py @@ -21,7 +21,7 @@ def upload_column(self, id, array): little = np.asarray(array, dtype=array.dtype.newbyteorder("<")) self.columns[id] = (str(array.dtype), little.tobytes(order="C")) - def add_series_fitted(self, spec_json, config=None, bounds=None): + def cache_series_extents(self, spec_json): # Only bounded extent metadata survives transfer release, never sample arrays. spec = json.loads(spec_json) style = next(iter(spec['render_type'].values())) @@ -67,6 +67,9 @@ def array(name): positive = values[values>0] extents.append((float(values.min()), float(values.max()), float(positive.min()) if positive.size else None)) self.model.cache_fit_extent(spec['series_id'], *extents) + + def add_series_fitted(self, spec_json, config=None, bounds=None): + self.cache_series_extents(spec_json) return self.model.add_series_fitted(spec_json, config, bounds) def remove_column(self, id): diff --git a/python/figgyplot/review-feedback-v1.schema.json b/python/figgyplot/review-feedback-v1.schema.json new file mode 100644 index 0000000..41445e2 --- /dev/null +++ b/python/figgyplot/review-feedback-v1.schema.json @@ -0,0 +1,257 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Figgyplot review feedback protocol 1", + "type": "object", + "required": [ + "protocol", + "session_id", + "round" + ], + "properties": { + "protocol": { + "const": 1 + }, + "session_id": { + "type": "string", + "minLength": 1 + }, + "round": { + "type": "object", + "required": [ + "id", + "number", + "status", + "source", + "documents", + "work_id" + ], + "properties": { + "id": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1 + }, + "status": { + "const": "ready" + }, + "work_id": { + "type": "string" + }, + "source": { + "type": "object" + }, + "documents": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/document" + } + } + } + } + }, + "$defs": { + "document": { + "type": "object", + "required": [ + "baseline", + "baseline_revision", + "baseline_hash", + "dpi", + "backend", + "columns", + "comments", + "approval" + ], + "properties": { + "baseline": { + "type": "object" + }, + "baseline_revision": { + "type": "integer", + "minimum": 0 + }, + "baseline_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "columns": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/binary" + } + }, + "comments": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "text", + "target" + ], + "properties": { + "channel": { + "enum": [ + "api", + "ui" + ] + }, + "text": { + "type": "string" + }, + "id": { + "type": "string" + }, + "target": {} + } + } + }, + "approval": { + "type": "object", + "required": [ + "id", + "disposition", + "revision", + "settings", + "persistent_hash", + "changes" + ], + "properties": { + "disposition": { + "enum": [ + "accept", + "request_changes", + "skip" + ] + }, + "changes": { + "type": "array", + "items": { + "$ref": "#/$defs/change" + } + } + } + } + } + }, + "binary": { + "type": "object", + "required": [ + "file", + "sha256", + "bytes", + "dtype", + "shape" + ], + "properties": { + "file": { + "type": "string", + "pattern": "^data/[0-9a-f]{64}\\.npy$" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "bytes": { + "type": "integer", + "minimum": 0 + }, + "dtype": { + "type": "string" + }, + "shape": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "change": { + "type": "object", + "required": [ + "id", + "op", + "path", + "object_id", + "selectors", + "before_exists", + "after_exists", + "before", + "after" + ], + "properties": { + "id": { + "type": "string" + }, + "op": { + "enum": [ + "add", + "remove", + "replace", + "move" + ] + }, + "path": { + "type": "string" + }, + "object_id": { + "type": "string" + }, + "selectors": { + "type": "object" + }, + "before_exists": { + "type": "boolean" + }, + "after_exists": { + "type": "boolean" + } + } + }, + "events": { + "type": "array", + "items": { + "$ref": "#/$defs/event" + } + }, + "event": { + "type": "object", + "required": [ + "protocol", + "session_id", + "round_id", + "work_id", + "manifest", + "source" + ], + "properties": { + "protocol": { + "const": 1 + }, + "session_id": { + "type": "string", + "minLength": 1 + }, + "round_id": { + "type": "string", + "minLength": 1 + }, + "work_id": { + "type": "string", + "minLength": 1 + }, + "manifest": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "object" + } + } + } + } +} diff --git a/python/figgyplot/review.py b/python/figgyplot/review.py new file mode 100644 index 0000000..48eaa48 --- /dev/null +++ b/python/figgyplot/review.py @@ -0,0 +1,638 @@ +"""Opt-in, durable, turn-based chart review. No LLM runtime is required.""" +from __future__ import annotations + +import argparse +from contextlib import ExitStack, contextmanager +import asyncio +import copy +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time +import uuid +import numpy as np + +from ._review_diff import changes, persistent, preserved +from ._review_store import Store, digest + + +def _id(): + return uuid.uuid4().hex + + +def _ui_comment(doc): + return next((c['text'] for c in doc['comments'] if c.get('channel')=='ui'),'') + + +def _replace_ui_comment(doc, text): + doc['comments']=[c for c in doc['comments'] if c.get('channel')!='ui'] + if text:doc['comments'].append(dict(id=_id(),text=text,target=None,channel='ui')) + + +def _source(value): + value = copy.deepcopy(value or {}) + if value.get('entrypoint'): + path = Path(value['entrypoint']).resolve() + value.update(entrypoint=str(path), digest=hashlib.sha256(path.read_bytes()).hexdigest()) + value.setdefault('function', 'build') + value.setdefault('arguments', {}) + value.setdefault('cwd',str(Path.cwd())) + return value + + +class _CapturedData: + def __init__(self, native, session, figure_id, round_id, instance_id): + self.native, self.session, self.figure_id = native, session, figure_id + self.round_id = round_id + self.instance_id = instance_id + + def __getattr__(self, name): + value = getattr(self.native, name) + if name=='show': + def show(*args, **kwargs): + self.session._assert_mutable(self.figure_id,self.round_id,self.instance_id) + if self.session._validation: + raise RuntimeError('Source builders create Figures; the controller presents review windows') + if self.session.status['state']=='building': + raise RuntimeError('Seal the review set with start() or present() before opening windows') + fig=self.session._figures[self.figure_id] + if any(not handle.closed for handle in fig._windows): + raise RuntimeError('A review Figure already has an open native view') + return value(*args, **kwargs) + return show + mutations = {'apply_settings','add_series','add_series_fitted','add_series_batch', + 'set_radial','set_bounded','fit','fit_active','drag','resize_handle', + 'undo','redo','reset_edits'} + if name not in mutations: + return value + def mutate(*args, **kwargs): + self.session._assert_mutable(self.figure_id,self.round_id,self.instance_id) + return value(*args, **kwargs) + return mutate + + def upload_column(self, name, array): + with self.session._store.locked(): + state = self.session._store.read() + if state['rounds'][-1]['id']!=self.round_id:raise ValueError('Stale Figure from a previous review round') + doc = self.session._mutable(state, self.figure_id) + if doc['instance_id']!=self.instance_id:raise ValueError('Stale Figure instance; restore the current checkpoint') + reference = self.session._store.put(array) + self.native.upload_column(name, array) + try: + doc['columns'][name] = reference + self.session._store.write(state) + except Exception: + self.native.remove_column(name) + raise + + def remove_column(self, name): + with self.session._store.locked(): + state = self.session._store.read() + if state['rounds'][-1]['id']!=self.round_id:raise ValueError('Stale Figure from a previous review round') + doc = self.session._mutable(state, self.figure_id) + if doc['instance_id']!=self.instance_id:raise ValueError('Stale Figure instance; restore the current checkpoint') + self.native.remove_column(name) + doc['columns'].pop(name, None) + self.session._store.write(state) + + +class Session: + """A recoverable review round, with explicit approval and separate comments. + + Create source data through ``session.figure`` so it is captured before native + uploads or notebook transfer release. Sessions and their cache are opt-in. + """ + protocol = 1 + + def __init__(self, cache_dir=None, *, source=None, backend='native'): + cache=Path(cache_dir) if cache_dir is not None else Path(tempfile.mkdtemp(prefix='figgyplot-review-')) + if cache.exists() and (not cache.is_dir() or any(cache.iterdir())): + raise ValueError('Use a dedicated empty review cache directory; use Session.open() for an existing cache') + self._store = Store(cache) + self._figures = {} + self._handles = {} + self._widgets = {} + self._live_round = None + self._validation = False + self._worker_id = _id() + with self._store.locked(): + if (self.cache_dir/'manifest.json').exists(): + raise ValueError('Review cache already exists; use Session.open(cache_dir)') + if backend not in ('native','webgpu'): + raise ValueError('Review backend must be native or webgpu') + state = dict(protocol=1, session_id=_id(), generation=0, completed_work={}, + rounds=[dict(id=_id(),number=1,status='building',source=_source(source), + backend=backend,documents={},work_id=None)]) + self._store.write(state) + + @classmethod + def open(cls, cache_dir): + if not (Path(cache_dir)/'manifest.json').is_file(): + raise ValueError('Review cache manifest does not exist') + session = cls.__new__(cls) + session._store = Store(cache_dir) + session._figures,session._handles,session._widgets = {},{},{} + session._live_round,session._validation = None,False + session._worker_id = _id() + with session._store.locked(): + state = session._store.read() + # Check retained data before maintenance so corrupt recovery cannot + # delete another complete checkpoint's only available payload. + for r in state['rounds']: + for doc in r['documents'].values(): + for ref in session._store.references(doc):session._store.get(ref) + session._store.collect(state) + return session + + @property + def cache_dir(self): + return self._store.path + + def snapshot(self): + with self._store.locked(): + state = self._store.read() + self._store.collect(state) + return copy.deepcopy(state) + + def _mutable(self, state, figure_id): + r = state['rounds'][-1] + if r['status'] not in ('building','reviewing'): + raise RuntimeError('Review round is sealed; wait for the next round or reopen a Figure') + doc = r['documents'][figure_id] + if doc.get('approval') is not None: + raise RuntimeError('Figure is approved; reopen it before editing') + return doc + + def _assert_mutable(self, figure_id, round_id, instance_id): + with self._store.locked(): + state=self._store.read() + if state['rounds'][-1]['id']!=round_id:raise ValueError('Stale Figure from a previous review round') + doc=self._mutable(state,figure_id) + if doc['instance_id']!=instance_id:raise ValueError('Stale Figure instance; restore the current checkpoint') + + def figure(self, *, id, **kwargs): + from .figure import Figure + if not isinstance(id,str) or not id: + raise ValueError('Review Figures require a non-empty stable ID') + with self._store.locked(): + state = self._store.read();r = state['rounds'][-1] + if r['status'] != 'building': + raise RuntimeError('The review set is sealed; create Figures before present()') + if id in r['documents']:raise ValueError('Duplicate review Figure ID: '+id) + fig = Figure(id=id,backend=kwargs.pop('backend',r['backend']),**kwargs) + settings=fig.get_settings() + r['documents'][id]=dict(baseline=settings,baseline_revision=fig.revision, + baseline_hash=digest(persistent(settings)),draft=settings,draft_revision=fig.revision, + dpi=fig.dpi,backend=fig.backend,columns={},comments=[],approval=None,instance_id=_id()) + try:self._store.write(state) + except Exception:fig.close();raise + self._bind(fig,id) + return fig + + def _bind(self, fig, figure_id): + previous=self._figures.get(figure_id) + if previous is not None and previous is not fig:previous.close() + with self._store.locked(): + r=self._store.read()['rounds'][-1];round_id=r['id'];instance_id=r['documents'][figure_id]['instance_id'] + fig._native = _CapturedData(fig._native,self,figure_id,round_id,instance_id) + fig._review_session = self + self._figures[figure_id] = fig + + def _seal(self): + with self._figure_transaction(): + state=self._store.read();r=state['rounds'][-1] + if r['status']=='building': + if not r['documents']:raise ValueError('A review round needs at least one Figure') + for fid,doc in r['documents'].items(): + fig=self._figures[fid];settings=fig.get_settings() + doc.update(baseline=settings,baseline_revision=fig.revision, + baseline_hash=digest(persistent(settings)),draft=settings,draft_revision=fig.revision) + doc['baseline_columns']=copy.deepcopy(doc['columns']) + doc['baseline_instance_id']=doc['instance_id'] + doc['bounded_data']=self._bounded_data(settings) + doc['baseline_bounded_data']=copy.deepcopy(doc['bounded_data']) + r['status']='reviewing';self._store.write(state) + return r['number'] + + def _bounded_data(self, settings): + # Bounded models own canonical f64/statistical values in SSOT, rather + # than GPU columns. Cache those values separately without uploading them. + result={} + def collect(value,path): + if isinstance(value,dict): + for key,item in value.items():collect(item,path+'/'+key) + elif isinstance(value,list): + if value and all(isinstance(v,(int,float)) and not isinstance(v,bool) for v in value): + result[path]=self._store.put(np.asarray(value,dtype=np.float64)) + else: + for index,item in enumerate(value):collect(item,path+'/'+str(index)) + elif isinstance(value,(int,float)) and not isinstance(value,bool): + result[path]=self._store.put(np.asarray([value])) + for family in ('radial','categorical','boxplot','radar'): + if family not in settings:continue + chart=settings[family] + if family=='radial': + for index,item in enumerate(chart['slices']):collect(item['value'],f'/radial/slices/{index}/value') + for index,item in enumerate((chart.get('split') or {}).get('children',[])): + collect(item['value'],f'/radial/split/children/{index}/value') + else: + for index,item in enumerate(chart['series']):collect(item['values'],f'/{family}/series/{index}/values') + return result + + @contextmanager + def _figure_transaction(self): + # Figure mutations already acquire Figure -> store locks. Preserve this + # order while collecting multiple native/browser snapshots as well. + with ExitStack() as stack: + for fid in sorted(self._figures):stack.enter_context(self._figures[fid]._lock) + stack.enter_context(self._store.locked()) + yield + + def start(self): + """Seal all Figures and their baselines without opening a view.""" + self._seal() + return self + + def present(self): + """Seal the review set and show its complete pending native/notebook set.""" + number=self._seal() + if self._validation:return self + if threading.current_thread() is not threading.main_thread(): + raise RuntimeError('Review presentation runs on the main thread') + state=self.snapshot();r=state['rounds'][-1] + prepared=[] + try: + for fid,doc in r['documents'].items(): + if doc['approval'] is not None:continue + fig=self._figures.get(fid) or self.restore(fid) + if fig.backend=='native':fig.prepare() + prepared.append(fig) + for fig in prepared: + if fig.backend=='native': + old=self._handles.get(fig.id) + if old is None or old.closed: + handle=fig.show(block=False) + handle.configure_review(json.dumps(dict(figure_id=fig.id,comments=self._comment_text(fig.id)))) + self._handles[fig.id]=handle + else: + from IPython.display import display + display(self.widget(fig)) + self._live_round=number + except Exception: + for handle in self._handles.values():handle.close() + raise + return self + + def widget(self, figure): + """The ordinary live editor plus a separate review panel (not PNG fallback).""" + self._live_round=self._seal() + if isinstance(figure,str):figure=self._figures.get(figure) or self.restore(figure,backend='webgpu') + if figure.backend!='webgpu':raise ValueError('Notebook review requires backend=webgpu') + if figure.id not in self._figures or self._figures[figure.id] is not figure: + raise ValueError('Figure does not belong to this review session') + widget=figure.widget();widget.review=dict(enabled=True,figure_id=figure.id,comments=self._comment_text(figure.id)) + self._widgets[figure.id]=widget + return widget + + def _comment_text(self, figure_id): + return _ui_comment(self.snapshot()['rounds'][-1]['documents'][figure_id]) + + def comment(self, figure_id, text, *, target=None): + if not isinstance(text,str):raise ValueError('Review comment must be text') + with self._store.locked(): + state=self._store.read();doc=self._mutable(state,figure_id) + doc['comments'].append(dict(id=_id(),text=text,target=target,channel='api')) + self._store.write(state) + + def _set_comment_text(self, figure_id, text): + if not isinstance(text,str):raise ValueError('Review comments must be text') + with self._store.locked(): + state=self._store.read();doc=self._mutable(state,figure_id) + previous=_ui_comment(doc) + if previous!=text: + _replace_ui_comment(doc,text) + self._store.write(state) + + def submit(self, figure_id, disposition, *, comments=None, expected_revision=None): + """Durably approve accepted state, then close views; never approve on close.""" + if disposition not in ('accept','request_changes','skip'): + raise ValueError('Disposition must be accept, request_changes or skip') + fig=self._figures.get(figure_id) + if fig is None:raise ValueError('Restore the Figure before submitting it') + if fig.backend=='webgpu' and any(not w._closed for w in fig._widgets): + raise RuntimeError('Use await session.submit_async() to flush notebook edits') + return self._submit(figure_id,disposition,comments,expected_revision) + + async def submit_async(self, figure_id, disposition, *, comments=None, expected_revision=None): + fig=self._figures[figure_id] + if fig.backend=='webgpu':await fig.sync() + return self._submit(figure_id,disposition,comments,expected_revision) + + def _submit(self, figure_id, disposition, comments, expected_revision): + if disposition not in ('accept','request_changes','skip'): + raise ValueError('Unknown review disposition') + if comments is not None and not isinstance(comments,str):raise ValueError('Review comments must be text') + fig=self._figures[figure_id] + with fig._lock, self._store.locked(): + approval=self._commit_approval(figure_id,disposition,comments,expected_revision) + # Closing a view may call observers that read the session. Release the + # manifest lock before closing, on both first delivery and retries. + self._close_views(fig,disposition) + return copy.deepcopy(approval) + + def _commit_approval(self, figure_id, disposition, comments, expected_revision): + state=self._store.read();r=state['rounds'][-1];doc=r['documents'][figure_id] + # Repeated delivery of the same approval never creates another work ID. + if doc['approval'] is not None: + if doc['approval']['disposition']!=disposition or (comments is not None and doc['approval']['comment_text']!=comments): + raise ValueError('Figure already approved with different feedback') + self._store.write(state) + return doc['approval'] + self._mutable(state,figure_id) + fig=self._figures[figure_id] + if doc['instance_id']!=fig._native.instance_id:raise ValueError('Stale Figure instance; restore the current checkpoint') + if any(not hasattr(widget,'_clients') for widget in fig._widgets): + raise RuntimeError('Close the PNG fallback view before approving; use a live review view') + clients=sum(len(widget._clients) for widget in fig._widgets if not widget._closed) + if clients>1 or sum(not handle.closed for handle in fig._windows)>1: + raise RuntimeError('Close duplicate review views before approving this Figure') + if r['status']=='building':raise RuntimeError('Present/seal the complete review set before approval') + fig._native.end_edit() + revision=fig.revision + if expected_revision is not None and revision!=expected_revision: + raise ValueError('Stale review revision; refresh before approving') + approved=fig.get_settings() + doc['bounded_data']=self._bounded_data(approved) + if comments is not None: + _replace_ui_comment(doc,comments) + for ref in self._store.references(doc):self._store.get(ref) + approval=dict(id=_id(),disposition=disposition,revision=revision,settings=approved, + instance_id=doc['instance_id'], + persistent_hash=digest(persistent(approved)),changes=changes(doc['baseline'],approved), + comment_text=_ui_comment(doc)) + doc.update(approval=approval,draft=approved,draft_revision=revision) + if all(d['approval'] is not None for d in r['documents'].values()): + needs_work=any(d['approval']['disposition']=='request_changes' or d['approval']['changes'] or d['comments'] for d in r['documents'].values()) + r.update(status='ready' if needs_work else 'completed',work_id=_id() if needs_work else None) + self._store.write(state) + self._store.collect(state) + return approval + + def _close_views(self, fig, disposition): + for window in list(fig._windows):window.close() + for widget in list(fig._widgets): + widget.review=dict(enabled=True,approved=True,disposition=disposition) + widget.close() + + def events(self): + state=self.snapshot() + return [dict(protocol=1,session_id=state['session_id'],round_id=r['id'],work_id=r['work_id'], + manifest=str(self.cache_dir/'manifest.json'),source=r['source']) + for r in state['rounds'] if r['status']=='ready'] + + @property + def status(self): + r=self.snapshot()['rounds'][-1] + return dict(round=r['number'],state=r['status'],work_id=r['work_id'], + pending=[fid for fid,doc in r['documents'].items() if doc['approval'] is None], + error=r.get('error')) + + def feedback(self, work_id): + state=self.snapshot() + r=next((r for r in state['rounds'] if r['work_id']==work_id and r['status']=='ready'),None) + if r is None:raise ValueError('Stale or unknown review work ID') + return dict(protocol=1,session_id=state['session_id'],round=copy.deepcopy(r)) + + def claim(self, work_id, *, worker_id=None, seconds=1800): + """Lease one staging writer before an agent edits source for this work.""" + from .figure import _timeout + seconds=_timeout(seconds);worker_id=worker_id or self._worker_id + if not isinstance(worker_id,str) or not worker_id:raise ValueError('worker_id must be non-empty text') + with self._store.locked(): + state=self._store.read();r=state['rounds'][-1] + if r['status']!='ready' or r['work_id']!=work_id:raise ValueError('Stale or unknown review work ID') + claim=r.get('claim');now=time.time() + if claim and claim['expires']>now and claim['worker_id']!=worker_id: + raise RuntimeError('Review work is already claimed by another source writer') + if not claim or claim['worker_id']!=worker_id or claim['expires']<=now: + claim=dict(token=_id(),worker_id=worker_id) + claim['expires']=now+seconds;r['claim']=claim;self._store.write(state) + return copy.deepcopy(claim) + + def restore(self, figure_id, *, backend=None): + from .figure import Figure + with self._store.locked(): + state=self._store.read();doc=state['rounds'][-1]['documents'][figure_id] + settings=doc['draft'];rect=settings['config']['chart_area'];dpi=doc['dpi'] + fig=Figure(figsize=(rect['width']/dpi,rect['height']/dpi),dpi=dpi, + backend=backend or doc['backend'],id=figure_id, + pool_bytes=max(8*1024**2,sum(ref['shape'][0]*8 for ref in doc['columns'].values())*2)) + try: + for name,ref in doc['columns'].items():fig._native.upload_column(name,self._store.get(ref)) + family=next((k for k in ('radial','categorical','boxplot','radar') if k in settings),None) + if family=='radial':fig._native.set_radial(json.dumps(settings[family])) + elif family:fig._native.set_bounded(family,json.dumps(settings[family])) + else: + # Establish panel/overlay identities before registering the + # complete series batch; no positional owner is guessed. + bootstrap=copy.deepcopy(settings);bootstrap['series']=[] + def empty_membership(config): + for overlay in config.get('overlays',[]):overlay['series']=[] + for panel in config.get('subplots',[]): + panel['series']=[];empty_membership(panel['config']) + empty_membership(bootstrap['config']) + fig.apply_settings(bootstrap) + if not family and settings['series']: + if fig.backend=='webgpu': + for spec in settings['series']:fig._native.cache_series_extents(json.dumps(spec)) + fig._native.add_series_batch(json.dumps(settings['series']),json.dumps(settings['config'])) + fig.apply_settings(settings) + if persistent(fig.get_settings())!=persistent(settings): + raise ValueError('Restored settings differ from the complete checkpoint') + fig._next_series=len(settings['series']) + doc['instance_id']=_id() + self._store.write(state) + except Exception: + fig.close();raise + self._bind(fig,figure_id) + return fig + + def reopen(self, figure_id): + with self._store.locked(): + state=self._store.read();r=state['rounds'][-1] + if r['status'] not in ('reviewing','ready'):raise RuntimeError('Round cannot be reopened') + r['documents'][figure_id]['approval']=None + r.pop('claim',None) + r.update(status='reviewing',work_id=None) + self._store.write(state) + return self._figures.get(figure_id) or self.restore(figure_id) + + def cancel(self): + with self._store.locked(): + state=self._store.read();state['rounds'][-1]['status']='cancelled' + self._store.write(state) + self.close() + + def poll(self): + """Pump native input and durable submissions; call only on the main thread.""" + from .desktop import poll_events + poll_events() + state=self.snapshot();r=state['rounds'][-1] + if self._live_round is not None and r['number']!=self._live_round: + self.close();self.present();return + failed=False + for fid,handle in list(self._handles.items()): + request=json.loads(handle.review_request_json()) + if request is None:continue + try: + self.submit(fid,request['disposition'],comments=request['comments'],expected_revision=request['revision']) + except Exception as exc: + handle.review_result(str(exc)) + failed=True + if failed:return + # Native editor changes bypass Python callbacks. Capture accepted drafts + # here for disconnect recovery, without waiting for a GPU readback. + with self._figure_transaction(): + state=self._store.read();r=state['rounds'][-1];changed=False + for fid,fig in self._figures.items(): + doc=r['documents'].get(fid) + if doc and fig._native is not None and doc['instance_id']!=fig._native.instance_id:continue + if doc and doc['approval'] is None and fig._native is not None and doc['draft_revision']!=fig.revision: + doc.update(draft=fig.get_settings(),draft_revision=fig.revision);changed=True + handle=self._handles.get(fid) + if doc and doc['approval'] is None and handle is not None: + text=json.loads(handle.info_json())['review'].get('comments','') + if text!=_ui_comment(doc): + _replace_ui_comment(doc,text);changed=True + if changed:self._store.write(state) + + def run_events(self, *, on_status=None): + """Wait through review and disconnected-agent periods until accept/cancel.""" + previous=None + while True: + self.poll() + status=self.status + if status!=previous and on_status is not None:on_status(status) + previous=status + if status['state'] in ('completed','cancelled'):break + time.sleep(.02) + + async def watch(self): + """Notebook controller: publish the next complete set after agent work.""" + if self._live_round is None:self.present() + while True: + r=self.snapshot()['rounds'][-1] + if r['status'] in ('completed','cancelled'):return + if r['number']!=self._live_round: + self.close();self.present() + await asyncio.sleep(.1) + + def regenerate(self, work_id, *, superseded=None, claim_token=None, present=False, timeout=180): + """Run updated source, render every result, reconcile edits, publish once.""" + superseded=dict(superseded or {}) + if any(not isinstance(reason,str) or not reason.strip() for reason in superseded.values()): + raise ValueError('Superseded edits/Figures require non-empty reasons') + state=self.snapshot() + if work_id in state['completed_work']:return state['completed_work'][work_id] + if claim_token is None:claim_token=self.claim(work_id)['token'] + feedback=self.feedback(work_id);old=feedback['round'];source=_source(old['source']) + if old.get('claim',{}).get('token')!=claim_token or old['claim']['expires']<=time.time(): + raise ValueError('Source writer lease is invalid or expired') + known={c['id'] for doc in old['documents'].values() for c in doc['approval']['changes']}|{'figure:'+fid for fid in old['documents']} + if set(superseded)-known:raise ValueError('Unknown superseded edit or Figure ID') + if not source.get('entrypoint'):raise ValueError('Record a source entrypoint before regeneration') + from .figure import _timeout + timeout=_timeout(timeout) + with tempfile.TemporaryDirectory(prefix='candidate-'+claim_token+'-',dir=str(self.cache_dir)) as candidate_path: + candidate=Session(candidate_path,source=source,backend=old['backend']);candidate._validation=True + try: + result=subprocess.run([sys.executable,'-m','figgyplot._review_runner',candidate_path], + capture_output=True,text=True,timeout=timeout) + if result.returncode: + raise RuntimeError('Candidate execution/render failed:\n'+result.stderr[-4000:]) + current=candidate.snapshot()['rounds'][-1] + if not all(doc.get('validated_png_sha256') for doc in current['documents'].values()): + raise ValueError('Candidate set lacks complete render validation') + for fid,doc in old['documents'].items(): + if fid not in current['documents']: + if 'figure:'+fid not in superseded:raise ValueError('Candidate omitted Figure '+fid) + continue + for change in doc['approval']['changes']: + if change['id'] not in superseded and not preserved(change,current['documents'][fid]['baseline']): + raise ValueError('Source did not reproduce approved edit: '+fid+' '+change['path']) + if _source(source)['digest']!=source['digest']:raise ValueError('Source changed during candidate validation') + with self._store.locked(): + state=self._store.read() + if work_id in state['completed_work']:return state['completed_work'][work_id] + active=state['rounds'][-1] + if active['status']!='ready' or active['work_id']!=work_id: + raise ValueError('Stale round; candidate was not published') + if active.get('claim',{}).get('token')!=claim_token or active['claim']['expires']<=time.time(): + raise ValueError('Source writer lease expired during validation') + for doc in current['documents'].values(): + for key in ('columns','baseline_columns','bounded_data','baseline_bounded_data'): + for name,ref in list(doc.get(key,{}).items()): + doc[key][name]=self._store.put(candidate._store.get(ref)) + current.update(number=active['number']+1,source=source,superseded=superseded) + active['status']='completed';state['rounds'].append(current) + state['completed_work'][work_id]=current['number'] + self._store.write(state);self._store.collect(state) + number=current['number'] + except Exception as exc: + try: + with self._store.locked(): + state=self._store.read();active=state['rounds'][-1] + if active['status']=='ready' and active['work_id']==work_id: + active['error']=dict(type=type(exc).__name__,message=str(exc)) + self._store.write(state) + except OSError:pass + raise + finally: + candidate.close() + if present: + self.close();self.present() + return number + + def close(self): + for fig in list(self._figures.values()):fig.close() + self._figures.clear();self._handles.clear();self._widgets.clear() + + def __enter__(self):return self + def __exit__(self,*args):self.close() + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('action',choices=['protocol','skill','status','events','feedback','claim','regenerate','show','cancel']) + parser.add_argument('cache',nargs='?') + parser.add_argument('work',nargs='?') + parser.add_argument('--superseded',help='JSON file mapping edit IDs or figure:ID to reasons') + parser.add_argument('--worker',help='Stable agent/staging-workspace identifier for a writer lease') + parser.add_argument('--claim-token',help='Writer lease token returned by claim') + parser.add_argument('--timeout',type=float,default=180,help='Maximum candidate execution/render seconds') + args=parser.parse_args() + package=Path(__file__).parent + if args.action=='protocol': + print(json.dumps(dict(protocol=1,skill=str(package/'skills/figgyplot-review/SKILL.md'),schema=str(package/'review-feedback-v1.schema.json'),event_schema=str(package/'review-feedback-v1.schema.json')+'#/$defs/events')));return + if args.action=='skill': + print((package/'skills/figgyplot-review/SKILL.md').read_text(encoding='utf-8'));return + if not args.cache:parser.error('cache is required for this action') + with Session.open(args.cache) as session: + if args.action=='status':result=session.status + elif args.action=='events':result=session.events() + elif args.action=='feedback':result=session.feedback(args.work) + elif args.action=='claim':result=session.claim(args.work,worker_id=args.worker) + elif args.action=='regenerate': + if not args.claim_token:parser.error('regenerate requires --claim-token from claim') + superseded=json.loads(Path(args.superseded).read_text()) if args.superseded else None + result=dict(round=session.regenerate(args.work,superseded=superseded,claim_token=args.claim_token,timeout=args.timeout)) + elif args.action=='show':session.present();session.run_events();result=session.events() + else:session.cancel();result={'cancelled':True} + print(json.dumps(result,ensure_ascii=False,allow_nan=False)) + + +if __name__=='__main__':main() diff --git a/python/figgyplot/skills/figgyplot-review/SKILL.md b/python/figgyplot/skills/figgyplot-review/SKILL.md new file mode 100644 index 0000000..ca9bf1d --- /dev/null +++ b/python/figgyplot/skills/figgyplot-review/SKILL.md @@ -0,0 +1,46 @@ +--- +name: figgyplot-review +description: Consume figgyplot protocol-1 review batches, reconcile exact human chart edits and separate comments into runnable source, validate and publish the next complete round. +--- + +# Figgyplot review adapter + +This skill ships with figgyplot. It uses `python -m figgyplot.review` and protocol +1. Run `protocol` to discover the installed schema and skill paths. A host may +copy this directory into its skill search path; no particular agent vendor, +model SDK or background service is required. Ordinary plotting remains usable +without installing this skill. + +1. Obtain the session cache path from the user/controller. Poll `events CACHE`. + No event means no complete batch is ready. Do not work on individual Figure + approval before the whole round is submitted. +2. Read `feedback CACHE WORK_ID`. Use the baseline and approved settings, exact + change operations and stable selectors. Read natural-language review comments + separately. They are instructions, not chart annotations. Binary references + are recovery/input files; do not paste raw arrays into model messages. +3. Before editing source, run `claim CACHE WORK_ID --worker YOUR_STABLE_ID`. + Keep its token and use one staging workspace/source writer for this session. + If another worker holds the lease, wait or resume that worker. Reclaim expired + leases after disconnect; never overwrite another active worker's source. +4. Inspect the recorded entry point/function, arguments, inputs and source digest. + Preserve stable Figure IDs. Update runnable source to retain each exact human + edit and satisfy the comments. Source builders use `build(session, **arguments)` + and `session.figure(id=...)`; they do not start an agent or approve themselves. +5. Run `regenerate CACHE WORK_ID --claim-token TOKEN`. This executes the builder, + renders every candidate, checks retained edits and source stability, and + atomically publishes one complete successor. The live controller displays it + after publication. Renew the lease with the same worker ID for long work. +6. Only explicitly supersede an approved edit when feedback requires it. Supply + `--superseded FILE` containing a JSON map from the operation's `id` (or + `figure:ID` for a removed Figure) to a concrete reason. Never waive all edits + merely to make validation pass. +7. On execution/render/stale-base failure, inspect the error and correct source. + The previous checkpoint and pending work remain recoverable. Do not delete + manifests or binary files. Repeated events and completion of the same work ID + are idempotent. Application code owns revision-driven cleanup. + +The human controller uses `Session.present()` / `run_events()` in native mode or +live widgets with `await session.watch()` in Jupyter. Agent regeneration belongs +outside that UI loop. `show CACHE` can resume a disconnected native controller. +Manual window closure is not approval. `accept` without edits/comments finishes +the round; `request_changes` submits another work batch after every disposition. diff --git a/python/figgyplot/webgpu.js b/python/figgyplot/webgpu.js index 837e830..2215406 100644 --- a/python/figgyplot/webgpu.js +++ b/python/figgyplot/webgpu.js @@ -54,6 +54,40 @@ export function render({model,el}) { let revision=model.get('state').revision,sequence=0,inflight=null,pending=null,barriers=[],local=clone(model.get('state').settings); let editHistory=model.get('state').edit_history??{},historyBusy=false; let propertyColumns=clone(model.get('state').property_columns??[]),propertyCatalog=clone(model.get('state').property_catalog??[]),controlQueue=Promise.resolve(),controlFinish=null; + // Session metadata is separate from chart SSOT and ordinary inspector fields. + const reviewPanel=document.createElement('section');reviewPanel.className='figgy-review';reviewPanel.hidden=true; + reviewPanel.innerHTML='
'; + root.append(reviewPanel); + const reviewComment=reviewPanel.querySelector('textarea'),reviewStatus=reviewPanel.querySelector('[data-review-status]'); + let reviewBusy=false,reviewLoaded=false; + function reviewState(){ + const state=model.get('review')??{};reviewPanel.hidden=!state.enabled; + reviewPanel.querySelectorAll('button').forEach(button=>button.disabled=reviewBusy||model.get('status')!=='ready'||!!state.approved); + if(state.enabled&&!reviewLoaded){reviewComment.value=state.comments??'';reviewLoaded=true;} + if(state.approved)reviewStatus.textContent='Review saved'; + } + async function submitReview(disposition){ + if(reviewBusy)return; + reviewBusy=true;reviewPanel.querySelectorAll('button,textarea').forEach(v=>v.disabled=true); + reviewStatus.textContent='Saving review…'; + try{ + await finishInline(true);await controlQueue;await queue;await flush(); + root.querySelector('.figgy-body').inert=true;root.querySelector('.figgy-toolbar').inert=true; + trace('review.send',{revision,disposition}); + model.send({action:'review_submit',disposition,comments:reviewComment.value,revision}); + }catch(e){reviewFailure(String(e));} + } + function reviewFailure(message){ + reviewBusy=false;reviewPanel.querySelectorAll('button,textarea').forEach(v=>v.disabled=false); + root.querySelector('.figgy-body').inert=false;root.querySelector('.figgy-toolbar').inert=false; + reviewStatus.textContent=message; + reviewState(); + } + const reviewMessage=message=>{if(message.action==='review_result'){trace('review.result',{error:message.error});reviewFailure(message.error??'Review could not be saved');}}; + reviewPanel.querySelector('[data-review-changes]').addEventListener('click',()=>submitReview('request_changes')); + reviewPanel.querySelector('[data-review-accept]').addEventListener('click',()=>submitReview('accept')); + reviewComment.addEventListener('change',()=>model.send({action:'review_comment',comments:reviewComment.value})); + model.on('change:review',reviewState);model.on('change:status',reviewState);model.on('msg:custom',reviewMessage);reviewState(); function historyControls(){for(const action of ['undo','redo']){const button=root.querySelector(`[data-${action}]`);button.disabled=historyBusy||!!inflight||!!pending||!editHistory[`can_${action}`];}root.querySelectorAll('[data-reset-edits]').forEach(button=>button.disabled=historyBusy||!!inflight||!!pending||!editHistory.can_reset);} function replayEdit(action,args={}){ const operation=controlQueue.then(async()=>{ @@ -1134,6 +1168,6 @@ export function render({model,el}) { model.send({action:'ready',client,info:stats});initializedResolve();raf=requestAnimationFrame(frame); },true); }catch(e){initializedReject(e);if(!disposed){fail(e);status.textContent='WebGPU failed';model.send({action:'failed',error:String(e)});}}})(); - return ()=>{window.removeEventListener('pointerup',releaseSidebar,true);window.removeEventListener('pointercancel',releaseSidebar,true);window.removeEventListener('blur',blurDrawing);disposed=true;controlFinish?.();controlFinish=null;startInit();initializedReject(Error('Widget disposed'));cancelAnimationFrame(raf);observer.disconnect();model.off('change:state',onState);model.off('msg:custom',onMessage);queue.finally(async()=>{try{await pendingFrame;}catch{}chart?.free();chart=null;}).catch(()=>{});model.send({action:'unmounted',client});root.remove();}; + return ()=>{window.removeEventListener('pointerup',releaseSidebar,true);window.removeEventListener('pointercancel',releaseSidebar,true);window.removeEventListener('blur',blurDrawing);disposed=true;controlFinish?.();controlFinish=null;startInit();initializedReject(Error('Widget disposed'));cancelAnimationFrame(raf);observer.disconnect();model.off('change:state',onState);model.off('msg:custom',onMessage);model.off('change:review',reviewState);model.off('change:status',reviewState);model.off('msg:custom',reviewMessage);queue.finally(async()=>{try{await pendingFrame;}catch{}chart?.free();chart=null;}).catch(()=>{});model.send({action:'unmounted',client});root.remove();}; } export default {render}; diff --git a/python/figgyplot/widget.py b/python/figgyplot/widget.py index 440b7a2..67925b4 100644 --- a/python/figgyplot/widget.py +++ b/python/figgyplot/widget.py @@ -95,6 +95,7 @@ class FigureWidget(anywidget.AnyWidget): browser_info = traitlets.Dict().tag(sync=True) draw_styles = traitlets.Dict().tag(sync=True) auto_prepare = traitlets.Bool(True).tag(sync=True) + review = traitlets.Dict().tag(sync=True) def __init__(self, figure, *, auto_prepare=True): self.figure = figure @@ -131,7 +132,26 @@ def refresh(self): def _command(self, widget, content, buffers): action = content.get("action") - if action == "mounted": + if action == "review_comment": + session = getattr(self.figure, '_review_session', None) + if session is not None and self.review.get('enabled'): + try:session._set_comment_text(self.figure.id, content.get('comments', '')) + except Exception as exc:self.send(dict(action='review_result',error=str(exc))) + elif action == "review_submit": + try: + session = getattr(self.figure, '_review_session', None) + if session is None or not self.review.get('enabled'): + raise RuntimeError('This Figure is not in a review session') + if session._figures.get(self.figure.id) is not self.figure: + raise RuntimeError('Stale review view from a previous round') + # The browser submits only after its input queue and accepted + # edit acknowledgements are drained. Verify that revision in the + # common commit path; do not start another barrier inside a comm. + session._submit(self.figure.id, content['disposition'], + content.get('comments'), content['revision']) + except Exception as exc: + self.send(dict(action='review_result',error=str(exc))) + elif action == "mounted": self._clients[content["client"]] = True self._mounted_event.set() elif action == "ready": diff --git a/src/bounded_host.rs b/src/bounded_host.rs index d752aa7..1801121 100644 --- a/src/bounded_host.rs +++ b/src/bounded_host.rs @@ -456,6 +456,7 @@ impl SurfaceWindow { if !ctx.input(|i| i.pointer.any_down()) && !ctx.egui_wants_keyboard_input() { c.end_edit(); } + self.editor.finalize_review(c.data.revision); if let Some(error) = error { return Err(error); } diff --git a/src/desktop.rs b/src/desktop.rs index f3611e3..d6bc83f 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -24,6 +24,7 @@ struct Control { dirty: AtomicBool, stats: Mutex, settings: Mutex>>, + review: Arc>, } #[pyclass(module = "figgyplot._native")] pub struct WindowHandle { @@ -31,6 +32,33 @@ pub struct WindowHandle { } #[pymethods] impl WindowHandle { + fn configure_review(&self, json: &str) -> PyResult<()> { + let mut state: Value = parse(json)?; + if !state["figure_id"].is_string() || !state["comments"].is_string() { + return Err(invalid( + "review controls require a Figure ID and comment text", + )); + } + state["request"] = Value::Null; + state["error"] = json!(""); + *self.control.review.lock().unwrap() = state; + self.refresh(); + Ok(()) + } + fn review_request_json(&self) -> String { + let state = self.control.review.lock().unwrap(); + if state["request"]["finalized"] == true { + state["request"].to_string() + } else { + "null".into() + } + } + fn review_result(&self, error: &str) { + let mut state = self.control.review.lock().unwrap(); + state["request"] = Value::Null; + state["error"] = json!(error); + self.refresh(); + } fn close(&self) { self.control.close.store(true, Ordering::Release); } @@ -39,6 +67,7 @@ impl WindowHandle { } fn info_json(&self) -> String { let mut stats = self.control.stats.lock().unwrap().clone(); + stats["review"] = self.control.review.lock().unwrap().clone(); if let Some(settings) = self.control.settings.lock().unwrap().as_ref() { stats["settings_window"] = if settings.close.load(Ordering::Acquire) { Value::Null @@ -172,6 +201,7 @@ impl SurfaceWindow { ); let ui_renderer = egui_wgpu::Renderer::new(c.renderer.device(), format, Default::default()); let mut editor = editor::Editor::default(); + editor.review = Some(Arc::clone(&control.review)); if settings_only { editor.begin_settings(&c); } @@ -1200,6 +1230,7 @@ impl SurfaceWindow { if !ctx.input(|i| i.pointer.any_down()) && !ctx.egui_wants_keyboard_input() { c.end_edit(); } + self.editor.finalize_review(c.data.revision); // Explicit export may temporarily select its RGBA target pipelines. c.renderer .ensure_target_format(self.config.format) @@ -1597,6 +1628,20 @@ impl ApplicationHandler for App { let Some(w) = self.windows.get_mut(&id) else { return; }; + let reviewing = !w.control.review.lock().unwrap()["request"].is_null(); + if reviewing + && matches!( + event, + WindowEvent::CloseRequested + | WindowEvent::MouseInput { .. } + | WindowEvent::CursorMoved { .. } + | WindowEvent::MouseWheel { .. } + | WindowEvent::KeyboardInput { .. } + | WindowEvent::Ime(_) + ) + { + return; + } if matches!(event, WindowEvent::CloseRequested) { w.control.close.store(true, Ordering::Release); return; diff --git a/src/editor.rs b/src/editor.rs index ada6f87..039ae07 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -5,6 +5,7 @@ use egui::{Color32, RichText, Stroke}; pub const INSPECTOR_WIDTH: f32 = 320.; pub(crate) struct Editor { + pub(crate) review: Option>>, pub inspector_open: bool, pub selection_hint: String, pub panel_focused: bool, @@ -33,6 +34,7 @@ pub(crate) struct Editor { impl Default for Editor { fn default() -> Self { Self { + review: None, inspector_open: false, selection_hint: String::new(), panel_focused: true, @@ -60,6 +62,86 @@ impl Default for Editor { } } +impl Editor { + pub(crate) fn review_ui(&mut self, root: &mut egui::Ui, c: &mut FigureAccess<'_>) { + let Some(shared) = self.review.clone() else { + return; + }; + let mut state = shared.lock().unwrap(); + if state.is_null() { + return; + } + let pending = !state["request"].is_null(); + let mut comments = state["comments"].as_str().unwrap_or("").to_owned(); + egui::Panel::bottom("review_comments") + .exact_size(148.) + .frame( + egui::Frame::NONE + .fill(Color32::from_rgb(245, 248, 252)) + .inner_margin(12), + ) + .show(root, |ui| { + ui.label(RichText::new("Review comments").strong()); + ui.add_enabled_ui(!pending, |ui| { + let input = egui::ScrollArea::vertical() + .id_salt("review_comment_scroll") + .max_height(48.) + .min_scrolled_height(48.) + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut comments) + .desired_width(f32::INFINITY) + .desired_rows(2) + .hint_text("Describe changes for the next version…"), + ) + }); + self.controls.insert( + "review_comments".into(), + input.inner.rect.intersect(input.inner_rect), + ); + ui.horizontal(|ui| { + for (key, label, disposition) in [ + ("review_changes", "Request changes", "request_changes"), + ("review_accept", "Accept", "accept"), + ] { + let button = ui.button(label); + self.controls.insert(key.into(), button.rect); + if button.clicked() { + c.end_edit(); + state["request"] = json!({"disposition":disposition, + "comments":comments,"revision":c.data.revision,"finalized":false}); + state["error"] = json!(""); + } + } + if pending { + ui.label("Saving review…"); + } else if let Some(error) = + state["error"].as_str().filter(|s| !s.is_empty()) + { + ui.colored_label(Color32::from_rgb(170, 45, 45), error); + } else { + ui.weak("Submitted after every Figure is reviewed"); + } + }); + }); + }); + state["comments"] = json!(comments); + } + + pub(crate) fn finalize_review(&self, revision: u64) { + if let Some(shared) = &self.review { + let mut state = shared.lock().unwrap(); + if state["request"].is_object() && state["request"]["finalized"] == false { + // Inline text blur can commit later in the same frame than the + // review button. Seal the revision after those accepted edits. + state["request"]["revision"] = json!(revision); + state["request"]["finalized"] = json!(true); + } + } + } +} + pub fn theme(ctx: &egui::Context) { crate::ui_fonts::install(ctx); ctx.set_theme(egui::ThemePreference::Light); @@ -553,6 +635,7 @@ impl Editor { }); }); } + self.review_ui(root, c); egui::CentralPanel::default() .frame(egui::Frame::NONE) .show(root, |ui| { diff --git a/src/lib.rs b/src/lib.rs index a280bcd..51ce634 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1903,6 +1903,7 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_function(wrap_pyfunction!(desktop::poll_events, m)?)?; m.add_function(wrap_pyfunction!(desktop::run_events, m)?)?; + m.add_function(wrap_pyfunction!(persistent_settings_json, m)?)?; m.add_function(wrap_pyfunction!(default_colorbar_json, m)?)?; m.add_function(wrap_pyfunction!(rebuild_legend_json, m)?)?; m.add_function(wrap_pyfunction!(bounded::default_radial_json, m)?)?; @@ -1922,6 +1923,12 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { Ok(()) } +#[pyfunction] +fn persistent_settings_json(settings: &str) -> PyResult { + let state: Value = parse(settings)?; + serde_json::to_string(&edit_history::persistent(state)).map_err(runtime) +} + /// Whether a font family resolves to a registered or installed face rather /// than the bundled fallback (native text stack). #[pyfunction] diff --git a/src/radial_editor.rs b/src/radial_editor.rs index ef8589c..b2e78c5 100644 --- a/src/radial_editor.rs +++ b/src/radial_editor.rs @@ -239,6 +239,7 @@ impl Editor { .insert("properties_viewport".into(), form.inner_rect); }); } + self.review_ui(root, c); egui::CentralPanel::default() .frame(egui::Frame::NONE.inner_margin(16.)) .show(root, |ui| { diff --git a/tests/jupyter_harness.py b/tests/jupyter_harness.py index 83e2cdd..f7ff5fa 100644 --- a/tests/jupyter_harness.py +++ b/tests/jupyter_harness.py @@ -184,7 +184,7 @@ def click_button(page, selector): @contextmanager -def notebook(tmp_path, source): +def notebook(tmp_path, source, *, allow_closed=False, initial_views=1): import nbformat from playwright.sync_api import sync_playwright from jupyter_client import BlockingKernelClient @@ -270,8 +270,8 @@ def notebook(tmp_path, source): # Ready includes async pipeline compilation and GPU completion. # Cold software-GPU startup has its own budget; gesture checks # keep Playwright's normal timeout and strict result assertions. - page.wait_for_function("['Ready','WebGPU failed'].includes(document.querySelector('.figgy-editor [data-status]')?.textContent)", timeout=180000) - assert page.locator('.figgy-editor [data-status]').inner_text() == 'Ready', page.locator('.figgy-error').inner_text() + page.wait_for_function("(count)=>{const states=Array.from(document.querySelectorAll('.figgy-editor [data-status]'));return states.length===count && states.every(s=>['Ready','WebGPU failed'].includes(s.textContent));}", arg=initial_views, timeout=180000) + assert page.locator('.figgy-editor [data-status]').all_text_contents() == ['Ready']*initial_views, page.locator('.figgy-error').all_text_contents() with urllib.request.urlopen(base+'/api/sessions') as response: session = next(s for s in json.load(response) if s['path'] == 'check.ipynb') client = BlockingKernelClient(connection_file=str(runtime/f"kernel-{session['kernel']['id']}.json")) @@ -305,7 +305,10 @@ def execute(code): entry['duration_ms'] = (time.monotonic()-started)*1000 yield page, execute assert not errors - assert page.locator('.figgy-error').inner_text() == '' + if allow_closed: + assert all(value=='' for value in page.locator('.figgy-error').all_text_contents()) + else: + assert page.locator('.figgy-error').inner_text() == '' outcome = 'passed' except Exception: import traceback diff --git a/tests/test_interaction_parity.py b/tests/test_interaction_parity.py index ad1f5fb..fbd7972 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -2631,3 +2631,149 @@ def check(pixels,rect): width,height=page.locator('.figgy-editor canvas').evaluate('(c)=>[c.width,c.height]') check(Image.open(path).convert('RGB'),[rect[0]*960/width,rect[1]*600/height,rect[2]*960/width,rect[3]*600/height]) assert not page.locator('.figgy-error').inner_text() + + +@pytest.mark.skipif(NEEDS_UI, reason='requires real native and notebook review inputs') +@pytest.mark.parametrize('backend',['native','webgpu']) +@pytest.mark.parametrize('family',['cartesian','radial']) +def test_review_submission_keeps_comments_and_view_on_storage_failure(tmp_path,monkeypatch,backend,family): + from figgyplot.review import Session + setup="ax=fig.axes[0];ax.plot([0,1],[1,3],id='signal')" if family=='cartesian' else "ax=fig.axes[0];ax.pie([3,2,1])" + comments='Keep the title and adjust the legend next' + cache=tmp_path/'review-cache' + if backend=='native': + with Session(cache) as session: + fig=session.figure(id='review-chart');exec(setup,{'fig':fig}) + session.present();handle=fig._windows[-1];inputs=Inputs(handle) + inputs.click('review_comments');inputs.xdo('type','--clearmodifiers',comments);inputs.pump() + fig.axes[0].set_title('Human chart title');inputs.pump() + inputs.click('draw');inputs.click('draw_text') + px,py=native_point(inputs.info(),(350,250));inputs.click_rect([px,py,0,0]) + inputs.xdo('type','--clearmodifiers','Human annotation');inputs.pump() + inputs.click('review_changes') + assert json.loads(handle.review_request_json())['comments']==comments + assert not handle.closed + def fail(*args):raise OSError('simulated storage failure') + with monkeypatch.context() as patch: + patch.setattr(session._store,'write',fail);session.poll() + inputs.pump() + assert not handle.closed and not session.events() + assert inputs.info()['review']['comments']==comments + assert 'simulated storage failure' in inputs.info()['review']['error'] + from PIL import ImageGrab + geometry=dict(line.split('=',1) for line in inputs.xdo('getwindowgeometry','--shell',inputs.wid).splitlines() if '=' in line) + x,y,w,h=(int(geometry[key]) for key in ('X','Y','WIDTH','HEIGHT')) + ImageGrab.grab(xdisplay=os.environ['DISPLAY']).crop((x,y,x+w,y+h)).save(tmp_path/'native-review.png') + inputs.click('review_changes');session.poll();assert handle.closed + feedback=session.feedback(session.events()[0]['work_id'])['round']['documents']['review-chart'] + assert feedback['comments'][0]['text']==comments + assert comments not in json.dumps(feedback['approval']['settings']) + owner=feedback['approval']['settings']['radial' if family=='radial' else 'config'] + assert ''.join(s['text'] for s in owner['annotations']['items'][-1]['kind']['Text']['text']['segments'])=='Human annotation' + assert feedback['approval']['changes'] + return + source="import json\nfrom figgyplot.review import Session\nsession=Session("+repr(str(cache))+",backend='webgpu')\nfig=session.figure(id='review-chart')\n"+setup+"\ndisplay(session.widget(fig))" + with notebook(tmp_path/'notebook-review',source,allow_closed=True) as (page,execute): + field=page.locator('[data-review-comments]');field.fill(comments);field.press('Tab') + execute("fig.axes[0].set_title('Human chart title'); await fig.sync()") + execute("real_write=session._store.write\ndef fail_write(*args):\n raise OSError('simulated storage failure')\nsession._store.write=fail_write") + click_button(page,'[data-draw]');click_button(page,'[data-tool=text]') + page.mouse.click(*browser_point(page,(350,250))) + page.locator('[data-inline-text]').fill('Human annotation') + click_button(page,'[data-review-changes]') + page.wait_for_function("document.querySelector('[data-review-status]').textContent.includes('simulated storage failure')") + assert field.input_value()==comments + assert json.loads(execute('print(json.dumps(session.events()))'))==[] + screenshot_editor(page,tmp_path/'browser-review.png') + execute('session._store.write=real_write') + click_button(page,'[data-review-changes]') + page.wait_for_function("!document.querySelector('.figgy-editor')") + feedback=json.loads(execute('print(json.dumps(session.feedback(session.events()[0]["work_id"])))'))['round']['documents']['review-chart'] + assert feedback['comments'][0]['text']==comments + assert comments not in json.dumps(feedback['approval']['settings']) + owner=feedback['approval']['settings']['radial' if family=='radial' else 'config'] + assert ''.join(s['text'] for s in owner['annotations']['items'][-1]['kind']['Text']['text']['segments'])=='Human annotation' + assert feedback['approval']['changes'] + + +@pytest.mark.skipif(NEEDS_UI, reason='requires real native review input') +@pytest.mark.parametrize('family',['cartesian','radial']) +def test_native_review_long_comments_keep_approval_controls_reachable(tmp_path,family): + from figgyplot.review import Session + comments='\n'.join('Review note '+str(i) for i in range(40)) + with Session(tmp_path/'cache') as session: + fig=session.figure(id='review-chart') + if family=='radial':fig.axes[0].pie([3,2,1]) + else:fig.axes[0].plot([0,1],[1,3],id='signal') + session.present();handle=fig._windows[-1];inputs=Inputs(handle) + with pytest.raises(RuntimeError,match='already has'):fig.show(block=False) + session.comment(fig.id,'Preserve the original data',target={'series_id':'signal'}) + inputs.click('review_comments') + import subprocess + subprocess.run(['xclip','-selection','clipboard'],input=comments,text=True, + stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,check=True) + inputs.key('ctrl+v');inputs.pump();session.poll() + info=inputs.info();width,height=info['surface_size'] + for key in ['review_comments','review_changes','review_accept']: + x,y,w,h=info['controls'][key] + assert 0<=xs.textContent.includes('duplicate review views'))") + assert page.locator('.figgy-editor').count()==2 + assert fields.nth(1).input_value()=='Second view draft' + assert json.loads(execute('print(json.dumps(session.events()))'))==[] + assert json.loads(execute('print(json.dumps(session.snapshot()["rounds"][-1]["documents"]["review-chart"]["approval"]))')) is None + execute('session.cancel()') + + +@pytest.mark.skipif(NEEDS_UI, reason='requires real native and notebook review rounds') +@pytest.mark.parametrize('backend',['native','webgpu']) +def test_review_controller_displays_a_complete_regenerated_round(tmp_path,backend): + from figgyplot.review import Session + source=tmp_path/'builder.py';cache=tmp_path/'cache' + def builder(title, extra=False): + text="def build(session):\n fig=session.figure(id='review-round')\n fig.axes[0].plot([0,1],[1,3],id='signal')\n fig.axes[0].set_title("+repr(title)+")\n" + if extra:text+=" other=session.figure(id='second-result')\n other.axes[0].plot([0,1],[3,1],id='other')\n" + return text + source.write_text(builder('Initial title')) + if backend=='native': + with Session(cache,source={'entrypoint':str(source)}) as session: + namespace={};exec(source.read_text(),namespace);namespace['build'](session) + session.present();old=session._figures['review-round']._windows[-1] + inputs=Inputs(old);inputs.click('review_comments');inputs.xdo('type','--clearmodifiers','Add the second chart');inputs.pump() + inputs.click('review_changes');session.poll();assert old.closed + source.write_text(builder('Updated source title',True)) + agent=Session.open(cache);work=agent.events()[0]['work_id'];assert agent.regenerate(work)==2 + session.poll();Inputs.pump() + assert set(session._handles)=={'review-round','second-result'} + assert all(not h.closed for h in session._handles.values()) + state=session._figures['review-round'].get_settings() + assert ''.join(s['text'] for s in state['config']['chart_title']['text']['segments'])=='Updated source title' + session.cancel() + return + initial="import asyncio,json,runpy\nfrom pathlib import Path\nfrom figgyplot.review import Session\nsession=Session("+repr(str(cache))+",source={'entrypoint':"+repr(str(source))+"},backend='webgpu')\nrunpy.run_path("+repr(str(source))+")['build'](session)\nfig=session._figures['review-round']\ndisplay(session.widget(fig))\nwatcher=asyncio.create_task(session.watch())" + with notebook(tmp_path/'round-publication',initial,allow_closed=True) as (page,execute): + page.locator('[data-review-comments]').fill('Add the second chart') + click_button(page,'[data-review-changes]') + page.wait_for_function("!document.querySelector('.figgy-editor')") + source.write_text(builder('Updated source title',True)) + result=execute("agent=Session.open("+repr(str(cache))+"); work=agent.events()[0]['work_id']; print(await asyncio.to_thread(agent.regenerate,work))") + assert result.strip()=='2' + page.wait_for_function("document.querySelectorAll('.figgy-editor').length===2 && [...document.querySelectorAll('.figgy-editor [data-status]')].every(s=>s.textContent==='Ready')",timeout=180000) + assert json.loads(execute("print(json.dumps(sorted(session._figures)))"))==['review-round','second-result'] + page.wait_for_function("[...document.querySelectorAll('.figgy-editor')].every(r=>{const d=r.figgy.diagnostics();return !d.dirty&&!d.frame_pending&&!d.busy&&!d.queued;})",polling=100,timeout=180000) + for index in range(2):page.locator('.figgy-editor').nth(index).screenshot(path=str(tmp_path/f'complete-next-round-{index}.png')) + execute('watcher.cancel(); session.cancel()') diff --git a/tests/test_review.py b/tests/test_review.py new file mode 100644 index 0000000..e9adcbe --- /dev/null +++ b/tests/test_review.py @@ -0,0 +1,370 @@ +"""Durability, turn barriers and executable-source reconciliation for review.""" +import json +from pathlib import Path +import subprocess +import sys + +import numpy as np +import pytest + +import figgyplot as fg +from figgyplot.review import Session +from figgyplot._review_diff import changes, persistent, preserved + + +def create(session, id='plot', family='line'): + fig=session.figure(id=id) + ax=fig.axes[0] + if family=='line':ax.plot([0,1],[2,3],id='signal') + elif family=='mapped':ax.scatter([0,1],[2,3],c=[1,2],s=[2,4],id='mapped') + elif family=='matrix':ax.imshow([[1,2],[3,4]],id='field') + elif family=='vector':ax.quiver([0,1],[1,2],[2,1],[1,3],id='arrows') + elif family=='pie':ax.pie([3,2,1]) + elif family=='bar':ax.bar(['A','B'],[3,4]) + elif family=='box':ax.boxplot([[1,2,3],[2,4,6]]) + elif family=='radar':ax.radar(['A','B','C'],[1,2,3]) + elif family=='panels': + fig.set_layout([['main','other']]);ax.plot([0,1],[1,2],id='one') + fig.subplots['other'].axes.twinx(id='pressure').plot([0,1],[3,4],id='two') + return fig + + +@pytest.mark.parametrize('family',['line','mapped','matrix','vector','pie','bar','box','radar','panels']) +@pytest.mark.parametrize('backend',['native','webgpu']) +def test_complete_binary_checkpoint_restores_all_families(tmp_path,family,backend): + with Session(tmp_path/'cache',backend=backend) as session: + fig=create(session,family=family) + session.start();baseline=fig.get_settings() + fig.axes[0].set_title('Approved title') + approved=fig.get_settings();counter='column_uploads' if backend=='native' else 'transfer_snapshot_bytes';uploads=fig.info[counter] + session.submit('plot','request_changes',comments='Keep this title',expected_revision=fig.revision) + assert fig.info[counter]==uploads + signal=session.events()[0] + assert 'settings' not in signal and 'columns' not in signal + assert session.events()==[signal] + checkpoint=session.feedback(signal['work_id'])['round']['documents']['plot'] + assert checkpoint['baseline']==baseline and checkpoint['approval']['settings']==approved + if family in ('pie','bar','box','radar'):assert checkpoint['bounded_data'] + assert 'Keep this title' not in json.dumps(approved) + with Session.open(tmp_path/'cache') as resumed: + recovered=resumed.restore('plot',backend=backend) + assert recovered.get_settings()==approved + if backend=='native':assert recovered.to_png().startswith(b'\x89PNG') + assert resumed.events()==[signal] + + +def test_all_figure_barrier_comments_and_idempotent_submission(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + a=create(session,'a');b=create(session,'b') + session.start();a.axes[0].set_title('Human edit') + accepted=session.submit('a','request_changes',comments='Move the legend') + assert not session.events() + with pytest.raises(RuntimeError,match='approved'):a.axes[0].set_title('Too late') + assert session.submit('a','request_changes',comments='Move the legend')==accepted + session.submit('b','accept') + first=session.events();assert len(first)==1 + assert Session.open(tmp_path).events()==first + with pytest.raises(ValueError,match='different'):session.submit('a','accept') + session.reopen('a') + assert not session.events() + with pytest.raises(ValueError,match='Stale'):session.feedback(first[0]['work_id']) + a.axes[0].set_title('Revised human edit') + session.submit('a','accept');assert len(session.events())==1 + + +def test_accept_without_changes_and_window_close_are_distinct(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + fig=create(session);session.start() + fig.close() + assert session.snapshot()['rounds'][-1]['documents']['plot']['approval'] is None + assert not session.events() + session.restore('plot');session.submit('plot','accept') + assert session.snapshot()['rounds'][-1]['status']=='completed' + assert not session.events() + + +def test_ui_comment_edits_preserve_separately_targeted_api_comments(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + create(session);session.start() + session.comment('plot','Keep the data',target={'series_id':'signal'}) + session._set_comment_text('plot','Adjust the title') + session._set_comment_text('plot','') + doc=session.snapshot()['rounds'][-1]['documents']['plot'] + assert len(doc['comments'])==1 + assert doc['comments'][0]['text']=='Keep the data' + session.submit('plot','request_changes',comments='Use a larger font') + comments=session.feedback(session.events()[0]['work_id'])['round']['documents']['plot']['comments'] + assert [c['text'] for c in comments]==['Keep the data','Use a larger font'] + assert comments[0]['target']=={'series_id':'signal'} + + +def test_cache_creation_and_open_do_not_claim_foreign_directories(tmp_path): + foreign=tmp_path/'data';foreign.mkdir() + payload=foreign/('0'*64+'.npy');payload.write_bytes(b'foreign file') + before=sorted(p.relative_to(tmp_path) for p in tmp_path.rglob('*')) + with pytest.raises(ValueError,match='empty|dedicated'):Session(tmp_path) + assert sorted(p.relative_to(tmp_path) for p in tmp_path.rglob('*'))==before + assert payload.read_bytes()==b'foreign file' + missing=tmp_path/'absent' + with pytest.raises(ValueError,match='manifest|cache'):Session.open(missing) + assert not missing.exists() + + +def test_approval_retry_closes_views_outside_manifest_lock(tmp_path): + # A close observer can inspect session state. A nested file lock would hang, + # so isolate this regression in a process with a finite parent-side timeout. + code=''' +import json,sys +from pathlib import Path +from figgyplot.review import Session +import figgyplot._review_store as storage +cache=Path(sys.argv[1]) +with Session(cache,backend='webgpu') as session: + fig=session.figure(id='plot');fig.axes[0].plot([0,1],[1,2]);session.start() + class Handle: + closed=False + def close(self): + assert session.status['state']=='ready' + self.closed=True + handle=Handle();fig._windows.append(handle) + original=storage._sync_directory + def fail(path): + state=json.loads((cache/'manifest.json').read_text()) + if path==cache and state['rounds'][-1]['documents']['plot']['approval'] is not None: + raise OSError('post-rename failure') + original(path) + storage._sync_directory=fail + try: + session.submit('plot','request_changes') + except OSError:pass + finally:storage._sync_directory=original + assert not handle.closed + session.submit('plot','request_changes') + assert handle.closed and len(session.events())==1 +''' + subprocess.run([sys.executable,'-c',code,str(tmp_path)],check=True,timeout=15) + + +def test_atomic_failure_preserves_old_checkpoint_and_keeps_view_open(tmp_path,monkeypatch): + import figgyplot._review_store as storage + class Handle: + closed=False + def close(self):self.closed=True + with Session(tmp_path,backend='webgpu') as session: + fig=create(session);session.start();handle=Handle();fig._windows.append(handle) + original=(tmp_path/'manifest.json').read_bytes() + def fail(*args):raise OSError('disk unavailable') + with monkeypatch.context() as m: + m.setattr(storage.os,'replace',fail) + with pytest.raises(OSError,match='disk unavailable'):session.submit('plot','request_changes',comments='Retry me') + assert (tmp_path/'manifest.json').read_bytes()==original + assert not handle.closed and not session.events() + session.submit('plot','request_changes',comments='Retry me') + assert handle.closed + assert session.feedback(session.events()[0]['work_id'])['round']['documents']['plot']['comments'][0]['text']=='Retry me' + + +def test_stale_revision_and_corrupt_data_reject_without_approval(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + fig=create(session);session.start();rev=fig.revision + fig.axes[0].set_title('Newer') + with pytest.raises(ValueError,match='Stale'):session.submit('plot','accept',expected_revision=rev) + ref=next(iter(session.snapshot()['rounds'][-1]['documents']['plot']['columns'].values())) + (tmp_path/ref['file']).write_bytes(b'broken') + with pytest.raises(ValueError,match='integrity'):session.submit('plot','accept') + assert session.snapshot()['rounds'][-1]['documents']['plot']['approval'] is None + with pytest.raises(ValueError,match='integrity'):Session.open(tmp_path) + + +def test_input_snapshot_survives_user_mutation_transfer_release_and_cleanup(tmp_path): + with Session(tmp_path,backend='webgpu') as session: + fig=session.figure(id='plot');x=np.array([1,2,3],dtype=np.float32) + fig.axes[0].plot(x,x,id='s');session.start() + doc=session.snapshot()['rounds'][-1]['documents']['plot'] + assert doc['columns']['s/x']['sha256']==doc['columns']['s/y']['sha256'] + x[:]=99;fig._native.acknowledged();assert not fig._native.columns + orphan=tmp_path/'data'/('0'*64+'.npy');orphan.write_bytes(b'unreferenced') + resumed=Session.open(tmp_path);assert not orphan.exists() + ref=doc['columns']['s/x'];assert np.array_equal(resumed._store.get(ref),[1,2,3]) + assert resumed._store.get(ref).dtype==np.dtype(' Date: Thu, 22 Oct 2026 02:28:02 +0000 Subject: [PATCH 03/11] Fix native click handling and inspector input layers --- .github/workflows/pr-tests.yml | 8 +- docs/HOSTS.md | 7 + docs/REVIEW_MODE_VALIDATION.md | 65 +- .../ci51-native-click-duration-20261022.json | 170 + .../native-buffered-click-20261022.json | 4581 +++++++++++++++++ .../native-inspector-menu-after-20261022.png | Bin 0 -> 83347 bytes .../native-inspector-menu-before-20261022.png | Bin 0 -> 85536 bytes ...native-inspector-popup-layer-20261022.json | 1450 ++++++ src/bounded_host.rs | 42 +- src/desktop.rs | 22 +- src/editor.rs | 44 +- tests/jupyter_harness.py | 21 + tests/native_input.py | 10 + tests/test_desktop_editor.py | 3 +- tests/test_desktop_host.py | 15 +- tests/test_interaction_parity.py | 49 +- tests/test_radial_desktop.py | 45 +- 17 files changed, 6496 insertions(+), 36 deletions(-) create mode 100644 docs/measurements/ci51-native-click-duration-20261022.json create mode 100644 docs/measurements/native-buffered-click-20261022.json create mode 100644 docs/measurements/native-inspector-menu-after-20261022.png create mode 100644 docs/measurements/native-inspector-menu-before-20261022.png create mode 100644 docs/measurements/native-inspector-popup-layer-20261022.json create mode 100644 tests/native_input.py diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 10cf638..8a0c630 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -161,7 +161,7 @@ jobs: trap 'kill "$wm_pid" 2>/dev/null || true' EXIT # Reject the observed stale-font/box failures before the full suite. # The queue-order cases must fail on the original a1e79a8 wheel. - .venv/bin/python -m pytest -vv --capture=tee-sys --durations=20 \ + .venv/bin/python -m pytest -x -vv --capture=tee-sys --durations=20 \ --basetemp=ci-results/layout-probe --junitxml=ci-results/layout-probe.xml \ 'tests/test_interaction_parity.py::test_inline_editor_resize_uses_current_units_and_applies_real_edits[1]' \ 'tests/test_interaction_parity.py::test_inline_editor_resize_uses_current_units_and_applies_real_edits[2]' \ @@ -170,12 +170,14 @@ jobs: 2>&1 | tee ci-results/layout-probe.log # Measure the last failed radial screenshot before the full suite. # Await current surface completion before the unchanged capture deadline. - .venv/bin/python -m pytest -vv --capture=tee-sys --durations=20 \ + .venv/bin/python -m pytest -x -vv --capture=tee-sys --durations=20 \ --basetemp=ci-results/priority --junitxml=ci-results/priority.xml \ tests/test_packaged_assets.py \ + 'tests/test_radial_desktop.py::test_click_remains_a_short_press_when_frames_are_slow' \ + 'tests/test_interaction_parity.py::test_radial_detail_tree_selection_edits_only_the_chosen_child[native]' \ 'tests/test_interaction_parity.py::test_annotation_drawing_editing_and_cancel_in_both_hosts[radial]' \ 2>&1 | tee ci-results/priority.log - .venv/bin/python -m pytest -vv --capture=tee-sys --durations=30 \ + .venv/bin/python -m pytest -x -vv --capture=tee-sys --durations=30 \ --basetemp=ci-results/full --junitxml=ci-results/junit.xml \ 2>&1 | tee ci-results/full.log SCRIPT diff --git a/docs/HOSTS.md b/docs/HOSTS.md index 8bcc865..66588c3 100644 --- a/docs/HOSTS.md +++ b/docs/HOSTS.md @@ -97,6 +97,13 @@ presentation or completed GPU execution. `benchmarks/ui_surface.py` measures surface submission rate; the existing `compare.py` measures completed RGBA readback. Do not compare the two as the same FPS metric. +Both native chart families also report `pointer_sequence` and `pointer_input` +for the last cursor-motion or mouse-button event included in a submitted surface frame. The event +records its kind, physical client coordinates, button/state and egui's consumed +flag. These diagnose delivered input independently of chart SSOT or selection; +a surface-frame increment alone does not identify which input was processed. +The UI harness retains bounded before/after input records and timestamps. + ## Jupyter Native and Jupyter WebGPU hosts support Cartesian, radial, categorical and boxplot diff --git a/docs/REVIEW_MODE_VALIDATION.md b/docs/REVIEW_MODE_VALIDATION.md index ac3125b..2c97f90 100644 --- a/docs/REVIEW_MODE_VALIDATION.md +++ b/docs/REVIEW_MODE_VALIDATION.md @@ -3,7 +3,7 @@ This records local validation of the unreleased #18 implementation. Required GitHub checks still run on the draft PR's exact commit. -## Final wheel +## Initial review implementation wheel - CPython 3.12.14, NumPy 2.5.4, Linux, Mesa software Vulkan, Chromium 154. - Wheel SHA-256: `93e14460b0430002f65d284b11dd99532864c52bc2ec6006f8fd215ff16103b2`. @@ -46,11 +46,68 @@ Additional regressions first failed against the preceding wheel, then passed: ## Earlier full-suite run Before those final review-specific fixes and six added regressions, one complete -UI-enabled invocation passed **633 tests in 2460.86 seconds**. The final wheel was +UI-enabled invocation passed **633 tests in 2460.86 seconds**. The then-current wheel was then rebuilt and the 46 review cases, four existing layout probes and preflight -checks above were rerun. This is not a claim that the final wheel completed a -second full-suite invocation locally. The draft PR's CI supplies that check. +checks above were rerun. Those results belong to the initial implementation; +the follow-up artifact and complete run are recorded separately below. No thresholds, gesture assertions, pixel tolerances or CI time limits were relaxed. The notebook harness now accepts an explicit expected initial view count and requires every such view to reach Ready within the existing startup budget. + +## Native input and popup follow-up + +The same draft PR also fixes a short-click failure exposed while diagnosing +CI #51. Its workflow/assets check and wheel build passed, but integration stopped +near the existing 45-minute budget after recording a native radial-detail failure. +The job log did not reach the final failure traceback before cancellation. + +A local reproduction of that scenario measured the old input driver's button +hold at 0.8216 seconds on failure and 0.7937 seconds on success. Egui 0.36.2's +default click limit is 0.8 seconds. The driver now sends a complete X11 click +before waiting for rendering; intentional drag input remains separate. A shared +helper covers the native test drivers. An injected 0.9-second frame delay fails +the preceding driver and passes the corrected driver. [The recorded input and +timing evidence](measurements/ci51-native-click-duration-20261022.json) separates +the local cause from what the cancelled CI log establishes. + +The first full run with the corrected click driver stopped with **219 passed +and one failed** in 772.35 seconds, exposing a separate application issue. The Cartesian +inspector shared the Foreground order used by popup menus and could intercept a +reopened menu's item click. The inspector now uses Middle, keeping menus above +it. Review controls reserve their space before inspector sizing. [The actual +menu hit layers, before/after settings and review bounds](measurements/native-inspector-popup-layer-20261022.json) +record both fixes. Raw screenshots of the synthetic fixture show the [occluded menu before](measurements/native-inspector-menu-before-20261022.png) and the [visible menu after](measurements/native-inspector-menu-after-20261022.png). Five focused cases passed in 124.92 seconds, including native +and browser opposite-axis editing, properties with long review comments, and +the slow-frame click regression. + +A subsequent full run stopped with **243 passed and one failed** in 1201.72 +seconds. A native boxplot text-placement click exposed another input assumption: +egui clears `press_origin` after processing a release, even when both press and +release are buffered in one frame. Bounded drawing now recovers that frame's +actual primary-button press coordinate from the ordered raw events. Existing +image hit tests, drag previews and release commits remain intact. [The failed +placement, successful placement and paired raw events](measurements/native-buffered-click-20261022.json) +show `clicked=true`, a cleared snapshot origin and the recovered coordinate. +Opening the text draft leaves the complete chart SSOT identical to its armed +state. All **eight chart-family cases passed in 352.12 seconds**, each exercising +native and browser creation, text editing, cancellation, drawing and export. + +Native diagnostics retain the last submitted pointer event and bounded input +records. Browser diagnostics separate server/browser startup, view readiness, +the test body, collection and shutdown in `harness-timing.json`. CI stops at the +first failure to print its traceback immediately, and runs the two click/detail +regressions in the priority group. The full collection, existing 45-minute budget, +pixel assertions and gesture deadlines remain unchanged. The opposite-axis SSOT +assertions that detected the application failure are unchanged. + +The final follow-up wheel's SHA-256 is +`88e7f7cc4bfa8edf93ae9240f289144b530fc493dbe1f73a66355fb7b1537c2b`. +The complete UI-enabled suite passed **640 tests in 2444.04 seconds (40m 44s)** +on that wheel, with **zero failures, errors or skips**. This includes the full +review workflow and the existing native/Jupyter interaction, layout, pixel and +SSOT assertions, with all CI interaction/timing flags enabled. Current preflight +passed all **117 cases in 3.02 seconds**; Cargo format/check passed. All 33 installed runtime +files match the wheel byte for byte, including the native extension; its 32 +non-native runtime files also match their packaged source files. Installed-wheel +smoke and editor-control inventory checks passed. diff --git a/docs/measurements/ci51-native-click-duration-20261022.json b/docs/measurements/ci51-native-click-duration-20261022.json new file mode 100644 index 0000000..e950ff2 --- /dev/null +++ b/docs/measurements/ci51-native-click-duration-20261022.json @@ -0,0 +1,170 @@ +{ + "scenario": "Native radial detail series selection through real X11 input", + "ci": { + "run": 39656619970, + "job": 119421653668, + "full_collection": 639, + "observed_failed_case": "test_radial_detail_tree_selection_edits_only_the_chosen_child[native]", + "started_utc": "2026-10-21T18:58:40Z", + "cancelled_utc": "2026-10-21T19:43:57Z", + "job_budget_minutes": 45, + "note": "CI log records the failed case but no final traceback before cancellation. Detailed cause below is reproduced locally for the same case." + }, + "local_reproduction": { + "failed": { + "press_to_release_command_seconds": 0.8215802909980994, + "target_rect": [ + 1069.0, + 224.0, + 243.0, + 24.0 + ], + "press": { + "button": "Left", + "consumed": true, + "kind": "button", + "position": [ + 1190.0, + 236.0 + ], + "state": "Pressed" + }, + "release": { + "button": "Left", + "consumed": true, + "kind": "button", + "position": [ + 1190.0, + 236.0 + ], + "state": "Released" + }, + "selected_after_release": null, + "surface_frames": [ + 45, + 52 + ] + }, + "passed": { + "press_to_release_command_seconds": 0.793658811977366, + "target_rect": [ + 1069.0, + 224.0, + 243.0, + 24.0 + ], + "press": { + "button": "Left", + "consumed": true, + "kind": "button", + "position": [ + 1190.0, + 236.0 + ], + "state": "Pressed" + }, + "release": { + "button": "Left", + "consumed": true, + "kind": "button", + "position": [ + 1190.0, + 236.0 + ], + "state": "Released" + }, + "selected_after_release": { + "group": 1, + "index": 1 + }, + "surface_frames": [ + 50, + 57 + ] + }, + "failure": "KeyError: section:Series style; the detail slice remained unselected" + }, + "input_contract": { + "egui_version": "0.36.2", + "max_click_duration_seconds": 0.8, + "source": "https://docs.rs/egui/0.36.2/egui/struct.InputOptions.html#structfield.max_click_duration" + }, + "regression": { + "test": "test_click_remains_a_short_press_when_frames_are_slow", + "injected_frame_delay_seconds": 0.9, + "before_fix": "failed: properties_open was false", + "fix": "Send X11 button press/release as one xdotool click before waiting for rendered frames.", + "after_fix": "passed with the same injected frame delay" + }, + "validation": { + "focused_passed": 3, + "focused_seconds": 94.07, + "cases": [ + "test_click_remains_a_short_press_when_frames_are_slow", + "test_radial_detail_tree_selection_edits_only_the_chosen_child[native]", + "test_radial_detail_tree_selection_edits_only_the_chosen_child[webgpu]" + ], + "full_suite": { + "tests": 640, + "failures": 0, + "errors": 0, + "skipped": 0, + "seconds": 2444.032, + "wheel_sha256": "88e7f7cc4bfa8edf93ae9240f289144b530fc493dbe1f73a66355fb7b1537c2b" + }, + "full_suite_attempts": [ + { + "collected": 640, + "passed": 219, + "failed": 1, + "seconds": 772.35, + "failed_case": "test_matching_opposite_axes_share_only_ui_edited_fields[x]", + "cause": "The Cartesian inspector intercepted its reopened popup menu." + }, + { + "collected": 640, + "passed": 243, + "failed": 1, + "seconds": 1201.72, + "failed_case": "test_annotation_drawing_editing_and_cancel_in_both_hosts[boxplot]", + "cause": "Native bounded drawing relied on press_origin, which egui clears after a press and release buffered in the same frame." + }, + { + "collected": 640, + "passed": 640, + "failed": 0, + "seconds": 2444.04, + "wheel_sha256": "88e7f7cc4bfa8edf93ae9240f289144b530fc493dbe1f73a66355fb7b1537c2b" + } + ] + }, + "ci_runtime_comparison": { + "run50": { + "run_id": 39630843161, + "full_suite_passed": 593, + "full_suite_seconds": 1781.99, + "seconds_from_full_start_to_native_detail_result": 1603 + }, + "run51": { + "run_id": 39656619970, + "full_collection": 639, + "seconds_from_full_start_to_native_detail_result": 2250, + "full_suite_complete": false + }, + "common_browser_fixtures": 77, + "host": { + "logical_cpus": 4, + "affinity_cpus": 4, + "chromium_version": "156.0.8078.4" + }, + "median_fixture_max_pipeline_ms": { + "run50": 432.9, + "run51": 542.3 + }, + "median_fixture_page_time_ms": { + "run50": 8975.8, + "run51": 11939.7 + }, + "interpretation": "Slower software-GPU/host timing is measured. This comparison does not identify all causes of the wall-time increase. New harness phase records separate startup, test, diagnostics and shutdown. No deadline is increased." + } +} diff --git a/docs/measurements/native-buffered-click-20261022.json b/docs/measurements/native-buffered-click-20261022.json new file mode 100644 index 0000000..a886a2b --- /dev/null +++ b/docs/measurements/native-buffered-click-20261022.json @@ -0,0 +1,4581 @@ +{ + "scenario": "A short native text-placement click on a boxplot is buffered into one egui frame", + "before": { + "wheel_sha256": "f5de068f208dbdefec7564cf5b07be418f329538fb07348ee8e6b48272972ed8", + "test": "test_annotation_drawing_editing_and_cancel_in_both_hosts[boxplot]", + "result": "failed: inline_text was absent after a complete X11 click", + "seconds": 4.9, + "armed": { + "stage": "text-armed", + "settings": { + "schema_version": 2, + "renderer_version": "0.18.1", + "config": { + "chart_area": { + "x": 0, + "y": 0, + "width": 960, + "height": 600 + }, + "top_x": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "bottom_x": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 80.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "left_y": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 110.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "right_y": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "chart_title": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 28.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0, + "top_margin": 32.0 + }, + "grid": { + "show_major_x": true, + "major_x_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_x_width": 1.0, + "major_x_style": "Solid", + "show_major_y": true, + "major_y_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_y_width": 1.0, + "major_y_style": "Solid", + "show_minor_x": false, + "minor_x_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_x_width": 0.5, + "minor_x_style": "Dot", + "show_minor_y": false, + "minor_y_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_y_width": 0.5, + "minor_y_style": "Dot" + }, + "legend": { + "visible": false, + "content": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 14.0, + "font": "" + }, + "corner": "TopRight", + "offset_x": 0.0, + "offset_y": 0.0, + "padding": 8.0, + "bg_color": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 0.85 + }, + "border_color": { + "r": 0.6, + "g": 0.6, + "b": 0.6, + "a": 1.0 + } + } + }, + "series": [], + "boxplot": { + "decorations": { + "data_area": null, + "offsets": [], + "selected": null + }, + "title": "", + "categories": [ + { + "id": "category_0", + "label": "Box 1" + }, + { + "id": "category_1", + "label": "Box 2" + } + ], + "series": [ + { + "id": "series_0", + "label": "Distribution", + "values": [ + { + "q1": 1.5, + "median": 2.0, + "q3": 3.0, + "whisker_low": 1.0, + "whisker_high": 4.0, + "mean": 2.3333333333333335, + "sample_count": 3, + "median_ci": null, + "outliers": [] + }, + { + "q1": 3.5, + "median": 5.0, + "q3": 7.0, + "whisker_low": 2.0, + "whisker_high": 9.0, + "mean": 5.333333333333333, + "sample_count": 3, + "median_ci": null, + "outliers": [] + } + ], + "color": { + "r": 0.12156863, + "g": 0.46666667, + "b": 0.7058824, + "a": 1.0 + }, + "style": null + } + ], + "direction": "Vertical", + "style": { + "material": "Matte", + "corner_radius": 0.0, + "outline": true, + "outline_width": 1.2, + "outline_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "median_width": 1.2, + "median_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "whisker_width": 1.2, + "whisker_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "caps": true, + "cap_ratio": 0.5, + "notched": false, + "notch_depth": 0.22, + "show_mean": false, + "mean_size": 7.0, + "mean_color": { + "r": 0.7058824, + "g": 0.1764706, + "b": 0.1764706, + "a": 1.0 + }, + "show_outliers": true, + "outlier_size": 7.0, + "outlier_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "outlier_shape": "Circle", + "outlier_filled": false, + "texture_strength": 0.2, + "texture_scale": 1.0, + "gloss": 0.45, + "emphasis_brightness": 0.08 + }, + "overrides": [], + "hovered": null, + "selected": null, + "labels": "None", + "label_decimals": 1, + "value_suffix": "", + "value_title": "", + "category_title": "", + "value_range": null, + "grid": true, + "legend": true, + "group_width": 0.65, + "box_gap": 8.0, + "font_family": "sans-serif", + "font_size": 18.333334, + "text_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "background": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 1.0 + } + }, + "identity": { + "figure": "2c2545dd-b73b-4701-b515-8bf79e7f47f4", + "groups": {}, + "layout": null, + "panels": [ + { + "id": "main", + "overlays": [] + } + ], + "retired": [] + } + }, + "host": { + "chart_family": "boxplot", + "chart_panel": [ + 45, + 135, + 901, + 563 + ], + "column_uploads": 0, + "controls": { + "advanced": [ + 448.59375, + 13.5, + 152.15625, + 30.0 + ], + "copy": [ + 610.75, + 10.5, + 36.0, + 36.0 + ], + "draw": [ + 335.5625, + 13.5, + 57.03125, + 30.0 + ], + "draw_arrow": [ + 139.125, + 60.0, + 60.4375, + 30.0 + ], + "draw_data_anchor": [ + 495.0625, + 60.0, + 90.59375, + 30.0 + ], + "draw_hline": [ + 209.5625, + 60.0, + 57.40625, + 30.0 + ], + "draw_hspan": [ + 343.4375, + 60.0, + 66.28125, + 30.0 + ], + "draw_select": [ + 8.0, + 60.0, + 60.0, + 30.0 + ], + "draw_text": [ + 78.0, + 60.0, + 51.125, + 30.0 + ], + "draw_vline": [ + 276.96875, + 60.0, + 56.46875, + 30.0 + ], + "draw_vspan": [ + 419.71875, + 60.0, + 65.34375, + 30.0 + ], + "edit_area": [ + 289.5625, + 10.5, + 36.0, + 36.0 + ], + "fit": [ + 402.59375, + 10.5, + 36.0, + 36.0 + ], + "layout_mode": [ + 289.5625, + 10.5, + 36.0, + 36.0 + ], + "properties": [ + 197.5625, + 10.5, + 36.0, + 36.0 + ], + "redo": [ + 130.9375, + 13.5, + 56.625, + 30.0 + ], + "save": [ + 656.75, + 10.5, + 36.0, + 36.0 + ], + "select_mode": [ + 243.5625, + 10.5, + 36.0, + 36.0 + ], + "undo": [ + 63.15625, + 13.5, + 57.78125, + 30.0 + ] + }, + "decoration_selected": null, + "decorations": { + "axis_bottom": [ + 41.39225387573242, + 508.3333740234375, + 897.6077270507812, + 6.0 + ], + "axis_left": [ + 41.39225387573242, + 29.0, + 6.0, + 485.3333740234375 + ], + "data_area": [ + 44.39225387573242, + 32.0, + 891.6077270507812, + 479.3333740234375 + ], + "legend": [ + 417.14874267578125, + 556.8780517578125, + 117.03582763671875, + 12.78863525390625 + ], + "tick_labels_bottom": [ + 243.8538360595703, + 524.8779907226562, + 492.6845703125, + 12.78863525390625 + ], + "tick_labels_left": [ + 14.0, + 25.07803726196289, + 20.392257690429688, + 492.1219787597656 + ] + }, + "drawing_tool": "text", + "error": "", + "figure_id": 1, + "gpu_context_id": 1, + "host": "winit-wgpu", + "inspector_overlay": false, + "layout_mode": false, + "png_encodes": 0, + "pointer_input": { + "button": "Left", + "consumed": true, + "kind": "button", + "position": [ + 105.0, + 75.0 + ], + "state": "Released" + }, + "pointer_sequence": 22, + "properties_open": false, + "review": null, + "revision": 2, + "rgba_readbacks": 0, + "scale_factor": 1.0, + "selected": null, + "surface_frames": 77, + "surface_size": [ + 992, + 744 + ], + "window_role": "chart" + } + }, + "placed": { + "stage": "text-placed", + "settings": { + "schema_version": 2, + "renderer_version": "0.18.1", + "config": { + "chart_area": { + "x": 0, + "y": 0, + "width": 960, + "height": 600 + }, + "top_x": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "bottom_x": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 80.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "left_y": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 110.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "right_y": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "chart_title": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 28.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0, + "top_margin": 32.0 + }, + "grid": { + "show_major_x": true, + "major_x_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_x_width": 1.0, + "major_x_style": "Solid", + "show_major_y": true, + "major_y_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_y_width": 1.0, + "major_y_style": "Solid", + "show_minor_x": false, + "minor_x_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_x_width": 0.5, + "minor_x_style": "Dot", + "show_minor_y": false, + "minor_y_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_y_width": 0.5, + "minor_y_style": "Dot" + }, + "legend": { + "visible": false, + "content": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 14.0, + "font": "" + }, + "corner": "TopRight", + "offset_x": 0.0, + "offset_y": 0.0, + "padding": 8.0, + "bg_color": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 0.85 + }, + "border_color": { + "r": 0.6, + "g": 0.6, + "b": 0.6, + "a": 1.0 + } + } + }, + "series": [], + "boxplot": { + "decorations": { + "data_area": null, + "offsets": [], + "selected": null + }, + "title": "", + "categories": [ + { + "id": "category_0", + "label": "Box 1" + }, + { + "id": "category_1", + "label": "Box 2" + } + ], + "series": [ + { + "id": "series_0", + "label": "Distribution", + "values": [ + { + "q1": 1.5, + "median": 2.0, + "q3": 3.0, + "whisker_low": 1.0, + "whisker_high": 4.0, + "mean": 2.3333333333333335, + "sample_count": 3, + "median_ci": null, + "outliers": [] + }, + { + "q1": 3.5, + "median": 5.0, + "q3": 7.0, + "whisker_low": 2.0, + "whisker_high": 9.0, + "mean": 5.333333333333333, + "sample_count": 3, + "median_ci": null, + "outliers": [] + } + ], + "color": { + "r": 0.12156863, + "g": 0.46666667, + "b": 0.7058824, + "a": 1.0 + }, + "style": null + } + ], + "direction": "Vertical", + "style": { + "material": "Matte", + "corner_radius": 0.0, + "outline": true, + "outline_width": 1.2, + "outline_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "median_width": 1.2, + "median_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "whisker_width": 1.2, + "whisker_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "caps": true, + "cap_ratio": 0.5, + "notched": false, + "notch_depth": 0.22, + "show_mean": false, + "mean_size": 7.0, + "mean_color": { + "r": 0.7058824, + "g": 0.1764706, + "b": 0.1764706, + "a": 1.0 + }, + "show_outliers": true, + "outlier_size": 7.0, + "outlier_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "outlier_shape": "Circle", + "outlier_filled": false, + "texture_strength": 0.2, + "texture_scale": 1.0, + "gloss": 0.45, + "emphasis_brightness": 0.08 + }, + "overrides": [], + "hovered": null, + "selected": null, + "labels": "None", + "label_decimals": 1, + "value_suffix": "", + "value_title": "", + "category_title": "", + "value_range": null, + "grid": true, + "legend": true, + "group_width": 0.65, + "box_gap": 8.0, + "font_family": "sans-serif", + "font_size": 18.333334, + "text_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "background": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 1.0 + } + }, + "identity": { + "figure": "2c2545dd-b73b-4701-b515-8bf79e7f47f4", + "groups": {}, + "layout": null, + "panels": [ + { + "id": "main", + "overlays": [] + } + ], + "retired": [] + } + }, + "host": { + "chart_family": "boxplot", + "chart_panel": [ + 45, + 135, + 901, + 563 + ], + "column_uploads": 0, + "controls": { + "advanced": [ + 448.59375, + 13.5, + 152.15625, + 30.0 + ], + "copy": [ + 610.75, + 10.5, + 36.0, + 36.0 + ], + "draw": [ + 335.5625, + 13.5, + 57.03125, + 30.0 + ], + "draw_arrow": [ + 139.125, + 60.0, + 60.4375, + 30.0 + ], + "draw_data_anchor": [ + 495.0625, + 60.0, + 90.59375, + 30.0 + ], + "draw_hline": [ + 209.5625, + 60.0, + 57.40625, + 30.0 + ], + "draw_hspan": [ + 343.4375, + 60.0, + 66.28125, + 30.0 + ], + "draw_select": [ + 8.0, + 60.0, + 60.0, + 30.0 + ], + "draw_text": [ + 78.0, + 60.0, + 51.125, + 30.0 + ], + "draw_vline": [ + 276.96875, + 60.0, + 56.46875, + 30.0 + ], + "draw_vspan": [ + 419.71875, + 60.0, + 65.34375, + 30.0 + ], + "edit_area": [ + 289.5625, + 10.5, + 36.0, + 36.0 + ], + "fit": [ + 402.59375, + 10.5, + 36.0, + 36.0 + ], + "layout_mode": [ + 289.5625, + 10.5, + 36.0, + 36.0 + ], + "properties": [ + 197.5625, + 10.5, + 36.0, + 36.0 + ], + "redo": [ + 130.9375, + 13.5, + 56.625, + 30.0 + ], + "save": [ + 656.75, + 10.5, + 36.0, + 36.0 + ], + "select_mode": [ + 243.5625, + 10.5, + 36.0, + 36.0 + ], + "undo": [ + 63.15625, + 13.5, + 57.78125, + 30.0 + ] + }, + "decoration_selected": null, + "decorations": { + "axis_bottom": [ + 41.39225387573242, + 508.3333740234375, + 897.6077270507812, + 6.0 + ], + "axis_left": [ + 41.39225387573242, + 29.0, + 6.0, + 485.3333740234375 + ], + "data_area": [ + 44.39225387573242, + 32.0, + 891.6077270507812, + 479.3333740234375 + ], + "legend": [ + 417.14874267578125, + 556.8780517578125, + 117.03582763671875, + 12.78863525390625 + ], + "tick_labels_bottom": [ + 243.8538360595703, + 524.8779907226562, + 492.6845703125, + 12.78863525390625 + ], + "tick_labels_left": [ + 14.0, + 25.07803726196289, + 20.392257690429688, + 492.1219787597656 + ] + }, + "drawing_tool": "text", + "error": "", + "figure_id": 1, + "gpu_context_id": 1, + "host": "winit-wgpu", + "inspector_overlay": false, + "layout_mode": false, + "png_encodes": 0, + "pointer_input": { + "button": "Left", + "consumed": true, + "kind": "button", + "position": [ + 355.0, + 360.0 + ], + "state": "Released" + }, + "pointer_sequence": 26, + "properties_open": false, + "review": null, + "revision": 2, + "rgba_readbacks": 0, + "scale_factor": 1.0, + "selected": null, + "surface_frames": 86, + "surface_size": [ + 992, + 744 + ], + "window_role": "chart" + } + } + }, + "after": { + "wheel_sha256": "88e7f7cc4bfa8edf93ae9240f289144b530fc493dbe1f73a66355fb7b1537c2b", + "same_frame_probe": { + "button_down": false, + "clicked": true, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 355.0, + 360.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 355.0, + 360.0 + ], + "pressed": false + } + ], + "frame": 85, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 355.0, + 360.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 355.0, + 360.0 + ], + "tool": "text" + }, + "armed": { + "stage": "text-armed", + "settings": { + "schema_version": 2, + "renderer_version": "0.18.1", + "config": { + "chart_area": { + "x": 0, + "y": 0, + "width": 960, + "height": 600 + }, + "top_x": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "bottom_x": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 80.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "left_y": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 110.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "right_y": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "chart_title": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 28.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0, + "top_margin": 32.0 + }, + "grid": { + "show_major_x": true, + "major_x_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_x_width": 1.0, + "major_x_style": "Solid", + "show_major_y": true, + "major_y_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_y_width": 1.0, + "major_y_style": "Solid", + "show_minor_x": false, + "minor_x_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_x_width": 0.5, + "minor_x_style": "Dot", + "show_minor_y": false, + "minor_y_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_y_width": 0.5, + "minor_y_style": "Dot" + }, + "legend": { + "visible": false, + "content": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 14.0, + "font": "" + }, + "corner": "TopRight", + "offset_x": 0.0, + "offset_y": 0.0, + "padding": 8.0, + "bg_color": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 0.85 + }, + "border_color": { + "r": 0.6, + "g": 0.6, + "b": 0.6, + "a": 1.0 + } + } + }, + "series": [], + "boxplot": { + "decorations": { + "data_area": null, + "offsets": [], + "selected": null + }, + "title": "", + "categories": [ + { + "id": "category_0", + "label": "Box 1" + }, + { + "id": "category_1", + "label": "Box 2" + } + ], + "series": [ + { + "id": "series_0", + "label": "Distribution", + "values": [ + { + "q1": 1.5, + "median": 2.0, + "q3": 3.0, + "whisker_low": 1.0, + "whisker_high": 4.0, + "mean": 2.3333333333333335, + "sample_count": 3, + "median_ci": null, + "outliers": [] + }, + { + "q1": 3.5, + "median": 5.0, + "q3": 7.0, + "whisker_low": 2.0, + "whisker_high": 9.0, + "mean": 5.333333333333333, + "sample_count": 3, + "median_ci": null, + "outliers": [] + } + ], + "color": { + "r": 0.12156863, + "g": 0.46666667, + "b": 0.7058824, + "a": 1.0 + }, + "style": null + } + ], + "direction": "Vertical", + "style": { + "material": "Matte", + "corner_radius": 0.0, + "outline": true, + "outline_width": 1.2, + "outline_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "median_width": 1.2, + "median_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "whisker_width": 1.2, + "whisker_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "caps": true, + "cap_ratio": 0.5, + "notched": false, + "notch_depth": 0.22, + "show_mean": false, + "mean_size": 7.0, + "mean_color": { + "r": 0.7058824, + "g": 0.1764706, + "b": 0.1764706, + "a": 1.0 + }, + "show_outliers": true, + "outlier_size": 7.0, + "outlier_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "outlier_shape": "Circle", + "outlier_filled": false, + "texture_strength": 0.2, + "texture_scale": 1.0, + "gloss": 0.45, + "emphasis_brightness": 0.08 + }, + "overrides": [], + "hovered": null, + "selected": null, + "labels": "None", + "label_decimals": 1, + "value_suffix": "", + "value_title": "", + "category_title": "", + "value_range": null, + "grid": true, + "legend": true, + "group_width": 0.65, + "box_gap": 8.0, + "font_family": "sans-serif", + "font_size": 18.333334, + "text_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "background": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 1.0 + } + }, + "identity": { + "figure": "1bccaa56-20df-4f2f-8d3f-56725fbe3656", + "groups": {}, + "layout": null, + "panels": [ + { + "id": "main", + "overlays": [] + } + ], + "retired": [] + } + }, + "host": { + "chart_family": "boxplot", + "chart_panel": [ + 45, + 135, + 901, + 563 + ], + "column_uploads": 0, + "controls": { + "advanced": [ + 448.59375, + 13.5, + 152.15625, + 30.0 + ], + "copy": [ + 610.75, + 10.5, + 36.0, + 36.0 + ], + "draw": [ + 335.5625, + 13.5, + 57.03125, + 30.0 + ], + "draw_arrow": [ + 139.125, + 60.0, + 60.4375, + 30.0 + ], + "draw_data_anchor": [ + 495.0625, + 60.0, + 90.59375, + 30.0 + ], + "draw_hline": [ + 209.5625, + 60.0, + 57.40625, + 30.0 + ], + "draw_hspan": [ + 343.4375, + 60.0, + 66.28125, + 30.0 + ], + "draw_select": [ + 8.0, + 60.0, + 60.0, + 30.0 + ], + "draw_text": [ + 78.0, + 60.0, + 51.125, + 30.0 + ], + "draw_vline": [ + 276.96875, + 60.0, + 56.46875, + 30.0 + ], + "draw_vspan": [ + 419.71875, + 60.0, + 65.34375, + 30.0 + ], + "edit_area": [ + 289.5625, + 10.5, + 36.0, + 36.0 + ], + "fit": [ + 402.59375, + 10.5, + 36.0, + 36.0 + ], + "layout_mode": [ + 289.5625, + 10.5, + 36.0, + 36.0 + ], + "properties": [ + 197.5625, + 10.5, + 36.0, + 36.0 + ], + "redo": [ + 130.9375, + 13.5, + 56.625, + 30.0 + ], + "save": [ + 656.75, + 10.5, + 36.0, + 36.0 + ], + "select_mode": [ + 243.5625, + 10.5, + 36.0, + 36.0 + ], + "undo": [ + 63.15625, + 13.5, + 57.78125, + 30.0 + ] + }, + "decoration_selected": null, + "decorations": { + "axis_bottom": [ + 41.39225387573242, + 508.3333740234375, + 897.6077270507812, + 6.0 + ], + "axis_left": [ + 41.39225387573242, + 29.0, + 6.0, + 485.3333740234375 + ], + "data_area": [ + 44.39225387573242, + 32.0, + 891.6077270507812, + 479.3333740234375 + ], + "legend": [ + 417.14874267578125, + 556.8780517578125, + 117.03582763671875, + 12.78863525390625 + ], + "tick_labels_bottom": [ + 243.8538360595703, + 524.8779907226562, + 492.6845703125, + 12.78863525390625 + ], + "tick_labels_left": [ + 14.0, + 25.07803726196289, + 20.392257690429688, + 492.1219787597656 + ] + }, + "drawing_tool": "text", + "error": "", + "figure_id": 3, + "gpu_context_id": 1, + "host": "winit-wgpu", + "inspector_overlay": false, + "layout_mode": false, + "layout_probe": [ + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 169.0, + 75.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 169.0, + 75.0 + ], + "pressed": false + } + ], + "frame": 30, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 169.0, + 75.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 169.0, + 75.0 + ], + "tool": "arrow" + }, + { + "button_down": true, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 355.0, + 360.0 + ], + "pressed": true + } + ], + "frame": 36, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 355.0, + 360.0 + ], + "press_origin": [ + 355.0, + 360.0 + ], + "released": false, + "resolved_origin": [ + 355.0, + 360.0 + ], + "tool": "arrow" + }, + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 105.0, + 75.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 105.0, + 75.0 + ], + "pressed": false + } + ], + "frame": 76, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 105.0, + 75.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 105.0, + 75.0 + ], + "tool": "text" + } + ], + "png_encodes": 0, + "pointer_input": { + "button": "Left", + "consumed": true, + "kind": "button", + "position": [ + 105.0, + 75.0 + ], + "state": "Released" + }, + "pointer_sequence": 23, + "properties_open": false, + "review": null, + "revision": 2, + "rgba_readbacks": 0, + "scale_factor": 1.0, + "selected": null, + "surface_frames": 79, + "surface_size": [ + 992, + 744 + ], + "window_role": "chart" + } + }, + "placed": { + "stage": "text-placed", + "settings": { + "schema_version": 2, + "renderer_version": "0.18.1", + "config": { + "chart_area": { + "x": 0, + "y": 0, + "width": 960, + "height": 600 + }, + "top_x": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "bottom_x": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 80.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "left_y": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 110.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "right_y": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "chart_title": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 28.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0, + "top_margin": 32.0 + }, + "grid": { + "show_major_x": true, + "major_x_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_x_width": 1.0, + "major_x_style": "Solid", + "show_major_y": true, + "major_y_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_y_width": 1.0, + "major_y_style": "Solid", + "show_minor_x": false, + "minor_x_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_x_width": 0.5, + "minor_x_style": "Dot", + "show_minor_y": false, + "minor_y_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_y_width": 0.5, + "minor_y_style": "Dot" + }, + "legend": { + "visible": false, + "content": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 14.0, + "font": "" + }, + "corner": "TopRight", + "offset_x": 0.0, + "offset_y": 0.0, + "padding": 8.0, + "bg_color": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 0.85 + }, + "border_color": { + "r": 0.6, + "g": 0.6, + "b": 0.6, + "a": 1.0 + } + } + }, + "series": [], + "boxplot": { + "decorations": { + "data_area": null, + "offsets": [], + "selected": null + }, + "title": "", + "categories": [ + { + "id": "category_0", + "label": "Box 1" + }, + { + "id": "category_1", + "label": "Box 2" + } + ], + "series": [ + { + "id": "series_0", + "label": "Distribution", + "values": [ + { + "q1": 1.5, + "median": 2.0, + "q3": 3.0, + "whisker_low": 1.0, + "whisker_high": 4.0, + "mean": 2.3333333333333335, + "sample_count": 3, + "median_ci": null, + "outliers": [] + }, + { + "q1": 3.5, + "median": 5.0, + "q3": 7.0, + "whisker_low": 2.0, + "whisker_high": 9.0, + "mean": 5.333333333333333, + "sample_count": 3, + "median_ci": null, + "outliers": [] + } + ], + "color": { + "r": 0.12156863, + "g": 0.46666667, + "b": 0.7058824, + "a": 1.0 + }, + "style": null + } + ], + "direction": "Vertical", + "style": { + "material": "Matte", + "corner_radius": 0.0, + "outline": true, + "outline_width": 1.2, + "outline_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "median_width": 1.2, + "median_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "whisker_width": 1.2, + "whisker_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "caps": true, + "cap_ratio": 0.5, + "notched": false, + "notch_depth": 0.22, + "show_mean": false, + "mean_size": 7.0, + "mean_color": { + "r": 0.7058824, + "g": 0.1764706, + "b": 0.1764706, + "a": 1.0 + }, + "show_outliers": true, + "outlier_size": 7.0, + "outlier_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "outlier_shape": "Circle", + "outlier_filled": false, + "texture_strength": 0.2, + "texture_scale": 1.0, + "gloss": 0.45, + "emphasis_brightness": 0.08 + }, + "overrides": [], + "hovered": null, + "selected": null, + "labels": "None", + "label_decimals": 1, + "value_suffix": "", + "value_title": "", + "category_title": "", + "value_range": null, + "grid": true, + "legend": true, + "group_width": 0.65, + "box_gap": 8.0, + "font_family": "sans-serif", + "font_size": 18.333334, + "text_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "background": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 1.0 + } + }, + "identity": { + "figure": "1bccaa56-20df-4f2f-8d3f-56725fbe3656", + "groups": {}, + "layout": null, + "panels": [ + { + "id": "main", + "overlays": [] + } + ], + "retired": [] + } + }, + "host": { + "chart_family": "boxplot", + "chart_panel": [ + 45, + 135, + 901, + 563 + ], + "column_uploads": 0, + "controls": { + "advanced": [ + 448.59375, + 13.5, + 152.15625, + 30.0 + ], + "copy": [ + 610.75, + 10.5, + 36.0, + 36.0 + ], + "draw": [ + 335.5625, + 13.5, + 57.03125, + 30.0 + ], + "draw_arrow": [ + 139.125, + 60.0, + 60.4375, + 30.0 + ], + "draw_data_anchor": [ + 495.0625, + 60.0, + 90.59375, + 30.0 + ], + "draw_hline": [ + 209.5625, + 60.0, + 57.40625, + 30.0 + ], + "draw_hspan": [ + 343.4375, + 60.0, + 66.28125, + 30.0 + ], + "draw_select": [ + 8.0, + 60.0, + 62.0, + 30.0 + ], + "draw_text": [ + 80.0, + 60.0, + 49.125, + 30.0 + ], + "draw_vline": [ + 276.96875, + 60.0, + 56.46875, + 30.0 + ], + "draw_vspan": [ + 419.71875, + 60.0, + 65.34375, + 30.0 + ], + "edit_area": [ + 289.5625, + 10.5, + 36.0, + 36.0 + ], + "fit": [ + 402.59375, + 10.5, + 36.0, + 36.0 + ], + "inline_text": [ + 355.0, + 361.0, + 40.0, + 32.0 + ], + "layout_mode": [ + 289.5625, + 10.5, + 36.0, + 36.0 + ], + "properties": [ + 197.5625, + 10.5, + 36.0, + 36.0 + ], + "redo": [ + 130.9375, + 13.5, + 56.625, + 30.0 + ], + "save": [ + 656.75, + 10.5, + 36.0, + 36.0 + ], + "select_mode": [ + 243.5625, + 10.5, + 36.0, + 36.0 + ], + "undo": [ + 63.15625, + 13.5, + 57.78125, + 30.0 + ] + }, + "decoration_selected": null, + "decorations": { + "axis_bottom": [ + 41.39225387573242, + 508.3333740234375, + 897.6077270507812, + 6.0 + ], + "axis_left": [ + 41.39225387573242, + 29.0, + 6.0, + 485.3333740234375 + ], + "data_area": [ + 44.39225387573242, + 32.0, + 891.6077270507812, + 479.3333740234375 + ], + "legend": [ + 417.14874267578125, + 556.8780517578125, + 117.03582763671875, + 12.78863525390625 + ], + "tick_labels_bottom": [ + 243.8538360595703, + 524.8779907226562, + 492.6845703125, + 12.78863525390625 + ], + "tick_labels_left": [ + 14.0, + 25.07803726196289, + 20.392257690429688, + 492.1219787597656 + ] + }, + "drawing_tool": "select", + "error": "", + "figure_id": 3, + "gpu_context_id": 1, + "host": "winit-wgpu", + "inspector_overlay": false, + "layout_mode": false, + "layout_probe": [ + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 169.0, + 75.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 169.0, + 75.0 + ], + "pressed": false + } + ], + "frame": 30, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 169.0, + 75.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 169.0, + 75.0 + ], + "tool": "arrow" + }, + { + "button_down": true, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 355.0, + 360.0 + ], + "pressed": true + } + ], + "frame": 36, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 355.0, + 360.0 + ], + "press_origin": [ + 355.0, + 360.0 + ], + "released": false, + "resolved_origin": [ + 355.0, + 360.0 + ], + "tool": "arrow" + }, + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 105.0, + 75.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 105.0, + 75.0 + ], + "pressed": false + } + ], + "frame": 76, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 105.0, + 75.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 105.0, + 75.0 + ], + "tool": "text" + }, + { + "button_down": false, + "clicked": true, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 355.0, + 360.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 355.0, + 360.0 + ], + "pressed": false + } + ], + "frame": 85, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 355.0, + 360.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 355.0, + 360.0 + ], + "tool": "text" + } + ], + "png_encodes": 0, + "pointer_input": { + "button": "Left", + "consumed": true, + "kind": "button", + "position": [ + 355.0, + 360.0 + ], + "state": "Released" + }, + "pointer_sequence": 27, + "properties_open": false, + "review": null, + "revision": 2, + "rgba_readbacks": 0, + "scale_factor": 1.0, + "selected": null, + "surface_frames": 97, + "surface_size": [ + 992, + 744 + ], + "window_role": "chart" + } + }, + "completed": { + "stage": "closing", + "settings": { + "schema_version": 2, + "renderer_version": "0.18.1", + "config": { + "chart_area": { + "x": 0, + "y": 0, + "width": 960, + "height": 600 + }, + "top_x": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "bottom_x": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 80.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "left_y": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 110.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "right_y": { + "scale": "Linear", + "min": 0.0, + "max": 1.0, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "chart_title": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 28.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0, + "top_margin": 32.0 + }, + "grid": { + "show_major_x": true, + "major_x_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_x_width": 1.0, + "major_x_style": "Solid", + "show_major_y": true, + "major_y_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_y_width": 1.0, + "major_y_style": "Solid", + "show_minor_x": false, + "minor_x_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_x_width": 0.5, + "minor_x_style": "Dot", + "show_minor_y": false, + "minor_y_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_y_width": 0.5, + "minor_y_style": "Dot" + }, + "legend": { + "visible": false, + "content": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 14.0, + "font": "" + }, + "corner": "TopRight", + "offset_x": 0.0, + "offset_y": 0.0, + "padding": 8.0, + "bg_color": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 0.85 + }, + "border_color": { + "r": 0.6, + "g": 0.6, + "b": 0.6, + "a": 1.0 + } + } + }, + "series": [], + "boxplot": { + "decorations": { + "data_area": null, + "offsets": [], + "selected": null + }, + "title": "", + "active_layer": "Annotations", + "annotations": { + "visible": true, + "items": [ + { + "id": "annotation_18e0b5fed1825545_f", + "kind": { + "Text": { + "anchor": { + "Ratio": { + "x": 0.32066503, + "y": 0.43349132 + } + }, + "text": { + "segments": [ + { + "text": "R", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "e", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "v", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "i", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "e", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "w", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": " ", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "t", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "h", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "i", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "s", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": " ", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "r", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "e", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "g", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "i", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "o", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "n", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": " ", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "a", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "g", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "a", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "i", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "n", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + } + ], + "color": { + "r": 0.13725491, + "g": 0.1764706, + "b": 0.23529412, + "a": 1.0 + }, + "font_size": 16.0, + "font": "" + }, + "box_style": null + } + } + }, + { + "id": "annotation_18e0b5ffed1445b9_10", + "kind": { + "Arrow": { + "from": { + "Data": { + "x": 0.16284030675888062, + "y": 5.242652893066406 + } + }, + "to": { + "Data": { + "x": 0.5452444553375244, + "y": 3.1527167558670044 + } + }, + "style": { + "line": { + "color": { + "r": 0.23529412, + "g": 0.23529412, + "b": 0.23529412, + "a": 1.0 + }, + "width": 1.5, + "style": "Solid" + }, + "head_length": 10.0, + "head_width": 8.0 + }, + "text": null + } + } + }, + { + "id": "annotation_18e0b60056466cbe_11", + "kind": { + "ReferenceLine": { + "axis": "Y", + "value": 3.1527167558670044, + "style": { + "color": { + "r": 0.23529412, + "g": 0.23529412, + "b": 0.23529412, + "a": 1.0 + }, + "width": 1.5, + "style": "Solid" + }, + "label": null + } + } + } + ] + }, + "categories": [ + { + "id": "category_0", + "label": "Box 1" + }, + { + "id": "category_1", + "label": "Box 2" + } + ], + "series": [ + { + "id": "series_0", + "label": "Distribution", + "values": [ + { + "q1": 1.5, + "median": 2.0, + "q3": 3.0, + "whisker_low": 1.0, + "whisker_high": 4.0, + "mean": 2.3333333333333335, + "sample_count": 3, + "median_ci": null, + "outliers": [] + }, + { + "q1": 3.5, + "median": 5.0, + "q3": 7.0, + "whisker_low": 2.0, + "whisker_high": 9.0, + "mean": 5.333333333333333, + "sample_count": 3, + "median_ci": null, + "outliers": [] + } + ], + "color": { + "r": 0.12156863, + "g": 0.46666667, + "b": 0.7058824, + "a": 1.0 + }, + "style": null + } + ], + "direction": "Vertical", + "style": { + "material": "Matte", + "corner_radius": 0.0, + "outline": true, + "outline_width": 1.2, + "outline_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "median_width": 1.2, + "median_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "whisker_width": 1.2, + "whisker_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "caps": true, + "cap_ratio": 0.5, + "notched": false, + "notch_depth": 0.22, + "show_mean": false, + "mean_size": 7.0, + "mean_color": { + "r": 0.7058824, + "g": 0.1764706, + "b": 0.1764706, + "a": 1.0 + }, + "show_outliers": true, + "outlier_size": 7.0, + "outlier_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "outlier_shape": "Circle", + "outlier_filled": false, + "texture_strength": 0.2, + "texture_scale": 1.0, + "gloss": 0.45, + "emphasis_brightness": 0.08 + }, + "overrides": [], + "hovered": null, + "selected": null, + "labels": "None", + "label_decimals": 1, + "value_suffix": "", + "value_title": "", + "category_title": "", + "value_range": null, + "grid": true, + "legend": true, + "group_width": 0.65, + "box_gap": 8.0, + "font_family": "sans-serif", + "font_size": 18.333334, + "text_color": { + "r": 0.1254902, + "g": 0.18039216, + "b": 0.23529412, + "a": 1.0 + }, + "background": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 1.0 + } + }, + "identity": { + "figure": "1bccaa56-20df-4f2f-8d3f-56725fbe3656", + "groups": {}, + "layout": null, + "panels": [ + { + "id": "main", + "overlays": [] + } + ], + "retired": [ + "annotation/annotation_18e0b600bef55f4a_12" + ] + } + }, + "host": { + "chart_family": "boxplot", + "chart_panel": [ + 45, + 135, + 901, + 563 + ], + "column_uploads": 0, + "controls": { + "advanced": [ + 448.59375, + 13.5, + 152.15625, + 30.0 + ], + "auto_range": [ + 1001.0, + 449.0, + 122.09375, + 30.0 + ], + "bounded_category_title": [ + 1001.0, + 775.25, + 280.0, + 20.125 + ], + "bounded_title": [ + 1001.0, + 719.125, + 280.0, + 20.125 + ], + "bounded_value_title": [ + 1001.0, + 831.375, + 280.0, + 20.125 + ], + "copy": [ + 610.75, + 10.5, + 36.0, + 36.0 + ], + "draw": [ + 335.5625, + 13.5, + 57.03125, + 30.0 + ], + "draw_arrow": [ + 139.125, + 60.0, + 60.4375, + 30.0 + ], + "draw_data_anchor": [ + 495.0625, + 60.0, + 90.59375, + 30.0 + ], + "draw_hline": [ + 209.5625, + 60.0, + 57.40625, + 30.0 + ], + "draw_hspan": [ + 343.4375, + 60.0, + 66.28125, + 30.0 + ], + "draw_select": [ + 8.0, + 60.0, + 62.0, + 30.0 + ], + "draw_text": [ + 80.0, + 60.0, + 49.125, + 30.0 + ], + "draw_vline": [ + 276.96875, + 60.0, + 56.46875, + 30.0 + ], + "draw_vspan": [ + 419.71875, + 60.0, + 65.34375, + 30.0 + ], + "edit_area": [ + 289.5625, + 10.5, + 36.0, + 36.0 + ], + "fit": [ + 402.59375, + 10.5, + 36.0, + 36.0 + ], + "font_family": [ + 1001.0, + 623.0, + 315.0, + 20.125 + ], + "font_size": [ + 1211.0, + 653.125, + 105.0, + 30.0 + ], + "layout_mode": [ + 289.5625, + 10.5, + 36.0, + 36.0 + ], + "object_search": [ + 1001.0, + 145.9375, + 273.0, + 20.125 + ], + "properties": [ + 197.5625, + 10.5, + 36.0, + 36.0 + ], + "properties_header": [ + 1001.0, + 397.0, + 209.28125, + 16.0 + ], + "properties_viewport": [ + 1001.0, + 423.0, + 315.0, + 289.0 + ], + "range_max": [ + 1123.875, + 489.0, + 51.125, + 30.0 + ], + "range_min": [ + 1034.40625, + 489.0, + 43.25, + 30.0 + ], + "redo": [ + 130.9375, + 13.5, + 56.625, + 30.0 + ], + "save": [ + 656.75, + 10.5, + 36.0, + 36.0 + ], + "select_mode": [ + 243.5625, + 10.5, + 36.0, + 36.0 + ], + "tree:bounded/annotation/annotation_18e0b5fed1825545_f": [ + 1057.0, + 295.0, + 255.0, + 24.0 + ], + "tree:bounded/annotation/annotation_18e0b5ffed1445b9_10": [ + 1057.0, + 321.0, + 255.0, + 24.0 + ], + "tree:bounded/annotation/annotation_18e0b60056466cbe_11": [ + 1057.0, + 347.0, + 255.0, + 24.0 + ], + "tree:bounded/annotations": [ + 1048.0, + 269.0, + 264.0, + 24.0 + ], + "tree:bounded/axes": [ + 1048.0, + 191.0, + 264.0, + 24.0 + ], + "tree:bounded/data": [ + 1048.0, + 217.0, + 264.0, + 24.0 + ], + "tree:bounded/legend": [ + 1045.0, + 181.0, + 267.0, + 8.0 + ], + "tree:bounded/series/series_0": [ + 1057.0, + 243.0, + 255.0, + 24.0 + ], + "tree_toggle:bounded": [ + 1013.0, + 113.0, + 20.0, + 24.0 + ], + "tree_toggle:bounded/annotations": [ + 1025.0, + 269.0, + 20.0, + 24.0 + ], + "tree_toggle:bounded/axes": [ + 1025.0, + 191.0, + 20.0, + 24.0 + ], + "tree_toggle:bounded/data": [ + 1025.0, + 217.0, + 20.0, + 24.0 + ], + "tree_toggle:figure/1bccaa56-20df-4f2f-8d3f-56725fbe3656": [ + 1001.0, + 87.0, + 20.0, + 24.0 + ], + "undo": [ + 63.15625, + 13.5, + 57.78125, + 30.0 + ] + }, + "decoration_selected": null, + "decorations": { + "axis_bottom": [ + 41.39225387573242, + 508.3333740234375, + 897.6077270507812, + 6.0 + ], + "axis_left": [ + 41.39225387573242, + 29.0, + 6.0, + 485.3333740234375 + ], + "data_area": [ + 44.39225387573242, + 32.0, + 891.6077270507812, + 479.3333740234375 + ], + "legend": [ + 417.14874267578125, + 556.8780517578125, + 117.03582763671875, + 12.78863525390625 + ], + "tick_labels_bottom": [ + 243.8538360595703, + 524.8779907226562, + 492.6845703125, + 12.78863525390625 + ], + "tick_labels_left": [ + 14.0, + 25.07803726196289, + 20.392257690429688, + 492.1219787597656 + ] + }, + "drawing_tool": "select", + "error": "", + "figure_id": 3, + "gpu_context_id": 1, + "host": "winit-wgpu", + "inspector_overlay": false, + "layout_mode": false, + "layout_probe": [ + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 169.0, + 75.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 169.0, + 75.0 + ], + "pressed": false + } + ], + "frame": 30, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 169.0, + 75.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 169.0, + 75.0 + ], + "tool": "arrow" + }, + { + "button_down": true, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 355.0, + 360.0 + ], + "pressed": true + } + ], + "frame": 36, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 355.0, + 360.0 + ], + "press_origin": [ + 355.0, + 360.0 + ], + "released": false, + "resolved_origin": [ + 355.0, + 360.0 + ], + "tool": "arrow" + }, + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 105.0, + 75.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 105.0, + 75.0 + ], + "pressed": false + } + ], + "frame": 76, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 105.0, + 75.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 105.0, + 75.0 + ], + "tool": "text" + }, + { + "button_down": false, + "clicked": true, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 355.0, + 360.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 355.0, + 360.0 + ], + "pressed": false + } + ], + "frame": 85, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 355.0, + 360.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 355.0, + 360.0 + ], + "tool": "text" + }, + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 105.0, + 75.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 105.0, + 75.0 + ], + "pressed": false + } + ], + "frame": 109, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 105.0, + 75.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 105.0, + 75.0 + ], + "tool": "text" + }, + { + "button_down": false, + "clicked": true, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 355.0, + 360.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 355.0, + 360.0 + ], + "pressed": false + } + ], + "frame": 118, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 355.0, + 360.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 355.0, + 360.0 + ], + "tool": "text" + }, + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 169.0, + 75.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 169.0, + 75.0 + ], + "pressed": false + } + ], + "frame": 201, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 169.0, + 75.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 169.0, + 75.0 + ], + "tool": "arrow" + }, + { + "button_down": true, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 364.0, + 379.0 + ], + "pressed": true + } + ], + "frame": 207, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 364.0, + 379.0 + ], + "press_origin": [ + 364.0, + 379.0 + ], + "released": false, + "resolved_origin": [ + 364.0, + 379.0 + ], + "tool": "arrow" + }, + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 524.0, + 473.0 + ], + "pressed": false + } + ], + "frame": 235, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 524.0, + 473.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": null, + "tool": "arrow" + }, + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 238.0, + 75.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 238.0, + 75.0 + ], + "pressed": false + } + ], + "frame": 254, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 238.0, + 75.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 238.0, + 75.0 + ], + "tool": "hline" + }, + { + "button_down": true, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 364.0, + 379.0 + ], + "pressed": true + } + ], + "frame": 260, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 364.0, + 379.0 + ], + "press_origin": [ + 364.0, + 379.0 + ], + "released": false, + "resolved_origin": [ + 364.0, + 379.0 + ], + "tool": "hline" + }, + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 524.0, + 473.0 + ], + "pressed": false + } + ], + "frame": 288, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 524.0, + 473.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": null, + "tool": "hline" + }, + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 377.0, + 75.0 + ], + "pressed": true + }, + { + "button": "Primary", + "position": [ + 377.0, + 75.0 + ], + "pressed": false + } + ], + "frame": 302, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 377.0, + 75.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": [ + 377.0, + 75.0 + ], + "tool": "hspan" + }, + { + "button_down": true, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 364.0, + 379.0 + ], + "pressed": true + } + ], + "frame": 308, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 364.0, + 379.0 + ], + "press_origin": [ + 364.0, + 379.0 + ], + "released": false, + "resolved_origin": [ + 364.0, + 379.0 + ], + "tool": "hspan" + }, + { + "button_down": false, + "clicked": false, + "drag_started": false, + "events": [ + { + "button": "Primary", + "position": [ + 524.0, + 473.0 + ], + "pressed": false + } + ], + "frame": 336, + "kind": "drawing_input", + "pass": 0, + "pointer": [ + 524.0, + 473.0 + ], + "press_origin": null, + "released": true, + "resolved_origin": null, + "tool": "hspan" + } + ], + "png_encodes": 0, + "pointer_input": { + "button": "Left", + "consumed": true, + "kind": "button", + "position": [ + 1070.0, + 664.0 + ], + "state": "Released" + }, + "pointer_sequence": 76, + "properties_open": true, + "review": null, + "revision": 8, + "rgba_readbacks": 0, + "scale_factor": 1.0, + "selected": null, + "surface_frames": 362, + "surface_size": [ + 1312, + 744 + ], + "window_role": "chart" + } + }, + "draft_settings_identical": true + }, + "input_contract": { + "egui_version": "0.36.2", + "source": "https://docs.rs/egui/0.36.2/src/egui/input_state/mod.rs.html", + "behavior": "PointerState clears press_origin when processing PointerButton release, including when a press and release are buffered in the same frame." + }, + "fix": "If the held-pointer snapshot has no origin, use the last primary-button press position in the current ordered raw events. Preserve the existing image hit test, drag preview and release commit.", + "scope": "Native wrapper input handling; no renderer, chart SSOT or timing threshold changes.", + "validation": { + "focused_passed": 8, + "seconds": 352.12, + "coverage": "Cartesian, logarithmic/inverted, vertical/horizontal boxplot, categorical, radial, radar and polar; each includes native and browser creation, editing, cancellation and export.", + "full_suite": { + "tests": 640, + "failures": 0, + "errors": 0, + "skipped": 0, + "seconds": 2444.032, + "wheel_sha256": "88e7f7cc4bfa8edf93ae9240f289144b530fc493dbe1f73a66355fb7b1537c2b" + } + } +} diff --git a/docs/measurements/native-inspector-menu-after-20261022.png b/docs/measurements/native-inspector-menu-after-20261022.png new file mode 100644 index 0000000000000000000000000000000000000000..c9c2f6de6a87b1e01a8b5cca42bb64188052b8fc GIT binary patch literal 83347 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 literal 0 HcmV?d00001 diff --git a/docs/measurements/native-inspector-menu-before-20261022.png b/docs/measurements/native-inspector-menu-before-20261022.png new file mode 100644 index 0000000000000000000000000000000000000000..38361d6eb7a7089beb40aa7d9acf09fd8d83012f GIT binary patch literal 85536 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 M!1P)L%;o9-1e(|^yZ`_I literal 0 HcmV?d00001 diff --git a/docs/measurements/native-inspector-popup-layer-20261022.json b/docs/measurements/native-inspector-popup-layer-20261022.json new file mode 100644 index 0000000..2ee9bb8 --- /dev/null +++ b/docs/measurements/native-inspector-popup-layer-20261022.json @@ -0,0 +1,1450 @@ +{ + "scenario": "Reopen the native axis scale menu after editing divergent opposite-axis ranges", + "before": { + "menu_probe": { + "button_clicked": false, + "frame": 999, + "id": "axis_scale", + "items": [ + { + "clicked": false, + "clip": [ + 0.0, + 533.0, + 1312.0, + 70.0 + ], + "contains_pointer": false, + "enabled": true, + "hovered": false, + "id": "id_0BCE", + "interact_rect": [ + 1005.0, + 533.0, + 162.0, + 30.0 + ], + "layer": "LayerId { Foreground id_40E5 }", + "layer_at_pointer": "LayerId { Foreground id_2B95 }", + "name": "Linear", + "pointer": [ + 1086.0, + 548.0 + ], + "rect": [ + 1005.0, + 533.0, + 162.0, + 30.0 + ], + "transform": null + }, + { + "clicked": false, + "clip": [ + 0.0, + 533.0, + 1312.0, + 70.0 + ], + "contains_pointer": false, + "enabled": true, + "hovered": false, + "id": "id_08C5", + "interact_rect": [ + 1005.0, + 573.0, + 162.0, + 30.0 + ], + "layer": "LayerId { Foreground id_40E5 }", + "layer_at_pointer": "LayerId { Foreground id_2B95 }", + "name": "Logarithmic", + "pointer": [ + 1086.0, + 548.0 + ], + "rect": [ + 1005.0, + 573.0, + 162.0, + 30.0 + ], + "transform": null + } + ], + "kind": "scale_control", + "pass": 0, + "popup_open": false, + "previous": "Logarithmic", + "value": "Logarithmic" + }, + "requested_scale": "Linear", + "accepted_before_config": { + "chart_area": { + "x": 0, + "y": 0, + "width": 960, + "height": 600 + }, + "top_x": { + "scale": "Logarithmic", + "min": 1.0, + "max": 201.0, + "major_spacing": 1.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 11.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Scientific", + "significant_digits": 2 + }, + "tick": "Both", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 3.0, + "offset_y": 4.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "bottom_x": { + "scale": "Logarithmic", + "min": 1.0, + "max": 101.0, + "major_spacing": 3.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Scientific", + "significant_digits": 2 + }, + "tick": "Both", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 80.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "left_y": { + "scale": "Linear", + "min": 1.0, + "max": 100.0, + "major_spacing": 10.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 110.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "right_y": { + "scale": "Linear", + "min": 1.0, + "max": 100.0, + "major_spacing": 10.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": false, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 11.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 6 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 3.0, + "offset_y": 4.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "chart_title": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 28.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0, + "top_margin": 32.0 + }, + "grid": { + "show_major_x": true, + "major_x_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_x_width": 1.0, + "major_x_style": "Solid", + "show_major_y": true, + "major_y_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_y_width": 1.0, + "major_y_style": "Solid", + "show_minor_x": false, + "minor_x_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_x_width": 0.5, + "minor_x_style": "Dot", + "show_minor_y": false, + "minor_y_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_y_width": 0.5, + "minor_y_style": "Dot" + }, + "legend": { + "visible": false, + "content": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 14.0, + "font": "" + }, + "corner": "TopRight", + "offset_x": 0.0, + "offset_y": 0.0, + "padding": 8.0, + "bg_color": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 0.85 + }, + "border_color": { + "r": 0.6, + "g": 0.6, + "b": 0.6, + "a": 1.0 + } + } + }, + "accepted_after_config": { + "chart_area": { + "x": 0, + "y": 0, + "width": 960, + "height": 600 + }, + "top_x": { + "scale": "Logarithmic", + "min": 1.0, + "max": 201.0, + "major_spacing": 1.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 11.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Scientific", + "significant_digits": 2 + }, + "tick": "Both", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 3.0, + "offset_y": 4.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "bottom_x": { + "scale": "Logarithmic", + "min": 1.0, + "max": 101.0, + "major_spacing": 3.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Scientific", + "significant_digits": 2 + }, + "tick": "Both", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 80.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "left_y": { + "scale": "Linear", + "min": 1.0, + "max": 100.0, + "major_spacing": 10.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 110.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "right_y": { + "scale": "Linear", + "min": 1.0, + "max": 100.0, + "major_spacing": 10.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": false, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 11.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 6 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 3.0, + "offset_y": 4.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "chart_title": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 28.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0, + "top_margin": 32.0 + }, + "grid": { + "show_major_x": true, + "major_x_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_x_width": 1.0, + "major_x_style": "Solid", + "show_major_y": true, + "major_y_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_y_width": 1.0, + "major_y_style": "Solid", + "show_minor_x": false, + "minor_x_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_x_width": 0.5, + "minor_x_style": "Dot", + "show_minor_y": false, + "minor_y_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_y_width": 0.5, + "minor_y_style": "Dot" + }, + "legend": { + "visible": false, + "content": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 14.0, + "font": "" + }, + "corner": "TopRight", + "offset_x": 0.0, + "offset_y": 0.0, + "padding": 8.0, + "bg_color": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 0.85 + }, + "border_color": { + "r": 0.6, + "g": 0.6, + "b": 0.6, + "a": 1.0 + } + } + } + }, + "after": { + "menu_probe": { + "button_clicked": false, + "frame": 1001, + "id": "axis_scale", + "items": [ + { + "clicked": true, + "clip": [ + 0.0, + 533.0, + 1312.0, + 70.0 + ], + "contains_pointer": true, + "enabled": true, + "hovered": true, + "id": "id_0BCE", + "interact_rect": [ + 1005.0, + 533.0, + 162.0, + 30.0 + ], + "layer": "LayerId { Foreground id_40E5 }", + "layer_at_pointer": "LayerId { Foreground id_40E5 }", + "name": "Linear", + "pointer": [ + 1086.0, + 548.0 + ], + "rect": [ + 1005.0, + 533.0, + 162.0, + 30.0 + ], + "transform": null + }, + { + "clicked": false, + "clip": [ + 0.0, + 533.0, + 1312.0, + 70.0 + ], + "contains_pointer": false, + "enabled": true, + "hovered": false, + "id": "id_08C5", + "interact_rect": [ + 1005.0, + 573.0, + 162.0, + 30.0 + ], + "layer": "LayerId { Foreground id_40E5 }", + "layer_at_pointer": "LayerId { Foreground id_40E5 }", + "name": "Logarithmic", + "pointer": [ + 1086.0, + 548.0 + ], + "rect": [ + 1005.0, + 573.0, + 162.0, + 30.0 + ], + "transform": null + } + ], + "kind": "scale_control", + "parent_layer": "LayerId { Middle id_2B95 }", + "pass": 0, + "popup_open": false, + "previous": "Logarithmic", + "value": "Linear" + }, + "requested_scale": "Linear", + "accepted_before_config": { + "chart_area": { + "x": 0, + "y": 0, + "width": 960, + "height": 600 + }, + "top_x": { + "scale": "Logarithmic", + "min": 1.0, + "max": 201.0, + "major_spacing": 1.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 11.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Scientific", + "significant_digits": 2 + }, + "tick": "Both", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 3.0, + "offset_y": 4.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "bottom_x": { + "scale": "Logarithmic", + "min": 1.0, + "max": 101.0, + "major_spacing": 3.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Scientific", + "significant_digits": 2 + }, + "tick": "Both", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 80.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "left_y": { + "scale": "Linear", + "min": 1.0, + "max": 100.0, + "major_spacing": 10.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 110.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "right_y": { + "scale": "Linear", + "min": 1.0, + "max": 100.0, + "major_spacing": 10.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": false, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 11.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 6 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 3.0, + "offset_y": 4.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "chart_title": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 28.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0, + "top_margin": 32.0 + }, + "grid": { + "show_major_x": true, + "major_x_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_x_width": 1.0, + "major_x_style": "Solid", + "show_major_y": true, + "major_y_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_y_width": 1.0, + "major_y_style": "Solid", + "show_minor_x": false, + "minor_x_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_x_width": 0.5, + "minor_x_style": "Dot", + "show_minor_y": false, + "minor_y_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_y_width": 0.5, + "minor_y_style": "Dot" + }, + "legend": { + "visible": false, + "content": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 14.0, + "font": "" + }, + "corner": "TopRight", + "offset_x": 0.0, + "offset_y": 0.0, + "padding": 8.0, + "bg_color": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 0.85 + }, + "border_color": { + "r": 0.6, + "g": 0.6, + "b": 0.6, + "a": 1.0 + } + } + }, + "accepted_after_config": { + "chart_area": { + "x": 0, + "y": 0, + "width": 960, + "height": 600 + }, + "top_x": { + "scale": "Logarithmic", + "min": 1.0, + "max": 201.0, + "major_spacing": 1.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 11.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Scientific", + "significant_digits": 2 + }, + "tick": "Both", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 3.0, + "offset_y": 4.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "bottom_x": { + "scale": "Linear", + "min": 1.0, + "max": 101.0, + "major_spacing": 20.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Scientific", + "significant_digits": 2 + }, + "tick": "Both", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 80.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "left_y": { + "scale": "Linear", + "min": 1.0, + "max": 100.0, + "major_spacing": 10.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 18.0, + "label_visible": true, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0 + }, + "out_margin": 110.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "right_y": { + "scale": "Linear", + "min": 1.0, + "max": 100.0, + "major_spacing": 10.0, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": false, + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 11.0, + "label_visible": false, + "label_font": "", + "label_offset_x": 0.0, + "label_offset_y": 0.0, + "format": "Decimal", + "significant_digits": 6 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 22.0, + "font": "" + }, + "visible": false, + "offset_x": 3.0, + "offset_y": 4.0 + }, + "out_margin": 25.0, + "line_offset": 0.0, + "line_visible": true, + "line_color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "line_width": 1.0, + "line_style": "Solid", + "major_tick_length": 5.0, + "minor_tick_length": 3.0 + }, + "chart_title": { + "text": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 28.0, + "font": "" + }, + "visible": true, + "offset_x": 0.0, + "offset_y": 0.0, + "top_margin": 32.0 + }, + "grid": { + "show_major_x": true, + "major_x_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_x_width": 1.0, + "major_x_style": "Solid", + "show_major_y": true, + "major_y_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1.0 + }, + "major_y_width": 1.0, + "major_y_style": "Solid", + "show_minor_x": false, + "minor_x_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_x_width": 0.5, + "minor_x_style": "Dot", + "show_minor_y": false, + "minor_y_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1.0 + }, + "minor_y_width": 0.5, + "minor_y_style": "Dot" + }, + "legend": { + "visible": false, + "content": { + "segments": [], + "color": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + }, + "font_size": 14.0, + "font": "" + }, + "corner": "TopRight", + "offset_x": 0.0, + "offset_y": 0.0, + "padding": 8.0, + "bg_color": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 0.85 + }, + "border_color": { + "r": 0.6, + "g": 0.6, + "b": 0.6, + "a": 1.0 + } + } + } + }, + "fix": { + "inspector_order_before": "Foreground", + "inspector_order_after": "Middle", + "popup_order": "Foreground", + "reason": "The inspector must not intercept input for its popup menus" + }, + "review_layout": { + "before": { + "properties": [ + 998.0, + 448.0, + 296.0, + 174.0 + ], + "review": [ + 12.0, + 605.0, + 1288.0, + 36.25 + ] + }, + "after_cartesian": { + "properties": [ + 998.0, + 397.1875, + 296.0, + 100.0 + ], + "review": [ + 12.0, + 605.0, + 1288.0, + 36.25 + ] + }, + "fix": "Reserve the review panel before sizing the inspector from remaining space" + }, + "focused_validation": { + "passed": 5, + "seconds": 124.92, + "cases": [ + "paired axes x (native/browser)", + "paired axes y (native/browser)", + "long comments with Cartesian properties", + "long comments with radial properties", + "short click under injected slow frames" + ] + } +} diff --git a/src/bounded_host.rs b/src/bounded_host.rs index 1801121..c9c3d73 100644 --- a/src/bounded_host.rs +++ b/src/bounded_host.rs @@ -208,7 +208,45 @@ impl SurfaceWindow { ] }; if self.editor.drawing.tool != crate::drawing::Tool::Select { - if let Some(origin) = ctx.input(|i| i.pointer.press_origin()) { + // press_origin is cleared when egui processes a release. + // A short press/release can arrive in the same frame. + let origin = ctx.input(|i| { + i.pointer.press_origin().or_else(|| { + i.raw.events.iter().rev().find_map(|event| match event { + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + .. + } => Some(*pos), + _ => None, + }) + }) + }); + if std::env::var_os("FIGGY_LAYOUT_PROBE").is_some() + && ctx.input(|i| i.pointer.any_pressed() || i.pointer.any_released()) + { + let mut probe = ctx.input(|i| json!({ + "kind":"drawing_input", "tool":self.editor.drawing.tool.name(), + "press_origin":i.pointer.press_origin().map(|p|[p.x,p.y]), + "resolved_origin":origin.map(|p|[p.x,p.y]), + "pointer":i.pointer.interact_pos().map(|p|[p.x,p.y]), + "released":i.pointer.any_released(), + "events":i.raw.events.iter().filter_map(|e| match e { + egui::Event::PointerButton{pos,button,pressed,..}=>Some(json!({ + "position":[pos.x,pos.y],"button":format!("{button:?}"),"pressed":pressed})), + _=>None, + }).collect::>() + })); + probe["clicked"] = json!(response.clicked()); + probe["drag_started"] = json!(response.drag_started()); + probe["button_down"] = json!(response.is_pointer_button_down_on()); + probe["frame"] = json!(ctx.cumulative_frame_nr()); + probe["pass"] = json!(ctx.current_pass_index()); + self.editor.layout_probe.push_back(probe); + while self.editor.layout_probe.len()>64 {self.editor.layout_probe.pop_front();} + } + if let Some(origin) = origin { if image_rect.contains(origin) && self.editor.drawing.gesture.is_none() && (response.drag_started() @@ -580,6 +618,8 @@ impl SurfaceWindow { "chart_panel":[self.panel.x,self.panel.y,self.panel.width,self.panel.height], "surface_size":[self.config.width,self.config.height], "scale_factor":self.window.scale_factor(), "surface_frames":self.frames, "gpu_context_id":c.context.id, "figure_id":c.data.figure_id, + "pointer_sequence":self.pointer_sequence,"pointer_input":self.pointer_input, + "layout_probe":self.editor.layout_probe, "column_uploads":c.data.uploads,"rgba_readbacks":c.data.readbacks,"png_encodes":c.data.png_encodes, "revision":c.data.revision, "decorations":self.bounded_surface.as_ref().map(|s|s.frame.decorations().entries().filter_map(|(id,e)|s.frame.decorations().bounds(id).map(|r|(e.id.element_id(),json!([r.x,r.y,r.width,r.height])))).collect::>()), diff --git a/src/desktop.rs b/src/desktop.rs index d6bc83f..d2905b3 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -110,6 +110,8 @@ struct SurfaceWindow { chart_pressed: bool, edit_area: bool, cursor: (f32, f32), + pointer_sequence: u64, + pointer_input: Value, panel: Rect, scale: f32, editor: editor::Editor, @@ -232,6 +234,8 @@ impl SurfaceWindow { chart_pressed: false, edit_area: false, cursor: (0., 0.), + pointer_sequence: 0, + pointer_input: Value::Null, panel: rect, scale: 1., editor, @@ -1508,7 +1512,7 @@ impl SurfaceWindow { let index=view.annotation_selected()?;let id=view.annotation_map().find(index)?;let bounds=view.annotation_map().selection_box(id)?; Some(json!({"index":index,"rect":[bounds.rect.x,bounds.rect.y,bounds.rect.width,bounds.rect.height]})) }); - *self.control.stats.lock().unwrap() = json!({"layout_probe":self.editor.layout_probe,"choice_candidates":self.chooser.as_ref().map(|v|v.choices.iter().map(|v|json!({"key":v.key,"label":v.label,"detail":v.detail})).collect::>()),"choice_preview":self.chooser.as_ref().and_then(|v|v.preview()).map(|v|&v.key),"focused_panel":self.panel_focus.id,"annotation_selection":annotation_selection,"host":"winit-wgpu","layout_mode":self.edit_area,"drawing_tool":self.editor.drawing.tool.name(),"window_role":if self.settings_only { "settings" } else { "chart" },"inspector_overlay":self.inspector_overlay(),"picked_values":self.editor.picked,"selection_value_readbacks":c.data.selection_value_readbacks,"error":self.editor.error,"controls":controls,"decorations":decorations,"selected":self.selected,"properties_open":self.editor.inspector_open,"chart_panel":[panel.x,panel.y,panel.width,panel.height],"gpu_context_id":c.context.id,"figure_id":c.data.figure_id,"pool_used_bytes":c.renderer.pool().used_bytes(),"surface_frames":self.frames,"last_input_to_submit_ms":self.latency_ms,"scale_factor":self.window.scale_factor(),"surface_size":[self.config.width,self.config.height],"column_uploads":c.data.uploads,"rgba_readbacks":c.data.readbacks,"png_encodes":c.data.png_encodes,"revision":c.data.revision}); + *self.control.stats.lock().unwrap() = json!({"pointer_sequence":self.pointer_sequence,"pointer_input":self.pointer_input,"layout_probe":self.editor.layout_probe,"choice_candidates":self.chooser.as_ref().map(|v|v.choices.iter().map(|v|json!({"key":v.key,"label":v.label,"detail":v.detail})).collect::>()),"choice_preview":self.chooser.as_ref().and_then(|v|v.preview()).map(|v|&v.key),"focused_panel":self.panel_focus.id,"annotation_selection":annotation_selection,"host":"winit-wgpu","layout_mode":self.edit_area,"drawing_tool":self.editor.drawing.tool.name(),"window_role":if self.settings_only { "settings" } else { "chart" },"inspector_overlay":self.inspector_overlay(),"picked_values":self.editor.picked,"selection_value_readbacks":c.data.selection_value_readbacks,"error":self.editor.error,"controls":controls,"decorations":decorations,"selected":self.selected,"properties_open":self.editor.inspector_open,"chart_panel":[panel.x,panel.y,panel.width,panel.height],"gpu_context_id":c.context.id,"figure_id":c.data.figure_id,"pool_used_bytes":c.renderer.pool().used_bytes(),"surface_frames":self.frames,"last_input_to_submit_ms":self.latency_ms,"scale_factor":self.window.scale_factor(),"surface_size":[self.config.width,self.config.height],"column_uploads":c.data.uploads,"rgba_readbacks":c.data.readbacks,"png_encodes":c.data.png_encodes,"revision":c.data.revision}); Ok(()) } } @@ -1647,6 +1651,22 @@ impl ApplicationHandler for App { return; } let response = w.input.on_window_event(&w.window, &event); + let pointer = match &event { + WindowEvent::CursorMoved { position, .. } => Some(json!({ + "kind":"motion", "position":[position.x,position.y], + "consumed":response.consumed + })), + WindowEvent::MouseInput { state, button, .. } => Some(json!({ + "kind":"button", "state":format!("{state:?}"), + "button":format!("{button:?}"), "position":[w.cursor.0,w.cursor.1], + "consumed":response.consumed + })), + _ => None, + }; + if let Some(pointer) = pointer { + w.pointer_sequence += 1; + w.pointer_input = pointer; + } if matches!( event, WindowEvent::MouseInput { diff --git a/src/editor.rs b/src/editor.rs index 039ae07..28a864d 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -616,12 +616,13 @@ impl Editor { }), ); }); + self.review_ui(root, c); if self.inspector_open { - let mut area = root.max_rect(); - area.min.y = root.available_rect_before_wrap().top(); - area.max.y -= 30.; + let area = root.available_rect_before_wrap(); egui::Area::new(egui::Id::new("inspector")) - .order(egui::Order::Foreground) + // Menus use Foreground. Keep the panel below them so reopening + // a menu cannot let its parent panel intercept the item click. + .order(egui::Order::Middle) .fixed_pos(egui::pos2(area.right() - INSPECTOR_WIDTH, area.top())) .movable(false) .show(root.ctx(), |ui| { @@ -635,7 +636,6 @@ impl Editor { }); }); } - self.review_ui(root, c); egui::CentralPanel::default() .frame(egui::Frame::NONE) .show(root, |ui| { @@ -1377,7 +1377,13 @@ impl Editor { changed = true; } let node = &mut config[key]; - changed |= scale_control(ui, node, "axis_scale", &mut self.controls); + changed |= scale_control( + ui, + node, + "axis_scale", + &mut self.controls, + &mut self.layout_probe, + ); let mut labels = node["label_style"]["visible"].as_bool().unwrap_or(true); let response = ui.checkbox(&mut labels, "Tick labels"); self.controls @@ -1636,10 +1642,14 @@ fn scale_control( axis: &mut Value, id: &str, controls: &mut HashMap, + probe: &mut std::collections::VecDeque, ) -> bool { ui.label("Scale"); let previous = axis["scale"].as_str().unwrap_or("Linear"); let mut value = previous.to_owned(); + let capture = std::env::var_os("FIGGY_LAYOUT_PROBE").is_some() + && ui.input(|i| i.pointer.any_pressed() || i.pointer.any_released()); + let mut items = vec![]; let response = egui::ComboBox::from_id_salt(id) .selected_text(if previous == "Logarithmic" { "Logarithmic (base 10)" @@ -1652,10 +1662,32 @@ fn scale_control( ("Logarithmic", "Logarithmic (base 10)"), ] { let response = ui.selectable_value(&mut value, name.to_owned(), label); + if capture { + let rect = |r:egui::Rect| [r.min.x,r.min.y,r.width(),r.height()]; + items.push(json!({"name":name,"clicked":response.clicked(), + "hovered":response.hovered(),"contains_pointer":response.contains_pointer(), + "enabled":response.enabled(),"id":format!("{:?}",response.id), + "rect":rect(response.rect),"interact_rect":rect(response.interact_rect), + "clip":rect(ui.clip_rect()),"layer":format!("{:?}",response.layer_id), + "pointer":ui.ctx().pointer_hover_pos().map(|p|[p.x,p.y]), + "layer_at_pointer":ui.ctx().pointer_hover_pos().and_then(|p|ui.ctx().layer_id_at(p)).map(|v|format!("{v:?}")), + "transform":ui.ctx().layer_transform_to_global(response.layer_id).map(|t|json!({ + "scaling":t.scaling,"translation":[t.translation.x,t.translation.y],"global_rect":rect(t*response.rect)}))})); + } controls.insert(format!("{id}_{name}"), response.rect); } }); controls.insert(id.into(), response.response.rect); + if capture { + probe.push_back(json!({"kind":"scale_control","id":id,"previous":previous, + "value":value,"items":items,"button_clicked":response.response.clicked(), + "parent_layer":format!("{:?}",response.response.layer_id), + "popup_open":egui::ComboBox::is_open(ui.ctx(),response.response.id), + "pass":ui.ctx().current_pass_index(),"frame":ui.ctx().cumulative_frame_nr()})); + while probe.len() > 64 { + probe.pop_front(); + } + } ui.label("Log view omits zero and negative values; source data is preserved."); if value == previous { return false; diff --git a/tests/jupyter_harness.py b/tests/jupyter_harness.py index f7ff5fa..e34fa6e 100644 --- a/tests/jupyter_harness.py +++ b/tests/jupyter_harness.py @@ -189,6 +189,12 @@ def notebook(tmp_path, source, *, allow_closed=False, initial_views=1): from playwright.sync_api import sync_playwright from jupyter_client import BlockingKernelClient tmp_path.mkdir(parents=True, exist_ok=True) + started=time.monotonic();timeline=[] + def phase(stage): + timeline.append({'stage':stage,'elapsed_seconds':time.monotonic()-started}) + try:(tmp_path/'harness-timing.json').write_text(json.dumps(timeline)) + except OSError:pass # A diagnostic must never replace a test failure. + phase('start') runtime = tmp_path / 'runtime' runtime.mkdir() # Configure first-run announcements before Jupyter starts. Clicking the @@ -208,6 +214,7 @@ def notebook(tmp_path, source, *, allow_closed=False, initial_views=1): server = subprocess.Popen([sys.executable, '-m', 'jupyterlab', '--no-browser', '--expose-app-in-browser', '--ip=127.0.0.1', f'--port={port}', f'--ServerApp.root_dir={tmp_path}', '--IdentityProvider.token='], env=env, stdout=log, stderr=subprocess.STDOUT) + phase('server-started') base = f'http://127.0.0.1:{port}' client = None try: @@ -220,6 +227,7 @@ def notebook(tmp_path, source, *, allow_closed=False, initial_views=1): if time.monotonic() > deadline: raise RuntimeError((tmp_path/'server.log').read_text()) time.sleep(.1) + phase('server-ready') with sync_playwright() as p: browser_env = dict(os.environ) browser_args = ['--no-sandbox', '--enable-unsafe-webgpu', '--use-angle=vulkan', '--enable-features=Vulkan', '--disable-vulkan-surface', @@ -231,8 +239,10 @@ def notebook(tmp_path, source, *, allow_closed=False, initial_views=1): # watchdog enabled, aligned with the existing startup deadline. browser_args.append('--gpu-watchdog-timeout-seconds=180') executable = os.environ.get('BROWSER_EXECUTABLE_PATH') or shutil.which('chromium') + phase('browser-launch') browser = p.chromium.launch(headless=os.environ.get('FIGGY_TEST_HEADFUL') != '1', env=browser_env, executable_path=executable, args=browser_args) + phase('browser-launched') driver = os.environ.get('FIGGY_BROWSER_VK_DRIVER') browser_info = { 'version': browser.version, @@ -272,6 +282,7 @@ def notebook(tmp_path, source, *, allow_closed=False, initial_views=1): # keep Playwright's normal timeout and strict result assertions. page.wait_for_function("(count)=>{const states=Array.from(document.querySelectorAll('.figgy-editor [data-status]'));return states.length===count && states.every(s=>['Ready','WebGPU failed'].includes(s.textContent));}", arg=initial_views, timeout=180000) assert page.locator('.figgy-editor [data-status]').all_text_contents() == ['Ready']*initial_views, page.locator('.figgy-error').all_text_contents() + phase('views-ready') with urllib.request.urlopen(base+'/api/sessions') as response: session = next(s for s in json.load(response) if s['path'] == 'check.ipynb') client = BlockingKernelClient(connection_file=str(runtime/f"kernel-{session['kernel']['id']}.json")) @@ -303,7 +314,9 @@ def execute(code): raise finally: entry['duration_ms'] = (time.monotonic()-started)*1000 + phase('test-start') yield page, execute + phase('test-end') assert not errors if allow_closed: assert all(value=='' for value in page.locator('.figgy-error').all_text_contents()) @@ -326,18 +339,26 @@ def execute(code): pass raise finally: + phase('diagnostics-start') if not diagnostics_saved: save_browser_diagnostics(page, tmp_path, browser_info, outcome, errors, console_errors, kernel_executions) + phase('diagnostics-end') context.close() + phase('context-closed') browser.close() + phase('browser-closed') finally: if client: client.stop_channels() + phase('server-stop') server.terminate() try: server.wait(timeout=15) except subprocess.TimeoutExpired: server.kill() server.wait() + phase('server-stopped') + if os.environ.get('FIGGY_TEST_TIMING')=='1': + print('FIGGY HARNESS TIMING '+json.dumps(timeline),flush=True) log.close() shutil.rmtree(runtime, ignore_errors=True) diff --git a/tests/native_input.py b/tests/native_input.py new file mode 100644 index 0000000..f5eee77 --- /dev/null +++ b/tests/native_input.py @@ -0,0 +1,10 @@ +"""Send complete X11 clicks before waiting for a rendered frame.""" + + +def click(emit, *, double=False): + # Pumping between press and release can exceed egui's 0.8s click duration + # on a slow software GPU. The host must receive a short click, not a hold. + if double: + emit('click','--repeat',2,'--delay',70,1) + else: + emit('click',1) diff --git a/tests/test_desktop_editor.py b/tests/test_desktop_editor.py index 425cf3e..9a9924d 100644 --- a/tests/test_desktop_editor.py +++ b/tests/test_desktop_editor.py @@ -12,6 +12,7 @@ import figgyplot as figgy from figgyplot.desktop import poll_events from object_navigation import ensure_native_control_visible +from native_input import click as send_native_click @pytest.mark.skipif(not os.environ.get('DISPLAY') or not shutil.which('xdotool') or not shutil.which('xclip'), reason='requires X11, xdotool and xclip') @@ -31,7 +32,7 @@ def info(): return json.loads(window.info_json()) def xdo(*args): return subprocess.check_output(['xdotool', *map(str,args)],text=True).strip() def click_rect(rect, wid=None): xdo('mousemove','--window',wid or chart_id,round(rect[0]+rect[2]/2),round(rect[1]+rect[3]/2));pump() - xdo('mousedown',1);pump();xdo('mouseup',1);pump() + send_native_click(xdo);pump() def click(name): ensure_native_control_visible(info,xdo,pump,chart_id,name) click_rect(info()['controls'][name]) diff --git a/tests/test_desktop_host.py b/tests/test_desktop_host.py index c21ea00..fbe69d7 100644 --- a/tests/test_desktop_host.py +++ b/tests/test_desktop_host.py @@ -7,6 +7,7 @@ import time import numpy as np import pytest +from native_input import click as send_native_click from figgyplot import subplots, release_gpu from figgyplot.desktop import poll_events from object_navigation import ensure_native_control_visible @@ -45,12 +46,7 @@ def click_rect(rect, double=False): x, y = point(rect) xdo("mousemove", "--window", wid, x, y) pump() - if double: - xdo("click", "--repeat", 2, "--delay", 70, 1) - else: - xdo("mousedown", 1) - pump() - xdo("mouseup", 1) + send_native_click(xdo,double=double) pump() def click(name): @@ -215,12 +211,7 @@ def click_rect(rect, double=False): x, y = round(rect[0] + rect[2] / 2), round(rect[1] + rect[3] / 2) xdo("mousemove", "--window", wid, x, y) pump() - if double: - xdo("click", "--repeat", 2, "--delay", 70, 1) - else: - xdo("mousedown", 1) - pump() - xdo("mouseup", 1) + send_native_click(xdo,double=double) pump() try: diff --git a/tests/test_interaction_parity.py b/tests/test_interaction_parity.py index fbd7972..2e6683e 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -1077,10 +1077,13 @@ def test_matching_opposite_axes_share_only_ui_edited_fields(tmp_path, dimension) fig.configure(config={key:{'label_style':{'visible':False,'font_size':11.,'significant_digits':6},'title_option':{'offset_x':3.,'offset_y':4.}}}) """ axes=['bottom_x','top_x','left_y','right_y'] + snapshots=[] def check_edit(get_state, edit, source, field, value, linked=True): before=get_state()['config'] edit(targets[source],field,value) after=get_state()['config'] + snapshots.append({'axis':source,'field':field,'value':value,'before':before,'after':after}) + (tmp_path/'axis-state-transitions.json').write_text(json.dumps(snapshots)) changed={'label_format':['label_style','format'],'significant_digits':['label_style','significant_digits'], 'tick_labels_visible':['label_style','visible'],'tick_direction':['tick'],'axis_scale':['scale']}.get(field,[field]) destination=opposite if source==primary else primary @@ -1125,6 +1128,9 @@ def edit(target,field,value): click_control(field) if field in ['label_format','tick_direction','axis_scale']: prefix='tick' if field=='tick_direction' else field + if field=='axis_scale' and value=='Linear': + from PIL import ImageGrab + ImageGrab.grab(xdisplay=os.environ['DISPLAY']).save(tmp_path/'native-linear-menu.png') inputs.click(prefix+'_'+value) elif field!='tick_labels_visible': inputs.key('ctrl+a');inputs.xdo('type','--clearmodifiers',str(value));inputs.key('Return') @@ -1139,7 +1145,10 @@ def patch(value): other.axes[0].plot([1,10,100],[1,10,100],id='signal') exec(code,{'fig':other}) assert other.get_settings()['config']==fig.get_settings()['config'] - finally: handle.close();Inputs.pump() + finally: + (tmp_path/'native-axis-input-events.json').write_text(json.dumps(inputs.events)) + (tmp_path/'native-axis-settings.json').write_text(json.dumps(fig.get_settings())) + handle.close();Inputs.pump() source="import json\nimport figgyplot as figgy\nfig,ax=figgy.subplots(backend='webgpu')\n"+setup+"display(fig.widget())" with notebook(tmp_path/('paired_'+dimension),source) as (page,execute): click_button(page, '[data-properties]') @@ -1504,6 +1513,11 @@ def owner(state): return state[kind] if kind else state['config'] def items(state): return owner(state).get('annotations',{}).get('items',[]) with figgy.Figure(backend='native') as fig: exec(setup, {'fig':fig,'ax':fig.axes[0]});handle=fig.show(block=False) + inputs=None;samples=[] + def drawing_probe(stage): + samples.append({'stage':stage,'settings':fig.get_settings(),'host':inputs.info()}) + (tmp_path/'native-drawing-states.json').write_text(json.dumps(samples)) + (tmp_path/'native-drawing-inputs.json').write_text(json.dumps(inputs.events)) try: inputs=Inputs(handle);inputs.click('draw');revision=fig.revision inputs.click('layout_mode');inputs.click('draw_arrow') @@ -1526,7 +1540,9 @@ def items(state): return owner(state).get('annotations',{}).get('items',[]) # The existing bounded hover route may resume after cancellation. if kind is None:assert fig.revision==revision inputs.click('draw_text');revision=fig.revision;point=native_point(inputs.info(),(330,240)) + drawing_probe('text-armed') inputs.click_rect([point[0]-1,point[1]-1,2,2]) + drawing_probe('text-placed') assert 'inline_text' in inputs.info()['controls'] and not items(fig.get_settings()) inputs.xdo('type','--clearmodifiers','Discard this');inputs.pump();inputs.key('Escape') assert not items(fig.get_settings()) @@ -1566,7 +1582,9 @@ def edit_native_text(): inputs.click('delete_annotation');assert len(items(fig.get_settings()))==len(ids)-1 assert not inputs.info()['error'] assert fig.info['rgba_readbacks']==fig.info['png_encodes']==0 - finally:handle.close();Inputs.pump() + finally: + if inputs is not None:drawing_probe('closing') + handle.close();Inputs.pump() source="import json\nimport figgyplot as figgy\nfig,ax=figgy.subplots(backend='webgpu')\n"+setup+"\ndisplay(fig.widget())" with notebook(tmp_path/family,source) as (page,execute): click_button(page, '[data-draw]') @@ -2576,13 +2594,29 @@ def test_radial_detail_tree_selection_edits_only_the_chosen_child(tmp_path,backe import copy with figgy.Figure(backend=backend) as fig: exec(setup,{'fig':fig,'copy':copy});handle=fig.show(block=False) + samples=[];started=time.monotonic();inputs=None + def probe(stage): + sample={'stage':stage,'elapsed_seconds':time.monotonic()-started, + 'settings':fig.get_settings(),'host':json.loads(handle.info_json())} + if inputs is not None: + (tmp_path/'native-input-events.json').write_text(json.dumps(inputs.events)) + samples.append(sample) + (tmp_path/'native-detail-states.json').write_text(json.dumps(samples)) try: - inputs=Inputs(handle);inputs.click('properties');choose_native_object(inputs,'Detail B','bounded/series/slice_1_1') - inputs.click('section:Series style');inputs.click('property:Series style/custom') + inputs=Inputs(handle);probe('opened') + inputs.click('properties');probe('properties') + choose_native_object(inputs,'Detail B','bounded/series/slice_1_1');probe('selected-child') + inputs.click('section:Series style');probe('style-section') + inputs.click('property:Series style/custom');probe('custom-style') inputs.click('property:Series style/style/gap_degrees');inputs.key('ctrl+a');inputs.xdo('type','--clearmodifiers','5');inputs.key('Return') + probe('edited-gap') chart=fig.get_settings()['radial'];assert chart['split']['children'][1]['style']['gap_degrees']==5 assert chart['split']['children'][0]['style'] is None and all(v['style'] is None for v in chart['slices']) assert not inputs.info()['error'] + except Exception: + probe('failure') + print('FIGGY NATIVE DETAIL FAILURE '+json.dumps(samples[-1]),flush=True) + raise finally:handle.close();Inputs.pump() return source='import copy,json\nimport figgyplot as fg\nfig=fg.Figure(backend="webgpu")\n'+setup+'\ndisplay(fig.widget())' @@ -2707,6 +2741,13 @@ def test_native_review_long_comments_keep_approval_controls_reachable(tmp_path,f else:fig.axes[0].plot([0,1],[1,3],id='signal') session.present();handle=fig._windows[-1];inputs=Inputs(handle) with pytest.raises(RuntimeError,match='already has'):fig.show(block=False) + inputs.click('properties') + if family=='cartesian':inputs.click_rect(inputs.info()['decorations']['axis_bottom']) + props=inputs.info()['controls']['properties_viewport'] + review=inputs.info()['controls']['review_comments'] + (tmp_path/'review-property-bounds.json').write_text(json.dumps({'properties':props,'review':review})) + assert (props[0]+props[2]<=review[0] or review[0]+review[2]<=props[0] + or props[1]+props[3]<=review[1] or review[1]+review[3]<=props[1]) session.comment(fig.id,'Preserve the original data',target={'series_id':'signal'}) inputs.click('review_comments') import subprocess diff --git a/tests/test_radial_desktop.py b/tests/test_radial_desktop.py index f216a7c..3ddd10c 100644 --- a/tests/test_radial_desktop.py +++ b/tests/test_radial_desktop.py @@ -8,6 +8,7 @@ import pytest import figgyplot as figgy from figgyplot.desktop import poll_events +from native_input import click as send_native_click requires_x11 = pytest.mark.skipif( not os.environ.get("DISPLAY") or not shutil.which("xdotool") or not shutil.which("xclip") or not shutil.which("wmctrl"), @@ -18,6 +19,7 @@ class Inputs: def __init__(self, window): self.window = window + self.events = [] self.pump() self.wid = self.xdo("search", "--pid", os.getpid(), "--name", "^Figgy$").splitlines()[-1] # A window manager may reuse an offscreen/right-edge placement after @@ -40,22 +42,40 @@ def pump(): def info(self): return json.loads(self.window.info_json()) + @staticmethod + def input_state(state): + # Keep the event trace bounded without retaining 64 complete layout + # snapshots inside each of 128 before/after input records. + result={key:state.get(key) for key in ['surface_frames','pointer_sequence', + 'pointer_input','selected','revision','properties_open','surface_size', + 'scale_factor','controls','error']} + result['scale_probe']=[entry for entry in state.get('layout_probe',[]) + if entry.get('kind')=='scale_control'][-6:] + return result + def input_frame(self, *args): """Wait for the host to present an input instead of assuming a fixed delay. Software Vulkan can take longer than the usual 100 ms under CI load; sending the next gesture before a toolbar click is consumed loses input. """ - before = self.info()['surface_frames'] + state = self.info();before = state['surface_frames'] + entry={'input':args,'before':self.input_state(state),'started':time.monotonic()} + self.events.append(entry) + del self.events[:-128] pointer = self.xdo('getmouselocation', '--shell') if args[0]=='mousemove' else None self.xdo(*args) if pointer is not None and pointer == self.xdo('getmouselocation', '--shell'): self.pump() + entry['after']=self.input_state(self.info()) + entry['finished']=time.monotonic() return deadline = time.monotonic()+5 while True: self.pump() - if self.info()['surface_frames'] > before: + state=self.info();entry['after']=self.input_state(state) + entry['finished']=time.monotonic() + if state['surface_frames'] > before: return assert time.monotonic() < deadline, self.info() @@ -65,8 +85,7 @@ def click_rect(self, rect): # Deliver a real motion event before pressing on the new chart surface. self.input_frame("mousemove", "--window", self.wid, x+8, y+8) self.input_frame("mousemove", "--window", self.wid, x, y) - self.input_frame("mousedown", 1) - self.input_frame("mouseup", 1) + send_native_click(self.input_frame) def click(self, name): from object_navigation import ensure_native_control_visible @@ -78,6 +97,24 @@ def key(self, key): self.pump() +@requires_x11 +def test_click_remains_a_short_press_when_frames_are_slow(tmp_path,monkeypatch): + with figgy.Figure(backend='native') as fig: + fig.axes[0].pie([3,2,1]) + handle=fig.show(block=False) + try: + inputs=Inputs(handle);original=inputs.pump + def slow_frame(): + time.sleep(.9) # Exceed egui's 0.8s click duration between frames. + original() + monkeypatch.setattr(inputs,'pump',slow_frame) + inputs.click('properties') + (tmp_path/'click-events.json').write_text(json.dumps(inputs.events)) + assert inputs.info()['pointer_input']['state']=='Released' + assert inputs.info()['properties_open'] + finally:handle.close();Inputs.pump() + + @requires_x11 def test_radial_native_edit_copy_selection_and_no_readback(tmp_path): fig, ax = figgy.subplots(backend="native") From 819692ac1ba362bb9061fd881b05b44be7f7e32a Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 03:24:03 +0000 Subject: [PATCH 04/11] Map annotation test input from live prepared DOM geometry --- docs/REVIEW_MODE_VALIDATION.md | 28 + .../ci52-annotation-input-20261022.json | 745 ++++++++++++++++++ tests/inline_editor_harness.py | 32 +- tests/test_interaction_parity.py | 37 +- 4 files changed, 831 insertions(+), 11 deletions(-) create mode 100644 docs/measurements/ci52-annotation-input-20261022.json diff --git a/docs/REVIEW_MODE_VALIDATION.md b/docs/REVIEW_MODE_VALIDATION.md index 2c97f90..7376a22 100644 --- a/docs/REVIEW_MODE_VALIDATION.md +++ b/docs/REVIEW_MODE_VALIDATION.md @@ -111,3 +111,31 @@ passed all **117 cases in 3.02 seconds**; Cargo format/check passed. All 33 inst files match the wheel byte for byte, including the native extension; its 32 non-native runtime files also match their packaged source files. Installed-wheel smoke and editor-control inventory checks passed. + +## CI #52 annotation-input follow-up + +The preceding local 640-case pass did not reproduce CI #52's browser input +failure. CI passed four layout probes and 51 priority cases, then stopped at +**250 passed and one failed** in 1472.42 seconds. The radial multiline-text +case sent its reopening double-click to **(1243, 873.94)** in a **980 x 820** +viewport. Its separately read protocol bounding box still described a +960 x 600 canvas, while the subsequent DOM record was 511 x 319.375. No text +hit was recorded; all three GPU submissions had completed and the host queue +was idle. This establishes an invalid test input, not a renderer/SSOT failure. +The evidence does not determine whether the earlier protocol box was stale +when returned or the DOM layout changed between the separate reads. + +The shared annotation-input helper now prepares annotation geometry and reads +the live DOM rectangle and coordinate units in one browser operation. It rejects +points outside the visible canvas before sending real Playwright mouse input. +Both existing-text helpers use this path. No timing deadline or post-input +assertion changed. A controlled browser case reproduces the recorded resize: +the old point delivers no canvas double-click, while the current point delivers +a trusted mouse event. [The CI request, accepted SSOT and settled host state](measurements/ci52-annotation-input-20261022.json) +retain the measured failure. + +All **five affected cases passed in 94.48 seconds**: the controlled input +regression, Cartesian/radial multiline editing, and scale 1/2 editing. Runtime +files and the release wheel are unchanged. The collection now contains **641 +cases**; no complete local 641-case pass is claimed. Required CI validates the +complete updated collection. diff --git a/docs/measurements/ci52-annotation-input-20261022.json b/docs/measurements/ci52-annotation-input-20261022.json new file mode 100644 index 0000000..e3f15c4 --- /dev/null +++ b/docs/measurements/ci52-annotation-input-20261022.json @@ -0,0 +1,745 @@ +{ + "run": 39711546907, + "job": 119605700627, + "commit": "9ceb223d81aa58eb6a6ffe5acfb70b47fa9360ea", + "case": "test_inline_text_resize_multiline_and_existing_format_in_both_hosts[radial]", + "full_suite": { + "passed": 250, + "failed": 1, + "seconds": 1472.42 + }, + "request": { + "annotation": [ + 846, + 506.0000305175781, + 62.265625, + 38.400001525878906 + ], + "canvas": { + "x": 389, + "y": 361.9375, + "width": 960, + "height": 600 + }, + "units": [ + 960, + 600 + ], + "point": [ + 1243, + 873.9375305175781 + ] + }, + "observed": { + "canvas": { + "x": 389, + "y": 438.9375, + "width": 511, + "height": 319.375 + }, + "backing": [ + 511, + 319 + ], + "viewport": [ + 980, + 820, + 1 + ], + "box": { + "x": 0, + "y": 0, + "width": 0, + "height": 0 + }, + "input": { + "x": 0, + "y": 0, + "width": 0, + "height": 0 + }, + "hint": { + "x": 0, + "y": 0, + "width": 0, + "height": 0 + }, + "font": 16, + "value": "First\nSecond", + "focused": false, + "selection": [ + 12, + 12 + ], + "accepted": { + "schema_version": 2, + "renderer_version": "0.18.1", + "config": { + "chart_area": { + "x": 0, + "y": 0, + "width": 960, + "height": 600 + }, + "top_x": { + "scale": "Linear", + "min": 0, + "max": 1, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "font_size": 18, + "label_visible": false, + "label_font": "", + "label_offset_x": 0, + "label_offset_y": 0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "font_size": 22, + "font": "" + }, + "visible": false, + "offset_x": 0, + "offset_y": 0 + }, + "out_margin": 25, + "line_offset": 0, + "line_visible": true, + "line_color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "line_width": 1, + "line_style": "Solid", + "major_tick_length": 5, + "minor_tick_length": 3 + }, + "bottom_x": { + "scale": "Linear", + "min": 0, + "max": 1, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "font_size": 18, + "label_visible": true, + "label_font": "", + "label_offset_x": 0, + "label_offset_y": 0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "font_size": 22, + "font": "" + }, + "visible": true, + "offset_x": 0, + "offset_y": 0 + }, + "out_margin": 80, + "line_offset": 0, + "line_visible": true, + "line_color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "line_width": 1, + "line_style": "Solid", + "major_tick_length": 5, + "minor_tick_length": 3 + }, + "left_y": { + "scale": "Linear", + "min": 0, + "max": 1, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "font_size": 18, + "label_visible": true, + "label_font": "", + "label_offset_x": 0, + "label_offset_y": 0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "font_size": 22, + "font": "" + }, + "visible": true, + "offset_x": 0, + "offset_y": 0 + }, + "out_margin": 110, + "line_offset": 0, + "line_visible": true, + "line_color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "line_width": 1, + "line_style": "Solid", + "major_tick_length": 5, + "minor_tick_length": 3 + }, + "right_y": { + "scale": "Linear", + "min": 0, + "max": 1, + "major_spacing": 0.2, + "minor_count": 4, + "inverted": false, + "label_style": { + "visible": true, + "color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "font_size": 18, + "label_visible": false, + "label_font": "", + "label_offset_x": 0, + "label_offset_y": 0, + "format": "Decimal", + "significant_digits": 3 + }, + "tick": "Inside", + "title_option": { + "text": { + "segments": [], + "color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "font_size": 22, + "font": "" + }, + "visible": false, + "offset_x": 0, + "offset_y": 0 + }, + "out_margin": 25, + "line_offset": 0, + "line_visible": true, + "line_color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "line_width": 1, + "line_style": "Solid", + "major_tick_length": 5, + "minor_tick_length": 3 + }, + "chart_title": { + "text": { + "segments": [], + "color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "font_size": 28, + "font": "" + }, + "visible": true, + "offset_x": 0, + "offset_y": 0, + "top_margin": 32 + }, + "grid": { + "show_major_x": true, + "major_x_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1 + }, + "major_x_width": 1, + "major_x_style": "Solid", + "show_major_y": true, + "major_y_color": { + "r": 0.78431374, + "g": 0.78431374, + "b": 0.78431374, + "a": 1 + }, + "major_y_width": 1, + "major_y_style": "Solid", + "show_minor_x": false, + "minor_x_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1 + }, + "minor_x_width": 0.5, + "minor_x_style": "Dot", + "show_minor_y": false, + "minor_y_color": { + "r": 0.9019608, + "g": 0.9019608, + "b": 0.9019608, + "a": 1 + }, + "minor_y_width": 0.5, + "minor_y_style": "Dot" + }, + "legend": { + "visible": false, + "content": { + "segments": [], + "color": { + "r": 0, + "g": 0, + "b": 0, + "a": 1 + }, + "font_size": 14, + "font": "" + }, + "corner": "TopRight", + "offset_x": 0, + "offset_y": 0, + "padding": 8, + "bg_color": { + "r": 1, + "g": 1, + "b": 1, + "a": 0.85 + }, + "border_color": { + "r": 0.6, + "g": 0.6, + "b": 0.6, + "a": 1 + } + } + }, + "series": [], + "radial": { + "decorations": { + "data_area": null, + "offsets": [], + "selected": { + "Annotation": 0 + } + }, + "title": "", + "annotations": { + "visible": true, + "items": [ + { + "id": "annotation_b4a941a9-9f48-43a1-9cfe-47374db14348", + "kind": { + "Text": { + "anchor": { + "Ratio": { + "x": 0.90570176, + "y": 0.8804348 + } + }, + "text": { + "segments": [ + { + "text": "F", + "bold": true, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "i", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "r", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "s", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "t", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "\n", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "S", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "e", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "c", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "o", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "n", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + }, + { + "text": "d", + "bold": false, + "italic": false, + "underline": false, + "superscript": false, + "subscript": false, + "greek": false + } + ], + "color": { + "r": 0.137, + "g": 0.176, + "b": 0.235, + "a": 1 + }, + "font_size": 16, + "font": "" + }, + "box_style": null + } + } + } + ] + }, + "slices": [ + { + "label": "Slice 1", + "value": 2, + "color": { + "r": 0.12156863, + "g": 0.46666667, + "b": 0.7058824, + "a": 1 + }, + "explode": 0, + "style": null, + "labels": null, + "label_format": null, + "label_color": null + }, + { + "label": "Slice 2", + "value": 3, + "color": { + "r": 1, + "g": 0.49803922, + "b": 0.05490196, + "a": 1 + }, + "explode": 0, + "style": null, + "labels": null, + "label_format": null, + "label_color": null + }, + { + "label": "Slice 3", + "value": 4, + "color": { + "r": 0.17254902, + "g": 0.627451, + "b": 0.17254902, + "a": 1 + }, + "explode": 0, + "style": null, + "labels": null, + "label_format": null, + "label_color": null + } + ], + "kind": { + "Donut": { + "inner_radius": 0.5 + } + }, + "start_angle_degrees": -90, + "split": null, + "style": { + "material": "Flat", + "tilt_degrees": 0, + "depth": 0, + "bevel": 0.015, + "gap_degrees": 0, + "roughness": 0.35, + "texture_strength": 0.2, + "texture_scale": 1, + "light": [ + -0.5, + -0.6, + 1 + ], + "shadow": false, + "inner_corner": 0, + "outer_corner": 0, + "outline": { + "rim": false, + "separators": false, + "emphasis": false, + "width": 1, + "color": { + "r": 0.08, + "g": 0.12, + "b": 0.2, + "a": 1 + } + }, + "hover_lift": 0.08, + "hover_brightness": 0.04, + "gloss": 0.55, + "texture_angle_degrees": 0 + }, + "labels": "Outside", + "label_format": "NamePercent", + "label_decimals": 1, + "value_suffix": "", + "interaction": { + "hovered": null, + "selected": null, + "hover_progress": 0 + }, + "font_family": "sans-serif", + "font_size": 18.333334, + "label_color": { + "r": 0.12, + "g": 0.16, + "b": 0.22, + "a": 1 + }, + "background": { + "r": 1, + "g": 1, + "b": 1, + "a": 1 + } + }, + "identity": { + "figure": "c592a4d1-1cd3-4725-bed2-ebdc552b9d07", + "groups": {}, + "layout": null, + "panels": [ + { + "id": "main", + "overlays": [] + } + ], + "retired": [] + } + }, + "host": { + "kind": "radial", + "busy": 0, + "queued": 0, + "dirty": false, + "frame_pending": false, + "frame_error": null, + "edit_inflight": null, + "edit_pending": false, + "canvas": [ + 511, + 319 + ], + "disposed": false + } + }, + "hit": null, + "annotation_geometry_after": [ + { + "element": "annotation_0", + "handles": [], + "id": "annotation_b4a941a9-9f48-43a1-9cfe-47374db14348", + "index": 0, + "kind": "Text", + "rect": [ + 846, + 506.0000305175781, + 62.265625, + 39.56614303588867 + ], + "selected": true + } + ], + "interpretation": "The recorded input point is outside the 980x820 viewport. The failure does not establish a renderer hit-test or SSOT error. Request-time DOM coordinates must be read together with prepared annotation geometry; a separately captured protocol bounding box may describe an earlier layout.", + "controlled_regression": { + "stale_point": [ + 1243.0, + 873.9375 + ], + "current": { + "annotation": [ + 846, + 506, + 62, + 38 + ], + "canvas": { + "x": 389, + "y": 438.9375, + "width": 511, + "height": 319.375 + }, + "units": [ + 960, + 600 + ], + "point": [ + 843.5770833333333, + 711.4708333333333 + ], + "viewport": [ + 980, + 820 + ], + "backing": [ + 960, + 600 + ] + }, + "delivered": [ + { + "trusted": true, + "x": 843, + "y": 711 + } + ] + }, + "validation": { + "affected_passed": 5, + "seconds": 94.48, + "complete_collection": 641, + "runtime_changed": false, + "deadlines_or_post_input_assertions_changed": false, + "complete_updated_ci": "pending" + } +} diff --git a/tests/inline_editor_harness.py b/tests/inline_editor_harness.py index 5d5249b..cdd2da2 100644 --- a/tests/inline_editor_harness.py +++ b/tests/inline_editor_harness.py @@ -70,13 +70,33 @@ def assert_contained(state): assert r['y']+r['height'] <= c['y']+c['height']+1, (name, state) +def annotation_point(page, index=0, offset=(8,6)): + """Prepare geometry and map it through the live DOM in one browser operation. + + A protocol bounding box read separately can describe the preceding layout. + The point must be visible and hit the canvas before sending real mouse input. + """ + return page.evaluate("""async ({index,offset})=>{ + const root=document.querySelector('.figgy-editor'),c=root.querySelector('canvas'); + const entries=await root.figgy.annotations(),annotation=entries.find(v=>v.index===index)??entries[index]; + if(!annotation)throw Error('Missing annotation '+index); + const r=c.getBoundingClientRect(),state=root.figgy.snapshot(); + const units=root.figgy.diagnostics().kind==='cartesian'?[c.width,c.height]: + [state.config.chart_area.width,state.config.chart_area.height]; + const point=[r.x+(annotation.rect[0]+offset[0])*r.width/units[0], + r.y+(annotation.rect[1]+offset[1])*r.height/units[1]]; + const result={annotation:annotation.rect,canvas:{x:r.x,y:r.y,width:r.width,height:r.height}, + units,point,viewport:[innerWidth,innerHeight],backing:[c.width,c.height]}; + if(point[0]<0||point[0]>=innerWidth||point[1]<0||point[1]>=innerHeight|| + document.elementFromPoint(...point)!==c) + throw Error('Annotation input target is outside the visible canvas: '+JSON.stringify(result)); + return result; + }""", {'index':index,'offset':list(offset)}) + + def open_existing(page): - entries = page.evaluate(ROOT + '.annotations()') - rect = entries[0]['rect'] - point = page.locator('canvas').evaluate("""(c,p)=>{ - const r=c.getBoundingClientRect();return [r.x+p[0]*r.width/c.width,r.y+p[1]*r.height/c.height]; - }""", [rect[0]+8, rect[1]+6]) - page.mouse.dblclick(*point) + target = annotation_point(page) + page.mouse.dblclick(*target['point']) entry = page.locator('[data-inline-text]') entry.wait_for(state='visible') return entry diff --git a/tests/test_interaction_parity.py b/tests/test_interaction_parity.py index 2e6683e..05a2ded 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -1646,6 +1646,35 @@ def edit_browser_text(): screenshot_editor(page,tmp_path/f'{family}-drawing.png') +@pytest.mark.skipif(NEEDS_UI, reason='requires a real browser') +def test_annotation_mouse_input_uses_live_layout_after_resize(tmp_path): + from playwright.sync_api import sync_playwright + from inline_editor_harness import annotation_point + with sync_playwright() as p: + browser=p.chromium.launch(executable_path=os.environ.get('BROWSER_EXECUTABLE_PATH') or shutil.which('chromium'), + headless=False,args=['--no-sandbox']) + try: + page=browser.new_page(viewport={'width':980,'height':820}) + page.set_content(''' +
''') + page.evaluate("""()=>{ + const root=document.querySelector('.figgy-editor'),canvas=root.querySelector('canvas'); + root.figgy={annotations:async()=>[{index:0,rect:[846,506,62,38]}], + snapshot:()=>({config:{chart_area:{width:960,height:600}}}),diagnostics:()=>({kind:'radial'})}; + window.clicks=[];canvas.addEventListener('dblclick',e=>clicks.push({trusted:e.isTrusted,x:e.clientX,y:e.clientY})); + }""") + old=page.locator('canvas').bounding_box() + stale=[old['x']+854*old['width']/960,old['y']+512*old['height']/600] + page.evaluate("""()=>{const c=document.querySelector('canvas');c.style.width='511px'; + c.style.height='319.375px';c.style.top='438.9375px';}""") + page.mouse.dblclick(*stale) + assert page.evaluate('clicks')==[] # The CI point lies outside the viewport. + current=annotation_point(page);page.mouse.dblclick(*current['point']) + clicks=page.evaluate('clicks');assert len(clicks)==1 and clicks[0]['trusted'] + (tmp_path/'annotation-input-layout.json').write_text(json.dumps({'stale_point':stale,'current':current,'delivered':clicks})) + finally:browser.close() + + @pytest.mark.skipif(NEEDS_UI, reason='requires real native and Jupyter inputs') @pytest.mark.parametrize('family', ['cartesian', 'radial']) def test_inline_text_resize_multiline_and_existing_format_in_both_hosts(tmp_path, family): @@ -1704,12 +1733,10 @@ def content(state): return ''.join(v['text'] for v in annotation(state)['kind'][ execute("state=fig.get_settings();state['"+kind+"']['active_layer']='Base';state['"+kind+"']['annotations']['items'][0]['kind']['Text']['text']['segments'][0]['bold']=True;fig.apply_settings(state);print('updated')") page.wait_for_function(f'({ROOT}.snapshot().{kind}.active_layer??"Base")==="Base" && {ROOT}.snapshot().{kind}.annotations.items[0].kind.Text.text.segments[0].bold===true') page.wait_for_function('async()=> (await '+ROOT+'.annotations()).length===1') - rect=page.evaluate(ROOT+'.annotations()')[0]['rect'];canvas=page.locator('canvas').bounding_box() - # Cartesian geometry is in current physical canvas pixels; bounded maps use document units. - size=[960,600] if family=='radial' else page.locator('canvas').evaluate('c=>[c.width,c.height]') - reopen={'annotation':rect,'canvas':canvas,'units':size} + from inline_editor_harness import annotation_point + reopen=annotation_point(page) (tmp_path/'inline-reopen-request.json').write_text(json.dumps(reopen,indent=2)) - page.mouse.dblclick(canvas['x']+(rect[0]+8)*canvas['width']/size[0],canvas['y']+(rect[1]+6)*canvas['height']/size[1]) + page.mouse.dblclick(*reopen['point']) try: entry.wait_for(state='visible');page.wait_for_function("document.activeElement?.hasAttribute('data-inline-text')") finally: From ff72cf373d4940f295c3902251de6ac28077d214 Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 04:16:21 +0000 Subject: [PATCH 05/11] Reject pre-resize canvas allocations in browser input readiness --- .github/workflows/pr-tests.yml | 6 +- docs/REVIEW_MODE_VALIDATION.md | 47 +++++++++ .../ci53-surface-readiness-20261022.json | 95 +++++++++++++++++++ tests/inline_editor_harness.py | 14 ++- tests/test_interaction_parity.py | 24 +++++ 5 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 docs/measurements/ci53-surface-readiness-20261022.json diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 8a0c630..aacc2f9 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -159,13 +159,15 @@ jobs: openbox > /tmp/figgyplot-openbox.log 2>&1 & wm_pid=$! trap 'kill "$wm_pid" 2>/dev/null || true' EXIT - # Reject the observed stale-font/box failures before the full suite. + # Reject invalid resize readiness and observed editor failures first. # The queue-order cases must fail on the original a1e79a8 wheel. .venv/bin/python -m pytest -x -vv --capture=tee-sys --durations=20 \ --basetemp=ci-results/layout-probe --junitxml=ci-results/layout-probe.xml \ + 'tests/test_interaction_parity.py::test_surface_readiness_rejects_matching_pre_resize_css_and_backing' \ + 'tests/test_interaction_parity.py::test_annotation_mouse_input_uses_live_layout_after_resize' \ 'tests/test_interaction_parity.py::test_inline_editor_resize_uses_current_units_and_applies_real_edits[1]' \ 'tests/test_interaction_parity.py::test_inline_editor_resize_uses_current_units_and_applies_real_edits[2]' \ - 'tests/test_interaction_parity.py::test_inline_text_resize_multiline_and_existing_format_in_both_hosts[cartesian]' \ + 'tests/test_interaction_parity.py::test_inline_text_resize_multiline_and_existing_format_in_both_hosts[radial]' \ 'tests/test_interaction_parity.py::test_inactive_overlay_objects_can_be_edited_and_dragged_directly[native]' \ 2>&1 | tee ci-results/layout-probe.log # Measure the last failed radial screenshot before the full suite. diff --git a/docs/REVIEW_MODE_VALIDATION.md b/docs/REVIEW_MODE_VALIDATION.md index 7376a22..dd12a3e 100644 --- a/docs/REVIEW_MODE_VALIDATION.md +++ b/docs/REVIEW_MODE_VALIDATION.md @@ -139,3 +139,50 @@ regression, Cartesian/radial multiline editing, and scale 1/2 editing. Runtime files and the release wheel are unchanged. The collection now contains **641 cases**; no complete local 641-case pass is claimed. Required CI validates the complete updated collection. + +## CI #53 resize-readiness correction + +CI #53 failed the same radial multiline case after **251 passed and one failed** +in 1726.82 seconds. Unlike the preceding separate observations, its input helper +read annotation geometry and live DOM bounds together. The DOM itself was still +960 x 600 in the new 980 x 820 viewport; the requested point remained +(1243, 873.94). Thus the preceding coordinate-read correction did not fix the +underlying readiness error: matching CSS and backing dimensions can both be old. +[The measured input, host and GPU state](measurements/ci53-surface-readiness-20261022.json) +preserve this distinction. No renderer or SSOT failure is established here. + +A controlled browser regression using the exact CI allocation **failed against +the old predicate** (one failed in 1.69 seconds). Readiness now also requires a +positive canvas allocation that fits the requested horizontal viewport, while +preserving CSS/backing/DPR agreement. The regression withholds the new allocation +for 200 ms and checks that readiness waits for its delivery. It does not wait for +the asserted text content, font or inline-editor bounds. This is an input +precondition, not a claim that all automatic layout or GPU work has completed; +notebook output may still scroll vertically. + +All **six affected cases passed in 96.74 seconds** on the unchanged runtime +wheel: that readiness regression, the trusted annotation-input regression, +Cartesian/radial multiline editing in both hosts and queued resize/editing at +scales 1 and 2. CI runs the exact failed radial case and both controlled input +regressions before the full suite. The collection now has **642 cases**. The +existing full suite, assertions and time limits remain in place; no complete +local 642-case pass is claimed. Local Chromium is 154; CI #53 used Chromium 156. + +## Limits of the current acceptance tests + +Counts include packaging and release guards and do not measure feature or UX +coverage. Isolated assertion audits found two concrete gaps: + +- All nine native feature round-trip cases accept a substituted white RGBA + image with one black pixel. Their model/replay assertions still execute, but + nonblank output plus replay-image equality is not an independent visual oracle. + A targeted data/colorbar pixel-change case rejects that same substituted image. +- All ten column-backed restoration family/backend cases accept arrays zeroed + only during restoration, while preserving shape and dtype. Settings equality + and a valid PNG signature do not verify every restored data value. A separate + input-snapshot test checks one stored float32 payload, not every restore handoff. + +These are demonstrated test gaps, not observed production data corruption. +Feature-specific independent visual assertions and complete restored-column +value/type/shape comparisons are still needed for those stronger guarantees. +No full-suite or usability guarantee follows from the affected-case pass above. diff --git a/docs/measurements/ci53-surface-readiness-20261022.json b/docs/measurements/ci53-surface-readiness-20261022.json new file mode 100644 index 0000000..9ce5ce1 --- /dev/null +++ b/docs/measurements/ci53-surface-readiness-20261022.json @@ -0,0 +1,95 @@ +{ + "ci": { + "run": 53, + "url": "https://github.com/shim9610/figgy-python/actions/runs/39716629618", + "commit": "819692ac1ba362bb9061fd881b05b44be7f7e32a", + "result": { + "passed": 251, + "failed": 1, + "seconds": 1726.82 + }, + "case": "test_inline_text_resize_multiline_and_existing_format_in_both_hosts[radial]", + "browser": "156.0.8078.4", + "input": { + "annotation": [ + 846, + 506.0000305175781, + 62.265625, + 38.400001525878906 + ], + "canvas": { + "x": 389, + "y": 361.9375, + "width": 960, + "height": 600 + }, + "units": [ + 960, + 600 + ], + "point": [ + 1243, + 873.9375305175781 + ], + "viewport": [ + 980, + 820 + ], + "backing": [ + 960, + 600 + ] + }, + "host": { + "kind": "radial", + "busy": 0, + "queued": 0, + "dirty": true, + "frame_pending": false, + "frame_error": null, + "edit_inflight": null, + "edit_pending": false, + "canvas": [ + 960, + 600 + ], + "disposed": false + }, + "gpu": { + "submitted": 1, + "completed": 1, + "completedCanvas": [ + 960, + 600 + ] + } + }, + "conclusion": "Matching CSS and backing dimensions can both describe the preceding allocation after viewport shrink. The readiness predicate accepted that state and subsequent input was outside the viewport. This observation corrects the earlier incomplete coordinate-read hypothesis; it does not establish a renderer or SSOT failure.", + "correction": "Require a positive CSS canvas allocation fitting the requested horizontal viewport as well as CSS/backing/DPR agreement. Keep the input helper's live geometry and canvas hit check. Editor font, containment, value and post-input assertions remain independent.", + "limitations": "This is an input precondition, not proof that all automatic layout or GPU rendering has completed. Full vertical containment is not required because notebook output may scroll.", + "local": { + "browser": "154.0.8037.57", + "negative": { + "cases": 1, + "failed": 1, + "seconds": 1.69, + "condition": "The recorded CI allocation must be rejected, but the old predicate returned true." + }, + "positive": { + "cases": 6, + "passed": 6, + "seconds": 96.74, + "coverage": [ + "controlled delayed CSS/backing resize", + "trusted annotation input after resize", + "radial multiline native/Jupyter editing", + "Cartesian multiline native/Jupyter editing", + "queued resize and real edits at DPR 1", + "queued resize and real edits at DPR 2" + ] + }, + "wheel_sha256": "88e7f7cc4bfa8edf93ae9240f289144b530fc493dbe1f73a66355fb7b1537c2b", + "runtime_changed": false + }, + "status": "Local correction validated; required CI must run on the new commit before it can be described as passing." +} diff --git a/tests/inline_editor_harness.py b/tests/inline_editor_harness.py index cdd2da2..46cd52a 100644 --- a/tests/inline_editor_harness.py +++ b/tests/inline_editor_harness.py @@ -44,13 +44,19 @@ class ObservedWidget(FigureWidget): }""" -def wait_surface(page, viewport, dpr=1): - # Size readiness is independent of font, containment and assertion success. - page.wait_for_function("""([width,height,dpr])=>{ +SURFACE_READY = """([width,height,dpr])=>{ const c=document.querySelector('.figgy-editor canvas'),r=c.getBoundingClientRect(); return innerWidth===width&&innerHeight===height&&devicePixelRatio===dpr&& + r.width>0&&r.height>0&&r.left>=0&&r.right<=width+1&& c.width===Math.round(r.width*dpr)&&c.height===Math.round(r.height*dpr); - }""", arg=[*viewport, dpr], polling=20) + }""" + + +def wait_surface(page, viewport, dpr=1): + # Jupyter may still have its preceding allocation after innerWidth changes. + # Matching old CSS/backing sizes do not make that overflow ready for input. + # Do not depend on the inline editor's font, bounds or asserted content here. + page.wait_for_function(SURFACE_READY, arg=[*viewport, dpr], polling=20) def observe(page, folder, label): diff --git a/tests/test_interaction_parity.py b/tests/test_interaction_parity.py index 05a2ded..afd2953 100644 --- a/tests/test_interaction_parity.py +++ b/tests/test_interaction_parity.py @@ -1646,6 +1646,30 @@ def edit_browser_text(): screenshot_editor(page,tmp_path/f'{family}-drawing.png') +@pytest.mark.skipif(NEEDS_UI, reason='requires a real browser') +def test_surface_readiness_rejects_matching_pre_resize_css_and_backing(tmp_path): + from playwright.sync_api import sync_playwright + from inline_editor_harness import SURFACE_READY,wait_surface + with sync_playwright() as p: + browser=p.chromium.launch(executable_path=os.environ.get('BROWSER_EXECUTABLE_PATH') or shutil.which('chromium'), + headless=False,args=['--no-sandbox']) + try: + page=browser.new_page(viewport={'width':980,'height':820}) + page.set_content(''' +
''') + old=page.evaluate("""()=>{const c=document.querySelector('canvas'),r=c.getBoundingClientRect(); + return {viewport:[innerWidth,innerHeight],canvas:[r.x,r.y,r.width,r.height],backing:[c.width,c.height]};}""") + assert not page.evaluate(SURFACE_READY,[980,820,1]),old + page.evaluate("""()=>{window.layoutDelivered=false;setTimeout(()=>{ + const c=document.querySelector('canvas');c.style.width='511px';c.style.height='319.375px'; + c.width=511;c.height=319;window.layoutDelivered=true; + },200);}""") + wait_surface(page,(980,820)) + assert page.evaluate('layoutDelivered') + (tmp_path/'surface-readiness.json').write_text(json.dumps({'before':old,'after':page.locator('canvas').evaluate('c=>[c.width,c.height,c.getBoundingClientRect().width]')})) + finally:browser.close() + + @pytest.mark.skipif(NEEDS_UI, reason='requires a real browser') def test_annotation_mouse_input_uses_live_layout_after_resize(tmp_path): from playwright.sync_api import sync_playwright From fed3f4bf2eaa3f5a53b93a3a99ec0e4e74a5adf6 Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 04:31:10 +0000 Subject: [PATCH 06/11] Run complete CI collection once with prior failures first --- .github/workflows/pr-tests.yml | 26 +- docs/REVIEW_MODE_VALIDATION.md | 29 +- docs/measurements/ci-test-order-20261022.json | 259 ++++++++++++++++++ scripts/run_ci_tests.py | 50 ++++ 4 files changed, 340 insertions(+), 24 deletions(-) create mode 100644 docs/measurements/ci-test-order-20261022.json create mode 100644 scripts/run_ci_tests.py diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index aacc2f9..38fd7e2 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -102,7 +102,7 @@ jobs: # Keep the required check red when preflight/build fails or is skipped. if: always() runs-on: ubuntu-24.04 - # Measured test workload is ~39m; build time has its own job budget. + # Measured full suite is ~41m; priority ordering adds no duplicate execution. timeout-minutes: 45 env: WGPU_BACKEND: vulkan @@ -159,27 +159,9 @@ jobs: openbox > /tmp/figgyplot-openbox.log 2>&1 & wm_pid=$! trap 'kill "$wm_pid" 2>/dev/null || true' EXIT - # Reject invalid resize readiness and observed editor failures first. - # The queue-order cases must fail on the original a1e79a8 wheel. - .venv/bin/python -m pytest -x -vv --capture=tee-sys --durations=20 \ - --basetemp=ci-results/layout-probe --junitxml=ci-results/layout-probe.xml \ - 'tests/test_interaction_parity.py::test_surface_readiness_rejects_matching_pre_resize_css_and_backing' \ - 'tests/test_interaction_parity.py::test_annotation_mouse_input_uses_live_layout_after_resize' \ - 'tests/test_interaction_parity.py::test_inline_editor_resize_uses_current_units_and_applies_real_edits[1]' \ - 'tests/test_interaction_parity.py::test_inline_editor_resize_uses_current_units_and_applies_real_edits[2]' \ - 'tests/test_interaction_parity.py::test_inline_text_resize_multiline_and_existing_format_in_both_hosts[radial]' \ - 'tests/test_interaction_parity.py::test_inactive_overlay_objects_can_be_edited_and_dragged_directly[native]' \ - 2>&1 | tee ci-results/layout-probe.log - # Measure the last failed radial screenshot before the full suite. - # Await current surface completion before the unchanged capture deadline. - .venv/bin/python -m pytest -x -vv --capture=tee-sys --durations=20 \ - --basetemp=ci-results/priority --junitxml=ci-results/priority.xml \ - tests/test_packaged_assets.py \ - 'tests/test_radial_desktop.py::test_click_remains_a_short_press_when_frames_are_slow' \ - 'tests/test_interaction_parity.py::test_radial_detail_tree_selection_edits_only_the_chosen_child[native]' \ - 'tests/test_interaction_parity.py::test_annotation_drawing_editing_and_cancel_in_both_hosts[radial]' \ - 2>&1 | tee ci-results/priority.log - .venv/bin/python -m pytest -x -vv --capture=tee-sys --durations=30 \ + # Order past failures first without running them a second time. + # test-plan.json retains all original and ordered node IDs. + .venv/bin/python scripts/run_ci_tests.py -x -vv --capture=tee-sys --durations=30 \ --basetemp=ci-results/full --junitxml=ci-results/junit.xml \ 2>&1 | tee ci-results/full.log SCRIPT diff --git a/docs/REVIEW_MODE_VALIDATION.md b/docs/REVIEW_MODE_VALIDATION.md index dd12a3e..9d24c73 100644 --- a/docs/REVIEW_MODE_VALIDATION.md +++ b/docs/REVIEW_MODE_VALIDATION.md @@ -163,8 +163,8 @@ notebook output may still scroll vertically. All **six affected cases passed in 96.74 seconds** on the unchanged runtime wheel: that readiness regression, the trusted annotation-input regression, Cartesian/radial multiline editing in both hosts and queued resize/editing at -scales 1 and 2. CI runs the exact failed radial case and both controlled input -regressions before the full suite. The collection now has **642 cases**. The +scales 1 and 2. CI orders the exact failed radial case and both controlled input +regressions first in the full suite. The collection now has **642 cases**. The existing full suite, assertions and time limits remain in place; no complete local 642-case pass is claimed. Local Chromium is 154; CI #53 used Chromium 156. @@ -186,3 +186,28 @@ These are demonstrated test gaps, not observed production data corruption. Feature-specific independent visual assertions and complete restored-column value/type/shape comparisons are still needed for those stronger guarantees. No full-suite or usability guarantee follows from the affected-case pass above. + +## Execute the complete collection once + +The historical 640-case local JUnit record contains 2443.445 seconds of case +time. Running the priority groups separately repeats 55 of those cases, adding +246.020 seconds: **2689.465 seconds (44m 49s)** before collection, job setup and +shutdown. That leaves no useful margin within the unchanged 45-minute job +budget. These are measured local costs, not a prediction of the CI runner's +exact runtime. [The per-case costs and collection comparison](measurements/ci-test-order-20261022.json) +retain the calculation. + +`scripts/run_ci_tests.py` now runs one complete pytest collection and orders the +past failures first. A comparison against ordinary pytest collection found +**642 original IDs, 642 ordered IDs and 642 unique IDs**, with identical +multisets: zero omissions or additions. The runner records both lists in the +CI artifact `test-plan.json`. It rejects duplicated node IDs and missing required +priority cases with exit code 4; both rejection paths were exercised locally. +The workflow retains every test, its assertions, fail-fast behavior and the +45-minute job budget. Only duplicate execution is removed. + +The first **57 cases passed together in 254.33 seconds** in the recorded order, +including the exact failed radial editor case, two resize/input regressions, +both scale cases, inactive-overlay editing, packaged assets, slow native clicks, +radial detail selection and radial annotation drawing in both hosts. Preflight +passed **117 cases in 2.03 seconds**. The runtime wheel is unchanged. diff --git a/docs/measurements/ci-test-order-20261022.json b/docs/measurements/ci-test-order-20261022.json new file mode 100644 index 0000000..847e445 --- /dev/null +++ b/docs/measurements/ci-test-order-20261022.json @@ -0,0 +1,259 @@ +{ + "historical_local_run": { + "cases": 640, + "seconds": 2444.04, + "junit_sha256": "dba1bfa4ae739ef881e342e26430f70fcd0c2be252c1f98e362d117e410ad090", + "summed_case_seconds": 2443.445 + }, + "duplicate_work": { + "cases": 55, + "summed_seconds": 246.02, + "full_plus_duplicate_case_seconds": 2689.465, + "items": [ + { + "nodeid": "tests.test_interaction_parity::test_annotation_drawing_editing_and_cancel_in_both_hosts[radial]", + "seconds": 128.761 + }, + { + "nodeid": "tests.test_interaction_parity::test_inline_text_resize_multiline_and_existing_format_in_both_hosts[radial]", + "seconds": 37.09 + }, + { + "nodeid": "tests.test_interaction_parity::test_inline_editor_resize_uses_current_units_and_applies_real_edits[1]", + "seconds": 14.95 + }, + { + "nodeid": "tests.test_interaction_parity::test_inline_editor_resize_uses_current_units_and_applies_real_edits[2]", + "seconds": 15.994 + }, + { + "nodeid": "tests.test_interaction_parity::test_inactive_overlay_objects_can_be_edited_and_dragged_directly[native]", + "seconds": 12.473 + }, + { + "nodeid": "tests.test_interaction_parity::test_radial_detail_tree_selection_edits_only_the_chosen_child[native]", + "seconds": 29.857 + }, + { + "nodeid": "tests.test_packaged_assets::test_source_asset_provenance", + "seconds": 0.012 + }, + { + "nodeid": "tests.test_packaged_assets::test_runtime_asset_check_does_not_require_tomllib", + "seconds": 0.011 + }, + { + "nodeid": "tests.test_packaged_assets::test_crlf_changes_are_rejected_with_asset_diagnostics", + "seconds": 0.006 + }, + { + "nodeid": "tests.test_packaged_assets::test_git_autocrlf_checkout_preserves_manifest_bytes[False]", + "seconds": 0.34 + }, + { + "nodeid": "tests.test_packaged_assets::test_git_autocrlf_checkout_preserves_manifest_bytes[True]", + "seconds": 0.326 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_check_does_not_depend_on_callers_git_subdirectory", + "seconds": 0.014 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_inside_another_checkout", + "seconds": 0.013 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_must_match_built_asset_provenance[bytes]", + "seconds": 0.019 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_must_match_built_asset_provenance[missing]", + "seconds": 0.018 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_must_match_built_asset_provenance[hash]", + "seconds": 0.018 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_must_match_built_asset_provenance[path]", + "seconds": 0.019 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_must_match_built_asset_provenance[files]", + "seconds": 0.019 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_cannot_change_native_sources_or_build_configuration[renderer]", + "seconds": 0.012 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_cannot_change_native_sources_or_build_configuration[model]", + "seconds": 0.012 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_cannot_change_native_sources_or_build_configuration[manifest]", + "seconds": 0.012 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_cannot_change_native_sources_or_build_configuration[mode]", + "seconds": 0.008 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_cannot_change_native_sources_or_build_configuration[crlf]", + "seconds": 0.009 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_cannot_change_native_sources_or_build_configuration[deletion]", + "seconds": 0.013 + }, + { + "nodeid": "tests.test_packaged_assets::test_host_patch_cannot_change_native_sources_or_build_configuration[invalid]", + "seconds": 0.013 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_lockfile_changes_are_rejected[checksum-cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc-figgy-model]", + "seconds": 0.015 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_lockfile_changes_are_rejected[checksum-cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc-figgy-renderer]", + "seconds": 0.015 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_lockfile_changes_are_rejected[version-9.0.0-figgy-model]", + "seconds": 0.015 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_lockfile_changes_are_rejected[version-9.0.0-figgy-renderer]", + "seconds": 0.014 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_lockfile_changes_are_rejected[source-git+https://github.com/shim9610/figgy#7777777777777777777777777777777777777777-figgy-model]", + "seconds": 0.016 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_lockfile_changes_are_rejected[source-git+https://github.com/shim9610/figgy#7777777777777777777777777777777777777777-figgy-renderer]", + "seconds": 0.016 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_pairing_metadata_changes_are_rejected[native_revision]", + "seconds": 0.016 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_pairing_metadata_changes_are_rejected[wasm_revision]", + "seconds": 0.015 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_pairing_metadata_changes_are_rejected[script_revision]", + "seconds": 0.015 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_pairing_metadata_changes_are_rejected[path]", + "seconds": 0.014 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_pairing_metadata_changes_are_rejected[renderer_version]", + "seconds": 0.014 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_pairing_metadata_changes_are_rejected[missing]", + "seconds": 0.014 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_pairing_metadata_changes_are_rejected[duplicate]", + "seconds": 0.015 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_overrides_rejected_even_with_unchanged_lock[[patch.crates-io]\\nfiggy-renderer = { git = \"https://github.com/shim9610/figgy\", rev = \"7777777777777777777777777777777777777777\" }]", + "seconds": 0.008 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_overrides_rejected_even_with_unchanged_lock[[patch.crates-io]\\nmodel_alias = { package = \"figgy-model\", path = \"../model\" }]", + "seconds": 0.008 + }, + { + "nodeid": "tests.test_packaged_assets::test_registry_overrides_rejected_even_with_unchanged_lock[[replace]\\n\"figgy-model:0.11.0\" = { path = \"../model\" }]", + "seconds": 0.009 + }, + { + "nodeid": "tests.test_packaged_assets::test_direct_nonregistry_renderer_rejected[, path = \"../renderer\"]", + "seconds": 0.009 + }, + { + "nodeid": "tests.test_packaged_assets::test_direct_nonregistry_renderer_rejected[, git = \"https://github.com/shim9610/figgy\"]", + "seconds": 0.009 + }, + { + "nodeid": "tests.test_packaged_assets::test_direct_nonregistry_renderer_rejected[, registry = \"other\"]", + "seconds": 0.009 + }, + { + "nodeid": "tests.test_packaged_assets::test_dirty_wasm_checkout_is_rejected_before_download[False]", + "seconds": 0.019 + }, + { + "nodeid": "tests.test_packaged_assets::test_dirty_wasm_checkout_is_rejected_before_download[True]", + "seconds": 0.021 + }, + { + "nodeid": "tests.test_packaged_assets::test_published_archive_matches_exact_wasm_source[None]", + "seconds": 0.004 + }, + { + "nodeid": "tests.test_packaged_assets::test_published_archive_matches_exact_wasm_source[checksum]", + "seconds": 0.003 + }, + { + "nodeid": "tests.test_packaged_assets::test_published_archive_matches_exact_wasm_source[revision]", + "seconds": 0.003 + }, + { + "nodeid": "tests.test_packaged_assets::test_published_archive_matches_exact_wasm_source[dirty]", + "seconds": 0.004 + }, + { + "nodeid": "tests.test_packaged_assets::test_published_archive_matches_exact_wasm_source[path]", + "seconds": 0.003 + }, + { + "nodeid": "tests.test_packaged_assets::test_published_archive_matches_exact_wasm_source[manifest]", + "seconds": 0.003 + }, + { + "nodeid": "tests.test_packaged_assets::test_published_archive_matches_exact_wasm_source[source]", + "seconds": 0.003 + }, + { + "nodeid": "tests.test_packaged_assets::test_published_archive_matches_exact_wasm_source[inventory]", + "seconds": 0.003 + }, + { + "nodeid": "tests.test_radial_desktop::test_click_remains_a_short_press_when_frames_are_slow", + "seconds": 5.689 + } + ] + }, + "unchanged_job_budget_seconds": 2700, + "scope": "Historical local case times, excluding setup/collection/shutdown overhead. These are not a CI runtime guarantee.", + "collection_comparison": { + "baseline_cases": 642, + "ordered_cases": 642, + "unique_cases": 642, + "same_multiset": true, + "missing": [], + "added": [], + "priority_cases": 57 + }, + "failure_guards": { + "missing_required_cases_exit": 4, + "duplicate_nodeids_exit": 4 + }, + "correction": "One complete pytest collection, stable priority ordering, one execution per node ID. Preserve all assertions, fail-fast behavior and the existing 45-minute budget.", + "ordered_priority_execution": { + "passed": 57, + "failed": 0, + "seconds": 254.33, + "preflight_passed": 117, + "preflight_seconds": 2.03, + "runtime_changed": false + } +} diff --git a/scripts/run_ci_tests.py b/scripts/run_ci_tests.py new file mode 100644 index 0000000..6835533 --- /dev/null +++ b/scripts/run_ci_tests.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Run the complete pytest collection once, with past failures first.""" +import json +from pathlib import Path +import sys + +import pytest + + +PRIORITY = ( + 'tests/test_interaction_parity.py::test_surface_readiness_rejects_matching_pre_resize_css_and_backing', + 'tests/test_interaction_parity.py::test_annotation_mouse_input_uses_live_layout_after_resize', + 'tests/test_interaction_parity.py::test_inline_editor_resize_uses_current_units_and_applies_real_edits[1]', + 'tests/test_interaction_parity.py::test_inline_editor_resize_uses_current_units_and_applies_real_edits[2]', + 'tests/test_interaction_parity.py::test_inline_text_resize_multiline_and_existing_format_in_both_hosts[radial]', + 'tests/test_interaction_parity.py::test_inactive_overlay_objects_can_be_edited_and_dragged_directly[native]', + 'tests/test_packaged_assets.py', + 'tests/test_radial_desktop.py::test_click_remains_a_short_press_when_frames_are_slow', + 'tests/test_interaction_parity.py::test_radial_detail_tree_selection_edits_only_the_chosen_child[native]', + 'tests/test_interaction_parity.py::test_annotation_drawing_editing_and_cancel_in_both_hosts[radial]', +) + + +class PriorityFirst: + def pytest_addoption(self, parser): + parser.addoption('--ci-test-plan', default='ci-results/test-plan.json', + help='Record every collected node ID before and after ordering') + + def pytest_collection_modifyitems(self, config, items): + original = [item.nodeid for item in items] + if len(set(original)) != len(original): + raise pytest.UsageError('CI collection contains duplicate test node IDs') + + def rank(item): + return next((i for i, key in enumerate(PRIORITY) + if item.nodeid == key or item.nodeid.startswith(key + '::')), len(PRIORITY)) + + found = {rank(item) for item in items} + missing = [key for i, key in enumerate(PRIORITY) if i not in found] + if missing: + raise pytest.UsageError('CI collection is missing required priority cases: ' + ', '.join(missing)) + items.sort(key=rank) + ordered = [item.nodeid for item in items] + plan = Path(config.getoption('--ci-test-plan')) + plan.parent.mkdir(parents=True, exist_ok=True) + plan.write_text(json.dumps(dict(count=len(items), original=original, ordered=ordered), indent=2) + '\n') + + +if __name__ == '__main__': + raise SystemExit(pytest.main(sys.argv[1:], plugins=[PriorityFirst()])) From c2f3703a85682fd485e8d5c0579601bf75bbe775 Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 04:36:45 +0000 Subject: [PATCH 07/11] Allow superseded integration runs to cancel --- .github/workflows/pr-tests.yml | 4 ++-- docs/REVIEW_MODE_VALIDATION.md | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 38fd7e2..abf2b91 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -99,8 +99,8 @@ jobs: integration: name: PR / Linux integration needs: build - # Keep the required check red when preflight/build fails or is skipped. - if: always() + # Fail closed on upstream failure, but let superseded runs be cancelled. + if: ${{ !cancelled() }} runs-on: ubuntu-24.04 # Measured full suite is ~41m; priority ordering adds no duplicate execution. timeout-minutes: 45 diff --git a/docs/REVIEW_MODE_VALIDATION.md b/docs/REVIEW_MODE_VALIDATION.md index 9d24c73..3fc4114 100644 --- a/docs/REVIEW_MODE_VALIDATION.md +++ b/docs/REVIEW_MODE_VALIDATION.md @@ -211,3 +211,12 @@ including the exact failed radial editor case, two resize/input regressions, both scale cases, inactive-overlay editing, packaged assets, slow native clicks, radial detail selection and radial annotation drawing in both hosts. Preflight passed **117 cases in 2.03 seconds**. The runtime wheel is unchanged. + +The integration job also used `if: always()`. GitHub's [cancellation reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-cancellation) +explicitly states that a running job with this condition continues on +cancellation. The job now uses `if: ${{ !cancelled() }}`: an upstream failed or +skipped build still runs the existing result guard and fails the required check, +while a superseded workflow can stop. Preflight passed **117 cases in 2.10 +seconds** after this workflow-only change. An already-running job retains the +workflow definition with which it started; this change does not alter that job +retroactively. From a48d65cc942e30b959ef7db009efb9b074d4d1c3 Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 06:23:46 +0000 Subject: [PATCH 08/11] Verify independent feature pixels and complete restored array contents --- docs/REVIEW_MODE_VALIDATION.md | 78 +++++++++- .../acceptance-oracles-20261022.json | 83 ++++++++++ scripts/run_ci_tests.py | 2 + tests/feature_visual_assertions.py | 142 ++++++++++++++++++ tests/recovery_assertions.py | 112 ++++++++++++++ tests/test_acceptance_oracles.py | 135 +++++++++++++++++ tests/test_feature_visual_browser.py | 44 ++++++ tests/test_renderer_018_features.py | 15 +- tests/test_review.py | 15 +- 9 files changed, 609 insertions(+), 17 deletions(-) create mode 100644 docs/measurements/acceptance-oracles-20261022.json create mode 100644 tests/feature_visual_assertions.py create mode 100644 tests/recovery_assertions.py create mode 100644 tests/test_acceptance_oracles.py create mode 100644 tests/test_feature_visual_browser.py diff --git a/docs/REVIEW_MODE_VALIDATION.md b/docs/REVIEW_MODE_VALIDATION.md index 3fc4114..4ce0fba 100644 --- a/docs/REVIEW_MODE_VALIDATION.md +++ b/docs/REVIEW_MODE_VALIDATION.md @@ -168,10 +168,10 @@ regressions first in the full suite. The collection now has **642 cases**. The existing full suite, assertions and time limits remain in place; no complete local 642-case pass is claimed. Local Chromium is 154; CI #53 used Chromium 156. -## Limits of the current acceptance tests +## Acceptance gaps observed before the oracle correction Counts include packaging and release guards and do not measure feature or UX -coverage. Isolated assertion audits found two concrete gaps: +coverage. Before the correction below, isolated assertion audits found two gaps: - All nine native feature round-trip cases accept a substituted white RGBA image with one black pixel. Their model/replay assertions still execute, but @@ -182,10 +182,10 @@ coverage. Isolated assertion audits found two concrete gaps: and a valid PNG signature do not verify every restored data value. A separate input-snapshot test checks one stored float32 payload, not every restore handoff. -These are demonstrated test gaps, not observed production data corruption. -Feature-specific independent visual assertions and complete restored-column -value/type/shape comparisons are still needed for those stronger guarantees. -No full-suite or usability guarantee follows from the affected-case pass above. +These were demonstrated test gaps, not observed production data corruption. +The independent scene and restored-array assertions below address these cases. +Finite acceptance scenes still do not establish every feature combination or +general usability. ## Execute the complete collection once @@ -220,3 +220,69 @@ while a superseded workflow can stop. Preflight passed **117 cases in 2.10 seconds** after this workflow-only change. An already-running job retains the workflow definition with which it started; this change does not alter that job retroactively. + +## Independent feature and recovery oracles + +The original API/combination replay fixtures remain intact. Each of the nine +native feature replay cases now also renders an independently authored reference +scene. Its expected probes come from declared input geometry and colors, never +from another rendered image or renderer-reported bounds. The document is +960 x 600 with a fixed 400 x 400 plot at (100,100), hidden Cartesian decoration +and fixed [0,4] ranges. The assertions check: + +- Mapped points: gray values, positions, different marker areas and empty space. +- Point/grid vectors: mapped colors, shafts, directional heads and empty tails. +- Steps: required horizontal/vertical segments and absence of linear diagonals. +- Baseline/series fills: filled interiors and empty regions below/above the + declared boundaries. +- Polar coordinates: asymmetric radii, radians, clockwise direction and chords. +- Radar: two differently colored polygons, declared range and empty interiors. +- Annotations: reference-line color/position, span opacity and text in its + authored region. + +The identical oracle checks nine actual displayed Jupyter WebGPU canvases. A +fractional Jupyter output origin otherwise creates a 601-row enclosing capture +for a 600px canvas. The fixture translates the entire widget onto the device +pixel grid, retaining exactly 960 x 600 CSS/backing dimensions at DPR 1. No rows +are cropped, pixels resampled or dimension assertions relaxed. + +Recovery tests retain independent copies of the original renderer inputs in +test code only. They compare every baseline/approved NPY payload and every +restored upload by column identity, dtype, shape, all values and payload bits. +The NPY oracle reads files directly rather than reusing the restore reader. +Bounded numeric checkpoints are also compared with literal expectations for +pie/bar/box/radar inputs, including box statistics and sample-count dtype. +Coverage includes mapped values/sizes, matrices and edge coordinates, point/grid +vector components and mapped grids, shared-X fill pairs and panel/overlay data. + +Native inspection reads every restored column through the production GPU +selection-readback path using temporary test-only probe series referencing the +existing buffers; sample-upload counters must remain unchanged. These probes +run after complete approved-SSOT assertions and are discarded with the test +Figure. Browser inspection compares actual transfer snapshot bytes. Additional +float32/float64 cases include signed zero, nontrivial finite precision, a custom +NaN payload and both infinities. Cache/upload/transfer bits are exact; native +finite GPU values use the packed pair-of-f32 representation's 2e-14 relative +bound, with zero absolute tolerance. Native selection JSON represents nonfinite +values as null, so exact nonfinite bit preservation is asserted at the lossless +boundaries rather than claimed for that JSON observation. + +Negative controls are part of ordinary CI: **45 wrong image cases**, **nine +single-pixel substitutions through the actual replay test**, **42 wrong restore +payloads** (zero/reversed/wrong dtype), and **14 wrong device/transfer contents** +must all be rejected. In the latter cases, settings, cache and observed upload +arguments remain correct: only the final native upload or browser transfer bytes +are zeroed. Re-running the original baseline test accepted all ten original +family/backend resident-data faults; the strengthened checks reject them. + +Local validation passed **207 focused cases in 10.93 seconds**, **nine headful +browser cases in 127.57 seconds**, and **117 preflight cases in 2.07 seconds**. +The unchanged runtime wheel is the previously verified +`88e7f7cc4bfa8edf93ae9240f289144b530fc493dbe1f73a66355fb7b1537c2b` artifact. +Ordinary and priority collection both contain **769 unique cases**: all previous +642 IDs remain, with 127 additions and zero duplicates. The new oracle and +browser cases run first within the single full collection. No full local +769-case pass is claimed; required CI must validate the final commit. +[The before/after contract and measurements](measurements/acceptance-oracles-20261022.json) +distinguish these results. These finite scenes do not claim every feature +combination, exact font rasterization or general usability. diff --git a/docs/measurements/acceptance-oracles-20261022.json b/docs/measurements/acceptance-oracles-20261022.json new file mode 100644 index 0000000..aeaa1cc --- /dev/null +++ b/docs/measurements/acceptance-oracles-20261022.json @@ -0,0 +1,83 @@ +{ + "baseline": "c2f3703a85682fd485e8d5c0579601bf75bbe775", + "runtime_changed": false, + "before": { + "single_pixel_feature_tests_accepted": 9, + "zero_restore_payload_tests_accepted": 10, + "zero_resident_data_tests_accepted": 10 + }, + "after": { + "native_feature_scenes": 9, + "browser_feature_scenes": 9, + "wrong_image_checks_rejected": 45, + "actual_roundtrip_single_pixel_checks_rejected": 9, + "wrong_restore_payload_checks_rejected": 42, + "wrong_device_or_transfer_checks_rejected": 14, + "normal_restore_cases": 22, + "float_precision_cases": 4, + "focused_cases_passed": 207, + "focused_seconds": 10.93, + "headful_browser_cases_passed": 9, + "headful_browser_seconds": 127.57, + "preflight_cases_passed": 117, + "preflight_seconds": 2.07 + }, + "collection": { + "previous": 642, + "current": 769, + "added": 127, + "omitted": 0, + "duplicates": 0 + }, + "visual_contract": { + "document": [ + 960, + 600 + ], + "plot": [ + 100, + 100, + 400, + 400 + ], + "cartesian_domain": [ + 0, + 4 + ], + "rgb_quantization_tolerance": 3, + "pixel_neighborhood": 1, + "marker_solid_area_radius_band": 2, + "oracle": "Authored scene geometry and colors; no rendered image or renderer bounds are used to generate expected probes.", + "semantic_faults": [ + "wrong colormap", + "swapped vector components", + "linear interpolation replacing steps", + "wrong fill baseline/reference", + "reversed polar direction", + "wrong radar value range", + "missing text annotation" + ] + }, + "recovery_contract": { + "lossless_boundaries": [ + "original renderer input", + "baseline and approved NPY files", + "restored upload arguments", + "browser transfer snapshots" + ], + "comparisons": [ + "complete column identities", + "dtype", + "shape", + "all values", + "payload bits" + ], + "gpu": "Read every native column via GPU selection readback, using temporary test-only probe series without any sample upload.", + "native_finite_rtol": 2e-14, + "native_finite_atol": 0, + "nonfinite": "Native selection JSON represents nonfinite values as null. NaN payloads, infinities and signed-zero bits are checked exactly at lossless boundaries, not promised as bit-identical GPU representation." + }, + "browser_capture": "The complete widget is translated by the fractional part of its DOM origin to align canvas pixels with the device grid. CSS and backing remain exactly 960x600 at DPR 1. No cropping, resampling or relaxed image shape is used.", + "scope": "These close the demonstrated oracle gaps for the listed scenes and recovery families. They do not establish every feature combination, font rasterization or usability.", + "required_ci": "Pending validation of the final commit; the previous 642-case CI result must not be described as the new 769-case result." +} diff --git a/scripts/run_ci_tests.py b/scripts/run_ci_tests.py index 6835533..54686c7 100644 --- a/scripts/run_ci_tests.py +++ b/scripts/run_ci_tests.py @@ -10,6 +10,8 @@ PRIORITY = ( 'tests/test_interaction_parity.py::test_surface_readiness_rejects_matching_pre_resize_css_and_backing', 'tests/test_interaction_parity.py::test_annotation_mouse_input_uses_live_layout_after_resize', + 'tests/test_acceptance_oracles.py', + 'tests/test_feature_visual_browser.py', 'tests/test_interaction_parity.py::test_inline_editor_resize_uses_current_units_and_applies_real_edits[1]', 'tests/test_interaction_parity.py::test_inline_editor_resize_uses_current_units_and_applies_real_edits[2]', 'tests/test_interaction_parity.py::test_inline_text_resize_multiline_and_existing_format_in_both_hosts[radial]', diff --git a/tests/feature_visual_assertions.py b/tests/feature_visual_assertions.py new file mode 100644 index 0000000..9db67d0 --- /dev/null +++ b/tests/feature_visual_assertions.py @@ -0,0 +1,142 @@ +"""Analytic feature scenes: authored geometry/colors, independent of readback.""" +import numpy as np + +BLUE = '#2040c0' +ORANGE = '#c04020' +RED = '#c02040' +GREEN = '#208020' +FEATURES = ('mapped_points','vector_points','vector_grid','steps','baseline_fill', + 'series_fill','polar','radar','annotations') + + +def populate(figure, feature): + """960x600 document, 400x400 plot at (100,100), ranges [0,4].""" + ax = figure.axes[0] + if feature == 'mapped_points': + ax.scatter([.8,2,3.2],[1,2,3],c=[.1,.5,.9],cmap='gray',vmin=0,vmax=1, + s=[0,.5,1],size_domain=(0,1),size_range=(8,24),size_scale='radius',id='points') + elif feature == 'vector_points': + ax.quiver([1,3],[1,3],[1,0],[0,-1],units='data',scale=1,color=BLUE,width=3, + head_length=12,head_width=10,values=[.25,.75],cmap='gray',vmin=0,vmax=1,id='arrows') + elif feature == 'vector_grid': + ax.quiver([1,3],[1,3],[[.8,0],[-.8,0]],[[0,.8],[0,-.8]],units='data', + scale=1,color=ORANGE,width=3,head_length=12,head_width=10, + values=[[.2,.4],[.6,.8]],cmap='gray',vmin=0,vmax=1,id='arrows') + elif feature == 'steps': + ax.step([.5,1.5,2.5,3.5],[1,3,2,1],where='mid',color=BLUE,linewidth=3,id='steps') + elif feature == 'baseline_fill': + ax.fill_between([.5,2,3.5],[1,3,1],.5,color=BLUE,alpha=1,id='band') + elif feature == 'series_fill': + ax.fill_between([.5,2,3.5],[2,3,2],[1,2,1],color=ORANGE,alpha=1,id='band') + elif feature == 'polar': + ax.set_polar(angle_unit='Radians',clockwise=True) + ax.plot(np.arange(5)*np.pi/2,[1,2,3,1,1],color=BLUE,linewidth=3,id='orbit') + ax.text(.65,.05,'Polar',coords='ratio',fontsize=10,color=GREEN,id='note') + elif feature == 'radar': + # Empty captions remove font-metric dependence; the fixed 400px box, + # 16px caption reserve and 14px padding give a 170px outer radius. + ax.radar(['','','',''],[[1,2,3,1],[3,1,2,2]],labels=['First','Second'],colors=[BLUE,RED],value_range=[0,4],font_size=16, + grid=False,legend=False,style=dict(line_width_px=4,fill_opacity=0,marker='None')) + ax.set_title('Independent title',fontsize=18,color='purple') + elif feature == 'annotations': + ax.plot([.5,3.5],[.5,.5],color=BLUE,linewidth=2,id='signal') + ax.axhline(2,color=RED,linewidth=3,id='reference') + ax.axvspan(1,2,color=BLUE,alpha=.25,id='band') + ax.text(.65,.25,'TEST',coords='ratio',fontsize=14.4,color=GREEN,id='note') + else: + raise AssertionError(feature) + if feature != 'radar': + ax.set_xlim(0,4);ax.set_ylim(0,4) + state = figure.get_settings() + config = state['config'] + config['chart_title'].update(visible=False,top_margin=0) + for key, margin in (('top_x',100),('bottom_x',100),('left_y',100),('right_y',460)): + axis = config[key] + axis.update(out_margin=margin,line_visible=False,major_tick_length=0,minor_tick_length=0) + axis['label_style']['visible'] = False + axis['title_option']['visible'] = False + for key in ('show_major_x','show_major_y','show_minor_x','show_minor_y'): + config['grid'][key] = False + if 'colorbar' in config: + config['colorbar']['visible'] = False + if feature == 'radar': + state['radar']['decorations']['data_area'] = dict(x=100,y=100,width=400,height=400) + figure.apply_settings(state) + return ax + + +def rgb(color): + return np.array([int(color[i:i+2],16) for i in (1,3,5)]) + + +def assert_color(image, point, color, *, radius=1): + x,y = point + patch = image[y-radius:y+radius+1,x-radius:x+radius+1,:3].astype(int) + expected = rgb(color) if isinstance(color,str) else np.array(color) + assert np.any(np.max(np.abs(patch-expected),axis=2)<=3), (point,expected.tolist(),patch.tolist()) + + +def assert_white(image, point): + x,y = point + assert np.all(image[y-1:y+2,x-1:x+2,:3]>=250), (point,image[y,x].tolist()) + + +def assert_feature_image(image, feature): + assert image.shape == (600,960,4), image.shape + if feature == 'mapped_points': + for x,y,gray,radius in ((180,400,26,8*100/144),(300,300,128,16*100/144),(420,200,230,24*100/144)): + assert_color(image,(x,y),[gray]*3) + assert_color(image,(x+int(radius)-2,y),[gray]*3) + assert_white(image,(x+int(radius)+8,y)) + reach = int(np.ceil(radius+8)) + patch = image[y-reach:y+reach+1,x-reach:x+reach+1,:3].astype(int) + area = np.count_nonzero(np.max(np.abs(patch-gray),axis=2)<=3) + assert np.pi*(radius-2)**2<=area<=np.pi*(radius+2)**2,(radius,area) + assert_white(image,(240,350)) + elif feature in ('vector_points','vector_grid'): + arrows = [(200,400,300,400),(400,200,400,300)] if feature=='vector_points' else [ + (200,400,280,400),(400,400,400,320),(200,200,120,200),(400,200,400,280)] + colors = ([64]*3,[191]*3) if feature=='vector_points' else ([51]*3,[102]*3,[153]*3,[204]*3) + for (x,y,tx,ty),color in zip(arrows,colors): + dx,dy = np.sign(tx-x),np.sign(ty-y) + assert_color(image,((x+tx)//2,(y+ty)//2),color) + # Inside the triangular head, off the shaft; direction matters. + assert_color(image,(int(tx-12*dx+4*dy),int(ty-12*dy-4*dx)),color) + assert_white(image,(int(x-12*dx),int(y-12*dy))) + assert_white(image,(int(tx+10*dx),int(ty+10*dy))) + elif feature == 'steps': + for point in ((175,400),(200,300),(250,200),(300,250),(350,300),(400,350),(425,400)): + assert_color(image,point,BLUE) + for point in ((175,350),(275,225),(375,350)): + assert_white(image,point) + elif feature == 'baseline_fill': + for point in ((300,350),(300,425),(300,225)): + assert_color(image,point,BLUE) + for point in ((300,475),(300,160)): + assert_white(image,point) + elif feature == 'series_fill': + for point in ((300,250),(175,350)): + assert_color(image,point,ORANGE) + for point in ((300,350),(300,170),(175,425)): + assert_white(image,point) + elif feature == 'polar': + for point in ((325,350),(225,350),(225,275),(325,275)): + assert_color(image,point,BLUE) + for point in ((325,250),(300,300)): + assert_white(image,point) + elif feature == 'radar': + for point in ((343,279),(343,364),(268,332),(279,279)): + assert_color(image,point,BLUE) + for point in ((321,236),(321,343),(258,343),(258,236)): + assert_color(image,point,RED) + assert_white(image,(300,300)) + assert_white(image,(375,250)) + elif feature == 'annotations': + assert_color(image,(400,300),RED) + assert_color(image,(250,150),[199,207,239]) + assert_white(image,(325,150)) + text = image[195:230,355:440,:3].astype(int) + assert np.count_nonzero(np.max(np.abs(text-rgb(GREEN)),axis=2)<=3)>=20,'missing or misplaced annotation text' + assert_white(image,(400,400)) + else: + raise AssertionError(feature) diff --git a/tests/recovery_assertions.py b/tests/recovery_assertions.py new file mode 100644 index 0000000..e6c5211 --- /dev/null +++ b/tests/recovery_assertions.py @@ -0,0 +1,112 @@ +"""Independent array expectations at cache, upload and GPU-readback boundaries.""" +from contextlib import contextmanager +import json + +import numpy as np +import figgyplot as fg + + +class RecordedUploads: + def __init__(self, native, arrays): + self.native, self.arrays = native, arrays + + def __getattr__(self, name): + return getattr(self.native, name) + + def upload_column(self, name, array): + self.native.upload_column(name, array) + assert name not in self.arrays, ('duplicate upload', name) + self.arrays[name] = np.array(array, copy=True) + + +@contextmanager +def record_uploads(monkeypatch): + arrays = {} + original = fg.Figure.__init__ + + def create(figure, *args, **kwargs): + original(figure, *args, **kwargs) + figure._native = RecordedUploads(figure._native, arrays) + + with monkeypatch.context() as patch: + patch.setattr(fg.Figure, '__init__', create) + yield arrays + + +def assert_arrays(expected, actual, stage): + assert set(actual) == set(expected), (stage, 'column identities', set(actual), set(expected)) + for name, original in expected.items(): + recovered = actual[name] + assert recovered.dtype == original.dtype, (stage, name, 'dtype', recovered.dtype, original.dtype) + assert recovered.shape == original.shape, (stage, name, 'shape', recovered.shape, original.shape) + np.testing.assert_array_equal(recovered, original, err_msg=f'{stage}: {name}') + # Also preserve signed zero and NaN payload bits at lossless boundaries. + assert recovered.tobytes() == original.tobytes(), (stage, name, 'payload bits') + + +def assert_cached_columns(cache, checkpoint, expected): + # Read the actual NPY files independently of Store.get (the restore reader). + for key in ('columns', 'baseline_columns'): + arrays = {name: np.load(cache / ref['file'], allow_pickle=False) + for name, ref in checkpoint[key].items()} + assert_arrays(expected, arrays, key) + for name, value in arrays.items(): + ref = checkpoint[key][name] + assert ref['dtype'] == value.dtype.str and ref['shape'] == list(value.shape) + + +def assert_cached_bounded(cache, checkpoint, family): + # Literal expectations for the public inputs in test_review.create. + expected = {} + if family == 'pie': + expected = {f'/radial/slices/{i}/value': np.array([value]) + for i, value in enumerate((3.,2.,1.))} + elif family in ('bar','radar'): + kind, values = ('categorical',[3.,4.]) if family=='bar' else ('radar',[1.,2.,3.]) + expected = {f'/{kind}/series/0/values': np.array(values)} + elif family == 'box': + stats = (dict(q1=1.5,median=2.,q3=2.5,whisker_low=1.,whisker_high=3.,mean=2.,sample_count=3), + dict(q1=3.,median=4.,q3=5.,whisker_low=2.,whisker_high=6.,mean=4.,sample_count=3)) + expected = {f'/boxplot/series/0/values/{i}/{key}': np.array([value]) + for i, row in enumerate(stats) for key, value in row.items()} + for key in ('bounded_data','baseline_bounded_data'): + arrays = {name: np.load(cache / ref['file'], allow_pickle=False) + for name, ref in checkpoint[key].items()} + assert_arrays(expected, arrays, key) + for name, array in arrays.items(): + ref = checkpoint[key][name] + assert ref['dtype'] == array.dtype.str and ref['shape'] == list(array.shape) + + +def assert_renderer_columns(figure, expected): + if figure.backend == 'webgpu': + actual = {name: np.frombuffer(data, dtype=np.dtype(dtype).newbyteorder('<')).copy() + for name, (dtype, data) in figure._native.columns.items()} + assert_arrays(expected, actual, 'browser transfer') + return + + # Post-restore inspection only. Probe series reference the existing buffers; + # they upload no samples and are discarded with this test Figure. Read every + # column through the production GPU selection readback, not the review cache. + native = figure._native + while isinstance(native, RecordedUploads) or hasattr(native, 'native'): + native = native.native + uploads = figure.info['column_uploads'] + for number, (name, original) in enumerate(expected.items()): + sid = f'recovery_readback_{number}' + native.add_series(json.dumps(dict(series_id=sid, label=None, x_column=name, y_column=name, + render_type={'Line': {'line': dict(line_color=dict(r=0,g=0,b=0,a=1), + line_width=1,line_style='Solid',interpolation='Linear')}}))) + state = json.loads(native.settings_json()) + for index, value in enumerate(original): + state['config']['picked_data'] = {'refs': [dict(kind='point', series_id=sid, point_index=index)]} + native.apply_settings(json.dumps(state)) + sample = json.loads(native.selection_details_json()) + for axis in ('x', 'y'): + if np.isfinite(value): + # Native columns use a pair of f32 values for f64 arithmetic. + np.testing.assert_allclose(sample[axis], value, rtol=2e-14, atol=0, + err_msg=f'GPU column {name}[{index}]') + else: + assert sample[axis] is None, (name, index, sample) + assert figure.info['column_uploads'] == uploads diff --git a/tests/test_acceptance_oracles.py b/tests/test_acceptance_oracles.py new file mode 100644 index 0000000..5ba9bbd --- /dev/null +++ b/tests/test_acceptance_oracles.py @@ -0,0 +1,135 @@ +"""Deliberately wrong results must fail the actual feature/recovery assertions.""" +import numpy as np +import pytest +import figgyplot as fg +from figgyplot.review import Session + +from feature_visual_assertions import FEATURES, populate, assert_feature_image +from test_review import test_complete_binary_checkpoint_restores_all_families as check_restore +from recovery_assertions import record_uploads, assert_arrays, assert_cached_columns, assert_renderer_columns + + +@pytest.fixture(scope='module',params=FEATURES) +def reference_image(request): + feature=request.param + with fg.Figure(backend='native',figsize=(9.6,6),dpi=100) as figure: + populate(figure,feature) + image=figure.render_rgba() + assert_feature_image(image,feature) + return feature,image + + +def semantic_error(figure, feature): + state=figure.get_settings() + if feature=='mapped_points':state['config']['colorbar']['colormap']='Viridis' + elif feature in ('vector_points','vector_grid'): + components=state['series'][0]['render_type']['Vector']['components'] + fields=components['Points' if feature=='vector_points' else 'Grid'] + fields['u'],fields['v']=fields['v'],fields['u'] + elif feature=='steps':state['series'][0]['render_type']['Line']['line']['interpolation']='Linear' + elif feature in ('baseline_fill','series_fill'): + state['series'][-1]['render_type']['Line']['line']['fill']={'ToBaseline':0} + elif feature=='polar':state['config']['polar']['clockwise']=False + elif feature=='radar':state['radar']['value_range']=[0,8] + elif feature=='annotations':state['config']['annotations']['items'].pop() + figure.apply_settings(state) + + +@pytest.mark.parametrize('fault',['white','single_pixel','wrong_color','shifted','semantic']) +def test_visual_oracle_rejects_wrong_output(reference_image,fault): + feature,original=reference_image + image=original.copy() + if fault in ('white','single_pixel'): + image[:]=255 + if fault=='single_pixel':image[0,0,:3]=0 + elif fault=='wrong_color':image[...,:3][np.any(image[...,:3]<250,axis=2)]=[240,220,0] + elif fault=='shifted':image=np.roll(image,(41,53),axis=(0,1)) + else: + with fg.Figure(backend='native',figsize=(9.6,6),dpi=100) as figure: + populate(figure,feature);semantic_error(figure,feature);image=figure.render_rgba() + with pytest.raises(AssertionError):assert_feature_image(image,feature) + + +@pytest.mark.parametrize('feature',FEATURES) +def test_roundtrip_check_rejects_the_previously_accepted_single_pixel(monkeypatch,feature): + from test_renderer_018_features import test_features_round_trip_and_replay_without_reupload as check + calls=[] + def substituted(figure,*args,**kwargs): + image=np.full((600,960,4),255,dtype=np.uint8) + image[0,0,:3]=0;calls.append(True) + return image + monkeypatch.setattr(fg.Figure,'render_rgba',substituted) + with pytest.raises(AssertionError):check('native',feature) + assert calls==[True,True,True] # Replay equality passed; the independent scene rejected it. + + +@pytest.mark.parametrize('backend',['native','webgpu']) +@pytest.mark.parametrize('family',['line','mapped','matrix','vector','vector_grid','fill_pair','panels']) +@pytest.mark.parametrize('fault',['zero','reversed','dtype']) +def test_restore_oracle_rejects_wrong_payload(tmp_path,monkeypatch,backend,family,fault): + restore=Session.restore + def changed(session,*args,**kwargs): + read=session._store.get + def wrong(reference): + array=read(reference) + if fault=='zero':return np.zeros_like(array) + if fault=='reversed':return array[::-1].copy() + return array.astype(np.float32) + with monkeypatch.context() as patch: + patch.setattr(session._store,'get',wrong) + return restore(session,*args,**kwargs) + monkeypatch.setattr(Session,'restore',changed) + with pytest.raises(AssertionError,match='restored upload'): + check_restore(tmp_path,family,backend,monkeypatch) + + +@pytest.mark.parametrize('backend',['native','webgpu']) +@pytest.mark.parametrize('family',['line','mapped','matrix','vector','vector_grid','fill_pair','panels']) +def test_renderer_oracle_rejects_wrong_resident_values(tmp_path,monkeypatch,backend,family): + # Keep cache files and the observed upload arguments correct. Corrupt only + # the final device upload / browser transfer snapshot to exercise the next + # verification boundary independently of the restored-upload assertion. + import importlib + module=importlib.import_module('figgyplot.figure') + from figgyplot.notebook import NotebookData + restore=Session.restore + class WrongDevice: + def __init__(self,native):self.native=native + def __getattr__(self,name):return getattr(self.native,name) + def upload_column(self,name,array):self.native.upload_column(name,np.zeros_like(array)) + def changed(session,*args,**kwargs): + with monkeypatch.context() as patch: + if backend=='native': + constructor=module.NativeFigure + patch.setattr(module,'NativeFigure',lambda *a,**k:WrongDevice(constructor(*a,**k))) + else: + upload=NotebookData.upload_column + def wrong_transfer(data,name,array): + upload(data,name,array) + dtype,payload=data.columns[name] + data.columns[name]=(dtype,bytes(len(payload))) + patch.setattr(NotebookData,'upload_column',wrong_transfer) + return restore(session,*args,**kwargs) + monkeypatch.setattr(Session,'restore',changed) + with pytest.raises(AssertionError,match='GPU column|browser transfer'): + check_restore(tmp_path,family,backend,monkeypatch) + + +@pytest.mark.parametrize('backend',['native','webgpu']) +@pytest.mark.parametrize('dtype',[np.float32,np.float64]) +def test_restore_preserves_float_types_bits_and_gpu_values(tmp_path,monkeypatch,backend,dtype): + y=np.array([-0.,np.pi,-2.5,2**-30,1e9+.125,-1e-9,np.nan,np.inf,-np.inf],dtype=dtype) + bits=y.view(np.uint32 if dtype==np.float32 else np.uint64) + bits[6]=0x7fc00042 if dtype==np.float32 else 0x7ff8000000000042 + x=np.arange(len(y),dtype=dtype) + with record_uploads(monkeypatch) as original, Session(tmp_path,backend=backend) as session: + figure=session.figure(id='precision');figure.axes[0].plot(x,y,id='signal') + assert original['signal/x'].dtype==x.dtype + assert original['signal/y'].tobytes()==y.tobytes() + session.start();session.submit('precision','request_changes') + checkpoint=session.feedback(session.events()[0]['work_id'])['round']['documents']['precision'] + assert_cached_columns(tmp_path,checkpoint,original) + with record_uploads(monkeypatch) as uploaded, Session.open(tmp_path) as resumed: + recovered=resumed.restore('precision',backend=backend) + assert_arrays(original,uploaded,'restored upload') + assert_renderer_columns(recovered,original) diff --git a/tests/test_feature_visual_browser.py b/tests/test_feature_visual_browser.py new file mode 100644 index 0000000..80f833f --- /dev/null +++ b/tests/test_feature_visual_browser.py @@ -0,0 +1,44 @@ +"""The same independent feature expectations on the displayed WebGPU canvas.""" +import io +import json +import os +from pathlib import Path + +import numpy as np +from PIL import Image +import pytest + +from browser_timing import DISPLAY_SETTLED +from feature_visual_assertions import FEATURES, assert_feature_image +from jupyter_harness import notebook + + +@pytest.mark.skipif(not os.environ.get('DISPLAY') or os.environ.get('FIGGY_TEST_JUPYTER')!='1', + reason='requires real Jupyter WebGPU') +@pytest.mark.parametrize('feature',FEATURES) +def test_displayed_feature_has_expected_geometry_and_colors(tmp_path,feature): + source=f'''import sys +sys.path.insert(0,{str(Path(__file__).resolve().parent)!r}) +import figgyplot as fg +from feature_visual_assertions import populate +fig=fg.Figure(backend='webgpu',figsize=(9.6,6),dpi=100) +populate(fig,{feature!r}) +display(fig.widget()) +''' + with notebook(tmp_path/feature,source) as (page,execute): + page.wait_for_function(DISPLAY_SETTLED,polling=100,timeout=180000) + canvas=page.locator('.figgy-editor canvas') + # Jupyter's fractional output origin otherwise produces a 601-row + # enclosing screenshot for a 600px canvas. Align the entire widget to + # the device grid; keep every canvas pixel and retain its exact size. + alignment=canvas.evaluate('''c=>{ + const r=c.getBoundingClientRect(),dx=Math.ceil(r.x)-r.x,dy=Math.ceil(r.y)-r.y; + c.closest('.figgy-editor').style.transform=`translate(${dx}px,${dy}px)`; + return {before:[r.x,r.y,r.width,r.height],translation:[dx,dy]}; + }''') + geometry=canvas.evaluate('c=>({css:[c.getBoundingClientRect().width,c.getBoundingClientRect().height],backing:[c.width,c.height],dpr:devicePixelRatio})') + assert geometry==dict(css=[960,600],backing=[960,600],dpr=1),geometry + png=canvas.screenshot(path=str(tmp_path/f'{feature}-canvas.png')) + image=np.asarray(Image.open(io.BytesIO(png)).convert('RGBA')) + assert_feature_image(image,feature) + (tmp_path/'visual-contract.json').write_text(json.dumps(dict(feature=feature,geometry=geometry,alignment=alignment))) diff --git a/tests/test_renderer_018_features.py b/tests/test_renderer_018_features.py index 0b4eeae..66f4688 100644 --- a/tests/test_renderer_018_features.py +++ b/tests/test_renderer_018_features.py @@ -5,6 +5,7 @@ import numpy as np import pytest import figgyplot as figgy +from feature_visual_assertions import FEATURES, populate as populate_reference_scene, assert_feature_image def populate(figure, feature): @@ -39,10 +40,6 @@ def populate(figure, feature): return ax -FEATURES = ['mapped_points', 'vector_points', 'vector_grid', 'steps', 'baseline_fill', - 'series_fill', 'polar', 'radar', 'annotations'] - - @pytest.mark.parametrize('backend', ['native', 'webgpu']) @pytest.mark.parametrize('feature', FEATURES) def test_features_round_trip_and_replay_without_reupload(backend, feature): @@ -60,9 +57,13 @@ def test_features_round_trip_and_replay_without_reupload(backend, feature): assert target.info.get('column_uploads') == uploads if backend == 'native': image = source.render_rgba() - assert image.shape == (600, 960, 4) - assert np.any(image[..., :3] < 150) - assert np.array_equal(image, target.render_rgba()) + assert image.shape == (600,960,4) + assert np.array_equal(image,target.render_rgba()) + # Retain the original API/combination replay coverage above. An + # independently authored scene rejects a shared wrong readback. + with figgy.Figure(backend='native',figsize=(9.6,6),dpi=100) as reference: + populate_reference_scene(reference,feature) + assert_feature_image(reference.render_rgba(),feature) @pytest.mark.parametrize('backend', ['native', 'webgpu']) diff --git a/tests/test_review.py b/tests/test_review.py index e9adcbe..ac1890e 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -10,6 +10,7 @@ import figgyplot as fg from figgyplot.review import Session from figgyplot._review_diff import changes, persistent, preserved +from recovery_assertions import record_uploads, assert_arrays, assert_cached_columns, assert_cached_bounded, assert_renderer_columns def create(session, id='plot', family='line'): @@ -19,6 +20,8 @@ def create(session, id='plot', family='line'): elif family=='mapped':ax.scatter([0,1],[2,3],c=[1,2],s=[2,4],id='mapped') elif family=='matrix':ax.imshow([[1,2],[3,4]],id='field') elif family=='vector':ax.quiver([0,1],[1,2],[2,1],[1,3],id='arrows') + elif family=='vector_grid':ax.quiver([0,1],[1,2],[[2,1],[1,3]],[[1,2],[3,1]],values=[[1,2],[3,4]],id='arrows') + elif family=='fill_pair':ax.fill_between([0,1],[2,3],[1,2],id='band') elif family=='pie':ax.pie([3,2,1]) elif family=='bar':ax.bar(['A','B'],[3,4]) elif family=='box':ax.boxplot([[1,2,3],[2,4,6]]) @@ -29,10 +32,10 @@ def create(session, id='plot', family='line'): return fig -@pytest.mark.parametrize('family',['line','mapped','matrix','vector','pie','bar','box','radar','panels']) +@pytest.mark.parametrize('family',['line','mapped','matrix','vector','vector_grid','fill_pair','pie','bar','box','radar','panels']) @pytest.mark.parametrize('backend',['native','webgpu']) -def test_complete_binary_checkpoint_restores_all_families(tmp_path,family,backend): - with Session(tmp_path/'cache',backend=backend) as session: +def test_complete_binary_checkpoint_restores_all_families(tmp_path,family,backend,monkeypatch): + with record_uploads(monkeypatch) as original, Session(tmp_path/'cache',backend=backend) as session: fig=create(session,family=family) session.start();baseline=fig.get_settings() fig.axes[0].set_title('Approved title') @@ -46,11 +49,15 @@ def test_complete_binary_checkpoint_restores_all_families(tmp_path,family,backen assert checkpoint['baseline']==baseline and checkpoint['approval']['settings']==approved if family in ('pie','bar','box','radar'):assert checkpoint['bounded_data'] assert 'Keep this title' not in json.dumps(approved) - with Session.open(tmp_path/'cache') as resumed: + assert_cached_columns(tmp_path/'cache',checkpoint,original) + assert_cached_bounded(tmp_path/'cache',checkpoint,family) + with record_uploads(monkeypatch) as uploaded, Session.open(tmp_path/'cache') as resumed: recovered=resumed.restore('plot',backend=backend) assert recovered.get_settings()==approved + assert_arrays(original,uploaded,'restored upload') if backend=='native':assert recovered.to_png().startswith(b'\x89PNG') assert resumed.events()==[signal] + assert_renderer_columns(recovered,original) def test_all_figure_barrier_comments_and_idempotent_submission(tmp_path): From 25e015710fe1802ee2dccde43c78d2a65ef3478a Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 06:34:19 +0000 Subject: [PATCH 09/11] Snapshot recovery expectations before cache writes can mutate inputs --- docs/REVIEW_MODE_VALIDATION.md | 15 +++++++------ .../acceptance-oracles-20261022.json | 19 ++++++++++------- tests/recovery_assertions.py | 5 +++-- tests/test_acceptance_oracles.py | 21 ++++++++++++++++--- tests/test_review.py | 12 +++++++---- 5 files changed, 49 insertions(+), 23 deletions(-) diff --git a/docs/REVIEW_MODE_VALIDATION.md b/docs/REVIEW_MODE_VALIDATION.md index 4ce0fba..58ba1a2 100644 --- a/docs/REVIEW_MODE_VALIDATION.md +++ b/docs/REVIEW_MODE_VALIDATION.md @@ -247,7 +247,7 @@ pixel grid, retaining exactly 960 x 600 CSS/backing dimensions at DPR 1. No rows are cropped, pixels resampled or dimension assertions relaxed. Recovery tests retain independent copies of the original renderer inputs in -test code only. They compare every baseline/approved NPY payload and every +test code only, before the cache writer or renderer can mutate them. They compare every baseline/approved NPY payload and every restored upload by column identity, dtype, shape, all values and payload bits. The NPY oracle reads files directly rather than reusing the restore reader. Bounded numeric checkpoints are also compared with literal expectations for @@ -270,19 +270,22 @@ boundaries rather than claimed for that JSON observation. Negative controls are part of ordinary CI: **45 wrong image cases**, **nine single-pixel substitutions through the actual replay test**, **42 wrong restore payloads** (zero/reversed/wrong dtype), and **14 wrong device/transfer contents** -must all be rejected. In the latter cases, settings, cache and observed upload +must all be rejected. Two additional controls require rejection when the cache +writer mutates its original input in place. Recording the expectation after the +writer incorrectly accepted both faults; pre-write snapshots now reject them. +In the resident-data cases, settings, cache and observed upload arguments remain correct: only the final native upload or browser transfer bytes are zeroed. Re-running the original baseline test accepted all ten original family/backend resident-data faults; the strengthened checks reject them. -Local validation passed **207 focused cases in 10.93 seconds**, **nine headful +Local validation passed **209 focused cases in 11.14 seconds**, **nine headful browser cases in 127.57 seconds**, and **117 preflight cases in 2.07 seconds**. The unchanged runtime wheel is the previously verified `88e7f7cc4bfa8edf93ae9240f289144b530fc493dbe1f73a66355fb7b1537c2b` artifact. -Ordinary and priority collection both contain **769 unique cases**: all previous -642 IDs remain, with 127 additions and zero duplicates. The new oracle and +Ordinary and priority collection both contain **771 unique cases**: all previous +642 IDs remain, with 129 additions and zero duplicates. The new oracle and browser cases run first within the single full collection. No full local -769-case pass is claimed; required CI must validate the final commit. +771-case pass is claimed; required CI must validate the final commit. [The before/after contract and measurements](measurements/acceptance-oracles-20261022.json) distinguish these results. These finite scenes do not claim every feature combination, exact font rasterization or general usability. diff --git a/docs/measurements/acceptance-oracles-20261022.json b/docs/measurements/acceptance-oracles-20261022.json index aeaa1cc..008ea8a 100644 --- a/docs/measurements/acceptance-oracles-20261022.json +++ b/docs/measurements/acceptance-oracles-20261022.json @@ -4,7 +4,8 @@ "before": { "single_pixel_feature_tests_accepted": 9, "zero_restore_payload_tests_accepted": 10, - "zero_resident_data_tests_accepted": 10 + "zero_resident_data_tests_accepted": 10, + "writer_mutation_tests_accepted_before_source_snapshot_fix": 2 }, "after": { "native_feature_scenes": 9, @@ -15,17 +16,18 @@ "wrong_device_or_transfer_checks_rejected": 14, "normal_restore_cases": 22, "float_precision_cases": 4, - "focused_cases_passed": 207, - "focused_seconds": 10.93, + "focused_cases_passed": 209, + "focused_seconds": 11.14, "headful_browser_cases_passed": 9, "headful_browser_seconds": 127.57, "preflight_cases_passed": 117, - "preflight_seconds": 2.07 + "preflight_seconds": 2.07, + "wrong_cache_writer_input_mutation_checks_rejected": 2 }, "collection": { "previous": 642, - "current": 769, - "added": 127, + "current": 771, + "added": 129, "omitted": 0, "duplicates": 0 }, @@ -75,9 +77,10 @@ "gpu": "Read every native column via GPU selection readback, using temporary test-only probe series without any sample upload.", "native_finite_rtol": 2e-14, "native_finite_atol": 0, - "nonfinite": "Native selection JSON represents nonfinite values as null. NaN payloads, infinities and signed-zero bits are checked exactly at lossless boundaries, not promised as bit-identical GPU representation." + "nonfinite": "Native selection JSON represents nonfinite values as null. NaN payloads, infinities and signed-zero bits are checked exactly at lossless boundaries, not promised as bit-identical GPU representation.", + "original_capture": "Copy the original array before delegating to the cache writer or renderer; restoration capture remains before the renderer upload." }, "browser_capture": "The complete widget is translated by the fractional part of its DOM origin to align canvas pixels with the device grid. CSS and backing remain exactly 960x600 at DPR 1. No cropping, resampling or relaxed image shape is used.", "scope": "These close the demonstrated oracle gaps for the listed scenes and recovery families. They do not establish every feature combination, font rasterization or usability.", - "required_ci": "Pending validation of the final commit; the previous 642-case CI result must not be described as the new 769-case result." + "required_ci": "Pending validation of the final commit; the previous 642-case CI result must not be described as the new 771-case result." } diff --git a/tests/recovery_assertions.py b/tests/recovery_assertions.py index e6c5211..b32bbce 100644 --- a/tests/recovery_assertions.py +++ b/tests/recovery_assertions.py @@ -14,9 +14,10 @@ def __getattr__(self, name): return getattr(self.native, name) def upload_column(self, name, array): - self.native.upload_column(name, array) assert name not in self.arrays, ('duplicate upload', name) - self.arrays[name] = np.array(array, copy=True) + original = np.array(array, copy=True) + self.native.upload_column(name, array) + self.arrays[name] = original @contextmanager diff --git a/tests/test_acceptance_oracles.py b/tests/test_acceptance_oracles.py index 5ba9bbd..db7f90f 100644 --- a/tests/test_acceptance_oracles.py +++ b/tests/test_acceptance_oracles.py @@ -6,7 +6,7 @@ from feature_visual_assertions import FEATURES, populate, assert_feature_image from test_review import test_complete_binary_checkpoint_restores_all_families as check_restore -from recovery_assertions import record_uploads, assert_arrays, assert_cached_columns, assert_renderer_columns +from recovery_assertions import RecordedUploads, record_uploads, assert_arrays, assert_cached_columns, assert_renderer_columns @pytest.fixture(scope='module',params=FEATURES) @@ -83,6 +83,18 @@ def wrong(reference): check_restore(tmp_path,family,backend,monkeypatch) +@pytest.mark.parametrize('backend',['native','webgpu']) +def test_checkpoint_oracle_rejects_writer_mutating_original_input(tmp_path,monkeypatch,backend): + from figgyplot._review_store import Store + put=Store.put + def changed(store,array): + array[:]=0 + return put(store,array) + monkeypatch.setattr(Store,'put',changed) + with pytest.raises(AssertionError,match='columns:'): + check_restore(tmp_path,'line',backend,monkeypatch) + + @pytest.mark.parametrize('backend',['native','webgpu']) @pytest.mark.parametrize('family',['line','mapped','matrix','vector','vector_grid','fill_pair','panels']) def test_renderer_oracle_rejects_wrong_resident_values(tmp_path,monkeypatch,backend,family): @@ -122,8 +134,11 @@ def test_restore_preserves_float_types_bits_and_gpu_values(tmp_path,monkeypatch, bits=y.view(np.uint32 if dtype==np.float32 else np.uint64) bits[6]=0x7fc00042 if dtype==np.float32 else 0x7ff8000000000042 x=np.arange(len(y),dtype=dtype) - with record_uploads(monkeypatch) as original, Session(tmp_path,backend=backend) as session: - figure=session.figure(id='precision');figure.axes[0].plot(x,y,id='signal') + original={} + with Session(tmp_path,backend=backend) as session: + figure=session.figure(id='precision') + figure._native=RecordedUploads(figure._native,original) + figure.axes[0].plot(x,y,id='signal') assert original['signal/x'].dtype==x.dtype assert original['signal/y'].tobytes()==y.tobytes() session.start();session.submit('precision','request_changes') diff --git a/tests/test_review.py b/tests/test_review.py index ac1890e..a4fb812 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -10,11 +10,14 @@ import figgyplot as fg from figgyplot.review import Session from figgyplot._review_diff import changes, persistent, preserved -from recovery_assertions import record_uploads, assert_arrays, assert_cached_columns, assert_cached_bounded, assert_renderer_columns +from recovery_assertions import RecordedUploads, record_uploads, assert_arrays, assert_cached_columns, assert_cached_bounded, assert_renderer_columns -def create(session, id='plot', family='line'): +def create(session, id='plot', family='line', uploads=None): fig=session.figure(id=id) + if uploads is not None: + # Capture before the review cache writer can touch the input. + fig._native=RecordedUploads(fig._native,uploads) ax=fig.axes[0] if family=='line':ax.plot([0,1],[2,3],id='signal') elif family=='mapped':ax.scatter([0,1],[2,3],c=[1,2],s=[2,4],id='mapped') @@ -35,8 +38,9 @@ def create(session, id='plot', family='line'): @pytest.mark.parametrize('family',['line','mapped','matrix','vector','vector_grid','fill_pair','pie','bar','box','radar','panels']) @pytest.mark.parametrize('backend',['native','webgpu']) def test_complete_binary_checkpoint_restores_all_families(tmp_path,family,backend,monkeypatch): - with record_uploads(monkeypatch) as original, Session(tmp_path/'cache',backend=backend) as session: - fig=create(session,family=family) + original={} + with Session(tmp_path/'cache',backend=backend) as session: + fig=create(session,family=family,uploads=original) session.start();baseline=fig.get_settings() fig.axes[0].set_title('Approved title') approved=fig.get_settings();counter='column_uploads' if backend=='native' else 'transfer_snapshot_bytes';uploads=fig.info[counter] From e799fd93d2a40359d4946aa73cd0d97a50446feb Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 07:32:00 +0000 Subject: [PATCH 10/11] Require complete passing execution across disjoint CI GPU shards --- .github/workflows/pr-tests.yml | 52 ++++++++-- docs/REVIEW_MODE_VALIDATION.md | 40 +++++++- .../acceptance-oracles-20261022.json | 23 +++-- .../ci58-execution-deadline-20261022.json | 18 ++++ scripts/check_ci_execution.py | 67 +++++++++++++ scripts/ci-preflight.sh | 2 +- scripts/run_ci_tests.py | 14 ++- tests/test_ci_execution.py | 96 +++++++++++++++++++ 8 files changed, 291 insertions(+), 21 deletions(-) create mode 100644 docs/measurements/ci58-execution-deadline-20261022.json create mode 100644 scripts/check_ci_execution.py create mode 100644 tests/test_ci_execution.py diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index abf2b91..059e365 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -96,14 +96,18 @@ jobs: retention-days: 5 if-no-files-found: error - integration: - name: PR / Linux integration + integration_shards: + name: PR / Linux integration shard ${{ matrix.shard }} needs: build # Fail closed on upstream failure, but let superseded runs be cancelled. if: ${{ !cancelled() }} runs-on: ubuntu-24.04 - # Measured full suite is ~41m; priority ordering adds no duplicate execution. + # Disjoint execution preserves every assertion and the per-job time budget. timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + shard: [0, 1, 2, 3] env: WGPU_BACKEND: vulkan FIGGY_TEST_JUPYTER: '1' @@ -113,6 +117,7 @@ jobs: FIGGY_REQUIRE_INTERACTION_PARITY: '1' FIGGYPLOT_MATURIN_ACTION: .ci/maturin-action/action.yml PYTHONUNBUFFERED: '1' + CI_SHARD: ${{ matrix.shard }} steps: - name: Require the verified wheel env: @@ -151,7 +156,7 @@ jobs: run: | .venv/bin/python -m playwright install --with-deps chromium .venv/bin/python scripts/ci_gpu_env.py - - name: Test previous failures first, then native and Jupyter integration + - name: Execute disjoint native and Jupyter test shard run: | mkdir -p ci-results xvfb-run -a -s '-screen 0 1600x1000x24' bash <<'SCRIPT' @@ -159,9 +164,10 @@ jobs: openbox > /tmp/figgyplot-openbox.log 2>&1 & wm_pid=$! trap 'kill "$wm_pid" 2>/dev/null || true' EXIT - # Order past failures first without running them a second time. - # test-plan.json retains all original and ordered node IDs. + # Collect the entire suite; execute each node ID on one runner. + # The required integration job audits exact JUnit execution IDs. .venv/bin/python scripts/run_ci_tests.py -x -vv --capture=tee-sys --durations=30 \ + --ci-shard-count=4 --ci-shard-index="$CI_SHARD" \ --basetemp=ci-results/full --junitxml=ci-results/junit.xml \ 2>&1 | tee ci-results/full.log SCRIPT @@ -169,8 +175,40 @@ jobs: if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: pr-linux-integration-results + name: pr-linux-integration-shard-${{ matrix.shard }} path: ci-results retention-days: 5 if-no-files-found: ignore + + integration: + name: PR / Linux integration + needs: integration_shards + if: ${{ !cancelled() }} + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Require every shard to succeed + env: + SHARD_RESULT: ${{ needs.integration_shards.result }} + run: test "$SHARD_RESULT" = success + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: pr-linux-integration-shard-* + path: ci-results + - name: Verify complete passing execution without duplicates or skips + run: | + for shard in 0 1 2 3; do + mv "ci-results/pr-linux-integration-shard-$shard" "ci-results/shard-$shard" + done + python3 scripts/check_ci_execution.py ci-results --shards=4 + - name: Save execution audit + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: pr-linux-integration-audit + path: ci-results/execution-audit.json + retention-days: 5 + if-no-files-found: error diff --git a/docs/REVIEW_MODE_VALIDATION.md b/docs/REVIEW_MODE_VALIDATION.md index 58ba1a2..bf37320 100644 --- a/docs/REVIEW_MODE_VALIDATION.md +++ b/docs/REVIEW_MODE_VALIDATION.md @@ -279,13 +279,43 @@ are zeroed. Re-running the original baseline test accepted all ten original family/backend resident-data faults; the strengthened checks reject them. Local validation passed **209 focused cases in 11.14 seconds**, **nine headful -browser cases in 127.57 seconds**, and **117 preflight cases in 2.07 seconds**. +browser cases in 127.57 seconds**, and **131 preflight cases in 3.27 seconds**. The unchanged runtime wheel is the previously verified `88e7f7cc4bfa8edf93ae9240f289144b530fc493dbe1f73a66355fb7b1537c2b` artifact. -Ordinary and priority collection both contain **771 unique cases**: all previous -642 IDs remain, with 129 additions and zero duplicates. The new oracle and -browser cases run first within the single full collection. No full local -771-case pass is claimed; required CI must validate the final commit. +Ordinary and priority collection both contain **785 unique cases**: all previous +642 IDs remain, with 143 additions and zero duplicates. Each runner collects the complete plan, orders the priority cases first and +executes one disjoint quarter. No full local 785-case pass is claimed; required +CI must validate the final commit. [The before/after contract and measurements](measurements/acceptance-oracles-20261022.json) distinguish these results. These finite scenes do not claim every feature combination, exact font rasterization or general usability. + + +### Complete execution is a required assertion + +[CI #58](https://github.com/shim9610/figgy-python/actions/runs/39734785159) +passed 470 of 771 cases before its configured 45-minute job limit cancelled the +run. It reported no assertion failure; it did not establish a full-suite pass. +The nine added browser oracles occupied approximately 125 seconds of the logged +test interval. Existing radial GPU work was substantially slower: the same +radial-detail WebGPU test's first submission increased from 11.622 seconds in +CI #56 to 42.342 seconds, while pipeline preparation increased from 0.501 to +0.660 seconds. This observation locates the larger stall in GPU completion, +without attributing it to a particular runner CPU or renderer change. +[The measurements](measurements/ci58-execution-deadline-20261022.json) preserve +that distinction. + +The suite now runs on four independent software-GPU runners. Each collects the +identical full ordered plan, then selects `ordered[index::4]`. Individual test +deadlines, correctness assertions and the 45-minute per-job limit are retained. +No test is intentionally run twice. The existing required check name, +`PR / Linux integration`, now verifies that every shard succeeded and audits +all four JUnit reports against their complete plans. Missing artifacts, changed +plans, wrong partitions, missing/duplicate execution IDs, failures, errors and +skips fail this check. Exact pytest node IDs are written into JUnit properties; +Python class names are not used to guess them. + +Fourteen local execution-contract tests passed, including a real two-shard +pytest/JUnit run and twelve deliberately invalid reports. Collection proves +that the four selected lists contain all 785 current IDs exactly once, including +all original 642 cases. This audit is also part of CI preflight. diff --git a/docs/measurements/acceptance-oracles-20261022.json b/docs/measurements/acceptance-oracles-20261022.json index 008ea8a..22e482a 100644 --- a/docs/measurements/acceptance-oracles-20261022.json +++ b/docs/measurements/acceptance-oracles-20261022.json @@ -20,16 +20,25 @@ "focused_seconds": 11.14, "headful_browser_cases_passed": 9, "headful_browser_seconds": 127.57, - "preflight_cases_passed": 117, - "preflight_seconds": 2.07, - "wrong_cache_writer_input_mutation_checks_rejected": 2 + "preflight_cases_passed": 131, + "preflight_seconds": 3.27, + "wrong_cache_writer_input_mutation_checks_rejected": 2, + "execution_contract_cases_passed": 14, + "execution_contract_seconds": 0.69 }, "collection": { "previous": 642, - "current": 771, - "added": 129, + "current": 785, + "added": 143, "omitted": 0, - "duplicates": 0 + "duplicates": 0, + "shards": 4, + "per_shard": [ + 197, + 196, + 196, + 196 + ] }, "visual_contract": { "document": [ @@ -82,5 +91,5 @@ }, "browser_capture": "The complete widget is translated by the fractional part of its DOM origin to align canvas pixels with the device grid. CSS and backing remain exactly 960x600 at DPR 1. No cropping, resampling or relaxed image shape is used.", "scope": "These close the demonstrated oracle gaps for the listed scenes and recovery families. They do not establish every feature combination, font rasterization or usability.", - "required_ci": "Pending validation of the final commit; the previous 642-case CI result must not be described as the new 771-case result." + "required_ci": "Pending validation of the final commit. CI #58 passed 470 of 771 tests before its job deadline and is not a complete result. The new required integration check audits exact passing JUnit IDs across all four disjoint shards." } diff --git a/docs/measurements/ci58-execution-deadline-20261022.json b/docs/measurements/ci58-execution-deadline-20261022.json new file mode 100644 index 0000000..388954e --- /dev/null +++ b/docs/measurements/ci58-execution-deadline-20261022.json @@ -0,0 +1,18 @@ +{ + "run": 39734785159, + "head": "25e015710fe1802ee2dccde43c78d2a65ef3478a", + "completed_cases": 470, + "total": 771, + "assertion_failures": 0, + "termination": "Job cancelled exactly at its configured 45-minute limit; 470 tests passed, no assertion failure reported.", + "new_visual_browser_cases": 9, + "new_visual_browser_interval_seconds": 125.166, + "radial_comparison": { + "nodeid": "tests/test_interaction_parity.py::test_radial_detail_tree_selection_edits_only_the_chosen_child[webgpu]", + "baseline_run": 39723351628, + "previous_first_gpu_submit_ms": 11621.7, + "current_first_gpu_submit_ms": 42342.3, + "previous_pipeline_ms": 500.7, + "current_pipeline_ms": 659.8 + } +} diff --git a/scripts/check_ci_execution.py b/scripts/check_ci_execution.py new file mode 100644 index 0000000..f5b7ea9 --- /dev/null +++ b/scripts/check_ci_execution.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Require every collected test to pass exactly once across CI shards.""" +import argparse +from collections import Counter +import json +from pathlib import Path +import xml.etree.ElementTree as ET + + +def validate(root, shards): + expected = None + executed = [] + selected = [] + for index in range(shards): + directory = Path(root) / f'shard-{index}' + plan = json.loads((directory / 'test-plan.json').read_text()) + if plan['shard'] != {'index': index, 'count': shards}: + raise ValueError(f'Wrong shard identity: {index}') + original, ordered = plan['original'], plan['ordered'] + if len(set(original)) != len(original) or Counter(original) != Counter(ordered): + raise ValueError('Duplicate or omitted collection IDs') + if plan['count'] != len(ordered): + raise ValueError('Wrong collection count') + if not original: + raise ValueError('Empty collection') + if expected is None: + expected = ordered + if ordered != expected: + raise ValueError('Shards collected different test plans') + if plan['selected'] != ordered[index::shards]: + raise ValueError('Wrong shard selection') + selected.extend(plan['selected']) + report = ET.parse(directory / 'junit.xml') + cases = report.findall('.//testcase') + for case in cases: + if any(case.find(tag) is not None for tag in ('failure', 'error', 'skipped')): + raise ValueError('Nonpassing test in JUnit report') + actual = [] + for case in cases: + ids = [prop.get('value') for prop in case.findall('./properties/property') + if prop.get('name') == 'ci_nodeid'] + if len(ids) != 1: + raise ValueError('Missing or duplicate exact JUnit node ID') + actual.append(ids[0]) + if actual != plan['selected']: + raise ValueError(f'JUnit execution differs from selected test IDs: {index}') + executed.extend(actual) + if Counter(selected) != Counter(expected) or Counter(executed) != Counter(expected): + raise ValueError('Execution omits or duplicates test IDs') + return {'count': len(expected), 'shards': shards, 'omitted': 0, 'duplicates': 0, + 'nonpassing': 0} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('root', type=Path) + parser.add_argument('--shards', type=int, required=True) + args = parser.parse_args() + if args.shards < 1: + parser.error('--shards must be positive') + result = validate(args.root, args.shards) + args.root.joinpath('execution-audit.json').write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result)) + + +if __name__ == '__main__': + main() diff --git a/scripts/ci-preflight.sh b/scripts/ci-preflight.sh index b1e87a6..d1b01c5 100644 --- a/scripts/ci-preflight.sh +++ b/scripts/ci-preflight.sh @@ -4,5 +4,5 @@ export FIGGYPLOT_MATURIN_ACTION=${FIGGYPLOT_MATURIN_ACTION:-.ci/maturin-action/a uv run --no-project --with actionlint-py --with shellcheck-py actionlint uv run --no-project python scripts/check_assets.py uv run --no-project --with 'PyYAML>=6,<7' python scripts/check_wheel_workflow.py "$FIGGYPLOT_MATURIN_ACTION" -uv run --no-project --with 'PyYAML>=6,<7' --with 'packaging>=24' --with pytest python -m pytest -q tests/test_wheel_workflow.py tests/test_packaged_assets.py tests/test_release.py tests/test_interaction_guard.py tests/test_harness_diagnostics.py +uv run --no-project --with 'PyYAML>=6,<7' --with 'packaging>=24' --with pytest python -m pytest -q tests/test_wheel_workflow.py tests/test_packaged_assets.py tests/test_release.py tests/test_interaction_guard.py tests/test_harness_diagnostics.py tests/test_ci_execution.py diff --git a/scripts/run_ci_tests.py b/scripts/run_ci_tests.py index 54686c7..54986a6 100644 --- a/scripts/run_ci_tests.py +++ b/scripts/run_ci_tests.py @@ -25,6 +25,8 @@ class PriorityFirst: def pytest_addoption(self, parser): + parser.addoption('--ci-shard-index', type=int, default=0) + parser.addoption('--ci-shard-count', type=int, default=1) parser.addoption('--ci-test-plan', default='ci-results/test-plan.json', help='Record every collected node ID before and after ordering') @@ -43,9 +45,19 @@ def rank(item): raise pytest.UsageError('CI collection is missing required priority cases: ' + ', '.join(missing)) items.sort(key=rank) ordered = [item.nodeid for item in items] + index = config.getoption('--ci-shard-index') + count = config.getoption('--ci-shard-count') + if count < 1 or not 0 <= index < count: + raise pytest.UsageError('CI shard index must be within a positive shard count') + items[:] = items[index::count] + selected = [item.nodeid for item in items] + for item in items: + item.user_properties.append(('ci_nodeid', item.nodeid)) plan = Path(config.getoption('--ci-test-plan')) plan.parent.mkdir(parents=True, exist_ok=True) - plan.write_text(json.dumps(dict(count=len(items), original=original, ordered=ordered), indent=2) + '\n') + plan.write_text(json.dumps(dict(count=len(ordered), original=original, ordered=ordered, selected=selected, + shard=dict(index=index, count=count)), indent=2) + '\n') + if __name__ == '__main__': diff --git a/tests/test_ci_execution.py b/tests/test_ci_execution.py new file mode 100644 index 0000000..f14d35b --- /dev/null +++ b/tests/test_ci_execution.py @@ -0,0 +1,96 @@ +"""CI must fail closed when collected, selected and executed IDs disagree.""" +import json +from pathlib import Path +import runpy +import subprocess +import sys +import xml.etree.ElementTree as ET + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +validate = runpy.run_path(str(ROOT / 'scripts/check_ci_execution.py'))['validate'] + + +@pytest.fixture +def reports(tmp_path): + ids = [f'tests/test_sample.py::test_case[{i}]' for i in range(6)] + for index in range(2): + directory = tmp_path / f'shard-{index}' + directory.mkdir() + selected = ids[index::2] + plan = dict(count=6, original=ids, ordered=ids, selected=selected, + shard=dict(index=index, count=2)) + (directory / 'test-plan.json').write_text(json.dumps(plan)) + report = ET.Element('testsuites') + suite = ET.SubElement(report, 'testsuite') + for nodeid in selected: + case = ET.SubElement(suite, 'testcase', name=nodeid.split('::')[1]) + props = ET.SubElement(case, 'properties') + ET.SubElement(props, 'property', name='ci_nodeid', value=nodeid) + ET.ElementTree(report).write(directory / 'junit.xml') + return tmp_path + + +def test_complete_execution_audit(reports): + assert validate(reports, 2) == dict(count=6, shards=2, omitted=0, duplicates=0, nonpassing=0) + + +@pytest.mark.parametrize('fault', ['missing_shard', 'wrong_shard', 'different_plan', + 'duplicate_collection', 'wrong_selection', 'missing_execution', 'duplicate_execution', + 'failure', 'error', 'skipped', 'missing_identity', 'duplicate_identity']) +def test_execution_audit_rejects_incomplete_or_nonpassing_results(reports, fault): + directory = reports / 'shard-1' + plan_path, xml_path = directory / 'test-plan.json', directory / 'junit.xml' + plan = json.loads(plan_path.read_text()) + report = ET.parse(xml_path) + suite = report.find('testsuite') + case = suite.find('testcase') + if fault == 'missing_shard': + xml_path.unlink() + elif fault == 'wrong_shard': + plan['shard']['index'] = 0 + elif fault == 'different_plan': + plan['ordered'] = list(reversed(plan['ordered'])) + elif fault == 'duplicate_collection': + plan['original'].append(plan['original'][0]) + elif fault == 'wrong_selection': + plan['selected'] = plan['ordered'][::2] + elif fault == 'missing_execution': + suite.remove(case) + elif fault == 'duplicate_execution': + suite.append(case) + elif fault in ('failure', 'error', 'skipped'): + ET.SubElement(case, fault) + elif fault == 'missing_identity': + case.remove(case.find('properties')) + else: + props = case.find('properties') + ET.SubElement(props, 'property', name='ci_nodeid', value=plan['selected'][0]) + plan_path.write_text(json.dumps(plan)) + if fault != 'missing_shard': + report.write(xml_path) + with pytest.raises((ValueError, FileNotFoundError)): + validate(reports, 2) + + +def test_actual_pytest_shards_produce_exact_auditable_execution(tmp_path): + (tmp_path / 'test_sample.py').write_text('import pytest\n' + '@pytest.mark.parametrize("value", range(6))\n' + 'def test_case(value): assert value >= 0\n') + # Exercise the actual plugin and pytest JUnit writer, without the unrelated + # repository priority groups required by the full CI collection. + for index in range(2): + directory = tmp_path / f'shard-{index}' + directory.mkdir() + code = ('import sys; sys.path.insert(0, sys.argv[1]); ' + 'import pytest, run_ci_tests as runner; ' + 'runner.PRIORITY=("test_sample.py",); ' + 'raise SystemExit(pytest.main(sys.argv[2:], plugins=[runner.PriorityFirst()]))') + result = subprocess.run([sys.executable, '-c', code, str(ROOT / 'scripts'), '-q', + '--ci-shard-count=2', f'--ci-shard-index={index}', + f'--ci-test-plan={directory / "test-plan.json"}', + f'--junitxml={directory / "junit.xml"}', 'test_sample.py'], + cwd=tmp_path, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + assert validate(tmp_path, 2)['count'] == 6 From 5c96e6a9a83bc14d6e976d3a6df96e89dc65e51c Mon Sep 17 00:00:00 2001 From: youo he Date: Thu, 22 Oct 2026 07:43:03 +0000 Subject: [PATCH 11/11] Isolate sample pytest roots when CI temp files are inside the repository --- docs/REVIEW_MODE_VALIDATION.md | 11 ++++++++++- docs/measurements/acceptance-oracles-20261022.json | 6 +++--- tests/test_ci_execution.py | 3 +++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/REVIEW_MODE_VALIDATION.md b/docs/REVIEW_MODE_VALIDATION.md index bf37320..5e0e9d1 100644 --- a/docs/REVIEW_MODE_VALIDATION.md +++ b/docs/REVIEW_MODE_VALIDATION.md @@ -279,7 +279,7 @@ are zeroed. Re-running the original baseline test accepted all ten original family/backend resident-data faults; the strengthened checks reject them. Local validation passed **209 focused cases in 11.14 seconds**, **nine headful -browser cases in 127.57 seconds**, and **131 preflight cases in 3.27 seconds**. +browser cases in 127.57 seconds**, and **131 preflight cases in 2.90 seconds**. The unchanged runtime wheel is the previously verified `88e7f7cc4bfa8edf93ae9240f289144b530fc493dbe1f73a66355fb7b1537c2b` artifact. Ordinary and priority collection both contain **785 unique cases**: all previous @@ -319,3 +319,12 @@ Fourteen local execution-contract tests passed, including a real two-shard pytest/JUnit run and twelve deliberately invalid reports. Collection proves that the four selected lists contain all 785 current IDs exactly once, including all original 642 cases. This audit is also part of CI preflight. + + +The first sharded run (CI #59) exposed a nested-test isolation error: its sample +pytest subprocess inherited the parent project's collection root when CI's +`--basetemp` lived inside the repository. The same repository-local layout +reproduced the failure locally. The sample subprocess now explicitly fixes its +root to its own temporary directory; the full audit remains strict. All 14 +execution-contract cases pass in that repository-local layout (0.64 seconds), +and preflight passes all 131 cases (2.90 seconds). diff --git a/docs/measurements/acceptance-oracles-20261022.json b/docs/measurements/acceptance-oracles-20261022.json index 22e482a..a516311 100644 --- a/docs/measurements/acceptance-oracles-20261022.json +++ b/docs/measurements/acceptance-oracles-20261022.json @@ -21,10 +21,10 @@ "headful_browser_cases_passed": 9, "headful_browser_seconds": 127.57, "preflight_cases_passed": 131, - "preflight_seconds": 3.27, + "preflight_seconds": 2.9, "wrong_cache_writer_input_mutation_checks_rejected": 2, "execution_contract_cases_passed": 14, - "execution_contract_seconds": 0.69 + "execution_contract_seconds": 0.64 }, "collection": { "previous": 642, @@ -91,5 +91,5 @@ }, "browser_capture": "The complete widget is translated by the fractional part of its DOM origin to align canvas pixels with the device grid. CSS and backing remain exactly 960x600 at DPR 1. No cropping, resampling or relaxed image shape is used.", "scope": "These close the demonstrated oracle gaps for the listed scenes and recovery families. They do not establish every feature combination, font rasterization or usability.", - "required_ci": "Pending validation of the final commit. CI #58 passed 470 of 771 tests before its job deadline and is not a complete result. The new required integration check audits exact passing JUnit IDs across all four disjoint shards." + "required_ci": "Pending validation of the final commit. CI #58 passed 470 of 771 tests before its deadline. CI #59 caught a repository-local subprocess root isolation error, reproduced locally and fixed with an explicit sample-test root. The required integration check audits exact passing JUnit IDs across all four disjoint shards." } diff --git a/tests/test_ci_execution.py b/tests/test_ci_execution.py index f14d35b..6a0f10e 100644 --- a/tests/test_ci_execution.py +++ b/tests/test_ci_execution.py @@ -87,7 +87,10 @@ def test_actual_pytest_shards_produce_exact_auditable_execution(tmp_path): 'import pytest, run_ci_tests as runner; ' 'runner.PRIORITY=("test_sample.py",); ' 'raise SystemExit(pytest.main(sys.argv[2:], plugins=[runner.PriorityFirst()]))') + # CI basetemp is inside the repository: fix the nested collection root + # so parent project discovery cannot prefix the sample node IDs. result = subprocess.run([sys.executable, '-c', code, str(ROOT / 'scripts'), '-q', + f'--rootdir={tmp_path}', '--ci-shard-count=2', f'--ci-shard-index={index}', f'--ci-test-plan={directory / "test-plan.json"}', f'--junitxml={directory / "junit.xml"}', 'test_sample.py'],