From 1c73a2c87964b024c945210ea933d86c1f0d0fea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 14:05:21 +0000 Subject: [PATCH 1/3] Add a pluggable TUI frame-renderer contract. Keep compose_frame as the built-in mattervis.native-v1 backend and send both static and session frames through one display request. External backends are selected by entry point and fail closed, without changing default JSONL fingerprints. Co-authored-by: Ming-Yu Guo --- docs/agents/README.md | 1 + docs/agents/tui_api.md | 18 +- docs/agents/tui_renderer_api.md | 175 +++ docs/cli.md | 18 +- docs/dev-notes.md | 19 + examples/mattervis_mark_renderer/README.md | 82 ++ .../pins/example.marks-v1.json | 10 + .../mattervis_mark_renderer/pyproject.toml | 17 + .../src/mattervis_mark_renderer/__init__.py | 151 +++ mat_viewer/cli.py | 21 +- mat_viewer/tui/__init__.py | 41 +- mat_viewer/tui/app.py | 6 + mat_viewer/tui/cli.py | 54 +- mat_viewer/tui/controller.py | 15 +- mat_viewer/tui/controller_depth.py | 38 +- mat_viewer/tui/observation_profile.py | 43 + mat_viewer/tui/render_backend.py | 714 ++++++++++++ mat_viewer/tui/render_contract.py | 877 ++++++++++++++ mat_viewer/tui/serializer.py | 6 +- mat_viewer/tui/session.py | 55 +- pyproject.toml | 3 + .../atom_ascii7_100x36_nodepth.txt | 36 + .../atom_ascii7_80x24_depth.txt | 24 + .../atom_unicode_100x36_nodepth.txt | 36 + .../atom_unicode_80x24_depth.txt | 24 + .../boundary_ascii7_80x24.txt | 24 + .../episode_ascii7_80x24.jsonl | 5 + .../framework_ascii7_100x36.txt | 36 + .../fixtures/tui_renderer_goldens/index.json | 45 + .../molecule_ascii7_80x24.txt | 24 + .../water_atom_unicode_80x24.txt | 24 + .../water_molecule_ascii7_100x36.txt | 36 + tests/tui/test_tui_renderer_backend.py | 1012 +++++++++++++++++ 33 files changed, 3658 insertions(+), 32 deletions(-) create mode 100644 docs/agents/tui_renderer_api.md create mode 100644 examples/mattervis_mark_renderer/README.md create mode 100644 examples/mattervis_mark_renderer/pins/example.marks-v1.json create mode 100644 examples/mattervis_mark_renderer/pyproject.toml create mode 100644 examples/mattervis_mark_renderer/src/mattervis_mark_renderer/__init__.py create mode 100644 mat_viewer/tui/observation_profile.py create mode 100644 mat_viewer/tui/render_backend.py create mode 100644 mat_viewer/tui/render_contract.py create mode 100644 tests/fixtures/tui_renderer_goldens/atom_ascii7_100x36_nodepth.txt create mode 100644 tests/fixtures/tui_renderer_goldens/atom_ascii7_80x24_depth.txt create mode 100644 tests/fixtures/tui_renderer_goldens/atom_unicode_100x36_nodepth.txt create mode 100644 tests/fixtures/tui_renderer_goldens/atom_unicode_80x24_depth.txt create mode 100644 tests/fixtures/tui_renderer_goldens/boundary_ascii7_80x24.txt create mode 100644 tests/fixtures/tui_renderer_goldens/episode_ascii7_80x24.jsonl create mode 100644 tests/fixtures/tui_renderer_goldens/framework_ascii7_100x36.txt create mode 100644 tests/fixtures/tui_renderer_goldens/index.json create mode 100644 tests/fixtures/tui_renderer_goldens/molecule_ascii7_80x24.txt create mode 100644 tests/fixtures/tui_renderer_goldens/water_atom_unicode_80x24.txt create mode 100644 tests/fixtures/tui_renderer_goldens/water_molecule_ascii7_100x36.txt create mode 100644 tests/tui/test_tui_renderer_backend.py diff --git a/docs/agents/README.md b/docs/agents/README.md index 4e572275..adc639c9 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -51,6 +51,7 @@ The same routing as a quick table: | Attach optional in-process panels to the existing Web or TUI viewer | [`extensions_api.md`](extensions_api.md) | | Use the Web sidebar's Display / Analysis / Operations tabs and optional right-side extension panel | [`extensions_api.md#web-tool-placement`](extensions_api.md#web-tool-placement) | | Control a local terminal view, select stable atom IDs, or run a stateful Python/JSONL agent session | [`tui_api.md`](tui_api.md) | +| Select an external terminal frame renderer without forking MatterVis | [`tui_renderer_api.md`](tui_renderer_api.md) | | Read terminal pictures with element symbols, molecule silhouettes, text depth bands, crossing-bond gaps, layer slicing, and optional rotation parallax | [`tui_api.md`](tui_api.md) | | Render CIF, Cube, VASP, XYZ, ASE trajectory, or LAMMPS files from the CLI | [`../cli.md`](../cli.md) | | Label trajectory frames with physical simulation time while keeping playback FPS independent | [`../cli.md`](../cli.md#frames-and-animations) | diff --git a/docs/agents/tui_api.md b/docs/agents/tui_api.md index 414b216e..7a5edb1d 100644 --- a/docs/agents/tui_api.md +++ b/docs/agents/tui_api.md @@ -37,7 +37,9 @@ payload = observation.as_dict() Snapshot-registry operations (`save_view`, `list_views`) do not alter active view state and therefore do not increment it. - `render_frame(charset="unicode"|"ascii7")` returns the current picture, including - rotation ghosts, through the same renderer used by Textual and sessions. + rotation ghosts, through the same renderer dispatch used by static CLI output + and sessions. The default backend is `mattervis.native-v1`. See + [`tui_renderer_api.md`](tui_renderer_api.md). - `observe()` returns `TerminalObservation` with schema `mattervis.tui.observation/v2`. `as_dict()` is JSON-safe. @@ -86,9 +88,17 @@ that only touches the configured limit may continue without clipping. Each `SessionObservation` contains exactly `width × height` padded `screen_lines`, a SHA-256 of those rendered bytes, and a deterministic state fingerprint over camera, display, focus, selection, viewport, and charset. The -fingerprint excludes coordinates, structure metadata, monotonic revision, and -source path; use the screen hash or an external content identifier when the -caller must bind an observation to a particular structure. +fingerprint excludes coordinates, structure metadata, monotonic revision, +renderer identity, and source path; use the screen hash or an external content +identifier when the caller must bind an observation to a particular structure. +`TerminalSession(..., renderer="example.marks-v1", renderer_config=path, +observation_profile="legacy"|"model")` freezes the renderer for the episode. +It is not a `set_display` argument. The default profile and the default +renderer leave JSONL observations and `state_fingerprint` unchanged. +`replay_manifest()` is the host-owned renderer identity and is not part of +that fingerprint. `observation_profile="model"` drops chemistry warnings, +formulas, and the source title from the emitted observation. Details and the +external-plugin contract are in [`tui_renderer_api.md`](tui_renderer_api.md). For subprocess callers, `mat-vis tui INPUT --session-format jsonl` reads one `mattervis.tui.action/v1` object per stdin line and writes one diff --git a/docs/agents/tui_renderer_api.md b/docs/agents/tui_renderer_api.md new file mode 100644 index 00000000..6f6783e1 --- /dev/null +++ b/docs/agents/tui_renderer_api.md @@ -0,0 +1,175 @@ +# Terminal renderer backends + +MatterVis owns the structure, the camera, projection, visibility, selection, +session lifecycle, and JSONL action transport. A renderer owns only the +picture: glyphs, depth notation, labels, occlusion, and layout. + +This is a Display-side extension. It does not add a second camera, a second +action reducer, or an Operate/Analysis tool. Selecting a renderer is episode +configuration. It is not an Agent `set_display` action, and it does not change +orbit, reset, viewport, or saved-view meanings. + +The built-in id is `mattervis.native-v1`. With no renderer selected, static +`mat-vis tui` output and default JSONL sessions stay on that adapter. +`state_fingerprint` does not include renderer identity. Default observation +objects do not grow a `renderer` field. + +## Request and result + +```python +from mat_viewer.tui import ( + NATIVE_BACKEND_ID, + RENDER_API_VERSION, + TerminalSession, + render_picture, + resolve_renderer_binding, +) + +binding = resolve_renderer_binding("example.marks-v1") +session = TerminalSession.from_file( + "structure.cif", + width=100, + height=36, + charset="ascii7", + renderer="example.marks-v1", + renderer_config="renderer.json", +) +manifest = session.replay_manifest() +``` + +`render_picture(...)` is the single dispatch used by +`compose_static_frame` and `DepthControls.render_frame`. `compose_frame` +remains the native implementation. The native adapter calls it with the host +crystal and camera, so the default picture is the compositor's picture. + +Public schemas: + +| Name | Value | +| --- | --- | +| API | `mattervis.tui.render/v1` | +| Request | `mattervis.tui.render_request/v1` | +| Display IR | `mattervis.tui.display_ir/v1` | +| Frame | `mattervis.tui.rendered_frame/v1` | +| Config | `mattervis.tui.renderer_config/v1` | +| Replay manifest | `mattervis.tui.renderer_replay/v1` | +| Entry point group | `mattervis.tui.renderers` | + +`RenderRequest` carries display sites, manifested bonds, cell vectors, the +host camera basis, and the host `project_points` arrays. Cartesian positions +and the cell matrix are Angstrom. Basis rows are camera right, up, and +forward. Projected `xy` is the camera-plane coordinate returned by +`project_points` (orthographic values are divided by camera distance). Depth +is the camera-forward component; larger is closer. A null viewport means the +native compositor still computes its own fit. Active sessions pass the +controller's fitted viewport. + +Bond provenance is graph-level (`provided`, `inferred`, or `unspecified`). +`explicit_bond_table: true` is provided input. Loader sources +`distance_heuristic`, `molcryskit`, and `canonical_scene` are inferred. +Any other metadata string is `unspecified` and is not copied into the +request. `bond_policy` is `manifested` (default, current drawing), +`provided_only` (inferred edges disabled, source graph not repaired), or +`bonds_off`. + +The request has no field for `CrystalIR`, MolCrysKit chemistry, CIP or IUPAC +names, gold answers, scorers, or benchmark labels. Building it does not call +MolCrysKit. + +`RenderedFrame.text` for the native backend is the compositor string. +External frames must be at most `width` by `height`, without a trailing +newline. `ascii7` is printable 7-bit ASCII (0x20–0x7E) with no ANSI escapes. +The static CLI status line can still contain a degree sign; JSONL screens do +not include that line. +The Unicode profile allows the terminal-safe characters the native viewer +already uses, including Braille and superscripts, and still rejects controls +and ANSI. The host recomputes `content_sha256` over those exact bytes +(`ascii` for ascii7, `utf-8` for Unicode). Session `frame.sha256` remains the +hash of the padded screen and is unchanged for default sessions. + +## Registration + +A backend is a zero-argument entry point in group `mattervis.tui.renderers`. +The entry point name is the backend id. The factory returns an object with: + +- `backend_id`, `api_version` (`mattervis.tui.render/v1`) +- `deterministic` (`True`; other values are rejected) +- `package_version`, `implementation_digest` (lowercase sha256 hex) +- `validate_options(options) -> mapping` +- `render(request) -> str | BackendFrame` + +`RendererBackend` is the `Protocol` for that object. `render` does not return +`RenderedFrame`; the host builds that record so a plugin cannot forge the +content hash or the backend digest. + +`mattervis.native-v1` is reserved. An entry point cannot replace it. +Options are schema-checked by the backend. Unknown config fields, a missing +id, an API mismatch, a package or digest pin mismatch, a malformed frame, and +a plugin exception fail closed. Dispatch does not draw the native picture +instead. + +CLI, for both `--no-interaction` and `--session-format jsonl`: + +```bash +mat-vis tui structure.cif --no-interaction --charset ascii7 \ + --width 80 --height 24 \ + --renderer example.marks-v1 \ + --renderer-config examples/mattervis_mark_renderer/pins/example.marks-v1.json +``` + +`--observation-profile model` is JSONL-only. `legacy` (the default) is the +historical observation. `model` keeps the frame, camera, display toggles, +selection identities, and `state_fingerprint`, and drops the source title, +chemistry warnings, and formula/composition scope fields. The title becomes +`display`. + +An installed example lives in `examples/mattervis_mark_renderer`. It is its +own wheel. See that directory's README for install and pin commands. + +`renderer_search_path` on `TerminalSession` / `resolve_renderer_binding` +discovers entry points in an extra directory of installed distributions. It +is an operator argument, not a JSONL action, and it is not read from CIF +metadata. The package still has to be importable. Do not pass +`module:function` strings from a model. + +## Replay identity + +`session.replay_manifest()` records backend id, API, package version, +implementation digest, config hash, options, bond policy, charset, and +viewport size. It is not part of `state_fingerprint` and is omitted from +default observations. Non-default episodes also attach that manifest, plus +the frame's `input_digest`, `view_digest`, and unpadded `content_sha256`, +to the observation under `renderer`. + +Same display IR, view, config, and implementation digest produce the same +frame bytes for a deterministic backend. Nondeterministic backends are +rejected. Renderer choice is an experiment factor. It does not change task +identity, action names, the scoring oracle, or the allowed tool set. MatterVis +does not own those. + +## Trust boundary + +Loading a Python entry point executes code with host privileges. A `Protocol` +is not a sandbox. A trusted local plugin may be an installed wheel. An +untrusted contestant renderer must run in a separate process or container +with isolated mounts and network, receiving only the public request JSON. +This version does not ship a second subprocess renderer. + +The native adapter is allowed to read the host crystal so it can keep calling +`compose_frame`. External `render` methods are not given that object. The +native molecule overview can still draw manifested species labels, because +that compositor reads `CrystalIR`. Experiments that must not expose those +labels should use atom level or an external renderer whose request has been +audited. + +Diagnostics on `BackendFrame` are a host audit artifact. They are not written +into the agent observation. + +## What this does not validate + +Satisfying the interface does not show that a renderer cannot infer chemistry +from coordinates. It does not validate a benchmark, a scorer, a task split, or +a human reading of either picture. It does not implement research harness +issue 17, and it does not close MatterVis issue 163. Regression coverage here +is byte identity of the default compositor, fail-closed selection, charset +bounds, camera and action stability across backends, and canaries for hidden +chemistry fields. diff --git a/docs/cli.md b/docs/cli.md index 163b3d53..b36bc8b8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -111,7 +111,23 @@ bytes. `--charset unicode|ascii7` is available for both static and JSONL output. `ascii7` selects printable ASCII geometry and implies monochrome output; -`unicode` preserves the default Braille renderer. JSONL sessions support +`unicode` preserves the default Braille renderer. `ascii7` frames are +printable 7-bit ASCII without ANSI escapes. The static status line above a +non-interactive frame can still contain a degree sign; it is not part of the +frame or of a JSONL observation. The Unicode profile may contain +Braille and superscripts; JSONL sessions stay monochrome, while interactive +and static colour Unicode can still contain ANSI from the built-in compositor. + +`--renderer ID` and `--renderer-config FILE` select one frame backend for both +static output and `--session-format jsonl`. The default is `mattervis.native-v1`, +and omitting the flags leaves the picture and default JSONL traces unchanged. +A missing id, incompatible API, bad pin, or malformed frame exits with an +error and does not draw the native picture instead. Renderer choice is not a +JSONL action. `--observation-profile model` applies only to JSONL observations +and strips chemistry warnings, formulas, and the source title. See +[the renderer contract](agents/tui_renderer_api.md). + +JSONL sessions support `observe`, `reset`, `orbit`, `align`, `pan`, `zoom`, `fit`, `set_display`, `select`, `focus`, `clear_selection`, `clear_focus`, and `close`. diff --git a/docs/dev-notes.md b/docs/dev-notes.md index 9290a2d9..a2e508a7 100644 --- a/docs/dev-notes.md +++ b/docs/dev-notes.md @@ -4,6 +4,25 @@ This file holds implementation-level details that were previously in AGENTS.md. They are important for developers working on specific modules but are too granular for the top-level developer contract. +## TUI renderer dispatch + +`render_picture` is the only production path from `compose_static_frame` and +`DepthControls.render_frame` to a backend. `compose_frame` stays the native +implementation. The native adapter reads the host crystal through a context +variable set only around `NativeRenderer.render`. External backends receive +`RenderRequest` and must not be handed `CrystalIR`. Do not catch a backend +failure and call the native compositor as a fallback. + +Default sessions omit renderer provenance so `state_fingerprint` and the +JSONL object stay on the historical contract. `replay_manifest()` is the +explicit replay record. `observation_profile="model"` is opt-in and does not +change the fingerprint inputs. + +The native molecule overview still reads manifested species labels from +`CrystalIR`. That is trusted built-in presentation, not a field on the public +request. An external renderer cannot see those labels unless they are already +encoded in approved display text such as site labels. + ## Interactive figure scheduling and delivery `capture_state` publishes state only. `update_view` schedules the ordinary diff --git a/examples/mattervis_mark_renderer/README.md b/examples/mattervis_mark_renderer/README.md new file mode 100644 index 00000000..f8eabe7f --- /dev/null +++ b/examples/mattervis_mark_renderer/README.md @@ -0,0 +1,82 @@ +# mattervis-mark-renderer + +Example terminal renderer for MatterVis. It is a separate distribution: install +it beside MatterVis and select it by id. The package is not part of +`mat_viewer` and does not replace `mattervis.native-v1`. + +The picture is ASCII only. Each visible site is one character: `1` (front), +`2` (middle), or `3` (back) when depth marks are enabled, otherwise the +configured marker (`#` by default). Enabled bonds leave a `.` in an empty +midpoint cell. Element symbols, Braille, and the native axes footer are not +drawn. Host-projected coordinates are reused; this package does not own the +camera. + +## Install + +From a checkout that already has MatterVis installed: + +```bash +python -m pip install --no-deps examples/mattervis_mark_renderer +``` + +`--no-deps` avoids resolving MatterVis again. The package version is `0.1.0`. + +## Select it + +Static frame: + +```bash +mat-vis tui structure.cif --no-interaction --charset ascii7 \ + --width 80 --height 24 \ + --renderer example.marks-v1 \ + --renderer-config examples/mattervis_mark_renderer/pins/example.marks-v1.json +``` + +Stateful JSONL session (same flags): + +```bash +printf '%s\n' '{"action":"observe"}' '{"action":"orbit","arguments":{"yaw_deg":20}}' '{"action":"close"}' | \ + mat-vis tui structure.cif --session-format jsonl --charset ascii7 \ + --width 80 --height 24 \ + --renderer example.marks-v1 \ + --renderer-config examples/mattervis_mark_renderer/pins/example.marks-v1.json +``` + +Python, without a shell patch: + +```python +from mat_viewer.tui import TerminalSession + +session = TerminalSession.from_file( + "structure.cif", + width=80, + height=24, + charset="ascii7", + renderer="example.marks-v1", + renderer_config="examples/mattervis_mark_renderer/pins/example.marks-v1.json", +) +print(session.observe().as_dict()["frame"]["text"]) +print(session.replay_manifest()) +``` + +An isolated directory of wheels can be passed as `renderer_search_path=[path]`. +The package must also be importable. Renderer choice is fixed for the episode. +It is not a `set_display` action. + +## Pin the implementation + +`require_package_version` is in the sample config. To pin the source digest +too, read it from the installed backend and set +`require_implementation_digest` to that sha256 hex string: + +```bash +python -c "from mattervis_mark_renderer import create_renderer; print(create_renderer().implementation_digest)" +``` + +A missing id, a wrong API version, an unknown option, a pin mismatch, or a +malformed frame fails the session. MatterVis does not draw the native picture +instead. + +Python entry points run with host privileges. This package is a trusted local +plugin, not a sandbox. An untrusted renderer must run in a separate process +that only receives the public render request. diff --git a/examples/mattervis_mark_renderer/pins/example.marks-v1.json b/examples/mattervis_mark_renderer/pins/example.marks-v1.json new file mode 100644 index 00000000..1f35d17e --- /dev/null +++ b/examples/mattervis_mark_renderer/pins/example.marks-v1.json @@ -0,0 +1,10 @@ +{ + "schema": "mattervis.tui.renderer_config/v1", + "backend_id": "example.marks-v1", + "api_version": "mattervis.tui.render/v1", + "bond_policy": "manifested", + "require_package_version": "0.1.0", + "options": { + "example.marks-v1.marker": "#" + } +} diff --git a/examples/mattervis_mark_renderer/pyproject.toml b/examples/mattervis_mark_renderer/pyproject.toml new file mode 100644 index 00000000..ac10a9f5 --- /dev/null +++ b/examples/mattervis_mark_renderer/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "mattervis-mark-renderer" +version = "0.1.0" +description = "Example MatterVis TUI renderer with a distinct ASCII depth policy" +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["matter-vis"] + +[project.entry-points."mattervis.tui.renderers"] +"example.marks-v1" = "mattervis_mark_renderer:create_renderer" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/examples/mattervis_mark_renderer/src/mattervis_mark_renderer/__init__.py b/examples/mattervis_mark_renderer/src/mattervis_mark_renderer/__init__.py new file mode 100644 index 00000000..dd952f8f --- /dev/null +++ b/examples/mattervis_mark_renderer/src/mattervis_mark_renderer/__init__.py @@ -0,0 +1,151 @@ +"""Independently installed MatterVis frame renderer. + +The policy is deliberately unlike ``mattervis.native-v1``: visible sites +become a single ASCII depth mark, bonds become a midpoint dot, and element +symbols are never drawn. Install this package and select +``example.marks-v1``. Do not copy it into ``mat_viewer``. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from hashlib import sha256 +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +from mat_viewer.tui.render_contract import RENDER_API_VERSION, REQUEST_SCHEMA + +BACKEND_ID = "example.marks-v1" +MARKER_OPTION = "example.marks-v1.marker" +_DEPTH_FRONT = 0.67 +_DEPTH_MID = 0.33 +_DECLARED_VERSION = "0.1.0" + + +class MarkRenderer: + """Depth-digit / marker picture over host-projected coordinates.""" + + backend_id = BACKEND_ID + api_version = RENDER_API_VERSION + deterministic = True + + @property + def package_version(self) -> str: + try: + return version("mattervis-mark-renderer") + except PackageNotFoundError: + return _DECLARED_VERSION + + @property + def implementation_digest(self) -> str: + return sha256(Path(__file__).read_bytes()).hexdigest() + + def validate_options(self, options: Mapping[str, object]) -> dict[str, str]: + unknown = sorted(set(options) - {MARKER_OPTION}) + if unknown: + names = ", ".join(unknown) + raise ValueError(f"unsupported renderer options: {names}") + marker = options.get(MARKER_OPTION, "#") + if ( + not isinstance(marker, str) + or len(marker) != 1 + or not 0x21 <= ord(marker) <= 0x7E + ): + raise ValueError( + f"{MARKER_OPTION} must be one printable ASCII character" + ) + return {MARKER_OPTION: marker} + + def render(self, request) -> str: + if request.request_schema_version != REQUEST_SCHEMA: + raise ValueError( + f"unsupported render request schema {request.request_schema_version!r}" + ) + options = request.render_options + marker = str(options.backend_options.get(MARKER_OPTION, "#")) + width = int(options.width) + height = int(options.height) + grid = [[" "] * width for _ in range(height)] + placed, bounds = _place_sites(request, marker) + for row, col, glyph, depth in sorted(placed, key=lambda item: item[3]): + if 0 <= row < height and 0 <= col < width: + grid[row][col] = glyph + if options.show_bonds and bounds is not None: + _place_bond_dots(request, grid, bounds) + return "\n".join("".join(row) for row in grid) + + +def create_renderer() -> MarkRenderer: + """Zero-argument entry-point factory.""" + return MarkRenderer() + + +def _place_sites(request, marker: str): + options = request.render_options + visible = [site for site in request.display_ir.sites if site.visible] + if not visible: + return [], None + points = [request.view.projected_xy[site.index] for site in visible] + depths = [request.view.projected_depth[site.index] for site in visible] + low_x = min(point[0] for point in points) + high_x = max(point[0] for point in points) + low_y = min(point[1] for point in points) + high_y = max(point[1] for point in points) + low_depth = min(depths) + high_depth = max(depths) + placed = [] + for point, depth in zip(points, depths): + placed.append( + ( + _cell(point[1], low_y, high_y, options.height, invert=True), + _cell(point[0], low_x, high_x, options.width, invert=False), + _glyph(depth, low_depth, high_depth, marker, show_depth=options.show_depth), + depth, + ) + ) + return placed, (low_x, high_x, low_y, high_y) + + +def _glyph(depth: float, low: float, high: float, marker: str, *, show_depth: bool) -> str: + if not show_depth: + return marker + span = high - low + if span <= 1e-12: + return "2" + norm = (depth - low) / span + if norm >= _DEPTH_FRONT: + return "1" + if norm >= _DEPTH_MID: + return "2" + return "3" + + +def _cell(value: float, low: float, high: float, size: int, *, invert: bool) -> int: + if size <= 1: + return 0 + span = high - low + if span <= 1e-12: + return 0 + fraction = (low + span - value) / span if invert else (value - low) / span + index = round(fraction * (size - 1)) + return min(max(index, 0), size - 1) + + +def _place_bond_dots(request, grid: list[list[str]], bounds: tuple[float, float, float, float]) -> None: + sites = request.display_ir.sites + visible = {site.index for site in sites if site.visible} + height = len(grid) + width = len(grid[0]) if grid else 0 + low_x, high_x, low_y, high_y = bounds + for bond in request.display_ir.bonds: + if not bond.enabled or bond.i not in visible or bond.j not in visible: + continue + start = request.view.projected_xy[bond.i] + end = request.view.projected_xy[bond.j] + row = _cell((start[1] + end[1]) / 2.0, low_y, high_y, height, invert=True) + col = _cell((start[0] + end[0]) / 2.0, low_x, high_x, width, invert=False) + if grid[row][col] == " ": + grid[row][col] = "." + + +__all__ = ["BACKEND_ID", "MARKER_OPTION", "MarkRenderer", "create_renderer"] diff --git a/mat_viewer/cli.py b/mat_viewer/cli.py index 499827fa..d4f087c2 100644 --- a/mat_viewer/cli.py +++ b/mat_viewer/cli.py @@ -394,6 +394,14 @@ def _tui_main(args: argparse.Namespace) -> None: print("Error: --height must be greater than zero.", file=sys.stderr) sys.exit(2) + from .tui.render_backend import RendererContractError, renderer_binding_from_cli + + try: + binding = renderer_binding_from_cli(args) + except RendererContractError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(2) + display_mode = args.display crystal = load_for_tui( filepath, @@ -441,7 +449,7 @@ def _tui_main(args: argparse.Namespace) -> None: from .tui.cli import interactive_depth_options, run_session - if run_session(args, crystal, cam, label_mode=label_mode): + if run_session(args, crystal, cam, label_mode=label_mode, binding=binding): return if not args.interaction: @@ -450,7 +458,13 @@ def _tui_main(args: argparse.Namespace) -> None: from .tui.cli import render_static_output output = render_static_output( - args, crystal, cam, pts_2d, depth, label_mode=label_mode + args, + crystal, + cam, + pts_2d, + depth, + label_mode=label_mode, + binding=binding, ) print(output) else: @@ -476,6 +490,9 @@ def _tui_main(args: argparse.Namespace) -> None: show_hydrogen=bool(getattr(args, "hydrogen", False)), show_boundary=bool(getattr(args, "boundary", False)), initial_level=initial_level, + renderer=binding.backend, + renderer_options=binding.options, + bond_policy=binding.bond_policy, ) app.run() diff --git a/mat_viewer/tui/__init__.py b/mat_viewer/tui/__init__.py index dd43a0ba..31fed1ad 100644 --- a/mat_viewer/tui/__init__.py +++ b/mat_viewer/tui/__init__.py @@ -12,6 +12,23 @@ from dataclasses import replace from .controller import TerminalViewController +from .render_backend import ( + NATIVE_BACKEND_ID, + RENDER_API_VERSION, + RENDERER_ENTRY_GROUP, + RendererBinding, + RendererContractError, + list_renderers, + render_picture, + resolve_renderer_binding, +) +from .render_contract import ( + REQUEST_SCHEMA, + BackendFrame, + RenderedFrame, + RendererBackend, + RenderRequest, +) from .session import ( ACTION_SCHEMA, SESSION_SCHEMA, @@ -32,20 +49,33 @@ ) __all__ = [ - "OBSERVATION_SCHEMA", "ACTION_SCHEMA", + "NATIVE_BACKEND_ID", + "OBSERVATION_SCHEMA", + "RENDERER_ENTRY_GROUP", + "RENDER_API_VERSION", + "REQUEST_SCHEMA", "SESSION_SCHEMA", + "BackendFrame", + "RenderRequest", + "RenderedFrame", + "RendererBackend", + "RendererBinding", + "RendererContractError", "SessionObservation", "TerminalAction", - "TerminalSession", "TerminalCameraState", "TerminalDisplayState", "TerminalFocusState", "TerminalObservation", + "TerminalSession", "TerminalViewController", - "TerminalViewportState", "TerminalViewSnapshot", "TerminalViewState", + "TerminalViewportState", + "list_renderers", + "render_picture", + "resolve_renderer_binding", "run_jsonl_session", "run_tui", ] @@ -101,7 +131,6 @@ def run_tui( if not interactive: from .serializer import serialize_crystal - from .compositor import compose_frame pts_2d, depth = project_points(cam, crystal.cart_coords) if format == "structured": @@ -112,12 +141,12 @@ def run_tui( show_minor=show_minor, )) else: - frame = compose_frame( + frame = render_picture( crystal, cam, pts_2d, depth, width=width, height=height, mono=mono, show_minor=show_minor, display_level=level, - ) + ).text from .observation import build_static_header print(build_static_header( diff --git a/mat_viewer/tui/app.py b/mat_viewer/tui/app.py index b2acf501..6bd5f5a5 100644 --- a/mat_viewer/tui/app.py +++ b/mat_viewer/tui/app.py @@ -192,6 +192,9 @@ def __init__( compact: bool = False, initial_level: str = AUTO_DISPLAY_LEVEL, extensions=(), + renderer=None, + renderer_options=None, + bond_policy: str = "manifested", ): extension_host = _ExtensionHost(extensions) super().__init__() @@ -221,6 +224,9 @@ def __init__( if initial_level in (*DISPLAY_LEVELS, AUTO_DISPLAY_LEVEL) else "atom" ), + renderer=renderer, + renderer_options=renderer_options, + bond_policy=bond_policy, ) except BaseException: extension_host.close() diff --git a/mat_viewer/tui/cli.py b/mat_viewer/tui/cli.py index 0cad0a8e..8c8f24fd 100644 --- a/mat_viewer/tui/cli.py +++ b/mat_viewer/tui/cli.py @@ -48,6 +48,32 @@ def add_session_arguments(parser: argparse.ArgumentParser) -> None: "--axes", action=argparse.BooleanOptionalAction, default=True, help="Fit the scene above a bottom row with an a/b/c triad and a scale bar.", ) + parser.add_argument( + "--renderer", + default=None, + help=( + "Terminal frame renderer id. Default mattervis.native-v1 keeps " + "today's picture. A missing or incompatible id fails closed." + ), + ) + parser.add_argument( + "--renderer-config", + default=None, + metavar="FILE", + help=( + "JSON renderer config (mattervis.tui.renderer_config/v1): options, " + "bond policy, and optional package/digest pins." + ), + ) + parser.add_argument( + "--observation-profile", + choices=("legacy", "model"), + default="legacy", + help=( + "JSONL observation profile. legacy is unchanged. model drops " + "chemistry warnings, formulas, and the source title." + ), + ) def interactive_depth_options(args: argparse.Namespace) -> dict: @@ -61,12 +87,21 @@ def interactive_depth_options(args: argparse.Namespace) -> dict: } -def run_session(args: argparse.Namespace, crystal, camera, *, label_mode: str) -> bool: +def run_session( + args: argparse.Namespace, + crystal, + camera, + *, + label_mode: str, + binding=None, +) -> bool: """Run the requested machine session and report whether it handled the CLI.""" if args.session_format != "jsonl": return False + from .render_backend import renderer_binding_from_cli from .session import TerminalSession, run_jsonl_session + resolved = binding if binding is not None else renderer_binding_from_cli(args) session = TerminalSession( crystal, camera=camera, @@ -87,6 +122,7 @@ def run_session(args: argparse.Namespace, crystal, camera, *, label_mode: str) - slice_axis=getattr(args, "slice_axis", "off"), slice_index=getattr(args, "slice_index", 0), slice_width=getattr(args, "slice_width", 1.0), + binding=resolved, ) run_jsonl_session(session) return True @@ -101,11 +137,13 @@ def compose_static_frame( *, label_mode: str, display_level: str = "auto", + binding=None, ) -> str: """Render static output with the same charset choices as agent sessions.""" - from .compositor import compose_frame + from .render_backend import render_picture, renderer_binding_from_cli - return compose_frame( + resolved = binding if binding is not None else renderer_binding_from_cli(args) + return render_picture( crystal, camera, points, @@ -127,10 +165,15 @@ def compose_static_frame( slice_axis=getattr(args, "slice_axis", "off"), slice_index=getattr(args, "slice_index", 0), slice_width=getattr(args, "slice_width", 1.0), - ) + backend=resolved.backend, + backend_options=resolved.options, + bond_policy=resolved.bond_policy, + ).text -def render_static_output(args, crystal, camera, points, depth, *, label_mode: str) -> str: +def render_static_output( + args, crystal, camera, points, depth, *, label_mode: str, binding=None +) -> str: """Render one bounded static CLI response, including its short header.""" if args.format == "structured": from .serializer import serialize_crystal @@ -152,6 +195,7 @@ def render_static_output(args, crystal, camera, points, depth, *, label_mode: st depth, label_mode=label_mode, display_level=display_level, + binding=binding, ) from .observation import build_static_header diff --git a/mat_viewer/tui/controller.py b/mat_viewer/tui/controller.py index b2ea1e63..d1abad40 100644 --- a/mat_viewer/tui/controller.py +++ b/mat_viewer/tui/controller.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import replace -from typing import Any +from typing import Any, Mapping import numpy as np @@ -38,6 +38,7 @@ resolve_molecule_reference, ) from .observation import build_observation_scope, build_terminal_title +from .render_contract import validate_bond_policy from .projection import ( ProjectedAtomHit, Viewport, @@ -118,8 +119,15 @@ def __init__( slice_axis: str = "off", slice_index: int = 0, slice_width: float = 1.0, + renderer: Any = None, + renderer_options: Mapping[str, Any] | None = None, + bond_policy: str = "manifested", ) -> None: validate_slice(crystal, slice_axis, slice_index, slice_width) + self._renderer = renderer + self._renderer_options = renderer_options + self._bond_policy = validate_bond_policy(bond_policy) + self._last_rendered = None self._slice_axis, self._slice_index, self._slice_width = slice_axis, slice_index, slice_width self._show_depth, self._parallax = bool(show_depth), bool(parallax) self._ghost_camera: Camera | None = None @@ -183,6 +191,11 @@ def width(self) -> int: def height(self) -> int: return self._height + @property + def last_rendered(self): + """Host frame record from the most recent ``render_frame`` call.""" + return getattr(self, "_last_rendered", None) + @property def state(self) -> TerminalViewState: """Return a detached immutable snapshot of the semantic view state.""" diff --git a/mat_viewer/tui/controller_depth.py b/mat_viewer/tui/controller_depth.py index 7d2fab86..83c0b2f7 100644 --- a/mat_viewer/tui/controller_depth.py +++ b/mat_viewer/tui/controller_depth.py @@ -11,9 +11,9 @@ import numpy as np from ..math.camera import project_points -from .compositor import compose_frame from .controller_validate import validate_finite from .depth import MAX_PARALLAX_FRAMES, layer_indices, validate_slice +from .render_backend import render_picture from .state import TerminalObservation @@ -21,9 +21,20 @@ class DepthControls: """Depth rendering, layer stepping, and bounded rotation sequences.""" def render_frame(self, *, charset: str = "unicode") -> str: - """Render the current picture, including transient rotation ghosts.""" + """Render the current picture, including transient rotation ghosts. + + Static CLI frames and this active path share :func:`render_picture`. + The default backend delegates to the existing compositor. + """ pts_2d, depth = project_points(self.camera, self.crystal.cart_coords) - return compose_frame( + ghost_points = ( + project_points(self._ghost_camera, self.crystal.cart_coords)[0] + if self._parallax and self._ghost_camera is not None + else None + ) + focus = getattr(self, "_focus", None) + selection = getattr(self, "_selection", None) + rendered = render_picture( self.crystal, self.camera, pts_2d, @@ -42,19 +53,28 @@ def render_frame(self, *, charset: str = "unicode") -> str: pan_y=self.camera.pan_y, display_level=self._display_level, viewport=self._fit_viewport, - selected_display_index=self._selection.display_index, + selected_display_index=( + None if selection is None else selection.display_index + ), + selected_display_copy_id=( + None if selection is None else selection.display_copy_id + ), + focus_copy_ids=( + () if focus is None else tuple(focus.framed_copy_ids) + ), show_axes=self._show_axes, charset=charset, show_depth=self._show_depth, slice_axis=self._slice_axis, slice_index=self._slice_index, slice_width=self._slice_width, - ghost_points=( - project_points(self._ghost_camera, self.crystal.cart_coords)[0] - if self._parallax and self._ghost_camera is not None - else None - ), + ghost_points=ghost_points, + backend=getattr(self, "_renderer", None), + backend_options=getattr(self, "_renderer_options", None), + bond_policy=getattr(self, "_bond_policy", "manifested"), ) + self._last_rendered = rendered + return rendered.text def rotation_sequence( self, *, frames: int = 3, yaw_deg: float = 10.0 diff --git a/mat_viewer/tui/observation_profile.py b/mat_viewer/tui/observation_profile.py new file mode 100644 index 00000000..4306a292 --- /dev/null +++ b/mat_viewer/tui/observation_profile.py @@ -0,0 +1,43 @@ +"""Opt-in model-facing terminal observations. + +The legacy session payload is unchanged. ``observation_profile="model"`` +keeps the picture, camera, display toggles, selection identities, and +state fingerprint, and drops fields that can carry chemistry warnings, +formulas, or source titles. + +The profile does not change action semantics or the renderer. A native +molecule picture can still draw manifested species labels because the +built-in compositor reads ``CrystalIR``. Untrusted renderers do not +receive that object; they receive :class:`RenderRequest`. +""" + +from __future__ import annotations + +import copy +from collections.abc import Mapping +from typing import Any + +MODEL_SCOPE_KEYS = ( + "display_level", + "source_site_atom_count", + "expanded_atom_count", + "display_atom_count", + "visible_atom_count", + "visible_marker_count", +) +MODEL_TITLE = "display" + + +def sanitize_model_observation(payload: Mapping[str, Any]) -> dict[str, Any]: + """Return a detached observation without chemistry-bearing metadata.""" + cleaned = copy.deepcopy(dict(payload)) + cleaned["title"] = MODEL_TITLE + cleaned["warnings"] = [] + cleaned["observation_profile"] = "model" + scope = cleaned.get("scope") + if isinstance(scope, Mapping): + cleaned["scope"] = {key: scope[key] for key in MODEL_SCOPE_KEYS if key in scope} + return cleaned + + +__all__ = ["MODEL_SCOPE_KEYS", "MODEL_TITLE", "sanitize_model_observation"] diff --git a/mat_viewer/tui/render_backend.py b/mat_viewer/tui/render_backend.py new file mode 100644 index 00000000..813fddce --- /dev/null +++ b/mat_viewer/tui/render_backend.py @@ -0,0 +1,714 @@ +"""Renderer dispatch, native adapter, and entry-point registry. + +Both the static CLI and the live terminal session call +:func:`render_picture`. The default backend is ``mattervis.native-v1``. +It delegates to :func:`mat_viewer.tui.compositor.compose_frame` with the +host's crystal and camera, so an unset renderer leaves picture bytes +unchanged. + +External backends are loaded from the ``mattervis.tui.renderers`` entry +point group. A requested backend that is missing, incompatible, or +crashes raises :class:`RendererContractError`. Dispatch never substitutes +the native backend. + +The native adapter is the only renderer that may see host crystal +objects, and only through a context set by this module. The object passed +to ``render`` is still the public :class:`RenderRequest`. +""" + +from __future__ import annotations + +import copy +import json +import os +from collections.abc import Mapping, Sequence +from contextvars import ContextVar +from dataclasses import dataclass +from functools import lru_cache +from hashlib import sha256 +from importlib.metadata import Distribution, PackageNotFoundError, entry_points +from pathlib import Path +from types import MappingProxyType +from typing import Any + +import numpy as np + +from .compositor import compose_frame +from .render_contract import ( + CONFIG_SCHEMA, + NATIVE_BACKEND_ID, + RENDER_API_VERSION, + RENDERER_ENTRY_GROUP, + BackendFrame, + RenderedFrame, + RendererContractError, + RenderRequest, + build_render_request, + frame_content_sha256, + renderer_config_hash, + validate_backend_text, + validate_bond_policy, + validate_diagnostics, + validate_observation_profile, +) + +_HOST_SOURCES: ContextVar[_HostFrameSources | None] = ContextVar( + "mattervis_native_host_sources", + default=None, +) +_CONFIG_KEYS = frozenset( + { + "schema", + "backend_id", + "api_version", + "options", + "bond_policy", + "require_package_version", + "require_implementation_digest", + } +) + + +@dataclass(frozen=True) +class _HostFrameSources: + """Private compositor inputs. Not part of the public request.""" + + crystal: Any + camera: Any + pts_2d: Any + depth: Any + compose_kwargs: Mapping[str, Any] + + +def native_host_sources_bound() -> bool: + """Whether dispatch is inside the built-in native adapter. + + In-process plugins can import private modules. This flag is an audit + aid, not a sandbox. Untrusted renderers need a separate process. + """ + return _HOST_SOURCES.get() is not None + + +@lru_cache(maxsize=1) +def _package_version() -> str: + try: + from importlib.metadata import version + + return version("matter-vis") + except PackageNotFoundError: + return "unknown" + + +@lru_cache(maxsize=1) +def native_implementation_digest() -> str: + """Digest the terminal package that produces the native picture.""" + root = Path(__file__).resolve().parent + digest = sha256() + for path in sorted(root.glob("*.py")): + digest.update(path.name.encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +class NativeRenderer: + """Built-in adapter. Picture bytes come from ``compose_frame``.""" + + backend_id = NATIVE_BACKEND_ID + api_version = RENDER_API_VERSION + deterministic = True + + @property + def package_version(self) -> str: + return _package_version() + + @property + def implementation_digest(self) -> str: + return native_implementation_digest() + + def validate_options(self, options: Mapping[str, Any]) -> Mapping[str, Any]: + if options: + keys = ", ".join(sorted(str(key) for key in options)) + raise RendererContractError( + f"{self.backend_id} does not accept backend options ({keys})" + ) + return MappingProxyType({}) + + def render(self, request: RenderRequest) -> str: + del request + sources = _HOST_SOURCES.get() + if sources is None: + raise RendererContractError( + "mattervis.native-v1 renders only through MatterVis frame dispatch" + ) + return compose_frame( + sources.crystal, + sources.camera, + sources.pts_2d, + sources.depth, + **sources.compose_kwargs, + ) + + +def create_native_renderer() -> NativeRenderer: + """Entry-point factory for the reserved built-in id.""" + return NativeRenderer() + + +@dataclass(frozen=True) +class RendererBinding: + """Renderer chosen once for an episode.""" + + backend: Any + backend_id: str + api_version: str + package_version: str + implementation_digest: str + options: Mapping[str, Any] + bond_policy: str + observation_profile: str + deterministic: bool + emit_provenance: bool + selection: str + config_hash: str + + def replay_dict(self) -> dict[str, Any]: + from .render_contract import REPLAY_SCHEMA + + return { + "schema": REPLAY_SCHEMA, + "backend_id": self.backend_id, + "api_version": self.api_version, + "package_version": self.package_version, + "implementation_digest": self.implementation_digest, + "config_hash": self.config_hash, + "options": dict(self.options), + "bond_policy": self.bond_policy, + "deterministic": self.deterministic, + "observation_profile": self.observation_profile, + "selection": self.selection, + } + + +def _entry_points(search_path: Sequence[str] | None) -> list[Any]: + if search_path is None: + return list(entry_points(group=RENDERER_ENTRY_GROUP)) + found = [] + for distribution in Distribution.discover(path=list(search_path)): + for item in distribution.entry_points: + if item.group == RENDERER_ENTRY_GROUP: + found.append(item) + return found + + +def list_renderers(search_path: Sequence[str] | None = None) -> dict[str, dict[str, Any]]: + """Return the built-in id plus installed entry points. + + An entry point that reuses ``mattervis.native-v1`` is ignored. The + built-in id is reserved so a plugin cannot replace the default adapter. + """ + found: dict[str, dict[str, Any]] = { + NATIVE_BACKEND_ID: { + "builtin": True, + "api_version": RENDER_API_VERSION, + "ambiguous": False, + } + } + for item in _entry_points(search_path): + if item.name == NATIVE_BACKEND_ID: + continue + current = found.get(item.name) + if current is None: + found[item.name] = { + "builtin": False, + "api_version": None, + "ambiguous": False, + } + else: + current["ambiguous"] = True + return found + + +def _require_backend(backend: Any, backend_id: str) -> None: + required = ( + "backend_id", + "api_version", + "deterministic", + "package_version", + "implementation_digest", + "validate_options", + "render", + ) + missing = [name for name in required if not hasattr(backend, name)] + if missing: + names = ", ".join(missing) + raise RendererContractError( + f"renderer {backend_id!r} is missing required attributes: {names}" + ) + if backend.backend_id != backend_id: + raise RendererContractError( + f"renderer {backend_id!r} loaded a backend id of {backend.backend_id!r}" + ) + if backend.api_version != RENDER_API_VERSION: + raise RendererContractError( + f"renderer {backend_id!r} implements API {backend.api_version!r}, " + f"not {RENDER_API_VERSION!r}" + ) + if backend.deterministic is not True: + raise RendererContractError( + f"renderer {backend_id!r} is not deterministic; " + "nondeterministic backends are rejected" + ) + if not isinstance(backend.package_version, str) or not backend.package_version: + raise RendererContractError( + f"renderer {backend_id!r} has no package version" + ) + digest = backend.implementation_digest + if not isinstance(digest, str) or len(digest) != 64: + raise RendererContractError( + f"renderer {backend_id!r} has no sha256 implementation digest" + ) + + +def _load_entry_backend(backend_id: str, search_path: Sequence[str] | None) -> Any: + matches = [item for item in _entry_points(search_path) if item.name == backend_id] + if not matches: + raise RendererContractError( + f"renderer {backend_id!r} is not installed " + f"(entry point group {RENDERER_ENTRY_GROUP!r}); " + f"refusing to fall back to {NATIVE_BACKEND_ID}" + ) + if len(matches) > 1: + raise RendererContractError( + f"renderer {backend_id!r} is provided by multiple installations" + ) + try: + factory = matches[0].load() + except Exception as exc: + raise RendererContractError(f"failed to load renderer {backend_id!r}") from exc + try: + backend = factory() + except Exception as exc: + raise RendererContractError( + f"renderer {backend_id!r} failed to construct" + ) from exc + return backend + + +def _plain_json(value: Any, path: str) -> Any: + if value is None or isinstance(value, (str, bool)): + return value + if isinstance(value, int): + return value + if isinstance(value, float): + if not np.isfinite(value): + raise RendererContractError(f"{path} must be finite") + return value + if isinstance(value, Mapping): + output = {} + for key, item in value.items(): + if not isinstance(key, str) or not key: + raise RendererContractError(f"{path} keys must be non-empty strings") + output[key] = _plain_json(item, f"{path}.{key}") + return output + if isinstance(value, (list, tuple)): + return [_plain_json(item, f"{path}[]") for item in value] + raise RendererContractError(f"{path} is not a JSON value") + + +def load_renderer_config(source: str | os.PathLike[str] | Mapping[str, Any]) -> dict[str, Any]: + """Read a renderer config object. Unknown fields fail closed.""" + if isinstance(source, Mapping): + data = dict(source) + else: + path = Path(source) + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise RendererContractError(f"renderer config not found: {path}") from exc + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise RendererContractError("renderer config is not valid JSON") from exc + if not isinstance(data, dict): + raise RendererContractError("renderer config must be a JSON object") + if data.get("schema") != CONFIG_SCHEMA: + raise RendererContractError(f"renderer config schema must be {CONFIG_SCHEMA!r}") + extra = sorted(set(data) - _CONFIG_KEYS) + if extra: + raise RendererContractError( + "renderer config has unsupported fields: " + ", ".join(extra) + ) + if "api_version" in data and data["api_version"] != RENDER_API_VERSION: + raise RendererContractError( + f"renderer config API {data['api_version']!r} is not {RENDER_API_VERSION!r}" + ) + if "backend_id" in data and ( + not isinstance(data["backend_id"], str) or not data["backend_id"] + ): + raise RendererContractError("renderer config backend_id must be text") + if "bond_policy" in data: + validate_bond_policy(data["bond_policy"]) + if "options" in data: + data["options"] = _plain_json(data["options"], "options") + if not isinstance(data["options"], dict): + raise RendererContractError("renderer options must be an object") + for pin in ("require_package_version", "require_implementation_digest"): + if pin in data and data[pin] is not None and not isinstance(data[pin], str): + raise RendererContractError(f"renderer config {pin} must be text") + return data + + +def _validated_options(backend: Any, options: Mapping[str, Any]) -> Mapping[str, Any]: + try: + validated = backend.validate_options(dict(options)) + except RendererContractError: + raise + except Exception as exc: + raise RendererContractError( + f"renderer {backend.backend_id!r} rejected its options: {exc}" + ) from exc + if not isinstance(validated, Mapping): + raise RendererContractError( + f"renderer {backend.backend_id!r} validate_options must return a mapping" + ) + return MappingProxyType(dict(validated)) + + +def _check_pins(backend: Any, config: Mapping[str, Any] | None) -> bool: + if not config: + return False + version = config.get("require_package_version") + digest = config.get("require_implementation_digest") + pinned = bool(version or digest) + if version is not None and backend.package_version != version: + raise RendererContractError( + f"renderer {backend.backend_id!r} package version " + f"{backend.package_version!r} does not match pin {version!r}" + ) + if digest is not None and backend.implementation_digest != digest: + raise RendererContractError( + f"renderer {backend.backend_id!r} implementation digest does not match the pin" + ) + return pinned + + +def resolve_renderer_binding( + renderer: Any = None, + *, + renderer_options: Mapping[str, Any] | None = None, + renderer_config: str | os.PathLike[str] | Mapping[str, Any] | None = None, + renderer_search_path: Sequence[str] | None = None, + bond_policy: str | None = None, + observation_profile: str = "legacy", +) -> RendererBinding: + """Resolve one renderer for an episode. Never falls back to native.""" + profile = validate_observation_profile(observation_profile) + config = None if renderer_config is None else load_renderer_config(renderer_config) + config_id = None if config is None else config.get("backend_id") + config_policy = None if config is None else config.get("bond_policy") + if bond_policy is not None and config_policy is not None and bond_policy != config_policy: + raise RendererContractError("renderer bond_policy conflicts with the config file") + policy = validate_bond_policy(bond_policy or config_policy or "manifested") + direct_options = dict(renderer_options or {}) + config_options = dict(config.get("options") or {}) if config else {} + if direct_options and config_options: + raise RendererContractError( + "renderer options were passed both directly and in the config" + ) + options = direct_options or config_options + + selection = "builtin" + if isinstance(renderer, NativeRenderer): + backend_id = NATIVE_BACKEND_ID + backend: Any = renderer + selection = "instance" + elif renderer is None or renderer == NATIVE_BACKEND_ID: + backend_id = NATIVE_BACKEND_ID + backend = NativeRenderer() + selection = "builtin" + elif isinstance(renderer, str): + backend_id = renderer + backend = _load_entry_backend(backend_id, renderer_search_path) + selection = "entry_point" + else: + backend = renderer + backend_id = str(getattr(backend, "backend_id", "")) + selection = "instance" + if not isinstance(backend, NativeRenderer) and backend_id == NATIVE_BACKEND_ID: + raise RendererContractError( + f"{NATIVE_BACKEND_ID} is reserved for the built-in adapter" + ) + + if config_id is not None and config_id != backend_id: + raise RendererContractError( + f"renderer id {backend_id!r} does not match config id {config_id!r}" + ) + _require_backend(backend, backend_id) + validated = _validated_options(backend, options) + pinned = _check_pins(backend, config) + emit = ( + profile != "legacy" + or backend_id != NATIVE_BACKEND_ID + or bool(validated) + or policy != "manifested" + or pinned + or selection == "instance" and backend_id != NATIVE_BACKEND_ID + ) + stored_backend = None if selection == "builtin" and backend_id == NATIVE_BACKEND_ID and not pinned else backend + if selection == "builtin" and not pinned: + stored_backend = None + return RendererBinding( + backend=stored_backend, + backend_id=backend_id, + api_version=backend.api_version, + package_version=backend.package_version, + implementation_digest=backend.implementation_digest, + options=validated, + bond_policy=policy, + observation_profile=profile, + deterministic=True, + emit_provenance=emit, + selection="builtin" if stored_backend is None else selection, + config_hash=renderer_config_hash( + backend_id=backend_id, + api_version=backend.api_version, + options=validated, + bond_policy=policy, + ), + ) + + +def _resolve_frame_size(width: int | None, height: int | None) -> tuple[int, int]: + """Match ``compose_frame`` when width or height is omitted.""" + if width is None or height is None: + try: + term_size = os.get_terminal_size() + width = width or term_size.columns - 2 + height = height or term_size.lines - 4 + except OSError: + width = width or 80 + height = height or 40 + return max(int(width), 1), max(int(height), 1) + + +def _drawing_crystal(crystal: Any, bond_policy: str, show_bonds: bool) -> tuple[Any, bool]: + """Return the crystal and bond toggle actually passed to ``compose_frame``. + + The default policy returns the original crystal object. ``provided_only`` + drops inferred edges for drawing and does not mutate the source graph. + """ + policy = validate_bond_policy(bond_policy) + if policy == "bonds_off": + return crystal, False + if policy == "manifested": + return crystal, show_bonds + from .render_contract import bond_graph_provenance + + if bond_graph_provenance(crystal) == "provided": + return crystal, show_bonds + clone = copy.copy(crystal) + clone.bonds = [] + return clone, show_bonds + + +def _backend_result(raw: Any) -> tuple[str, Mapping[str, Any] | None]: + if isinstance(raw, str): + return raw, None + if isinstance(raw, BackendFrame): + return raw.text, raw.diagnostics + raise RendererContractError("renderer must return str or BackendFrame") + + +def _host_frame( + text: str, + request: RenderRequest, + backend: Any, + *, + strict: bool, + diagnostics: Mapping[str, Any] | None, +) -> RenderedFrame: + charset = request.render_options.charset + width = request.render_options.width + height = request.render_options.height + if strict: + lines = validate_backend_text(text, width=width, height=height, charset=charset) + checked = validate_diagnostics(diagnostics) + else: + lines = tuple(text.split("\n")) + checked = None + return RenderedFrame( + text=text, + lines=lines, + width=width, + height=height, + charset=charset, + backend_id=backend.backend_id, + api_version=backend.api_version, + package_version=backend.package_version, + implementation_digest=backend.implementation_digest, + config_hash=renderer_config_hash( + backend_id=backend.backend_id, + api_version=backend.api_version, + options=request.render_options.backend_options, + bond_policy=request.render_options.bond_policy, + ), + input_digest=request.input_digest, + view_digest=request.view_digest, + content_sha256=frame_content_sha256(text, charset=charset), + diagnostics=checked, + ) + + +def render_picture( + crystal: Any, + camera: Any, + pts_2d: Any, + depth: Any, + *, + width: int | None = None, + height: int | None = None, + mono: bool = False, + label_mode: str = "label", + show_bonds: bool = True, + show_cell: bool = True, + show_minor: bool = True, + show_hydrogen: bool = False, + show_boundary: bool = False, + zoom: float = 1.0, + pan_x: float = 0.0, + pan_y: float = 0.0, + display_level: str = "atom", + viewport: Any = None, + selected_display_index: int | None = None, + selected_display_copy_id: str | None = None, + focus_copy_ids: tuple[str, ...] = (), + charset: str = "unicode", + show_axes: bool = True, + show_depth: bool = True, + slice_axis: str = "off", + slice_index: int = 0, + slice_width: float = 1.0, + ghost_points: Any = None, + backend: Any = None, + backend_options: Mapping[str, Any] | None = None, + bond_policy: str = "manifested", +) -> RenderedFrame: + """Render one terminal picture through the selected backend. + + ``backend=None`` is ``mattervis.native-v1``. Native text is the return + value of ``compose_frame`` for the same arguments. + """ + if charset not in {"unicode", "ascii7"}: + raise ValueError("charset must be 'unicode' or 'ascii7'") + active = NativeRenderer() if backend is None else backend + if active.backend_id == NATIVE_BACKEND_ID and not isinstance(active, NativeRenderer): + raise RendererContractError( + f"{NATIVE_BACKEND_ID} is reserved for the built-in adapter" + ) + options = _validated_options(active, dict(backend_options or {})) + frame_width, frame_height = _resolve_frame_size(width, height) + draw_crystal, draw_bonds = _drawing_crystal(crystal, bond_policy, show_bonds) + request = build_render_request( + crystal, + camera, + pts_2d, + depth, + width=frame_width, + height=frame_height, + mono=mono, + label_mode=label_mode, + show_bonds=show_bonds, + show_cell=show_cell, + show_minor=show_minor, + show_hydrogen=show_hydrogen, + show_boundary=show_boundary, + zoom=zoom, + pan_x=pan_x, + pan_y=pan_y, + display_level=display_level, + viewport=viewport, + selected_display_index=selected_display_index, + selected_display_copy_id=selected_display_copy_id, + focus_copy_ids=focus_copy_ids, + charset=charset, + show_axes=show_axes, + show_depth=show_depth, + slice_axis=slice_axis, + slice_index=slice_index, + slice_width=slice_width, + ghost_points=ghost_points, + bond_policy=bond_policy, + backend_options=options, + ) + compose_kwargs = { + "width": frame_width, + "height": frame_height, + "mono": mono, + "label_mode": label_mode, + "show_bonds": draw_bonds, + "show_cell": show_cell, + "show_minor": show_minor, + "show_hydrogen": show_hydrogen, + "show_boundary": show_boundary, + "zoom": zoom, + "pan_x": pan_x, + "pan_y": pan_y, + "display_level": display_level, + "viewport": viewport, + "selected_display_index": selected_display_index, + "charset": charset, + "show_axes": show_axes, + "show_depth": show_depth, + "slice_axis": slice_axis, + "slice_index": slice_index, + "slice_width": slice_width, + "ghost_points": ghost_points, + } + if isinstance(active, NativeRenderer): + token = _HOST_SOURCES.set( + _HostFrameSources( + crystal=draw_crystal, + camera=camera, + pts_2d=pts_2d, + depth=depth, + compose_kwargs=compose_kwargs, + ) + ) + try: + raw = active.render(request) + finally: + _HOST_SOURCES.reset(token) + text, _diagnostics = _backend_result(raw) + return _host_frame(text, request, active, strict=False, diagnostics=None) + raw = active.render(request) + text, diagnostics = _backend_result(raw) + return _host_frame(text, request, active, strict=True, diagnostics=diagnostics) + + +def renderer_binding_from_cli(args: Any) -> RendererBinding: + """Resolve ``--renderer`` / ``--renderer-config`` from a CLI namespace.""" + return resolve_renderer_binding( + getattr(args, "renderer", None), + renderer_config=getattr(args, "renderer_config", None), + observation_profile=getattr(args, "observation_profile", "legacy"), + ) + + +__all__ = [ + "NATIVE_BACKEND_ID", + "RENDERER_ENTRY_GROUP", + "RENDER_API_VERSION", + "NativeRenderer", + "RendererBinding", + "RendererContractError", + "create_native_renderer", + "list_renderers", + "load_renderer_config", + "native_host_sources_bound", + "native_implementation_digest", + "render_picture", + "renderer_binding_from_cli", + "resolve_renderer_binding", +] diff --git a/mat_viewer/tui/render_contract.py b/mat_viewer/tui/render_contract.py new file mode 100644 index 00000000..840d83e0 --- /dev/null +++ b/mat_viewer/tui/render_contract.py @@ -0,0 +1,877 @@ +"""Public, chemistry-free TUI render contract. + +MatterVis owns the source structure, camera, projection, and session. +A renderer receives only this request and returns text. The request is +JSON-serializable and deliberately has no slot for ``CrystalIR``, +MolCrysKit chemistry, names, gold answers, or scorer output. + +Coordinate frame (host-derived, not recomputed by the contract): + +- Cartesian site positions and the cell matrix are Angstrom. Cell rows + are the ``a``, ``b``, and ``c`` vectors. +- Fractional coordinates are unitless, in that cell. +- ``basis_right_up_forward`` rows are the camera right, up, and forward + unit vectors in world Cartesian coordinates. +- ``projected_xy`` and ``projected_depth`` are the arrays + :func:`mat_viewer.math.camera.project_points` already produced for this + frame. Orthographic ``xy`` is the camera-plane position divided by + camera distance. Depth is the camera-forward component; larger is closer. +- A null ``viewport`` means the native compositor still computes its own + fit. Active sessions pass the controller's fitted viewport in projected + ``xy`` units. +""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from dataclasses import dataclass +from hashlib import sha256 +from typing import Any, Protocol + +import numpy as np + +from .compositor import atom_is_drawn +from .text import terminal_text + +RENDER_API_VERSION = "mattervis.tui.render/v1" +REQUEST_SCHEMA = "mattervis.tui.render_request/v1" +DISPLAY_IR_SCHEMA = "mattervis.tui.display_ir/v1" +FRAME_SCHEMA = "mattervis.tui.rendered_frame/v1" +CONFIG_SCHEMA = "mattervis.tui.renderer_config/v1" +REPLAY_SCHEMA = "mattervis.tui.renderer_replay/v1" +NATIVE_BACKEND_ID = "mattervis.native-v1" +RENDERER_ENTRY_GROUP = "mattervis.tui.renderers" +GROUPING_PROVENANCE = "manifested_molecule_index" + +BOND_POLICIES = ("manifested", "provided_only", "bonds_off") +OBSERVATION_PROFILES = ("legacy", "model") + +_BOND_SOURCE_PROVENANCE = { + "distance_heuristic": "inferred", + "molcryskit": "inferred", + "canonical_scene": "inferred", + "explicit": "provided", + "provided": "provided", +} +_FORBIDDEN_KEY_FRAGMENTS = ( + "chemistry", + "gold", + "scorer", + "cip", + "iupac", + "smiles", + "inchi", + "warning", + "spacegroup", + "canonical_formula", + "answer", + "benchmark", + "stereochem", +) +_FORBIDDEN_EXACT_KEYS = frozenset({"score", "split", "repair", "task"}) + + +class RendererContractError(ValueError): + """Fail-closed renderer selection, config, or frame-contract error. + + Callers must not catch this and draw the native backend instead. + """ + + +def canonical_dumps(value: Any) -> str: + """Stable ASCII JSON used for renderer digests.""" + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ) + + +def sha256_text(value: str) -> str: + return sha256(value.encode("ascii")).hexdigest() + + +def frame_content_sha256(text: str, *, charset: str) -> str: + """Hash exact backend text. + + This is not the session screen hash. Session observations hash the + padded ``width × height`` screen. ASCII-7 uses ASCII bytes; the Unicode + profile uses UTF-8. + """ + encoding = "ascii" if charset == "ascii7" else "utf-8" + return sha256(text.encode(encoding)).hexdigest() + + +def renderer_config_hash( + *, + backend_id: str, + api_version: str, + options: Mapping[str, Any], + bond_policy: str, +) -> str: + payload = { + "api_version": api_version, + "backend_id": backend_id, + "bond_policy": bond_policy, + "options": dict(options), + } + return sha256_text(canonical_dumps(payload)) + + +def validate_bond_policy(value: str) -> str: + if value not in BOND_POLICIES: + allowed = ", ".join(BOND_POLICIES) + raise RendererContractError(f"bond_policy must be one of {allowed}") + return value + + +def validate_observation_profile(value: str) -> str: + if value not in OBSERVATION_PROFILES: + allowed = ", ".join(OBSERVATION_PROFILES) + raise RendererContractError( + f"observation_profile must be one of {allowed}" + ) + return value + + +def key_is_forbidden(key: str) -> bool: + lowered = str(key).lower() + if lowered in _FORBIDDEN_EXACT_KEYS: + return True + return any(fragment in lowered for fragment in _FORBIDDEN_KEY_FRAGMENTS) + + +def assert_public_payload(value: Any, *, path: str = "$") -> None: + """Reject contract documents that grew a chemistry or answer field.""" + if isinstance(value, Mapping): + for key, item in value.items(): + if key_is_forbidden(str(key)): + raise RendererContractError( + f"render payload field {path}.{key} is not public display data" + ) + assert_public_payload(item, path=f"{path}.{key}") + return + if isinstance(value, (list, tuple)): + for index, item in enumerate(value): + assert_public_payload(item, path=f"{path}[{index}]") + + +def bond_graph_provenance(crystal: Any) -> str: + """Classify the manifested bond graph without copying raw metadata. + + Provenance is graph-level because ``BondIR`` does not carry a per-edge + source. Unknown metadata strings stay ``unspecified`` and are not copied + into the request. ``explicit_bond_table`` is the only provided-input flag. + """ + metadata = getattr(crystal, "metadata", None) + if not isinstance(metadata, Mapping): + return "unspecified" + if metadata.get("explicit_bond_table") is True: + return "provided" + source = metadata.get("bond_source") + if isinstance(source, str): + return _BOND_SOURCE_PROVENANCE.get(source, "unspecified") + return "unspecified" + + +def bond_is_enabled(policy: str, provenance: str) -> bool: + if policy == "manifested": + return True + if policy == "bonds_off": + return False + return provenance == "provided" + + +@dataclass(frozen=True) +class BackendFrame: + """Optional backend return when a frame has audit diagnostics. + + Diagnostics are host-side. They are not appended to the picture and are + not copied into agent observations. + """ + + text: str + diagnostics: Mapping[str, Any] | None = None + + +@dataclass(frozen=True) +class DisplaySite: + index: int + atom_id: str + source_site_id: str + display_copy_id: str + display_fragment_id: str + element: str + label: str + cart_angstrom: tuple[float, float, float] + frac: tuple[float, float, float] + occupancy: float + image_shift: tuple[int, int, int] + symmetry_operation_index: int + is_minor: bool + is_boundary_replica: bool + visible: bool + molecule_index: int + + def as_dict(self) -> dict[str, Any]: + return { + "index": self.index, + "atom_id": self.atom_id, + "source_site_id": self.source_site_id, + "display_copy_id": self.display_copy_id, + "display_fragment_id": self.display_fragment_id, + "element": self.element, + "label": self.label, + "cart_angstrom": list(self.cart_angstrom), + "frac": list(self.frac), + "occupancy": self.occupancy, + "image_shift": list(self.image_shift), + "symmetry_operation_index": self.symmetry_operation_index, + "is_minor": self.is_minor, + "is_boundary_replica": self.is_boundary_replica, + "visible": self.visible, + "molecule_index": self.molecule_index, + } + + +@dataclass(frozen=True) +class DisplayBond: + i: int + j: int + image_translation: tuple[int, int, int] + start_display_copy_id: str + end_display_copy_id: str + distance_angstrom: float + provenance: str + enabled: bool + + def as_dict(self) -> dict[str, Any]: + return { + "i": self.i, + "j": self.j, + "image_translation": list(self.image_translation), + "start_display_copy_id": self.start_display_copy_id, + "end_display_copy_id": self.end_display_copy_id, + "distance_angstrom": self.distance_angstrom, + "provenance": self.provenance, + "enabled": self.enabled, + } + + +@dataclass(frozen=True) +class DisplayCell: + a_angstrom: float + b_angstrom: float + c_angstrom: float + alpha_deg: float + beta_deg: float + gamma_deg: float + matrix_angstrom: tuple[tuple[float, float, float], ...] + periodic_boundary: tuple[bool, bool, bool] | None + + def as_dict(self) -> dict[str, Any]: + return { + "a_angstrom": self.a_angstrom, + "b_angstrom": self.b_angstrom, + "c_angstrom": self.c_angstrom, + "alpha_deg": self.alpha_deg, + "beta_deg": self.beta_deg, + "gamma_deg": self.gamma_deg, + "matrix_angstrom": [list(row) for row in self.matrix_angstrom], + "periodic_boundary": ( + None if self.periodic_boundary is None else list(self.periodic_boundary) + ), + } + + +@dataclass(frozen=True) +class DisplayIR: + sites: tuple[DisplaySite, ...] + bonds: tuple[DisplayBond, ...] + cell: DisplayCell | None + bond_policy: str + bond_graph_provenance: str + molecule_groups: tuple[tuple[int, ...], ...] + unassigned_site_indices: tuple[int, ...] + + def as_dict(self) -> dict[str, Any]: + return { + "schema": DISPLAY_IR_SCHEMA, + "sites": [site.as_dict() for site in self.sites], + "bonds": [bond.as_dict() for bond in self.bonds], + "cell": None if self.cell is None else self.cell.as_dict(), + "bond_policy": self.bond_policy, + "bond_graph_provenance": self.bond_graph_provenance, + "molecule_groups": [list(group) for group in self.molecule_groups], + "unassigned_site_indices": list(self.unassigned_site_indices), + "grouping_provenance": GROUPING_PROVENANCE, + } + + +@dataclass(frozen=True) +class RenderViewport: + x_min: float + x_max: float + y_min: float + y_max: float + scale: float + width: int + height: int + + def as_dict(self) -> dict[str, Any]: + return { + "x_min": self.x_min, + "x_max": self.x_max, + "y_min": self.y_min, + "y_max": self.y_max, + "scale": self.scale, + "width": self.width, + "height": self.height, + } + + +@dataclass(frozen=True) +class RenderView: + projection: str + azimuth_deg: float + elevation_deg: float + roll_deg: float + distance: float + fov_deg: float + perspective_near_is_larger: bool + target_angstrom: tuple[float, float, float] + basis_right_up_forward: tuple[tuple[float, float, float], ...] + viewport_zoom: float + pan_x: float + pan_y: float + projected_xy: tuple[tuple[float, float], ...] + projected_depth: tuple[float, ...] + viewport: RenderViewport | None + ghost_projected_xy: tuple[tuple[float, float], ...] | None + selected_display_index: int | None + selected_display_copy_id: str | None + focus_copy_ids: tuple[str, ...] + + def as_dict(self) -> dict[str, Any]: + return { + "projection": self.projection, + "azimuth_deg": self.azimuth_deg, + "elevation_deg": self.elevation_deg, + "roll_deg": self.roll_deg, + "distance": self.distance, + "fov_deg": self.fov_deg, + "perspective_near_is_larger": self.perspective_near_is_larger, + "target_angstrom": list(self.target_angstrom), + "basis_right_up_forward": [ + list(row) for row in self.basis_right_up_forward + ], + "viewport_zoom": self.viewport_zoom, + "pan_x": self.pan_x, + "pan_y": self.pan_y, + "projected_xy": [list(point) for point in self.projected_xy], + "projected_depth": list(self.projected_depth), + "coordinate_frame": "angstrom_camera_plane", + "depth_convention": "larger_is_closer", + "viewport": None if self.viewport is None else self.viewport.as_dict(), + "ghost_projected_xy": ( + None + if self.ghost_projected_xy is None + else [list(point) for point in self.ghost_projected_xy] + ), + "selected_display_index": self.selected_display_index, + "selected_display_copy_id": self.selected_display_copy_id, + "focus_copy_ids": list(self.focus_copy_ids), + } + + +@dataclass(frozen=True) +class RenderOptions: + charset: str + width: int + height: int + mono: bool + display_level: str + label_mode: str + show_bonds: bool + show_cell: bool + show_minor: bool + show_hydrogen: bool + show_boundary: bool + show_axes: bool + show_depth: bool + slice_axis: str + slice_index: int + slice_width: float + bond_policy: str + backend_options: Mapping[str, Any] + + def as_dict(self) -> dict[str, Any]: + return { + "charset": self.charset, + "width": self.width, + "height": self.height, + "mono": self.mono, + "display_level": self.display_level, + "label_mode": self.label_mode, + "show_bonds": self.show_bonds, + "show_cell": self.show_cell, + "show_minor": self.show_minor, + "show_hydrogen": self.show_hydrogen, + "show_boundary": self.show_boundary, + "show_axes": self.show_axes, + "show_depth": self.show_depth, + "slice_axis": self.slice_axis, + "slice_index": self.slice_index, + "slice_width": self.slice_width, + "bond_policy": self.bond_policy, + "backend_options": dict(self.backend_options), + } + + +@dataclass(frozen=True) +class RenderRequest: + display_ir: DisplayIR + view: RenderView + render_options: RenderOptions + input_digest: str + view_digest: str + request_schema_version: str = REQUEST_SCHEMA + + def as_dict(self) -> dict[str, Any]: + payload = { + "request_schema_version": self.request_schema_version, + "display_ir": self.display_ir.as_dict(), + "view": self.view.as_dict(), + "render_options": self.render_options.as_dict(), + "input_digest": self.input_digest, + "view_digest": self.view_digest, + } + assert_public_payload(payload) + return payload + + +@dataclass(frozen=True) +class RenderedFrame: + """Host-owned frame identity. Hashes are recomputed by the host.""" + + text: str + lines: tuple[str, ...] + width: int + height: int + charset: str + backend_id: str + api_version: str + package_version: str + implementation_digest: str + config_hash: str + input_digest: str + view_digest: str + content_sha256: str + diagnostics: Mapping[str, Any] | None = None + schema: str = FRAME_SCHEMA + + +class RendererBackend(Protocol): + """Presentation backend. The host owns :class:`RenderedFrame`. + + ``render`` returns picture text, or a :class:`BackendFrame` when the + backend also has audit diagnostics. MatterVis recomputes the content + hash, config hash, and backend identity. A plugin cannot supply those + fields, and it does not receive ``CrystalIR``. + """ + + backend_id: str + api_version: str + deterministic: bool + package_version: str + implementation_digest: str + + def validate_options(self, options: Mapping[str, Any]) -> Mapping[str, Any]: + """Return the namespaced options this backend will honor.""" + ... + + def render(self, request: RenderRequest) -> str | BackendFrame: + """Draw one frame from the public request.""" + ... + + +def _finite_float(value: Any, name: str) -> float: + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise RendererContractError(f"{name} must be a finite number") from exc + if not np.isfinite(number): + raise RendererContractError(f"{name} must be a finite number") + return number + + +def _finite_vec3(value: Any, name: str) -> tuple[float, float, float]: + array = np.asarray(value, dtype=float).reshape(-1) + if array.shape != (3,) or not np.all(np.isfinite(array)): + raise RendererContractError(f"{name} must be a finite 3-vector") + return (float(array[0]), float(array[1]), float(array[2])) + + +def _int3(value: Any, name: str) -> tuple[int, int, int]: + try: + items = tuple(value) + except TypeError as exc: + raise RendererContractError(f"{name} must have three integers") from exc + if len(items) != 3: + raise RendererContractError(f"{name} must have three integers") + out: list[int] = [] + for item in items: + if isinstance(item, bool) or not isinstance(item, (int, np.integer)): + raise RendererContractError(f"{name} must have three integers") + out.append(int(item)) + return (out[0], out[1], out[2]) + + +def _projected_xy(value: Any, count: int, name: str) -> tuple[tuple[float, float], ...]: + array = np.asarray(value, dtype=float) + if count == 0: + if array.size == 0: + return () + raise RendererContractError(f"{name} must align with display sites") + if array.shape != (count, 2) or not np.all(np.isfinite(array)): + raise RendererContractError(f"{name} must align with display sites") + return tuple((float(point[0]), float(point[1])) for point in array) + + +def _projected_depth(value: Any, count: int) -> tuple[float, ...]: + array = np.asarray(value, dtype=float).reshape(-1) + if array.shape != (count,) or not np.all(np.isfinite(array)): + raise RendererContractError("projected depth must align with display sites") + return tuple(float(item) for item in array) + + +def _safe_text(value: Any) -> str: + return terminal_text("" if value is None else value) + + +def _periodic_boundary(crystal: Any) -> tuple[bool, bool, bool] | None: + metadata = getattr(crystal, "metadata", None) + raw = metadata.get("pbc") if isinstance(metadata, Mapping) else None + if ( + isinstance(raw, (list, tuple)) + and len(raw) == 3 + and all(isinstance(item, (bool, np.bool_)) for item in raw) + ): + return (bool(raw[0]), bool(raw[1]), bool(raw[2])) + if getattr(crystal, "lattice", None) is not None: + return (True, True, True) + return None + + +def _display_cell(crystal: Any) -> DisplayCell | None: + lattice = getattr(crystal, "lattice", None) + if lattice is None: + return None + matrix = np.asarray(lattice.matrix, dtype=float) + if matrix.shape != (3, 3) or not np.all(np.isfinite(matrix)): + raise RendererContractError("cell matrix must be a finite 3x3 matrix") + rows = tuple( + (float(matrix[row, 0]), float(matrix[row, 1]), float(matrix[row, 2])) + for row in range(3) + ) + return DisplayCell( + a_angstrom=_finite_float(lattice.a, "cell.a"), + b_angstrom=_finite_float(lattice.b, "cell.b"), + c_angstrom=_finite_float(lattice.c, "cell.c"), + alpha_deg=_finite_float(lattice.alpha, "cell.alpha"), + beta_deg=_finite_float(lattice.beta, "cell.beta"), + gamma_deg=_finite_float(lattice.gamma, "cell.gamma"), + matrix_angstrom=rows, + periodic_boundary=_periodic_boundary(crystal), + ) + + +def _display_sites(crystal: Any, *, show_minor: bool, show_hydrogen: bool, show_boundary: bool) -> tuple[DisplaySite, ...]: + sites: list[DisplaySite] = [] + for index, atom in enumerate(crystal.atoms): + sites.append( + DisplaySite( + index=index, + atom_id=_safe_text(atom.atom_id), + source_site_id=_safe_text(atom.source_site_id), + display_copy_id=_safe_text(atom.display_copy_id), + display_fragment_id=_safe_text(atom.display_fragment_id), + element=_safe_text(atom.element), + label=_safe_text(atom.label or atom.element), + cart_angstrom=_finite_vec3(atom.cart, f"site[{index}].cart"), + frac=_finite_vec3(atom.frac, f"site[{index}].frac"), + occupancy=_finite_float(atom.occupancy, f"site[{index}].occupancy"), + image_shift=_int3(atom.image_shift, f"site[{index}].image_shift"), + symmetry_operation_index=int(atom.symmetry_operation_index), + is_minor=bool(atom.is_minor), + is_boundary_replica=bool(atom.is_boundary_replica), + visible=atom_is_drawn( + atom, + show_minor=show_minor, + show_hydrogen=show_hydrogen, + show_boundary=show_boundary, + ), + molecule_index=int(atom.molecule_index), + ) + ) + return tuple(sites) + + +def _display_bonds(crystal: Any, *, policy: str, provenance: str) -> tuple[DisplayBond, ...]: + enabled = bond_is_enabled(policy, provenance) + bonds: list[DisplayBond] = [] + atoms = crystal.atoms + for bond in crystal.bonds: + start_id = bond.start_display_copy_id or atoms[bond.i].display_copy_id + end_id = bond.end_display_copy_id or atoms[bond.j].display_copy_id + bonds.append( + DisplayBond( + i=int(bond.i), + j=int(bond.j), + image_translation=_int3(bond.image_relation, "bond image translation"), + start_display_copy_id=_safe_text(start_id), + end_display_copy_id=_safe_text(end_id), + distance_angstrom=_finite_float(bond.distance, "bond distance"), + provenance=provenance, + enabled=enabled, + ) + ) + return tuple(bonds) + + +def _molecule_groups( + sites: tuple[DisplaySite, ...], +) -> tuple[tuple[tuple[int, ...], ...], tuple[int, ...]]: + grouped: dict[int, list[int]] = {} + unassigned: list[int] = [] + for site in sites: + if site.molecule_index >= 0: + grouped.setdefault(site.molecule_index, []).append(site.index) + else: + unassigned.append(site.index) + groups = tuple(tuple(grouped[key]) for key in sorted(grouped)) + return groups, tuple(unassigned) + + +def _viewport_payload(viewport: Any) -> RenderViewport | None: + if viewport is None: + return None + return RenderViewport( + x_min=_finite_float(viewport.x_min, "viewport.x_min"), + x_max=_finite_float(viewport.x_max, "viewport.x_max"), + y_min=_finite_float(viewport.y_min, "viewport.y_min"), + y_max=_finite_float(viewport.y_max, "viewport.y_max"), + scale=_finite_float(viewport.scale, "viewport.scale"), + width=int(viewport.width), + height=int(viewport.height), + ) + + +def build_render_request( + crystal: Any, + camera: Any, + pts_2d: Any, + depth: Any, + *, + width: int, + height: int, + mono: bool, + label_mode: str, + show_bonds: bool, + show_cell: bool, + show_minor: bool, + show_hydrogen: bool, + show_boundary: bool, + zoom: float, + pan_x: float, + pan_y: float, + display_level: str, + viewport: Any, + selected_display_index: int | None, + selected_display_copy_id: str | None, + focus_copy_ids: tuple[str, ...], + charset: str, + show_axes: bool, + show_depth: bool, + slice_axis: str, + slice_index: int, + slice_width: float, + ghost_points: Any, + bond_policy: str, + backend_options: Mapping[str, Any], +) -> RenderRequest: + """Build a public request from host geometry without reading chemistry.""" + policy = validate_bond_policy(bond_policy) + provenance = bond_graph_provenance(crystal) + sites = _display_sites( + crystal, + show_minor=show_minor, + show_hydrogen=show_hydrogen, + show_boundary=show_boundary, + ) + count = len(sites) + groups, unassigned = _molecule_groups(sites) + basis = np.asarray(camera.rotation_matrix, dtype=float) + if basis.shape != (3, 3) or not np.all(np.isfinite(basis)): + raise RendererContractError("camera basis must be a finite 3x3 matrix") + ghost = None if ghost_points is None else _projected_xy(ghost_points, count, "ghost projection") + display_ir = DisplayIR( + sites=sites, + bonds=_display_bonds(crystal, policy=policy, provenance=provenance), + cell=_display_cell(crystal), + bond_policy=policy, + bond_graph_provenance=provenance, + molecule_groups=groups, + unassigned_site_indices=unassigned, + ) + view = RenderView( + projection=str(camera.projection.value), + azimuth_deg=_finite_float(camera.azimuth, "camera.azimuth"), + elevation_deg=_finite_float(camera.elevation, "camera.elevation"), + roll_deg=_finite_float(camera.roll, "camera.roll"), + distance=_finite_float(camera.distance, "camera.distance"), + fov_deg=_finite_float(camera.fov_deg, "camera.fov_deg"), + perspective_near_is_larger=bool(camera.perspective_near_is_larger), + target_angstrom=_finite_vec3(camera.target, "camera.target"), + basis_right_up_forward=tuple( + (float(basis[row, 0]), float(basis[row, 1]), float(basis[row, 2])) + for row in range(3) + ), + viewport_zoom=_finite_float(zoom, "viewport zoom"), + pan_x=_finite_float(pan_x, "pan_x"), + pan_y=_finite_float(pan_y, "pan_y"), + projected_xy=_projected_xy(pts_2d, count, "projected coordinates"), + projected_depth=_projected_depth(depth, count), + viewport=_viewport_payload(viewport), + ghost_projected_xy=ghost, + selected_display_index=selected_display_index, + selected_display_copy_id=( + None if selected_display_copy_id is None else _safe_text(selected_display_copy_id) + ), + focus_copy_ids=tuple(_safe_text(item) for item in focus_copy_ids), + ) + options = RenderOptions( + charset=charset, + width=int(width), + height=int(height), + mono=bool(mono), + display_level=str(display_level), + label_mode=str(label_mode), + show_bonds=bool(show_bonds), + show_cell=bool(show_cell), + show_minor=bool(show_minor), + show_hydrogen=bool(show_hydrogen), + show_boundary=bool(show_boundary), + show_axes=bool(show_axes), + show_depth=bool(show_depth), + slice_axis=str(slice_axis), + slice_index=int(slice_index), + slice_width=_finite_float(slice_width, "slice width"), + bond_policy=policy, + backend_options=dict(backend_options), + ) + display_payload = display_ir.as_dict() + view_payload = { + "view": view.as_dict(), + "render_options": options.as_dict(), + } + assert_public_payload(display_payload) + assert_public_payload(view_payload) + return RenderRequest( + display_ir=display_ir, + view=view, + render_options=options, + input_digest=sha256_text(canonical_dumps(display_payload)), + view_digest=sha256_text(canonical_dumps(view_payload)), + ) + + +def validate_backend_text( + text: str, + *, + width: int, + height: int, + charset: str, +) -> tuple[str, ...]: + """Reject external frames that overflow or leave the charset profile. + + ASCII-7 is printable 7-bit text with no ANSI escapes. The Unicode profile + allows the characters the native terminal already accepts, including + Braille and superscripts, and still rejects C0/C1 controls and ANSI. + Lines may be shorter than ``width``; the session pads them. A trailing + newline is rejected so line count stays explicit. + """ + if not isinstance(text, str): + raise RendererContractError("renderer frame must be text") + if text.endswith("\n") or "\r" in text or "\x00" in text: + raise RendererContractError("renderer frame has an invalid line ending") + lines = text.split("\n") + if len(lines) > height: + raise RendererContractError("renderer frame exceeds the viewport height") + for line in lines: + if len(line) > width: + raise RendererContractError("renderer frame exceeds the viewport width") + if terminal_text(line) != line or "\x1b" in line: + raise RendererContractError("renderer frame contains a control character") + if charset == "ascii7" and any( + ord(character) < 0x20 or ord(character) > 0x7E for character in line + ): + raise RendererContractError( + "ascii7 frame must be printable 7-bit ASCII without ANSI escapes" + ) + return tuple(lines) + + +def validate_diagnostics(diagnostics: Mapping[str, Any] | None) -> Mapping[str, Any] | None: + if diagnostics is None: + return None + if not isinstance(diagnostics, Mapping): + raise RendererContractError("renderer diagnostics must be an object") + try: + encoded = json.loads(canonical_dumps(dict(diagnostics))) + except (TypeError, ValueError) as exc: + raise RendererContractError("renderer diagnostics must be finite JSON") from exc + assert_public_payload(encoded) + return encoded + + +__all__ = [ + "BOND_POLICIES", + "CONFIG_SCHEMA", + "DISPLAY_IR_SCHEMA", + "FRAME_SCHEMA", + "GROUPING_PROVENANCE", + "NATIVE_BACKEND_ID", + "OBSERVATION_PROFILES", + "RENDERER_ENTRY_GROUP", + "RENDER_API_VERSION", + "REPLAY_SCHEMA", + "REQUEST_SCHEMA", + "BackendFrame", + "DisplayBond", + "DisplayCell", + "DisplayIR", + "DisplaySite", + "RenderOptions", + "RenderRequest", + "RenderView", + "RenderViewport", + "RenderedFrame", + "RendererContractError", + "assert_public_payload", + "bond_graph_provenance", + "bond_is_enabled", + "build_render_request", + "canonical_dumps", + "frame_content_sha256", + "key_is_forbidden", + "renderer_config_hash", + "sha256_text", + "validate_backend_text", + "validate_bond_policy", + "validate_diagnostics", + "validate_observation_profile", +] diff --git a/mat_viewer/tui/serializer.py b/mat_viewer/tui/serializer.py index ec087eef..07c0feb8 100644 --- a/mat_viewer/tui/serializer.py +++ b/mat_viewer/tui/serializer.py @@ -270,18 +270,18 @@ def _bond_stat_distance(bond) -> float: # ── ASCII art ─────────────────────────────────────────────────────── if include_art and len(pts_2d) > 0: - from .compositor import compose_frame + from .render_backend import render_picture from ..math.camera import project_points _, depth = project_points(camera, crystal.cart_coords) - art = compose_frame( + art = render_picture( crystal, camera, pts_2d, depth, width=art_width, height=art_height, mono=True, label_mode="auto", show_bonds=True, show_cell=True, show_minor=show_minor, display_level=resolved_level, - ) + ).text lines.append(" art: |") for art_line in art.split("\n"): lines.append(f" {art_line}") diff --git a/mat_viewer/tui/session.py b/mat_viewer/tui/session.py index 88f6394a..938a15fb 100644 --- a/mat_viewer/tui/session.py +++ b/mat_viewer/tui/session.py @@ -13,6 +13,8 @@ from typing import Any, IO, Mapping from .controller import TerminalViewController +from .observation_profile import sanitize_model_observation +from .render_backend import RendererBinding, resolve_renderer_binding from .state import TerminalObservation SESSION_SCHEMA = "mattervis.tui.session/v1" @@ -48,6 +50,8 @@ class SessionObservation: screen_hash: str state_fingerprint: str schema: str = SESSION_SCHEMA + observation_profile: str = "legacy" + renderer_provenance: Mapping[str, Any] | None = None def as_dict(self) -> dict[str, Any]: payload = self.observation.as_dict() @@ -64,6 +68,10 @@ def as_dict(self) -> dict[str, Any]: "state_fingerprint": self.state_fingerprint, } ) + if self.observation_profile == "model": + payload = sanitize_model_observation(payload) + if self.renderer_provenance is not None: + payload["renderer"] = dict(self.renderer_provenance) return payload @@ -89,17 +97,52 @@ class TerminalSession: "close", ) - def __init__(self, crystal, **controller_options: Any) -> None: + def __init__( + self, + crystal, + *, + renderer: Any = None, + renderer_options: Mapping[str, Any] | None = None, + renderer_config: Any = None, + renderer_search_path: Any = None, + bond_policy: str | None = None, + observation_profile: str = "legacy", + binding: RendererBinding | None = None, + **controller_options: Any, + ) -> None: self.crystal = crystal + if binding is not None: + self._binding = binding + else: + self._binding = resolve_renderer_binding( + renderer, + renderer_options=renderer_options, + renderer_config=renderer_config, + renderer_search_path=renderer_search_path, + bond_policy=bond_policy, + observation_profile=observation_profile, + ) self._controller_options = dict(controller_options) self.charset = self._controller_options.pop("charset", "unicode") if self.charset not in {"unicode", "ascii7"}: raise ValueError("charset must be 'unicode' or 'ascii7'") # Machine observations never contain terminal-dependent ANSI escapes. + # Renderer identity is episode configuration, not a set_display action. self._controller_options["mono"] = True + self._controller_options["renderer"] = self._binding.backend + self._controller_options["renderer_options"] = self._binding.options + self._controller_options["bond_policy"] = self._binding.bond_policy self._closed = False self.controller = TerminalViewController(crystal, **self._controller_options) + def replay_manifest(self) -> dict[str, Any]: + """Episode renderer identity. Omitted from default observations.""" + payload = self._binding.replay_dict() + payload["charset"] = self.charset + payload["width"] = self.controller.width + payload["height"] = self.controller.height + return payload + @classmethod def from_file( cls, @@ -238,11 +281,21 @@ def _wrap(self, observation: TerminalObservation) -> SessionObservation: ensure_ascii=True, allow_nan=False, ).encode("ascii") + provenance = None + if self._binding.emit_provenance: + rendered = self.controller.last_rendered + provenance = self._binding.replay_dict() + if rendered is not None: + provenance["input_digest"] = rendered.input_digest + provenance["view_digest"] = rendered.view_digest + provenance["content_sha256"] = rendered.content_sha256 return SessionObservation( observation=observation, screen_lines=lines, screen_hash=sha256(screen).hexdigest(), state_fingerprint=sha256(state_material).hexdigest(), + observation_profile=self._binding.observation_profile, + renderer_provenance=provenance, ) def _require_open(self) -> None: diff --git a/pyproject.toml b/pyproject.toml index 3bc79660..c8f1a565 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ dependencies = [ [project.scripts] mat-vis = "mat_viewer.cli:main" +[project.entry-points."mattervis.tui.renderers"] +"mattervis.native-v1" = "mat_viewer.tui.render_backend:create_native_renderer" + [project.optional-dependencies] plotly = ["plotly"] plotly-export = ["plotly", "kaleido"] diff --git a/tests/fixtures/tui_renderer_goldens/atom_ascii7_100x36_nodepth.txt b/tests/fixtures/tui_renderer_goldens/atom_ascii7_100x36_nodepth.txt new file mode 100644 index 00000000..65134ecb --- /dev/null +++ b/tests/fixtures/tui_renderer_goldens/atom_ascii7_100x36_nodepth.txt @@ -0,0 +1,36 @@ + : + : + : + : /C0 + : /-/ + : /--/ + : /-/ + : /-/ + : /-/ + : /--/ + : /-/ + : /-/ + : /-/ + : /--/ + : /-/ + : /-/ + /--/ + /-/:::::: + /-/:: :::::::::: + /-/: :::::::::: + /--/: :::::::::: + /-/: :::::::::: + /-/: ::::: + /-/ + /--/ + /-/ + :/-/ + :/-/ + :/--/ + :/-/ + :::O1 +:: + + + +axes a:out b:E c:N |----------------| 0.2 A \ No newline at end of file diff --git a/tests/fixtures/tui_renderer_goldens/atom_ascii7_80x24_depth.txt b/tests/fixtures/tui_renderer_goldens/atom_ascii7_80x24_depth.txt new file mode 100644 index 00000000..c9d1593d --- /dev/null +++ b/tests/fixtures/tui_renderer_goldens/atom_ascii7_80x24_depth.txt @@ -0,0 +1,24 @@ + : + : C0^3 + : /- + : / + : / / + : / + : -/ + : -/ + : /--/ + : /-/ + /--/ + /-/:::::: + /-/:: :::::::::: + /--/: :::::::::: + /-/:: :::::::::: + /--/:: ::::: + /-/:: + /--/:: + /-/::: + /--/:: + O1^1:: + :::: + :: +axes a:out b:E c:N depth 1:front 2:mid 3:back |-----------| 0.2 A \ No newline at end of file diff --git a/tests/fixtures/tui_renderer_goldens/atom_unicode_100x36_nodepth.txt b/tests/fixtures/tui_renderer_goldens/atom_unicode_100x36_nodepth.txt new file mode 100644 index 00000000..f40e7d4c --- /dev/null +++ b/tests/fixtures/tui_renderer_goldens/atom_unicode_100x36_nodepth.txt @@ -0,0 +1,36 @@ + : + : + : + : /C0 + : /-/ + : /--/ + : /-/ + : /-/ + : /-/ + : /--/ + : /-/ + : /-/ + : /-/ + : /--/ + : /-/ + : /-/ + /--/ + /-/:::::: + /-/:: :::::::::: + /-/: :::::::::: + /--/: :::::::::: + /-/: :::::::::: + /-/: ::::: + /-/ + /--/ + /-/ + :/-/ + :/-/ + :/--/ + :/-/ + :::O1 +:: + + + +axes a:out b:E c:N |----------------| 0.2 A \ No newline at end of file diff --git a/tests/fixtures/tui_renderer_goldens/atom_unicode_80x24_depth.txt b/tests/fixtures/tui_renderer_goldens/atom_unicode_80x24_depth.txt new file mode 100644 index 00000000..1389f2e2 --- /dev/null +++ b/tests/fixtures/tui_renderer_goldens/atom_unicode_80x24_depth.txt @@ -0,0 +1,24 @@ + : + : C0³ + : /- + : / + : / / + : / + : -/ + : -/ + : /--/ + : /-/ + /--/ + /-/:::::: + /-/:: :::::::::: + /--/: :::::::::: + /-/:: :::::::::: + /--/:: ::::: + /-/:: + /--/:: + /-/::: + /--/:: + O1¹::: + :::: + :: +axes a:out b:E c:N depth 1:front 2:mid 3:back |-----------| 0.2 A \ No newline at end of file diff --git a/tests/fixtures/tui_renderer_goldens/boundary_ascii7_80x24.txt b/tests/fixtures/tui_renderer_goldens/boundary_ascii7_80x24.txt new file mode 100644 index 00000000..ff6aa2a9 --- /dev/null +++ b/tests/fixtures/tui_renderer_goldens/boundary_ascii7_80x24.txt @@ -0,0 +1,24 @@ +: ::::: +: ::C0^3 :::: +: ::/ +: ::/: / +: ::: / +: :::-/ +: :::/- +: ::/- +: :::/-/ +: :::/-/ +: :::/--/ +: :::/-/ +: :::/-/ +: ::::/-/ +: :::/--/ +: ::::/-/ +: ::: /-/ +: :::: /-/ +: ::: /--/ +: ::: /-/ +: :::: O1^1 +:::::: + ::::: +axes a:out b:E c:N depth 1:front 2:mid 3:back |----------------| 1 A \ No newline at end of file diff --git a/tests/fixtures/tui_renderer_goldens/episode_ascii7_80x24.jsonl b/tests/fixtures/tui_renderer_goldens/episode_ascii7_80x24.jsonl new file mode 100644 index 00000000..e5b75a0d --- /dev/null +++ b/tests/fixtures/tui_renderer_goldens/episode_ascii7_80x24.jsonl @@ -0,0 +1,5 @@ +{"closed":false,"observation":{"camera":{"azimuth":30.0,"elevation":20.0,"pan_x":0.0,"pan_y":0.0,"projection":"orthographic","roll":0.0,"target":[0.0,0.0,0.0],"zoom":1.0},"capabilities":["observe","set_camera","orbit","rotation_sequence","set_slice","step_slice","align","fit","pan","zoom","set_display","set_selection_mode","select_atom","select_next","select_neighbor","select_direction","select_screen","pin_selection","clear_selection","inspect_selected","focus_atom","focus_molecule","focus_selection","focus_local","clear_focus","save_view","restore_view","list_views","reset_view"],"display":{"display_level":"atom","label_mode":"auto","mono":true,"parallax":false,"show_axes":true,"show_bonds":true,"show_boundary":false,"show_cell":true,"show_depth":true,"show_hydrogen":false,"show_minor":false,"slice_axis":"off","slice_index":0,"slice_width":1.0},"focus":{"framed_copy_ids":[],"hidden_copy_ids":[],"kind":null,"matched_copy_ids":[]},"frame":{"height":24,"lines":[" : "," : C0^3 "," : /- "," : / "," : / / "," : / "," : -/ "," : -/ "," : /--/ "," : /-/ "," /--/ "," /-/:::::: "," /-/:: :::::::::: "," /--/: :::::::::: "," /-/:: :::::::::: "," /--/:: :::::"," /-/:: "," /--/:: "," /-/::: "," /--/:: "," O1^1:: "," :::: "," :: ","axes a:out b:E c:N depth 1:front 2:mid 3:back |-----------| 0.2 A "],"sha256":"2cb8be90cddb38e326a2b20794ad82d4ef745d349f0bbccaa511aea5377f34fb","text":" : \n : C0^3 \n : /- \n : / \n : / / \n : / \n : -/ \n : -/ \n : /--/ \n : /-/ \n /--/ \n /-/:::::: \n /-/:: :::::::::: \n /--/: :::::::::: \n /-/:: :::::::::: \n /--/:: :::::\n /-/:: \n /--/:: \n /-/::: \n /--/:: \n O1^1:: \n :::: \n :: \naxes a:out b:E c:N depth 1:front 2:mid 3:back |-----------| 0.2 A ","width":80},"revision":0,"schema":"mattervis.tui.session/v1","scope":{"canonical_composition":{"C":1,"O":1},"canonical_formula":"CO","display_atom_count":2,"display_composition":{"C":1,"O":1},"display_formula":"CO","display_level":"atom","display_mode":"structure","expanded_atom_count":null,"source_site_atom_count":null,"visible_atom_count":2,"visible_composition":{"C":1,"O":1},"visible_formula":"CO","visible_marker_count":2},"selection":{"atom_id":null,"display_copy_id":null,"display_index":null,"label":null,"mode":false,"pinned":false},"state_fingerprint":"2b9cc8fb5747017f06bb0c3591e96fd855f22e9f9a769c5919a5f5a29ac651bb","title":"CO 2 displayed [structure] | az=30\u00b0 el=20\u00b0 | ortho | label","viewport":{"height":24,"scale":103.99650782763032,"width":80,"x_max":0.3733333333333333,"x_min":-0.3733333333333333,"y_max":0.22116127243543116,"y_min":-0.22116127243543116},"warnings":["CHEMISTRY NOT AVAILABLE: no MolCrysKit chemistry records for this structure"]},"ok":true,"schema":"mattervis.tui.session/v1"} +{"closed":false,"observation":{"camera":{"azimuth":44.99999999999999,"elevation":20.0,"pan_x":0.0,"pan_y":0.0,"projection":"orthographic","roll":108.88172123090686,"target":[0.0,0.0,0.0],"zoom":1.0},"capabilities":["observe","set_camera","orbit","rotation_sequence","set_slice","step_slice","align","fit","pan","zoom","set_display","set_selection_mode","select_atom","select_next","select_neighbor","select_direction","select_screen","pin_selection","clear_selection","inspect_selected","focus_atom","focus_molecule","focus_selection","focus_local","clear_focus","save_view","restore_view","list_views","reset_view"],"display":{"display_level":"atom","label_mode":"auto","mono":true,"parallax":false,"show_axes":true,"show_bonds":true,"show_boundary":false,"show_cell":true,"show_depth":true,"show_hydrogen":false,"show_minor":false,"slice_axis":"off","slice_index":0,"slice_width":1.0},"focus":{"framed_copy_ids":[],"hidden_copy_ids":[],"kind":null,"matched_copy_ids":[]},"frame":{"height":24,"lines":[" : "," : "," : "," : "," : C0^3 "," : / -- "," : /- / "," : -- "," : / -- "," : /---/ "," /----/ "," /---/::: "," /---/::: : :::::: "," /----/:: : :::::: "," /---/::: : :::::: "," /---/:: : ::::: "," /----/: : :::::: "," O1^1:: : :::::: ","::: : :::"," : "," : "," : "," : ","axes a:W b:E c:N depth 1:front 2:mid 3:back |--------| 0.2 A "],"sha256":"58a45f90950f754e3983b1f50f4aa60749ad2754ec6f1fea55a15cef61852f7f","text":" : \n : \n : \n : \n : C0^3 \n : / -- \n : /- / \n : -- \n : / -- \n : /---/ \n /----/ \n /---/::: \n /---/::: : :::::: \n /----/:: : :::::: \n /---/::: : :::::: \n /---/:: : ::::: \n /----/: : :::::: \n O1^1:: : :::::: \n::: : :::\n : \n : \n : \n : \naxes a:W b:E c:N depth 1:front 2:mid 3:back |--------| 0.2 A ","width":80},"revision":1,"schema":"mattervis.tui.session/v1","scope":{"canonical_composition":{"C":1,"O":1},"canonical_formula":"CO","display_atom_count":2,"display_composition":{"C":1,"O":1},"display_formula":"CO","display_level":"atom","display_mode":"structure","expanded_atom_count":null,"source_site_atom_count":null,"visible_atom_count":2,"visible_composition":{"C":1,"O":1},"visible_formula":"CO","visible_marker_count":2},"selection":{"atom_id":null,"display_copy_id":null,"display_index":null,"label":null,"mode":false,"pinned":false},"state_fingerprint":"f5f60b1977f14b31896fa6c2f1e259ae9e8bb28d6eec8ad1194b3dada6f43cd3","title":"CO 2 displayed [structure] | az=45\u00b0 el=20\u00b0 r=109\u00b0 | ortho | label","viewport":{"height":24,"scale":74.81442283089812,"width":80,"x_max":0.5279730632859554,"x_min":-0.5279730632859554,"y_max":0.1805774227771549,"y_min":-0.1805774227771549},"warnings":["CHEMISTRY NOT AVAILABLE: no MolCrysKit chemistry records for this structure"]},"ok":true,"schema":"mattervis.tui.session/v1"} +{"closed":false,"observation":{"camera":{"azimuth":44.99999999999999,"elevation":20.0,"pan_x":0.0,"pan_y":0.0,"projection":"orthographic","roll":108.88172123090686,"target":[0.0,0.0,0.0],"zoom":1.0},"capabilities":["observe","set_camera","orbit","rotation_sequence","set_slice","step_slice","align","fit","pan","zoom","set_display","set_selection_mode","select_atom","select_next","select_neighbor","select_direction","select_screen","pin_selection","clear_selection","inspect_selected","focus_atom","focus_molecule","focus_selection","focus_local","clear_focus","save_view","restore_view","list_views","reset_view"],"display":{"display_level":"atom","label_mode":"element","mono":true,"parallax":false,"show_axes":true,"show_bonds":true,"show_boundary":false,"show_cell":true,"show_depth":false,"show_hydrogen":false,"show_minor":false,"slice_axis":"off","slice_index":0,"slice_width":1.0},"focus":{"framed_copy_ids":[],"hidden_copy_ids":[],"kind":null,"matched_copy_ids":[]},"frame":{"height":24,"lines":[" : "," : "," : "," : "," : /-C "," : /----/ "," : /---/ "," : /---/ "," : /----/ "," : /---/ "," /----/ "," /---/::: "," /---/::: : :::::: "," /----/:: : :::::: "," /---/::: : :::::: "," /---/:: : ::::: "," /----/: : :::::: "," :O-/:: : :::::: ","::: : :::"," : "," : "," : "," : ","axes a:W b:E c:N |-----------------------| 0.5 A "],"sha256":"b20af9b20ec79534838a2a0b9bb7a979945cd0ab1dd54dd0c9baa834f17c0e47","text":" : \n : \n : \n : \n : /-C \n : /----/ \n : /---/ \n : /---/ \n : /----/ \n : /---/ \n /----/ \n /---/::: \n /---/::: : :::::: \n /----/:: : :::::: \n /---/::: : :::::: \n /---/:: : ::::: \n /----/: : :::::: \n :O-/:: : :::::: \n::: : :::\n : \n : \n : \n : \naxes a:W b:E c:N |-----------------------| 0.5 A ","width":80},"revision":2,"schema":"mattervis.tui.session/v1","scope":{"canonical_composition":{"C":1,"O":1},"canonical_formula":"CO","display_atom_count":2,"display_composition":{"C":1,"O":1},"display_formula":"CO","display_level":"atom","display_mode":"structure","expanded_atom_count":null,"source_site_atom_count":null,"visible_atom_count":2,"visible_composition":{"C":1,"O":1},"visible_formula":"CO","visible_marker_count":2},"selection":{"atom_id":null,"display_copy_id":null,"display_index":null,"label":null,"mode":false,"pinned":false},"state_fingerprint":"b33495ea476da94ef6bc958b554e845f8b6a329b5fec28fb3941bd5b74d2760b","title":"CO 2 displayed [structure] | az=45\u00b0 el=20\u00b0 r=109\u00b0 | ortho | element","viewport":{"height":24,"scale":74.81442283089812,"width":80,"x_max":0.5279730632859554,"x_min":-0.5279730632859554,"y_max":0.1805774227771549,"y_min":-0.1805774227771549},"warnings":["CHEMISTRY NOT AVAILABLE: no MolCrysKit chemistry records for this structure"]},"ok":true,"schema":"mattervis.tui.session/v1"} +{"closed":false,"observation":{"camera":{"azimuth":30.0,"elevation":20.0,"pan_x":0.0,"pan_y":0.0,"projection":"orthographic","roll":0.0,"target":[0.0,0.0,0.0],"zoom":1.0},"capabilities":["observe","set_camera","orbit","rotation_sequence","set_slice","step_slice","align","fit","pan","zoom","set_display","set_selection_mode","select_atom","select_next","select_neighbor","select_direction","select_screen","pin_selection","clear_selection","inspect_selected","focus_atom","focus_molecule","focus_selection","focus_local","clear_focus","save_view","restore_view","list_views","reset_view"],"display":{"display_level":"atom","label_mode":"auto","mono":true,"parallax":false,"show_axes":true,"show_bonds":true,"show_boundary":false,"show_cell":true,"show_depth":true,"show_hydrogen":false,"show_minor":false,"slice_axis":"off","slice_index":0,"slice_width":1.0},"focus":{"framed_copy_ids":[],"hidden_copy_ids":[],"kind":null,"matched_copy_ids":[]},"frame":{"height":24,"lines":[" : "," : C0^3 "," : /- "," : / "," : / / "," : / "," : -/ "," : -/ "," : /--/ "," : /-/ "," /--/ "," /-/:::::: "," /-/:: :::::::::: "," /--/: :::::::::: "," /-/:: :::::::::: "," /--/:: :::::"," /-/:: "," /--/:: "," /-/::: "," /--/:: "," O1^1:: "," :::: "," :: ","axes a:out b:E c:N depth 1:front 2:mid 3:back |-----------| 0.2 A "],"sha256":"2cb8be90cddb38e326a2b20794ad82d4ef745d349f0bbccaa511aea5377f34fb","text":" : \n : C0^3 \n : /- \n : / \n : / / \n : / \n : -/ \n : -/ \n : /--/ \n : /-/ \n /--/ \n /-/:::::: \n /-/:: :::::::::: \n /--/: :::::::::: \n /-/:: :::::::::: \n /--/:: :::::\n /-/:: \n /--/:: \n /-/::: \n /--/:: \n O1^1:: \n :::: \n :: \naxes a:out b:E c:N depth 1:front 2:mid 3:back |-----------| 0.2 A ","width":80},"revision":0,"schema":"mattervis.tui.session/v1","scope":{"canonical_composition":{"C":1,"O":1},"canonical_formula":"CO","display_atom_count":2,"display_composition":{"C":1,"O":1},"display_formula":"CO","display_level":"atom","display_mode":"structure","expanded_atom_count":null,"source_site_atom_count":null,"visible_atom_count":2,"visible_composition":{"C":1,"O":1},"visible_formula":"CO","visible_marker_count":2},"selection":{"atom_id":null,"display_copy_id":null,"display_index":null,"label":null,"mode":false,"pinned":false},"state_fingerprint":"2b9cc8fb5747017f06bb0c3591e96fd855f22e9f9a769c5919a5f5a29ac651bb","title":"CO 2 displayed [structure] | az=30\u00b0 el=20\u00b0 | ortho | label","viewport":{"height":24,"scale":103.99650782763032,"width":80,"x_max":0.3733333333333333,"x_min":-0.3733333333333333,"y_max":0.22116127243543116,"y_min":-0.22116127243543116},"warnings":["CHEMISTRY NOT AVAILABLE: no MolCrysKit chemistry records for this structure"]},"ok":true,"schema":"mattervis.tui.session/v1"} +{"closed":true,"ok":true,"schema":"mattervis.tui.session/v1"} diff --git a/tests/fixtures/tui_renderer_goldens/framework_ascii7_100x36.txt b/tests/fixtures/tui_renderer_goldens/framework_ascii7_100x36.txt new file mode 100644 index 00000000..c0b076f7 --- /dev/null +++ b/tests/fixtures/tui_renderer_goldens/framework_ascii7_100x36.txt @@ -0,0 +1,36 @@ + : + : + : + C0^3: + :C2^3 ::: + :C5^3 + C8^3/ + C11^3/ + C14^3/ + C16^3 + C19^3: + C21^3 + C23^2 + C26^2 + C29^2/ + C32^2/: + C35^2/: + C37^2: + C40^2/: + C42^2:: + C45^2:: + C47^1: + C50^1/: + C53^1/:: + C56^1/::: + C58^1::: + C61^1/:: + C63^1::: + C66^1::: + C68^1::: + C69^1: + ::: +:: + + +axes a:out b:E c:N depth 1:front 2:mid 3:back |------------------------| 10 A \ No newline at end of file diff --git a/tests/fixtures/tui_renderer_goldens/index.json b/tests/fixtures/tui_renderer_goldens/index.json new file mode 100644 index 00000000..5f9422e4 --- /dev/null +++ b/tests/fixtures/tui_renderer_goldens/index.json @@ -0,0 +1,45 @@ +{ + "cases": { + "atom_ascii7_100x36_nodepth": { + "file": "atom_ascii7_100x36_nodepth.txt", + "sha256": "b17dea198f3c98a9912bb4157e85935a776e52cc51f776ef2c204e768c9fe087" + }, + "atom_ascii7_80x24_depth": { + "file": "atom_ascii7_80x24_depth.txt", + "sha256": "be4df68ee444bbcc08dbf8aefda9afafd0d4793ccbac8b924656aab06aa068d3" + }, + "atom_unicode_100x36_nodepth": { + "file": "atom_unicode_100x36_nodepth.txt", + "sha256": "6c7a19d7cb08f3304b0bb0fbe35c000956f3b6dd70a9cb1d7023f26d309f8471" + }, + "atom_unicode_80x24_depth": { + "file": "atom_unicode_80x24_depth.txt", + "sha256": "9bbab7b1d9cd2a4e02e1eb6adbf655fa821636532212b67ba4cfd8e4ce9e9349" + }, + "boundary_ascii7_80x24": { + "file": "boundary_ascii7_80x24.txt", + "sha256": "9481ddfe4bf303ba586ac1f6563f3f2b7e268884b5133a84e57d0052ac5149b2" + }, + "framework_ascii7_100x36": { + "file": "framework_ascii7_100x36.txt", + "sha256": "ae16d6f98193c1b12ed600361fb98695d4f1b973b35f3093485c04517ddc54bd" + }, + "molecule_ascii7_80x24": { + "file": "molecule_ascii7_80x24.txt", + "sha256": "e272f6292cdbcb30d6d68a69b66df187fe5090fe09796978c82534649746c836" + }, + "water_atom_unicode_80x24": { + "file": "water_atom_unicode_80x24.txt", + "sha256": "1ba9457cf0fe1d68a542e862a42d4dfc502e72e2d0f519a51cc0ec1523bc02a0" + }, + "water_molecule_ascii7_100x36": { + "file": "water_molecule_ascii7_100x36.txt", + "sha256": "27f1ccd1dc14db7c08bbb8b57f8e37e7b0996ae82b16d88cae1520713cb85cf4" + } + }, + "description": "Built-in compositor frames frozen for mattervis.native-v1.", + "episode": { + "file": "episode_ascii7_80x24.jsonl", + "sha256": "0e37d8f0adb1a0365a60544c7b99525d21ef0fceddf4fee98a72d18ad809408b" + } +} diff --git a/tests/fixtures/tui_renderer_goldens/molecule_ascii7_80x24.txt b/tests/fixtures/tui_renderer_goldens/molecule_ascii7_80x24.txt new file mode 100644 index 00000000..29c3c799 --- /dev/null +++ b/tests/fixtures/tui_renderer_goldens/molecule_ascii7_80x24.txt @@ -0,0 +1,24 @@ + : + : + : + : / + : / + : + : + : / + : M0^3 + : CO + : + : / + :::::: / + ::: ::::::::: + :::: :::::::::: + ::: ::::::::: + ::: ::::: + :::: + N^1 ::: + N ::: + :::: + ::: + :: +axes a:out b:E c:N depth 1:front 2:mid 3:back |---------| 0.2 A \ No newline at end of file diff --git a/tests/fixtures/tui_renderer_goldens/water_atom_unicode_80x24.txt b/tests/fixtures/tui_renderer_goldens/water_atom_unicode_80x24.txt new file mode 100644 index 00000000..7eb6951c --- /dev/null +++ b/tests/fixtures/tui_renderer_goldens/water_atom_unicode_80x24.txt @@ -0,0 +1,24 @@ + + + + + + + + + + + + O1² + + + + + + + + + + + +axes a:out b:E c:N depth 1:front 2:mid 3:back |-------| 0.002 A \ No newline at end of file diff --git a/tests/fixtures/tui_renderer_goldens/water_molecule_ascii7_100x36.txt b/tests/fixtures/tui_renderer_goldens/water_molecule_ascii7_100x36.txt new file mode 100644 index 00000000..05dc1ef5 --- /dev/null +++ b/tests/fixtures/tui_renderer_goldens/water_molecule_ascii7_100x36.txt @@ -0,0 +1,36 @@ + + + + + + + + + + + + + /---------------------------\ + /--/ \----------------------/----- + /-/ /---------/ + /--/ M0^2 /---------/ + /--/ H2O /---------/ + /-/ /---------/ + /--/ /---------/ + /-/ /---------/ + /--//---------/ + -----/ + + + + + + + + + + + + + +axes a:out b:E c:N depth 1:front 2:mid 3:back |-------------------------| 0.2 A \ No newline at end of file diff --git a/tests/tui/test_tui_renderer_backend.py b/tests/tui/test_tui_renderer_backend.py new file mode 100644 index 00000000..54a81f81 --- /dev/null +++ b/tests/tui/test_tui_renderer_backend.py @@ -0,0 +1,1012 @@ +"""Pluggable TUI renderer contract. + +Frozen text files are the built-in compositor. Dispatch must reproduce them +for the default backend. External backends are selected by entry point and +must not receive chemistry records. +""" + +from __future__ import annotations + +from hashlib import sha256 +from io import StringIO +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import numpy as np +import pytest + +from mat_viewer.math.camera import Camera, project_points +from mat_viewer.tui import ( + NATIVE_BACKEND_ID, + RENDER_API_VERSION, + TerminalAction, + TerminalSession, +) +from mat_viewer.tui.compositor import compose_frame +from mat_viewer.tui.crystal_ir import AtomIR, BondIR, CrystalIR, Lattice +from mat_viewer.tui.loader_adapter import load_for_tui +from mat_viewer.tui.render_backend import ( + native_host_sources_bound, + render_picture, + resolve_renderer_binding, +) +from mat_viewer.tui.render_contract import ( + RENDERER_ENTRY_GROUP, + RendererContractError, + build_render_request, +) +from mat_viewer.tui.session import ACTION_SCHEMA, run_jsonl_session + + +ROOT = Path(__file__).resolve().parents[2] +GOLDEN = ROOT / "tests" / "fixtures" / "tui_renderer_goldens" +WATER = ROOT / "benchmarks" / "structures" / "molecular_water.cif" +EXAMPLE_SRC = ROOT / "examples" / "mattervis_mark_renderer" / "src" +EXAMPLE_PACKAGE = EXAMPLE_SRC / "mattervis_mark_renderer" +PIN = ROOT / "examples" / "mattervis_mark_renderer" / "pins" / "example.marks-v1.json" +CANARY = "CANARY-GOLD-9f3a" +LEGACY_KEYS = { + "capabilities", + "camera", + "display", + "focus", + "frame", + "revision", + "schema", + "scope", + "selection", + "state_fingerprint", + "title", + "viewport", + "warnings", +} + + +def _lattice(length: float = 8.0) -> Lattice: + return Lattice( + a=length, + b=length, + c=length, + alpha=90.0, + beta=90.0, + gamma=90.0, + matrix=np.diag([length, length, length]), + ) + + +def _atom(element: str, xyz, index: int, **kwargs) -> AtomIR: + return AtomIR( + element, + np.array(xyz, dtype=float), + np.zeros(3), + atom_id=kwargs.pop("atom_id", f"site:{element}{index}"), + label=kwargs.pop("label", f"{element}{index}"), + index=index, + display_copy_id=kwargs.pop("display_copy_id", f"copy:{element}{index}"), + **kwargs, + ) + + +def _pair() -> CrystalIR: + return CrystalIR( + title="pair", + formula="CO", + lattice=_lattice(), + atoms=[ + _atom("C", (-1.0, 0.0, 0.0), 0), + _atom("O", (1.0, 0.0, 0.0), 1), + ], + bonds=[BondIR(0, 1, 2.0)], + metadata={"bond_source": "distance_heuristic"}, + ) + + +def _molecule() -> CrystalIR: + return CrystalIR( + title="molecules", + lattice=_lattice(), + atoms=[ + _atom("C", (-1.2, 0.0, 0.0), 0, molecule_index=0), + _atom("O", (-0.2, 0.4, 0.0), 1, molecule_index=0), + _atom("N", (1.4, 0.0, 0.2), 2, molecule_index=1), + ], + bonds=[BondIR(0, 1, 1.2)], + n_molecules=2, + species_map={"CO": [0], "N": [1]}, + metadata={"bond_source": "distance_heuristic"}, + ) + + +def _framework() -> CrystalIR: + atoms = [ + _atom("C", (float(index), 0.0, 0.1 * (index % 3)), index, molecule_index=0) + for index in range(70) + ] + bonds = [BondIR(index - 1, index, 1.0) for index in range(1, 70)] + return CrystalIR( + title="framework", + lattice=_lattice(80.0), + atoms=atoms, + bonds=bonds, + n_molecules=1, + species_map={"C70": [0]}, + metadata={"bond_source": "distance_heuristic"}, + ) + + +def _boundary() -> CrystalIR: + return CrystalIR( + title="boundary", + lattice=_lattice(), + atoms=[ + _atom("C", (0.2, 0.0, 0.0), 0), + _atom( + "O", + (7.5, 0.2, 0.0), + 1, + image_shift=(1, 0, 0), + is_boundary_replica=True, + display_copy_id="copy:O1#img", + ), + ], + bonds=[BondIR(0, 1, 1.4, image_relation=(1, 0, 0))], + metadata={"bond_source": "distance_heuristic", "explicit_bond_table": False}, + ) + + +def _camera(crystal: CrystalIR) -> Camera: + return Camera.from_view_name("diagonal", crystal) + + +def _picture(crystal: CrystalIR, *, charset: str, width: int, height: int, **kwargs) -> str: + camera = kwargs.pop("camera", None) or _camera(crystal) + points, depth = project_points(camera, crystal.cart_coords) + arguments = dict( + width=width, + height=height, + mono=charset == "ascii7", + charset=charset, + label_mode="label", + show_bonds=True, + show_cell=True, + show_minor=False, + show_hydrogen=False, + show_boundary=False, + zoom=camera.viewport_zoom, + pan_x=camera.pan_x, + pan_y=camera.pan_y, + display_level="atom", + show_axes=True, + show_depth=True, + ) + arguments.update(kwargs) + direct = compose_frame(crystal, camera, points, depth, **arguments) + rendered = render_picture(crystal, camera, points, depth, **arguments) + assert rendered.text == direct + assert rendered.backend_id == NATIVE_BACKEND_ID + return direct + + +def _frame_specs(): + return ( + ("atom_ascii7_80x24_depth", _pair(), dict(charset="ascii7", width=80, height=24)), + ("atom_ascii7_100x36_nodepth", _pair(), dict(charset="ascii7", width=100, height=36, show_depth=False)), + ("atom_unicode_80x24_depth", _pair(), dict(charset="unicode", width=80, height=24)), + ("atom_unicode_100x36_nodepth", _pair(), dict(charset="unicode", width=100, height=36, show_depth=False)), + ("molecule_ascii7_80x24", _molecule(), dict(charset="ascii7", width=80, height=24, display_level="molecule")), + ("framework_ascii7_100x36", _framework(), dict(charset="ascii7", width=100, height=36, display_level="molecule")), + ("boundary_ascii7_80x24", _boundary(), dict(charset="ascii7", width=80, height=24, show_boundary=True)), + ) + + +def _episode_stdout() -> str: + source = StringIO() + requests = ( + {"schema": ACTION_SCHEMA, "action": "observe"}, + {"schema": ACTION_SCHEMA, "action": "orbit", "arguments": {"yaw_deg": 15.0}}, + { + "schema": ACTION_SCHEMA, + "action": "set_display", + "arguments": {"label_mode": "element", "show_depth": False}, + }, + {"schema": ACTION_SCHEMA, "action": "reset"}, + {"schema": ACTION_SCHEMA, "action": "close"}, + ) + for request in requests: + source.write(json.dumps(request) + "\n") + source.seek(0) + target = StringIO() + session = TerminalSession(_pair(), width=80, height=24, charset="ascii7") + run_jsonl_session(session, input_stream=source, output_stream=target) + return target.getvalue() + + +def write_goldens() -> None: + GOLDEN.mkdir(parents=True, exist_ok=True) + index = {"description": "Built-in compositor frames frozen for mattervis.native-v1.", "cases": {}} + for name, crystal, kwargs in _frame_specs(): + text = _picture(crystal, **kwargs) + (GOLDEN / f"{name}.txt").write_text(text, encoding="utf-8") + index["cases"][name] = { + "file": f"{name}.txt", + "sha256": sha256(text.encode("utf-8")).hexdigest(), + } + water = load_for_tui(str(WATER)) + for name, kwargs in ( + ("water_atom_unicode_80x24", dict(charset="unicode", width=80, height=24, display_level="atom")), + ("water_molecule_ascii7_100x36", dict(charset="ascii7", width=100, height=36, display_level="molecule")), + ): + text = _picture(water, **kwargs) + (GOLDEN / f"{name}.txt").write_text(text, encoding="utf-8") + index["cases"][name] = { + "file": f"{name}.txt", + "sha256": sha256(text.encode("utf-8")).hexdigest(), + } + episode = _episode_stdout() + (GOLDEN / "episode_ascii7_80x24.jsonl").write_text(episode, encoding="utf-8") + index["episode"] = { + "file": "episode_ascii7_80x24.jsonl", + "sha256": sha256(episode.encode("utf-8")).hexdigest(), + } + (GOLDEN / "index.json").write_text(json.dumps(index, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _legacy_fingerprint(session: TerminalSession, observation) -> str: + state = observation.observation.state.as_dict() + state.pop("revision", None) + state["charset"] = session.charset + material = json.dumps( + state, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + return sha256(material).hexdigest() + + +def _external_backend(render, **overrides): + values = dict( + backend_id="example.fake-v1", + api_version=RENDER_API_VERSION, + deterministic=True, + package_version="0.0.0", + implementation_digest="ab" * 32, + ) + values.update(overrides) + + class Backend: + backend_id = values["backend_id"] + api_version = values["api_version"] + deterministic = values["deterministic"] + package_version = values["package_version"] + implementation_digest = values["implementation_digest"] + + def validate_options(self, options): + if options and not values.get("allow_options", False): + raise ValueError("unsupported renderer options") + return dict(options) + + def render(self, request): + return render(self, request) + + return Backend() + + +def _install_mark_renderer(target: Path) -> None: + shutil.copytree(EXAMPLE_PACKAGE, target / "mattervis_mark_renderer") + dist = target / "mattervis_mark_renderer-0.1.0.dist-info" + dist.mkdir() + (dist / "METADATA").write_text( + "Metadata-Version: 2.1\nName: mattervis-mark-renderer\nVersion: 0.1.0\n", + encoding="utf-8", + ) + (dist / "entry_points.txt").write_text( + f"[{RENDERER_ENTRY_GROUP}]\nexample.marks-v1 = mattervis_mark_renderer:create_renderer\n", + encoding="utf-8", + ) + + +@pytest.mark.parametrize("name,crystal,kwargs", _frame_specs(), ids=[item[0] for item in _frame_specs()]) +def test_frozen_native_frames_match_dispatch(name, crystal, kwargs) -> None: + text = _picture(crystal, **kwargs) + golden = (GOLDEN / f"{name}.txt").read_text(encoding="utf-8") + assert text == golden + assert sha256(text.encode("utf-8")).hexdigest() == json.loads((GOLDEN / "index.json").read_text())["cases"][name]["sha256"] + + +def test_frozen_water_and_episode() -> None: + water = load_for_tui(str(WATER)) + for name, kwargs in ( + ("water_atom_unicode_80x24", dict(charset="unicode", width=80, height=24, display_level="atom")), + ("water_molecule_ascii7_100x36", dict(charset="ascii7", width=100, height=36, display_level="molecule")), + ): + text = _picture(water, **kwargs) + assert text == (GOLDEN / f"{name}.txt").read_text(encoding="utf-8") + episode = _episode_stdout() + assert episode == (GOLDEN / "episode_ascii7_80x24.jsonl").read_text(encoding="utf-8") + rows = [json.loads(line) for line in episode.splitlines() if line] + assert [row["ok"] for row in rows] == [True, True, True, True, True] + initial = rows[0]["observation"] + rotated = rows[1]["observation"] + displayed = rows[2]["observation"] + reset = rows[3]["observation"] + assert rotated["state_fingerprint"] != initial["state_fingerprint"] + assert reset["state_fingerprint"] == initial["state_fingerprint"] + assert reset["frame"]["sha256"] == initial["frame"]["sha256"] + assert displayed["display"]["label_mode"] == "element" + assert displayed["display"]["show_depth"] is False + assert "renderer" not in initial + assert "observation_profile" not in initial + assert set(initial) == LEGACY_KEYS + + +def test_active_path_matches_compose_frame_after_orbit() -> None: + from mat_viewer.tui.controller import TerminalViewController + + crystal = _pair() + controller = TerminalViewController( + crystal, width=80, height=24, mono=True, label_mode="label", display_level="atom" + ) + controller.orbit(yaw_deg=20.0, pitch_deg=10.0) + points, depth = project_points(controller.camera, crystal.cart_coords) + direct = compose_frame( + crystal, + controller.camera, + points, + depth, + width=controller.width, + height=controller.height, + mono=True, + label_mode="label", + show_bonds=True, + show_cell=True, + show_minor=False, + show_hydrogen=False, + show_boundary=False, + zoom=controller.camera.viewport_zoom, + pan_x=controller.camera.pan_x, + pan_y=controller.camera.pan_y, + display_level=controller._display_level, + viewport=controller._fit_viewport, + selected_display_index=controller._selection.display_index, + charset="ascii7", + show_axes=True, + show_depth=True, + ) + assert controller.render_frame(charset="ascii7") == direct + assert native_host_sources_bound() is False + + +def test_static_frame_uses_the_same_dispatch() -> None: + from argparse import Namespace + + from mat_viewer.tui.cli import compose_static_frame + + crystal = _pair() + camera = _camera(crystal) + points, depth = project_points(camera, crystal.cart_coords) + args = Namespace( + width=80, + height=24, + charset="ascii7", + mono=False, + no_bonds=False, + no_cell=False, + show_minor=False, + hydrogen=False, + boundary=False, + axes=True, + no_depth=False, + slice_axis="off", + slice_index=0, + slice_width=1.0, + renderer=None, + renderer_config=None, + observation_profile="legacy", + ) + static = compose_static_frame( + args, crystal, camera, points, depth, label_mode="label", display_level="atom" + ) + direct = _picture(crystal, charset="ascii7", width=80, height=24, camera=camera) + assert static == direct + + +def test_default_session_fingerprint_and_keys_are_legacy() -> None: + session = TerminalSession(_pair(), width=40, height=12, charset="ascii7") + observation = session.observe() + payload = observation.as_dict() + assert set(payload) == LEGACY_KEYS + assert observation.state_fingerprint == _legacy_fingerprint(session, observation) + assert "renderer" not in payload + manifest = session.replay_manifest() + assert manifest["backend_id"] == NATIVE_BACKEND_ID + assert manifest["schema"] == "mattervis.tui.renderer_replay/v1" + assert session.observe().as_dict()["state_fingerprint"] == observation.state_fingerprint + + +def test_renderer_is_not_a_session_action_and_stays_fixed() -> None: + marks = _load_marks() + session = TerminalSession(_pair(), width=40, height=12, charset="ascii7", renderer=marks) + before = session.observe() + backend = session.controller._renderer + assert "renderer" not in TerminalSession.ACTIONS + with pytest.raises(ValueError, match="unknown arguments"): + session.execute(TerminalAction("set_display", {"renderer": "example.marks-v1"})) + with pytest.raises(ValueError, match="unknown arguments"): + session.execute(TerminalAction("set_display", {"bond_policy": "bonds_off"})) + assert session.observe().state_fingerprint == before.state_fingerprint + session.execute(TerminalAction("orbit", {"yaw_deg": 5.0})) + assert session.controller._renderer is backend + session.reset() + assert session.controller._renderer is backend + + +def test_renderer_swap_preserves_camera_actions_and_changes_pixels(monkeypatch) -> None: + monkeypatch.syspath_prepend(str(EXAMPLE_SRC)) + from mattervis_mark_renderer import create_renderer + + native = TerminalSession(_pair(), width=40, height=12, charset="ascii7") + marks = TerminalSession( + _pair(), width=40, height=12, charset="ascii7", renderer=create_renderer() + ) + native_view = native.execute(TerminalAction("orbit", {"yaw_deg": 15.0, "pitch_deg": 5.0})) + marks_view = marks.execute(TerminalAction("orbit", {"yaw_deg": 15.0, "pitch_deg": 5.0})) + assert native_view is not None and marks_view is not None + assert native_view.state_fingerprint == marks_view.state_fingerprint + assert native_view.state_fingerprint == _legacy_fingerprint(native, native_view) + assert native_view.screen_hash != marks_view.screen_hash + assert native_view.observation.state.camera.as_dict() == marks_view.observation.state.camera.as_dict() + text = "".join(marks_view.screen_lines) + assert set(text) <= set(" 123#.") + assert "C" not in text and "O" not in text + assert any(character in text for character in "123") + + +def test_ascii7_external_frame_stays_bounded_and_replays() -> None: + crystal = _pair() + camera = _camera(crystal) + points, depth = project_points(camera, crystal.cart_coords) + backend = _load_marks() + first = render_picture( + crystal, camera, points, depth, + width=40, height=12, charset="ascii7", mono=True, backend=backend, + ) + second = render_picture( + crystal, camera, points, depth, + width=40, height=12, charset="ascii7", mono=True, backend=backend, + ) + assert first.text == second.text + assert first.content_sha256 == second.content_sha256 + assert first.input_digest == second.input_digest + assert first.view_digest == second.view_digest + assert all(ord(character) < 128 and (character == "\n" or 0x20 <= ord(character) <= 0x7E) for character in first.text) + native = render_picture( + crystal, camera, points, depth, width=40, height=12, charset="ascii7", mono=True + ) + assert native.text != first.text + + +def test_static_and_active_initial_frames_agree() -> None: + from mat_viewer.tui.controller import TerminalViewController + + crystal = _pair() + camera = _camera(crystal) + controller = TerminalViewController( + crystal, + camera=camera, + width=40, + height=12, + mono=True, + label_mode="element", + display_level="atom", + ) + active = controller.render_frame(charset="ascii7") + points, depth = project_points(controller.camera, crystal.cart_coords) + static = render_picture( + crystal, + controller.camera, + points, + depth, + width=40, + height=12, + mono=True, + charset="ascii7", + label_mode="element", + display_level="atom", + zoom=controller.camera.viewport_zoom, + pan_x=controller.camera.pan_x, + pan_y=controller.camera.pan_y, + viewport=controller._fit_viewport, + show_minor=False, + show_hydrogen=False, + show_boundary=False, + ).text + assert static == active + backend = _load_marks() + marked = render_picture( + crystal, + controller.camera, + points, + depth, + width=40, + height=12, + mono=True, + charset="ascii7", + label_mode="element", + display_level="atom", + zoom=controller.camera.viewport_zoom, + pan_x=controller.camera.pan_x, + pan_y=controller.camera.pan_y, + viewport=controller._fit_viewport, + show_minor=False, + backend=backend, + ) + again = render_picture( + crystal, + controller.camera, + points, + depth, + width=40, + height=12, + mono=True, + charset="ascii7", + label_mode="element", + display_level="atom", + zoom=controller.camera.viewport_zoom, + pan_x=controller.camera.pan_x, + pan_y=controller.camera.pan_y, + viewport=controller._fit_viewport, + show_minor=False, + backend=backend, + ) + assert marked.text == again.text + assert marked.text != static + + +def test_render_request_excludes_hidden_chemistry_and_preserves_bonds() -> None: + crystal = _boundary() + crystal.chemistry = _Poison((CANARY,)) + crystal.title = CANARY + crystal.canonical_formula = CANARY + crystal.spacegroup = CANARY + crystal.metadata = { + "bond_source": CANARY, + "gold": CANARY, + "explicit_bond_table": False, + "pbc": (True, True, False), + } + crystal.species_map = {CANARY: [0, 1]} + crystal.source_molecule_species = {0: CANARY} + camera = _camera(crystal) + points, depth = project_points(camera, crystal.cart_coords) + request = build_render_request( + crystal, + camera, + points, + depth, + width=40, + height=12, + mono=True, + label_mode="element", + show_bonds=True, + show_cell=True, + show_minor=False, + show_hydrogen=False, + show_boundary=True, + zoom=1.0, + pan_x=0.0, + pan_y=0.0, + display_level="atom", + viewport=None, + selected_display_index=None, + selected_display_copy_id=None, + focus_copy_ids=(), + charset="ascii7", + show_axes=True, + show_depth=True, + slice_axis="off", + slice_index=0, + slice_width=1.0, + ghost_points=None, + bond_policy="manifested", + backend_options={}, + ) + payload = json.dumps(request.as_dict()) + assert CANARY not in payload + assert request.display_ir.bond_graph_provenance == "unspecified" + assert request.display_ir.bonds[0].image_translation == (1, 0, 0) + assert request.display_ir.bonds[0].enabled is True + assert request.display_ir.sites[1].image_shift == (1, 0, 0) + assert request.display_ir.cell is not None + assert request.display_ir.cell.periodic_boundary == (True, True, False) + assert not hasattr(request, "crystal") + + +def test_poisoned_chemistry_does_not_change_model_observations() -> None: + left = _pair() + right = _pair() + left.chemistry = _Poison((f"{CANARY}-left",)) + right.chemistry = _Poison((f"{CANARY}-right",)) + left.title = f"{CANARY}-left" + right.title = f"{CANARY}-right" + left.canonical_formula = f"{CANARY}-left" + right.canonical_formula = f"{CANARY}-right" + left.spacegroup = "P-1" + right.spacegroup = "P1" + left.metadata = {**left.metadata, "gold": f"{CANARY}-left"} + right.metadata = {**right.metadata, "gold": f"{CANARY}-right"} + left_session = TerminalSession(left, width=40, height=12, charset="ascii7", observation_profile="model") + right_session = TerminalSession(right, width=40, height=12, charset="ascii7", observation_profile="model") + left_payload = left_session.observe().as_dict() + right_payload = right_session.observe().as_dict() + assert left_payload == right_payload + assert CANARY not in json.dumps(left_payload) + assert left_payload["title"] == "display" + assert left_payload["warnings"] == [] + assert left_payload["observation_profile"] == "model" + legacy = TerminalSession(left, width=40, height=12, charset="ascii7").observe() + assert legacy.state_fingerprint == left_session.observe().state_fingerprint + assert CANARY in json.dumps(legacy.as_dict()) + assert legacy.screen_hash == left_session.observe().screen_hash + + +def test_species_labels_stay_out_of_the_request_and_external_frame(monkeypatch) -> None: + monkeypatch.syspath_prepend(str(EXAMPLE_SRC)) + from mattervis_mark_renderer import create_renderer + + crystal = _molecule() + crystal.species_map = {CANARY: [0], "N": [1]} + camera = _camera(crystal) + points, depth = project_points(camera, crystal.cart_coords) + rendered = render_picture( + crystal, + camera, + points, + depth, + width=40, + height=12, + charset="ascii7", + mono=True, + display_level="molecule", + backend=create_renderer(), + ) + assert CANARY not in json.dumps(build_render_request( + crystal, + camera, + points, + depth, + width=40, + height=12, + mono=True, + label_mode="label", + show_bonds=True, + show_cell=True, + show_minor=False, + show_hydrogen=False, + show_boundary=False, + zoom=camera.viewport_zoom, + pan_x=0.0, + pan_y=0.0, + display_level="molecule", + viewport=None, + selected_display_index=None, + selected_display_copy_id=None, + focus_copy_ids=(), + charset="ascii7", + show_axes=True, + show_depth=True, + slice_axis="off", + slice_index=0, + slice_width=1.0, + ghost_points=None, + bond_policy="manifested", + backend_options={"example.marks-v1.marker": "#"}, + ).as_dict()) + assert CANARY not in rendered.text + + +def test_geometry_builder_does_not_touch_chemistry() -> None: + crystal = _pair() + + class Boom: + def __getattr__(self, name): + raise AssertionError(f"chemistry accessed: {name}") + + crystal.chemistry = Boom() + camera = _camera(crystal) + points, depth = project_points(camera, crystal.cart_coords) + render_picture(crystal, camera, points, depth, width=20, height=8, charset="ascii7", mono=True) + + +def test_provided_only_disables_inferred_bonds_without_repair() -> None: + crystal = _pair() + original = crystal.bonds + camera = _camera(crystal) + points, depth = project_points(camera, crystal.cart_coords) + common = dict( + width=40, + height=12, + mono=True, + label_mode="label", + show_bonds=True, + show_cell=True, + show_minor=False, + show_hydrogen=False, + show_boundary=False, + zoom=1.0, + pan_x=0.0, + pan_y=0.0, + display_level="atom", + viewport=None, + selected_display_index=None, + selected_display_copy_id=None, + focus_copy_ids=(), + charset="ascii7", + show_axes=True, + show_depth=True, + slice_axis="off", + slice_index=0, + slice_width=1.0, + ghost_points=None, + backend_options={}, + ) + limited = build_render_request( + crystal, camera, points, depth, bond_policy="provided_only", **common + ) + empty = CrystalIR( + lattice=_lattice(), + atoms=list(crystal.atoms), + bonds=[], + metadata={"bond_source": "distance_heuristic"}, + ) + untouched = build_render_request( + empty, camera, points, depth, bond_policy="manifested", **common + ) + assert limited.display_ir.bonds[0].enabled is False + assert limited.display_ir.bonds[0].provenance == "inferred" + assert crystal.bonds is original + assert untouched.display_ir.bonds == () + shown = render_picture(crystal, camera, points, depth, bond_policy="manifested", **common).text + hidden = render_picture(crystal, camera, points, depth, bond_policy="bonds_off", **common).text + assert shown != hidden + assert crystal.bonds is original + + +def test_missing_and_incompatible_backends_fail_closed(monkeypatch) -> None: + def no_entries(search_path=None): + return [] + + monkeypatch.setattr("mat_viewer.tui.render_backend._entry_points", no_entries) + with pytest.raises(RendererContractError, match="refusing to fall back"): + resolve_renderer_binding("example.missing-v1") + + class Spoof: + backend_id = NATIVE_BACKEND_ID + api_version = RENDER_API_VERSION + deterministic = True + package_version = "0" + implementation_digest = "cd" * 32 + + def validate_options(self, options): + return {} + + def render(self, request): + raise AssertionError("spoofed native renderer was called") + + with pytest.raises(RendererContractError, match="reserved"): + resolve_renderer_binding(Spoof()) + crystal = _pair() + camera = _camera(crystal) + points, depth = project_points(camera, crystal.cart_coords) + with pytest.raises(RendererContractError, match="reserved"): + render_picture(crystal, camera, points, depth, width=20, height=8, backend=Spoof()) + + bad = _external_backend(lambda self, request: "ok", api_version="mattervis.tui.render/v0") + with pytest.raises(RendererContractError, match="not"): + resolve_renderer_binding(bad) + drifting = _external_backend(lambda self, request: "ok", deterministic=False) + with pytest.raises(RendererContractError, match="not deterministic"): + resolve_renderer_binding(drifting) + with pytest.raises(RendererContractError, match="does not accept"): + resolve_renderer_binding(NATIVE_BACKEND_ID, renderer_options={"extra": 1}) + with pytest.raises(RendererContractError, match="does not match the pin"): + resolve_renderer_binding( + NATIVE_BACKEND_ID, + renderer_config={ + "schema": "mattervis.tui.renderer_config/v1", + "backend_id": NATIVE_BACKEND_ID, + "require_implementation_digest": "0" * 64, + }, + ) + + +def test_plugin_crash_and_malformed_frame_do_not_fall_back() -> None: + def explode(self, request): + assert native_host_sources_bound() is False + raise RuntimeError("plugin exploded") + + crashing = _external_backend(explode) + session = TerminalSession(_pair(), width=20, height=6, charset="ascii7", renderer=crashing) + with pytest.raises(RuntimeError, match="plugin exploded"): + session.observe() + source = StringIO('{"action":"observe"}\n{"action":"observe"}\n') + target = StringIO() + run_jsonl_session(session, input_stream=source, output_stream=target) + rows = [json.loads(line) for line in target.getvalue().splitlines()] + assert [row["ok"] for row in rows] == [False, False] + assert "plugin exploded" in rows[0]["error"]["message"] + assert "observation" not in rows[0] + + def wide(self, request): + return "x" * (request.render_options.width + 1) + + malformed = _external_backend(wide) + with pytest.raises(RendererContractError, match="exceeds"): + TerminalSession(_pair(), width=20, height=6, charset="ascii7", renderer=malformed).observe() + + def ansi(self, request): + return "\x1b[31mred" + + with pytest.raises(RendererContractError, match="control character"): + render_picture( + _pair(), + _camera(_pair()), + *project_points(_camera(_pair()), _pair().cart_coords), + width=20, + height=4, + charset="ascii7", + backend=_external_backend(ansi), + ) + + +def test_unicode_profile_allows_braille_and_ascii7_rejects_it() -> None: + braille = "⣿" + + def draw(self, request): + line = braille.ljust(request.render_options.width) + return "\n".join([line] * request.render_options.height) + + crystal = _pair() + camera = _camera(crystal) + points, depth = project_points(camera, crystal.cart_coords) + rendered = render_picture( + crystal, camera, points, depth, width=8, height=3, charset="unicode", backend=_external_backend(draw) + ) + assert braille in rendered.text + with pytest.raises(RendererContractError, match="ascii7"): + render_picture( + crystal, camera, points, depth, width=8, height=3, charset="ascii7", backend=_external_backend(draw) + ) + + +def test_installed_entry_point_changes_the_screen(tmp_path: Path, monkeypatch) -> None: + _install_mark_renderer(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + session = TerminalSession( + _pair(), + width=40, + height=12, + charset="ascii7", + renderer="example.marks-v1", + renderer_config=PIN, + renderer_search_path=(str(tmp_path),), + ) + native = TerminalSession(_pair(), width=40, height=12, charset="ascii7") + external = session.observe() + baseline = native.observe() + assert external.screen_hash != baseline.screen_hash + assert external.state_fingerprint == baseline.state_fingerprint + assert session.replay_manifest()["backend_id"] == "example.marks-v1" + assert session.replay_manifest()["selection"] == "entry_point" + assert "renderer" in external.as_dict() + text = "\n".join(external.screen_lines) + assert "C" not in text + + +def test_cli_renderer_fail_closed_and_external_smoke(tmp_path: Path) -> None: + _install_mark_renderer(tmp_path) + env = os.environ.copy() + env["PYTHONPATH"] = str(tmp_path) + os.pathsep + env.get("PYTHONPATH", "") + missing = subprocess.run( + [ + sys.executable, + "-m", + "mat_viewer", + "tui", + str(WATER), + "--no-interaction", + "--renderer", + "example.missing-v1", + "--width", + "40", + "--height", + "12", + ], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + ) + assert missing.returncode == 2 + assert missing.stdout == "" + assert "refusing to fall back" in missing.stderr + + native = subprocess.run( + [ + sys.executable, + "-m", + "mat_viewer", + "tui", + str(WATER), + "--no-interaction", + "--charset", + "ascii7", + "--width", + "40", + "--height", + "12", + "--level", + "atom", + ], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=True, + ) + external = subprocess.run( + [ + sys.executable, + "-m", + "mat_viewer", + "tui", + str(WATER), + "--no-interaction", + "--charset", + "ascii7", + "--width", + "40", + "--height", + "12", + "--level", + "atom", + "--renderer", + "example.marks-v1", + "--renderer-config", + str(PIN), + ], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=True, + ) + assert external.stdout != native.stdout + frame = "\n".join(external.stdout.splitlines()[1:]) + assert any(character in frame for character in "123") + assert "H" not in frame and "O" not in frame + assert all( + character == "\n" or 0x20 <= ord(character) <= 0x7E for character in frame + ) + + +class _Poison: + def __init__(self, warnings: tuple[str, ...]) -> None: + self.warnings = warnings + self.status = "explicit" + + +def _load_marks(): + sys.path.insert(0, str(EXAMPLE_SRC)) + from mattervis_mark_renderer import create_renderer + + return create_renderer() + + +if __name__ == "__main__": + write_goldens() From 50a93099cd11b64de135ec521c5f07cd3d392929 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 14:10:49 +0000 Subject: [PATCH 2/3] Keep renderer wiring inside the module size budget. Resolve the CLI renderer in the terminal CLI helper and attach it to the controller after construction so cli.py and the view controller stay within their line caps. Co-authored-by: Ming-Yu Guo --- mat_viewer/cli.py | 23 ++++------------------- mat_viewer/tui/app.py | 5 +++++ mat_viewer/tui/cli.py | 32 ++++++++++++++++++++++++++++---- mat_viewer/tui/controller.py | 15 +-------------- mat_viewer/tui/render_backend.py | 15 +++++++++++++++ mat_viewer/tui/session.py | 27 ++++++++++++++++++--------- 6 files changed, 71 insertions(+), 46 deletions(-) diff --git a/mat_viewer/cli.py b/mat_viewer/cli.py index d4f087c2..8f196758 100644 --- a/mat_viewer/cli.py +++ b/mat_viewer/cli.py @@ -382,6 +382,7 @@ def _tui_main(args: argparse.Namespace) -> None: sys.exit(1) from .math.camera import Camera, ProjectionMode, project_points + from .tui.cli import prepare_tui_renderer from .tui.loader_adapter import load_for_tui if args.zoom <= 0: @@ -394,14 +395,7 @@ def _tui_main(args: argparse.Namespace) -> None: print("Error: --height must be greater than zero.", file=sys.stderr) sys.exit(2) - from .tui.render_backend import RendererContractError, renderer_binding_from_cli - - try: - binding = renderer_binding_from_cli(args) - except RendererContractError as exc: - print(f"Error: {exc}", file=sys.stderr) - sys.exit(2) - + prepare_tui_renderer(args) display_mode = args.display crystal = load_for_tui( filepath, @@ -449,7 +443,7 @@ def _tui_main(args: argparse.Namespace) -> None: from .tui.cli import interactive_depth_options, run_session - if run_session(args, crystal, cam, label_mode=label_mode, binding=binding): + if run_session(args, crystal, cam, label_mode=label_mode): return if not args.interaction: @@ -458,13 +452,7 @@ def _tui_main(args: argparse.Namespace) -> None: from .tui.cli import render_static_output output = render_static_output( - args, - crystal, - cam, - pts_2d, - depth, - label_mode=label_mode, - binding=binding, + args, crystal, cam, pts_2d, depth, label_mode=label_mode ) print(output) else: @@ -490,9 +478,6 @@ def _tui_main(args: argparse.Namespace) -> None: show_hydrogen=bool(getattr(args, "hydrogen", False)), show_boundary=bool(getattr(args, "boundary", False)), initial_level=initial_level, - renderer=binding.backend, - renderer_options=binding.options, - bond_policy=binding.bond_policy, ) app.run() diff --git a/mat_viewer/tui/app.py b/mat_viewer/tui/app.py index 6bd5f5a5..4df2eaf8 100644 --- a/mat_viewer/tui/app.py +++ b/mat_viewer/tui/app.py @@ -224,6 +224,11 @@ def __init__( if initial_level in (*DISPLAY_LEVELS, AUTO_DISPLAY_LEVEL) else "atom" ), + ) + from .render_backend import bind_controller_renderer + + bind_controller_renderer( + self.controller, renderer=renderer, renderer_options=renderer_options, bond_policy=bond_policy, diff --git a/mat_viewer/tui/cli.py b/mat_viewer/tui/cli.py index 8c8f24fd..7bd7c1cc 100644 --- a/mat_viewer/tui/cli.py +++ b/mat_viewer/tui/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import sys def add_session_arguments(parser: argparse.ArgumentParser) -> None: @@ -76,14 +77,38 @@ def add_session_arguments(parser: argparse.ArgumentParser) -> None: ) +def prepare_tui_renderer(args: argparse.Namespace): + """Resolve ``--renderer`` once. A bad selection exits before drawing. + + The binding is cached on the CLI namespace so static, JSONL, and + interactive startup share one episode choice. + """ + cached = getattr(args, "_mattervis_renderer_binding", None) + if cached is not None: + return cached + from .render_backend import RendererContractError, renderer_binding_from_cli + + try: + binding = renderer_binding_from_cli(args) + except RendererContractError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(2) + args._mattervis_renderer_binding = binding + return binding + + def interactive_depth_options(args: argparse.Namespace) -> dict: """Depth options accepted by both the Textual app and semantic controller.""" + binding = prepare_tui_renderer(args) return { "show_depth": not getattr(args, "no_depth", False), "parallax": getattr(args, "parallax", False), "slice_axis": getattr(args, "slice_axis", "off"), "slice_index": getattr(args, "slice_index", 0), "slice_width": getattr(args, "slice_width", 1.0), + "renderer": binding.backend, + "renderer_options": binding.options, + "bond_policy": binding.bond_policy, } @@ -98,10 +123,9 @@ def run_session( """Run the requested machine session and report whether it handled the CLI.""" if args.session_format != "jsonl": return False - from .render_backend import renderer_binding_from_cli from .session import TerminalSession, run_jsonl_session - resolved = binding if binding is not None else renderer_binding_from_cli(args) + resolved = binding if binding is not None else prepare_tui_renderer(args) session = TerminalSession( crystal, camera=camera, @@ -140,9 +164,9 @@ def compose_static_frame( binding=None, ) -> str: """Render static output with the same charset choices as agent sessions.""" - from .render_backend import render_picture, renderer_binding_from_cli + from .render_backend import render_picture - resolved = binding if binding is not None else renderer_binding_from_cli(args) + resolved = binding if binding is not None else prepare_tui_renderer(args) return render_picture( crystal, camera, diff --git a/mat_viewer/tui/controller.py b/mat_viewer/tui/controller.py index d1abad40..b2ea1e63 100644 --- a/mat_viewer/tui/controller.py +++ b/mat_viewer/tui/controller.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import replace -from typing import Any, Mapping +from typing import Any import numpy as np @@ -38,7 +38,6 @@ resolve_molecule_reference, ) from .observation import build_observation_scope, build_terminal_title -from .render_contract import validate_bond_policy from .projection import ( ProjectedAtomHit, Viewport, @@ -119,15 +118,8 @@ def __init__( slice_axis: str = "off", slice_index: int = 0, slice_width: float = 1.0, - renderer: Any = None, - renderer_options: Mapping[str, Any] | None = None, - bond_policy: str = "manifested", ) -> None: validate_slice(crystal, slice_axis, slice_index, slice_width) - self._renderer = renderer - self._renderer_options = renderer_options - self._bond_policy = validate_bond_policy(bond_policy) - self._last_rendered = None self._slice_axis, self._slice_index, self._slice_width = slice_axis, slice_index, slice_width self._show_depth, self._parallax = bool(show_depth), bool(parallax) self._ghost_camera: Camera | None = None @@ -191,11 +183,6 @@ def width(self) -> int: def height(self) -> int: return self._height - @property - def last_rendered(self): - """Host frame record from the most recent ``render_frame`` call.""" - return getattr(self, "_last_rendered", None) - @property def state(self) -> TerminalViewState: """Return a detached immutable snapshot of the semantic view state.""" diff --git a/mat_viewer/tui/render_backend.py b/mat_viewer/tui/render_backend.py index 813fddce..1b052ef8 100644 --- a/mat_viewer/tui/render_backend.py +++ b/mat_viewer/tui/render_backend.py @@ -687,6 +687,20 @@ def render_picture( return _host_frame(text, request, active, strict=True, diagnostics=diagnostics) +def bind_controller_renderer( + controller: Any, + *, + renderer: Any, + renderer_options: Mapping[str, Any] | None, + bond_policy: str, +) -> None: + """Attach one episode renderer without growing the controller signature.""" + controller._renderer = renderer + controller._renderer_options = renderer_options + controller._bond_policy = validate_bond_policy(bond_policy) + controller._last_rendered = None + + def renderer_binding_from_cli(args: Any) -> RendererBinding: """Resolve ``--renderer`` / ``--renderer-config`` from a CLI namespace.""" return resolve_renderer_binding( @@ -703,6 +717,7 @@ def renderer_binding_from_cli(args: Any) -> RendererBinding: "NativeRenderer", "RendererBinding", "RendererContractError", + "bind_controller_renderer", "create_native_renderer", "list_renderers", "load_renderer_config", diff --git a/mat_viewer/tui/session.py b/mat_viewer/tui/session.py index 938a15fb..91547489 100644 --- a/mat_viewer/tui/session.py +++ b/mat_viewer/tui/session.py @@ -14,7 +14,11 @@ from .controller import TerminalViewController from .observation_profile import sanitize_model_observation -from .render_backend import RendererBinding, resolve_renderer_binding +from .render_backend import ( + RendererBinding, + bind_controller_renderer, + resolve_renderer_binding, +) from .state import TerminalObservation SESSION_SCHEMA = "mattervis.tui.session/v1" @@ -129,11 +133,8 @@ def __init__( # Machine observations never contain terminal-dependent ANSI escapes. # Renderer identity is episode configuration, not a set_display action. self._controller_options["mono"] = True - self._controller_options["renderer"] = self._binding.backend - self._controller_options["renderer_options"] = self._binding.options - self._controller_options["bond_policy"] = self._binding.bond_policy self._closed = False - self.controller = TerminalViewController(crystal, **self._controller_options) + self.controller = self._new_controller() def replay_manifest(self) -> dict[str, Any]: """Episode renderer identity. Omitted from default observations.""" @@ -177,11 +178,19 @@ def observe(self) -> SessionObservation: self._require_open() return self._wrap(self.controller.observe()) + def _new_controller(self) -> TerminalViewController: + controller = TerminalViewController(self.crystal, **self._controller_options) + bind_controller_renderer( + controller, + renderer=self._binding.backend, + renderer_options=self._binding.options, + bond_policy=self._binding.bond_policy, + ) + return controller + def reset(self) -> SessionObservation: self._require_open() - self.controller = TerminalViewController( - self.crystal, **self._controller_options - ) + self.controller = self._new_controller() return self.observe() def execute(self, action: TerminalAction) -> SessionObservation | tuple[SessionObservation, ...] | None: @@ -283,7 +292,7 @@ def _wrap(self, observation: TerminalObservation) -> SessionObservation: ).encode("ascii") provenance = None if self._binding.emit_provenance: - rendered = self.controller.last_rendered + rendered = getattr(self.controller, "_last_rendered", None) provenance = self._binding.replay_dict() if rendered is not None: provenance["input_digest"] = rendered.input_digest From 1c89bcc7c010bf014a2ebdc34c3c21048c3c456e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 14:21:19 +0000 Subject: [PATCH 3/3] Compare frozen TUI episodes without cross-CPython byte equality. Picture text and frame.sha256 stay exact. Viewport floats may differ by one ULP, which changes state_fingerprint, so the episode trace is checked with a float tolerance and within-run fingerprint equality. Co-authored-by: Ming-Yu Guo --- docs/agents/tui_api.md | 3 + docs/agents/tui_renderer_api.md | 7 +- docs/dev-notes.md | 6 +- .../fixtures/tui_renderer_goldens/index.json | 2 +- tests/tui/test_tui_renderer_backend.py | 79 ++++++++++++++++++- 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/docs/agents/tui_api.md b/docs/agents/tui_api.md index 7a5edb1d..5e308f91 100644 --- a/docs/agents/tui_api.md +++ b/docs/agents/tui_api.md @@ -91,6 +91,9 @@ fingerprint over camera, display, focus, selection, viewport, and charset. The fingerprint excludes coordinates, structure metadata, monotonic revision, renderer identity, and source path; use the screen hash or an external content identifier when the caller must bind an observation to a particular structure. +Picture bytes are the cross-interpreter byte-identity contract. Viewport +floats inside the fingerprint can differ by one unit in the last place across +CPython builds, so the fingerprint value is compared within one process. `TerminalSession(..., renderer="example.marks-v1", renderer_config=path, observation_profile="legacy"|"model")` freezes the renderer for the episode. It is not a `set_display` argument. The default profile and the default diff --git a/docs/agents/tui_renderer_api.md b/docs/agents/tui_renderer_api.md index 6f6783e1..ddd5b9c5 100644 --- a/docs/agents/tui_renderer_api.md +++ b/docs/agents/tui_renderer_api.md @@ -12,7 +12,12 @@ orbit, reset, viewport, or saved-view meanings. The built-in id is `mattervis.native-v1`. With no renderer selected, static `mat-vis tui` output and default JSONL sessions stay on that adapter. `state_fingerprint` does not include renderer identity. Default observation -objects do not grow a `renderer` field. +objects do not grow a `renderer` field. Picture bytes are the cross-interpreter +byte-identity contract for the default native backend. The fingerprint hashes +viewport floats as produced by that process. Those floats can differ by one +unit in the last place across CPython builds, so a full JSONL trace is not +required to be byte-identical across interpreters. Within one process the +fingerprint stays stable: reset restores it, and a camera change replaces it. ## Request and result diff --git a/docs/dev-notes.md b/docs/dev-notes.md index a2e508a7..a321023e 100644 --- a/docs/dev-notes.md +++ b/docs/dev-notes.md @@ -16,7 +16,11 @@ failure and call the native compositor as a fallback. Default sessions omit renderer provenance so `state_fingerprint` and the JSONL object stay on the historical contract. `replay_manifest()` is the explicit replay record. `observation_profile="model"` is opt-in and does not -change the fingerprint inputs. +change the fingerprint inputs. Do not round viewport values before hashing: +that would change default fingerprints. Picture bytes are the byte-identity +contract across CPython versions. Episode JSONL compares those bytes exactly +and treats viewport floats as tolerant, because one ULP of `y_min`/`y_max` +changes the fingerprint without changing the frame. The native molecule overview still reads manifested species labels from `CrystalIR`. That is trusted built-in presentation, not a field on the public diff --git a/tests/fixtures/tui_renderer_goldens/index.json b/tests/fixtures/tui_renderer_goldens/index.json index 5f9422e4..9d6de190 100644 --- a/tests/fixtures/tui_renderer_goldens/index.json +++ b/tests/fixtures/tui_renderer_goldens/index.json @@ -37,7 +37,7 @@ "sha256": "27f1ccd1dc14db7c08bbb8b57f8e37e7b0996ae82b16d88cae1520713cb85cf4" } }, - "description": "Built-in compositor frames frozen for mattervis.native-v1.", + "description": "Built-in compositor frames frozen for mattervis.native-v1. Picture files are exact bytes. The episode JSONL is a structural reference: viewport floats and state_fingerprint are not a cross-CPython byte contract.", "episode": { "file": "episode_ascii7_80x24.jsonl", "sha256": "0e37d8f0adb1a0365a60544c7b99525d21ef0fceddf4fee98a72d18ad809408b" diff --git a/tests/tui/test_tui_renderer_backend.py b/tests/tui/test_tui_renderer_backend.py index 54a81f81..2a8962f4 100644 --- a/tests/tui/test_tui_renderer_backend.py +++ b/tests/tui/test_tui_renderer_backend.py @@ -10,6 +10,7 @@ from hashlib import sha256 from io import StringIO import json +import math import os from pathlib import Path import shutil @@ -227,7 +228,15 @@ def _episode_stdout() -> str: def write_goldens() -> None: GOLDEN.mkdir(parents=True, exist_ok=True) - index = {"description": "Built-in compositor frames frozen for mattervis.native-v1.", "cases": {}} + index = { + "description": ( + "Built-in compositor frames frozen for mattervis.native-v1. " + "Picture files are exact bytes. The episode JSONL is a structural " + "reference: viewport floats and state_fingerprint are not a " + "cross-CPython byte contract." + ), + "cases": {}, + } for name, crystal, kwargs in _frame_specs(): text = _picture(crystal, **kwargs) (GOLDEN / f"{name}.txt").write_text(text, encoding="utf-8") @@ -319,6 +328,48 @@ def test_frozen_native_frames_match_dispatch(name, crystal, kwargs) -> None: assert sha256(text.encode("utf-8")).hexdigest() == json.loads((GOLDEN / "index.json").read_text())["cases"][name]["sha256"] +def _assert_episode_matches_golden(actual: str, golden: str) -> None: + """Compare one JSONL episode without requiring cross-CPython byte equality. + + Picture text and ``frame.sha256`` stay exact. Viewport and camera floats + may differ by a few ULPs, which also changes ``state_fingerprint``. That + fingerprint is still checked within a single run by the caller. + """ + actual_rows = [json.loads(line) for line in actual.splitlines() if line] + golden_rows = [json.loads(line) for line in golden.splitlines() if line] + assert len(actual_rows) == len(golden_rows) + for index, (got, expected) in enumerate(zip(actual_rows, golden_rows)): + _assert_json_close(got, expected, path=f"$[{index}]") + + +def _assert_json_close(got, expected, path: str) -> None: + if isinstance(expected, bool) or isinstance(got, bool): + assert isinstance(got, bool) and isinstance(expected, bool) and got is expected, path + return + if isinstance(expected, float) or isinstance(got, float): + assert isinstance(got, (int, float)) and isinstance(expected, (int, float)), path + assert math.isclose(got, expected, rel_tol=1e-12, abs_tol=1e-15), ( + f"{path}: {got!r} != {expected!r}" + ) + return + if isinstance(expected, dict): + assert isinstance(got, dict), path + assert set(got) == set(expected), path + for key, item in expected.items(): + if key == "state_fingerprint": + assert isinstance(got[key], str) and len(got[key]) == 64, path + assert isinstance(item, str) and len(item) == 64, path + continue + _assert_json_close(got[key], item, f"{path}.{key}") + return + if isinstance(expected, list): + assert isinstance(got, list) and len(got) == len(expected), path + for index, item in enumerate(expected): + _assert_json_close(got[index], item, f"{path}[{index}]") + return + assert got == expected, path + + def test_frozen_water_and_episode() -> None: water = load_for_tui(str(WATER)) for name, kwargs in ( @@ -328,7 +379,8 @@ def test_frozen_water_and_episode() -> None: text = _picture(water, **kwargs) assert text == (GOLDEN / f"{name}.txt").read_text(encoding="utf-8") episode = _episode_stdout() - assert episode == (GOLDEN / "episode_ascii7_80x24.jsonl").read_text(encoding="utf-8") + golden = (GOLDEN / "episode_ascii7_80x24.jsonl").read_text(encoding="utf-8") + _assert_episode_matches_golden(episode, golden) rows = [json.loads(line) for line in episode.splitlines() if line] assert [row["ok"] for row in rows] == [True, True, True, True, True] initial = rows[0]["observation"] @@ -345,6 +397,29 @@ def test_frozen_water_and_episode() -> None: assert set(initial) == LEGACY_KEYS +def test_episode_golden_tolerates_viewport_ulp_not_picture_bytes() -> None: + golden = (GOLDEN / "episode_ascii7_80x24.jsonl").read_text(encoding="utf-8") + rows = [json.loads(line) for line in golden.splitlines() if line] + viewport = rows[0]["observation"]["viewport"] + viewport["y_min"] = math.nextafter(viewport["y_min"], math.inf) + viewport["y_max"] = math.nextafter(viewport["y_max"], -math.inf) + rows[0]["observation"]["state_fingerprint"] = "ab" * 32 + nudged = "".join(json.dumps(row) + "\n" for row in rows) + _assert_episode_matches_golden(nudged, golden) + + shifted = json.loads(nudged.splitlines()[0]) + shifted["observation"]["viewport"]["y_min"] += 1e-4 + broken_viewport = json.dumps(shifted) + "\n" + "\n".join(nudged.splitlines()[1:]) + "\n" + with pytest.raises(AssertionError): + _assert_episode_matches_golden(broken_viewport, golden) + + picture = json.loads(nudged.splitlines()[0]) + picture["observation"]["frame"]["text"] = picture["observation"]["frame"]["text"] + " " + broken_picture = json.dumps(picture) + "\n" + "\n".join(nudged.splitlines()[1:]) + "\n" + with pytest.raises(AssertionError): + _assert_episode_matches_golden(broken_picture, golden) + + def test_active_path_matches_compose_frame_after_orbit() -> None: from mat_viewer.tui.controller import TerminalViewController