# Viz Mapper

> Translate a verified SimSpec into a typed viz_spec.json conformant to the V8 viz_spec.schema.json. Output JSON only — no HTML, no CSS, no JavaScript. Server-side ajv validation enforces shape post-receive (grammar path is currently OFF — schema exceeds Anthropic compiled-grammar size cap). The frontend renderer (public/runtime/render.js) consumes viz_spec + frames + narration + provenance and ren…

- **Type:** Skill
- **Install:** `agentstack add skill-leventilo-mobius-viz-mapper`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [leventilo](https://agentstack.voostack.com/s/leventilo)
- **Installs:** 0
- **Category:** [AI & ML](https://agentstack.voostack.com/c/ai-and-ml)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [leventilo](https://github.com/leventilo)
- **Source:** https://github.com/leventilo/mobius/tree/main/skills/viz-mapper

## Install

```sh
agentstack add skill-leventilo-mobius-viz-mapper
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# viz-mapper V2 (V8 monstrueux)

## 1. Purpose

Translate verified `simspec.verified.json` + `physics.json` + `claims.json` + `figures.json` into a typed `viz_spec.json` that conforms exactly to the V8 `viz_spec.schema.json`. Server-side ajv validation enforces shape post-receive.

This skill does NOT:
- author HTML, CSS, or JavaScript
- decide the archetype (it copies `simspec.viz.scene_archetype`)
- run code_execution (the tool is attached only because Skills v1 require it)
- compute physics (primitives in Phase E own that)
- write narration (the `narration` skill owns that)

The downstream renderer (public/runtime/render.js) auto-projects any V7 spec into V8 shape via `projectV7ToV8`, dispatches each `experiment.panels[]` entry to a panel renderer, evaluates `experiment.derived[]` as a DAG on every slider change, and updates live `readouts[]` and dynamic annotations.

## 2. Output format (mandatory)

Emit exactly one JSON object inside a single fenced ```json ... ``` block at the END of the reply. No prose before or after. No tool_use.

Top-level shape:

```json
{
  "paper": { "title": "...", "arxivId": "...", "authors": ["..."] },
  "experiments": [ { /* one or more experiment objects */ } ]
}
```

For a single-phenomenon paper emit `experiments` length 1. For a paper with multiple distinct setups emit `experiments` length N — each independent. Most papers emit ONE experiment containing MULTIPLE panels via `experiment.panels[]`.

## 3. Inputs

Read from the container filesystem:
- `/mnt/session/runs/{runId}/simspec.verified.json` — canonical input
- `/mnt/session/runs/{runId}/physics.json` — equations, regime
- `/mnt/session/runs/{runId}/claims.json` — numerical claims
- `/mnt/session/runs/{runId}/figures.json` — figure metadata

## 4. V8 monstrueux: structural decisions

### 4.1 panels[]: multi-region scenes

V7 forced one canvas per experiment via the `archetype` field. V8 introduces `panels[]` so a single experiment can contain N rendering regions sharing the same sliders, derived state, readouts, and layer toggles. Examples:

- Young's double-slit: 1 panel (field_2d) with 3 traces (envelope, intensity, measured).
- Lorenz attractor: 4 panels in a 3×2 grid — 3 stacked time_series (X(t), Y(t), Z(t) in col:1 rows 1..3) plus 1 scatter return-map (col:2, row:1, row_span:3).
- Schrödinger tunneling: 1 panel (field_2d) with 1 fill-style trace + dynamic V₀ annotation.

Choose `layout: {kind: "single"}` when N=1; `layout: {kind: "grid", rows, cols}` for N>1. Maximum 4 panels for time_series, 2 for field_2d, 1 for ray_canvas / orbital_3d / heatmap_2d.

### 4.2 derived[]: compute DAG

V7's `equations[].compute_js` is one expression for display. V8 introduces `derived[]` as a topologically-sorted DAG of intermediate quantities. Each derived has:

- `id`: matches paper variable name (e.g. `visibility_V`, `intensity_I`)
- `expr`: JS expression in the compute_analytic sandbox. Bare names (`sin`, `cos`, `PI`) — never `Math.sin`. For `kind: "array"` the expr returns a length-N array, typically via `(function(){ return x.map(xi => ...); })()`. The implicit `x` is a Float32Array of length `default_grid_n` on `[0,1]`.
- `deps`: list of slider ids and other derived ids referenced. Used for topo sort.
- `kind`: `"scalar"` (one number) or `"array"` (length-N Float32Array).

Authoring rule: build the DAG leaf-to-root. Sliders are leaves. Final array nodes are rendered by traces.

### 4.3 readouts[]: live numeric strip

A readout is one row in a strip of live values shown under (or above) the canvas. Each binds to a `dynamic_value` referencing a `kind:"scalar"` derived id, with optional `scale` (multiplier for unit conversion), `format`, and `unit`.

Example: `{ id: "ro-dx", label: "δx =", dynamic_value: { compute_id: "fringe_sep", scale: 1000, format: "fixed2", unit: " mm" }, position: "under_canvas" }`.

### 4.4 dynamic annotations

When an annotation's text contains a number that changes with sliders, use `text_template` with a `{value}` placeholder + `dynamic_value`. The renderer substitutes `{value}` on every recompute.

Example: `{ ..., text_template: "δx = {value} mm", dynamic_value: { compute_id: "fringe_sep", scale: 1000, format: "fixed2" } }`.

Static annotations keep using plain `text`.

### 4.5 layer_toggles[]: trace-id-driven chip strip

V7 `layer_toggles` was a string keyword list (`["rays", "grid"]`). V8 promotes it to objects: `{ id, label, trace_ids[], default_visible }`. `trace_ids` lists the panel.traces[] ids whose visibility is bound to the chip; the renderer hides traces in real time via a click handler. V7 string form still works (auto-projected).

## 5. Field-by-field authoring rules

### 5.1 paper

Copy from simspec.paper.

### 5.2 experiment.id, label

`id`: kebab/snake matching `^[a-zA-Z_][a-zA-Z0-9_-]{0,63}$`.
`label`: human-readable (≤96 chars).

### 5.3 archetype (still required)

Copy `simspec.viz.scene_archetype` verbatim. The archetype acts as a thin orchestrator dispatching to `panels[]`. Use it as a hint to the renderer, but the actual visual is decided by panel.kind values.

### 5.4 evolution_kind, state_source, swept_param

- `evolution_kind` ← `simspec.simulation.evolution_kind` (`static | time_series | parameter_sweep`).
- `state_source`: `frames` for primitive-driven series; `analytic` for closed-form.
- `swept_param`: required iff `parameter_sweep`. Either a slider id OR a derived id (V8).

### 5.5 canvas

Defaults: `{ width: 760, height: 480, aspect: "16/9", dpr_max: 2 }`. Use `1024×600` for grid layouts.

### 5.6 palette_map

Map `role → c0..c5`. Lowercase + spaces + dashes + underscores + digits, 1-64 chars, must start with a letter. Every role referenced by sliders/annotations/equations/primitive_bindings/traces/readouts/layer_toggles MUST be a key in palette_map.

### 5.7 sliders[]

Walk simspec.viz.sliders[]. Copy id/label/binds_to/unit_ucum, set min/max from range, step = (max-min)/100. Pick formatter from {fixed1, fixed2, fixed3, scientific, integer, percent, degrees, none}. R-INLINE: every slider must be a real interactive control, no hardcoded constants.

### 5.8 default_grid_n

512 default; 1024 for fine-detail like Young fringes; 256 for Schrödinger frames. Bounded [32, 4096].

### 5.9 derived[]

Authoring protocol:

1. Start from slider ids as leaves. Add scalar derived for closed-form quantities (e.g. `fringe_sep = lam * L / d`, `visibility_V = (b/lam) * sin(...) ...`).
2. Build array derived for plotted quantities. The implicit `x` is on [0,1]; encode the physical domain into the expr (e.g. for Young's screen ±12.5 mm: `var xi_m = (x - 0.5) * 0.025; ...`). Pattern: `(function(){ return x.map(function(xi){ var x_mm = (xi - 0.5) * 25; return cos(2*PI*d*x_mm/(lam*L*1000))**2; }); })()`.
3. List every slider id and every other derived id referenced in `deps[]`.
4. Final node consumed by a trace must be `kind: "array"`.
5. **Sandbox tokens**: bare math names (`sin`, `cos`, `PI`, `sqrt`, `abs`, `exp`, `pow`, `min`, `max`, `floor`, etc.). Forbidden: `window`, `eval`, `Function`, `import`, `require`, `this`, `__proto__`, `constructor`, `prototype`, `new`, backticks, `${}` interpolation. The `function` keyword + `=>` are allowed for the array idiom.

### 5.10 readouts[]

One readout per claim from the paper that depends on sliders. Examples for Young: δx (fringe spacing), V (visibility). Bind each to a scalar derived id via `dynamic_value`. Use `scale` for unit conversion (e.g. `scale: 1000` to display metres as mm).

### 5.11 panels[]

For each visual region in the paper's key figure(s), emit one panel:

```json
{
  "id": "panel-main",
  "kind": "field_2d",
  "label": "Far-field intensity",
  "position": { "row": 1, "col": 1 },
  "axes": { "x_label": "screen x", "x_unit": "mm", "y_range": { "min": 0, "max": 1 } },
  "extras": { "show_legend": true, "show_gridlines": false },
  "traces": [
    { "id": "tr-env", "label": "envelope", "source": "analytic", "compute_id": "envelope_E",
      "palette_role": "fringe maxima", "style": "line", "stroke_width_px": 1, "stroke_dash_pattern": [4, 4], "z_order": 0 },
    { "id": "tr-int", "label": "intensity", "source": "analytic", "compute_id": "intensity_I",
      "palette_role": "interference field", "style": "line", "stroke_width_px": 1.6, "z_order": 1 }
  ]
}
```

panel.kind enum: `field_2d`, `time_series`, `scatter`, `heatmap_2d`, `ray_canvas`, `orbital_3d`, `particle_cloud`, `lattice`, `schematic`. Choose by visual character of the figure.

### 5.12 panel.traces[]

- `source: "analytic"` + `compute_id` referencing a `kind: "array"` derived.
- `source: "frames"` + `primitive_id` + `state_key` referencing simspec.primitives[].state.
- `palette_role` → palette_map.
- `style`: `line` (default), `fill` (Schrödinger ψ²), `dots`, `bars`, `area`.
- `shadow_blur_px` + `shadow_color_role` for emphasis (Young measured-detector trace).
- `stroke_dash_pattern: [4, 4]` for envelope/reference curves.
- `z_order` for stable layering.

### 5.13 annotations[]

Use `text_template + dynamic_value` whenever the displayed number depends on sliders. Use plain `text` for static labels. Anchor space normalised `[0,1]` for archetype-agnostic placement.

### 5.14 equations[]

Up to 8 governing equations. Optional `compute_js` (legacy V7) is supported but in V8 prefer authoring derived[] and using equations[] purely for KaTeX display. New `size: "small" | "medium" | "large"` controls KaTeX rendering size.

### 5.15 interactions

```json
{
  "play_button": (evolution_kind != "static"),
  "scroll_lock": false,
  "drag_camera": (archetype ∈ {orbital-3d, glass-dispersion, particle-cloud, lattice}),
  "reset_button": (evolution_kind != "static"),
  "layer_toggles": [ /* layer_toggle_spec[] */ ]
}
```

## 6. Worked example 1 — Young's double-slit (1 panel, 3 traces, 4 derived, 2 readouts, 1 dynamic annotation)

```json
{
  "paper": { "title": "Young's double-slit interference with single photons", "arxivId": "2401.02351" },
  "experiments": [
    {
      "id": "young",
      "label": "Two-slit interference",
      "archetype": "field-2d",
      "evolution_kind": "static",
      "state_source": "analytic",
      "default_grid_n": 1024,
      "caption": "δx = λL/d sets the fringe spacing; sinc² envelope from finite slit width.",
      "canvas": { "width": 760, "height": 480, "aspect": "16/9", "dpr_max": 2 },
      "palette_map": {
        "single-photon source": "c0",
        "double-slit aperture": "c1",
        "interference field": "c3",
        "envelope curve": "c4",
        "scanning detector": "c5"
      },
      "layout": { "kind": "single" },
      "sliders": [
        { "id": "lam", "kind": "physics", "label": "wavelength λ", "binds_to": "primitives[id=monochromatic-source].parameters.wavelength.value",
          "min": 4e-7, "max": 1e-6, "default": 8.1e-7, "step": 1e-8, "unit_ucum": "m", "formatter": "scientific", "palette_role": "single-photon source" },
        { "id": "d", "kind": "physics", "label": "slit separation d", "binds_to": "primitives[id=double-slit-aperture].parameters.slit_separation.value",
          "min": 1e-4, "max": 2e-3, "default": 6.2e-4, "step": 1e-5, "unit_ucum": "m", "formatter": "scientific", "palette_role": "double-slit aperture" },
        { "id": "b", "kind": "physics", "label": "slit width b", "binds_to": "primitives[id=double-slit-aperture].parameters.slit_width.value",
          "min": 5e-5, "max": 4e-4, "default": 1.3e-4, "step": 5e-6, "unit_ucum": "m", "formatter": "scientific", "palette_role": "double-slit aperture" },
        { "id": "L", "kind": "physics", "label": "screen distance L", "binds_to": "primitives[id=screen-intensity].parameters.screen_distance.value",
          "min": 0.5, "max": 5, "default": 1.5, "step": 0.05, "unit_ucum": "m", "formatter": "fixed2", "palette_role": "scanning detector" }
      ],
      "derived": [
        { "id": "fringe_sep", "label": "δx = λL/d (m)", "expr": "lam * L / d", "deps": ["lam", "L", "d"], "kind": "scalar" },
        { "id": "visibility_V", "label": "fringe visibility", "expr": "(b > 0 && d > 0) ? abs(sin(PI * b / d) / (PI * b / d)) : 0", "deps": ["b", "d"], "kind": "scalar" },
        { "id": "envelope_E", "label": "sinc² envelope", "kind": "array",
          "expr": "(function(){ return x.map(function(xi){ var y_m = (xi - 0.5) * 0.025; var sa = PI * b * y_m / (lam * L); var s = abs(sa)  0 ? (0.5 * k0 * k0) / V0 : 0", "deps": ["V0", "k0"], "kind": "scalar" }
      ],
      "readouts": [
        { "id": "ro-E", "label": "E =", "dynamic_value": { "compute_id": "kinetic_E", "format": "fixed2" }, "position": "under_canvas", "palette_role": "wavefunction curve" },
        { "id": "ro-V0", "label": "V₀ =", "dynamic_value": { "compute_id": "V0_val", "format": "fixed1" }, "position": "under_canvas", "palette_role": "barrier region" }
      ],
      "panels": [
        { "id": "rho-panel", "kind": "field_2d", "label": "Probability density |ψ|²",
          "axes": { "x_label": "x" },
          "extras": { "show_legend": false, "fill_under": true },
          "traces": [
            { "id": "tr-rho", "label": "|ψ|²", "source": "frames", "primitive_id": "crank_nicolson_1d", "state_key": "rho",
              "palette_role": "wavefunction curve", "style": "fill", "fill_opacity": 0.32, "shadow_blur_px": 8, "shadow_color_role": "wavefunction curve",
              "stroke_width_px": 1.4, "z_order": 0 }
          ]
        }
      ],
      "annotations": [
        { "id": "ann-V0", "type": "math_label", "anchor": { "space": "normalized", "x": 0.5, "y": 0.06 },
          "text_template": "V₀ = {value}",
          "dynamic_value": { "compute_id": "V0_val", "format": "fixed1" },
          "palette_role": "barrier region" },
        { "id": "ann-E", "type": "math_label", "anchor": { "space": "normalized", "x": 0.85, "y": 0.06 },
          "text_template": "E = {value}",
          "dynamic_value": { "compute_id": "kinetic_E", "format": "fixed2" },
          "palette_role": "wavefunction curve" }
      ],
      "equations": [
        { "id": "eq-schroedinger", "latex": "i\\hbar \\partial_t \\psi = -\\tfrac{\\hbar^2}{2m}\\partial_x^2 \\psi + V(x)\\psi",
          "role": "governing", "palette_role": "wavefunction curve", "position": "caption", "size": "medium" }
      ],
      "camera_presets": [],
      "interactions": {
        "play_button": true, "scroll_lock": false, "drag_camera": false, "reset_button": true,
        "layer_toggles": []
      },
      "primitive_bindings": [
        { "primitive_id": "crank_nicolson_1d", "state_key": "rho", "palette_role": "wavefunction curve" }
      ]
    }
  ]
}
```

## 9. R1-R10 enforcement

- R1 anti-hardcode: every numeric in viz_spec originates from simspec/claims, not paper copy-paste.
- R2 DAG completeness: every derived[] declares deps[] for every variable referenced in expr.
- R3 trace source consistency: source=analytic → compute_id (kind=array); source=frames → primitive_id + state_key.
- R4 palette coverage: every palette_role referenced is a key in palette_map.
- R5 layer_toggle.trace_ids must reference real panel.traces[].id values.
- R6 readout.dynamic_value.compute_id MUST be a kind:"scalar" derived; trace.compute_id MUST be kind:"array".
- R7 critic-loop friendly: refinement suggestions edit specific derived/trace/readout entries without restructuring untouched fields.
- R8 swept_param matches sliders[].id OR derived[].id (V8 extension).
- R9 frames require binding (panel.traces[] with source=frames OR primitive_bindings[]).
- R10 no duplicate ids inside panels[], derived[], readouts[], or layer_toggles[].

## 10. Generation protocol (5 steps)

1. Read physics.json governing equations. Identify observable quantities.
2. Identify panels[] from figures.json. Assign layout.rows/cols.
3. Build derived[] DAG leaf-to-root. Scalar vs array kind.
4. For each panel, assign traces[] referencing derived ids or primitive state_keys.
5. Build readouts[] from claims.json predicted values, annotations[] from figure markup.

## 11. Refusal case

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [leventilo](https://github.com/leventilo)
- **Source:** [leventilo/mobius](https://github.com/leventilo/mobius)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-leventilo-mobius-viz-mapper
- Seller: https://agentstack.voostack.com/s/leventilo
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
