Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

A local 20B model solves 52 of 75 design-rule-verified layout tasks in this editor’s command API. The editor itself runs in your browser.

Reticle is an editor for very large hierarchical 2D layout scenes, the kind a chip’s physical design is made of. It renders and edits integer-coordinate geometry (rectangles, polygons, and paths on named layers) organized into cells, instances, and arrays. A cell placed thousands of times expands to billions of leaf shapes that stay browsable, because the hierarchy is never flattened for viewing: the renderer pays for what is on screen rather than for the flattened count. That is architecturally supported, not fps-benchmarked. No frame-rate figure for a billion-leaf design has been measured, so none is claimed here; the figures that have been measured, and the commands that reproduce them, are in Rendering and docs/PERF.md. It is written in Rust and compiled to native and to WebAssembly from one codebase.

Three things you can do with it

Open and share real chips in a browser. Import a GDSII, OASIS, CIF, DXF, Magic, or ZIP layout (any of those also accepted .gz-compressed) and browse it at interactive speed with nothing installed: open a local file, drop one onto the window, or pass ?gds=<url> to load a stream from a link. A session can be made read-only and shared, so a reviewer opens the exact view you are looking at and pans it themselves without being able to change it.

Generate verified structures from language. Six parameterized generators (a guard ring, a via farm, a pad ring, a seal ring, a density fill, and a probe-able test structure) turn a few numbers into structure that would otherwise be drawn by hand. Each is DRC-clean by construction against the SKY130 subset, checked by a property test that runs every generator over 400 random valid parameter sets and asserts zero design-rule violations. The same six surface three ways from one schema: the Generate panel in the app, one MCP tool per generator, and the generator tasks in the benchmark. See Layout generators.

Benchmark agents on physically verified tasks. A serializable command API exposes every edit, and a propose-verify-correct harness makes a model build layouts under the real design-rule and connectivity checks, so a task passes only when an objective checker accepts it. The suite is 95 tasks across five tiers at v0.7.0; a bare local 20B model driven by Reticle’s own loop passed 52 of the 75 tasks of the v0.4.0 suite, an older and smaller task set that is named here because a score is only meaningful against the suite it was measured on. An agent system such as Claude Code brings its own loop, so its result is not head-to-head comparable with a bare model. See the Agent benchmark suite and Benchmark methodology.

On top of the geometry that carries all of this sit pan, zoom, select, measure, and annotate tools; a full drawing and vertex-editing suite; boolean and transform operations; a design-rule checker; a router; connectivity extraction; GDSII, OASIS, CIF, DXF, Magic, and ZIP import (each also accepted .gz-compressed), GDSII and OASIS export; embedded scripting; and real-time multi-user collaboration.

What is new in v8

v8 widens the surface on every side while keeping the same evidence discipline. The browser bundle is now an installable Progressive Web App whose shell loads offline (Install and offline). A reticle-diff crate and a canvas overlay answer what changed between two versions (Layout diff). Notes anchor to a shape or cell and survive a lossless V1-to-V2 document migration (Comments). The reticle-sync layer now proves several editors’ edits converge to a byte-identical document with per-actor selective undo, tested end to end over a relay; the shipped app still wires one editor publishing to read-only viewers, and the view-only permission holds at both layers (Multi-writer). On the formats side there is LEF/DEF import cross-checked against OpenROAD (LEF/DEF), a documented subset of KLayout .lydrc DRC decks validated against KLayout headless (.lydrc compatibility), a standards-conformant OASIS writer whose output KLayout reads (GDS / OASIS interop), and a second PDK, IHP SG13G2, that every generator runs against DRC-clean (A second PDK). Extraction now recognizes SKY130 MOSFETs and runs a device-level LVS-lite cross-checked against Magic (Device recognition). A second, best-effort vision oracle (llava:7b) corroborates the authoritative checker on a small sample (Multimodal verification). The core read, generate, render, and save paths are exposed to Python as a stable-ABI wheel (Python bindings), and the browser can convert a GDS to a streamable archive in a Web Worker into the Origin Private File System with no server (In-browser conversion).

Why it exists

Reticle works the exact problem a semiconductor tooling team solves: visualizing and editing massive layout geometry at interactive speed. That one problem pulls together performance engineering, computational geometry, GPU rendering, spatial indexing, schema evolution, and distributed collaboration, with a checker-graded agent layer on top.

The north-star

Open a dense chip-like layout of over one million polygons in a browser. Pan and zoom at 60 fps. Run an incremental design-rule check and jump to a violation. Draw a polygon and boolean-union it against a neighbor. Watch a second user’s cursor and edits appear live, and watch an agent build under the same checks a person would.

How this book is organized

The Design chapters walk through each subsystem in dependency order, from the exact-integer geometry core up through rendering, checking, routing, and collaboration. The Automation and agents chapters cover the command API, the verify loop, the layout generators, the MCP server, and the benchmark. The Reference chapters cover how performance is measured, how to use the application, and how to contribute. Every subsystem is a separate crate; see Architecture for the crate graph.

Positioning

This chapter is an honest map of where Reticle sits among layout tools. It states what Reticle is, what the established tools do that Reticle does not, and the full list of things Reticle deliberately is not. The goal is that a reader from the EDA world knows within a page whether this project is relevant to them and can trust that nothing here is oversold.

The field

Physical IC layout has a mature, mostly open or commercial tooling landscape. Reticle overlaps a narrow slice of it.

  • KLayout is the reference open-source layout viewer and editor. It reads and writes GDSII and OASIS at production scale, has a full DRC and LVS scripting engine (Ruby and Python), a rich GUI, and a large plugin ecosystem. It is the tool most engineers reach for to open, inspect, and script mask data. Reticle does not approach KLayout’s format coverage, DRC-language maturity, or feature breadth.
  • Magic is the classic open-source layout editor from Berkeley, still widely used, with interactive layout, a built-in continuous DRC, and its own extraction to a SPICE netlist. Magic’s device-level extraction (recognizing transistors, resistors, and capacitors from geometry and emitting a netlist a simulator can read) is a capability Reticle does not have.
  • Commercial place-and-route and signoff flows (the tools from Cadence, Synopsys, and Siemens EDA) cover the parts of the flow that turn a netlist into a manufacturable, verified mask set: synthesis, floorplanning, placement, clock-tree synthesis, detailed routing, parasitic extraction, static timing analysis, signoff DRC and LVS against a foundry-qualified rule deck, and tape-out. These are the tools a design must pass through to be fabricated. Reticle does none of this and is not on that path.
  • The open SKY130 flow (OpenLane / OpenROAD on the SkyWater SKY130 PDK) is the open reference for taking RTL to a GDSII that a shuttle can fabricate. Reticle uses the same SKY130 PDK data as a source of real, cited numbers (layers, a stack, and a DRC rule subset), but it is not part of the OpenLane flow and produces nothing that flow consumes.

Where Reticle sits

Reticle is a browser-native, GPU-accelerated viewer and editor for very large hierarchical layout scenes, with a checker layer and an agent layer on top. Its distinct emphasis is threefold:

  1. Interactive rendering of very large scenes in a browser. Reticle renders hierarchical IC geometry through a retained GPU scene on wgpu (WebGPU natively and in the browser, with a WebGL2 fallback), never flattening the hierarchy for browsing, so an arrayed cell with billions of effective leaf shapes costs only what is on screen. The measured retained path holds interactive frame rates on scenes of ten million leaf shapes (see Performance methodology and PERF.md). Running this in a plain browser tab, with no install, is the part of the problem Reticle pushes hardest on.
  2. Objective checkers as a first-class engine. The design-rule checker, router, and connectivity extractor are each pinned to an independent reference oracle by property tests, so their correctness is demonstrated rather than asserted. They are built to be driven programmatically, not only from a GUI. The same discipline covers the six parameterized generators (guard ring, via farm, pad ring, seal ring, density fill, test structure): a property test runs each over 400 random valid parameter sets and asserts zero DRC violations, so the structures they emit are clean by construction.
  3. An agent surface graded by those checkers. Reticle exposes its whole editing engine as a serializable command API and drives a model through a propose-verify-correct loop whose success is decided by the DRC subset and the connectivity checker, not by the model’s own claim. A benchmark suite scores that loop; an MCP server offers the surface (including one tool per generator) to any model host. When the surface is driven by a bare local model, Reticle supplies the loop and grades the result; an agent system such as Claude Code brings its own loop, so its result is not head-to-head comparable with a bare model. This agent-plus-objective- checker angle is where Reticle does something the established tools do not package.

So the honest one-line placement: Reticle is a fast, browser-native layout viewer and editor with a verified checker core and a checker-graded agent layer. It is a portfolio-grade engineering project and a research vehicle for machine-driven layout, not a production EDA tool.

What the established tools do that Reticle does not

These are real capabilities of KLayout, Magic, or the commercial and open signoff flows that Reticle does not have. This list is deliberately blunt.

  • Full-fidelity format coverage. Reticle reads and writes GDSII with full hierarchy (via gds21). Its Oasis type is not interoperable OASIS: it is an in-house, OASIS-inspired container (ADR 0004) that other tools do not read, used only to round-trip Reticle’s own geometry. A separate conformant-OASIS writer (oasis_std) emits a practical SEMI P39 subset that KLayout does read - export only, verified against KLayout in-container (see the GDS / OASIS interop chapter). Even so, KLayout’s format coverage is far broader.
  • A production DRC rule language. Reticle’s DRC is a fixed set of eight rule kinds (width, spacing, enclosure, extension, notch, area, density, angle) evaluated over indexed geometry. It is not a general rule-scripting language, and its SKY130 deck is a small cited subset (see SKY130 grounding). KLayout’s DRC and Magic’s continuous DRC are far more complete.
  • Layout-versus-schematic (LVS). Reticle has connectivity extraction and can compare an extracted netlist against an expected one (opens and shorts), but it does not do LVS in the device-recognition sense: it does not identify transistors, resistors, or capacitors from geometry, and it does not match a layout against a schematic netlist.

The remaining, larger set of things Reticle does not do is the not-list below.

The full not-list

Reticle deliberately does not include, and does not claim, any of the following. Each is stated so no reader mistakes the project’s scope.

  • No logic or physical synthesis. Reticle does not turn RTL, a gate netlist, or a behavioral description into geometry. There is no synthesis, no technology mapping, no floorplanning, no placement of a synthesized netlist. The router routes explicit nets on a grid; it is not a place-and-route flow.
  • No timing. There is no static timing analysis, no parasitic (RC) extraction for timing, no delay calculation, and no timing-driven optimization anywhere in the project. Reticle knows geometry and connectivity, not timing.
  • No device-level LVS, but device RECOGNITION ships. Reticle recognizes MOS devices from layout geometry (extract_devices, crates/reticle-extract/src/device.rs), reachable from the automate panel, from reticle export-spice, and through the xschem bridge. It does not recognize resistors, capacitors or diodes. What it does not do at all is match a layout against a schematic from any product surface: both comparators (compare_devices, compare_layout_to_spice) are crate-level API with zero callers outside reticle-extract. The MCP netlist_compare tool is the geometric opens-and-shorts compare (compare_netlists), not LVS. CORRECTED 2026-07-31. WAS: “Reticle does not recognize devices (transistors, resistors, capacitors, diodes) from layout geometry”. NOW: as stated above. The old wording contradicted README.md’s own MOSFET-recognition claim. The second half of the original bullet (“does not match a layout against a schematic”) was correct and is kept verbatim. Check:
    git grep -n "extract_devices" -- crates      # 3 product callers outside reticle-extract
    git grep -n "compare_layout_to_spice\|compare_devices" -- crates   # none outside it
    
  • No tape-out signoff. Passing Reticle’s checks does not mean a layout is manufacturable. The SKY130 DRC subset is a fast first filter over everyday geometry mistakes against cited values; it is explicitly not tape-out clean and omits antenna, density, latch-up, most implant and well rules, and the exact-size and differential contact and via rules. There is no signoff DRC, no signoff LVS, no fill insertion for manufacturing, no antenna fixing, and no foundry-qualified rule deck. See SKY130 grounding for exactly which rules are and are not checked.

Beyond those four, Reticle also does not provide: a schematic editor, mask data preparation (OPC, fracturing), a parametric-cell scripting language matching KLayout’s PCell system, or any manufacturing handoff.

Two clauses of that list were retired on 2026-07-31, both understatements:

  • Circuit simulation is present, deliberately bounded. A pure-Rust modified-nodal-analysis DC and transient solver over small extracted netlists (reticle-sim, solve_operating_point / solve_transient), reachable as waveform.run_oracle and reticle waveform-oracle, with a waveform viewer. It is not ngspice, not a field solver, and never signoff. See Simulation and Waveforms. Check: git grep -n "pub use transient::" -- crates/reticle-sim/src/lib.rs.
  • User PCells ship in a bounded form: PCellDef with a sandboxed rhai producer (native only) and the live pcell.edit_params / pcell.regenerate commands. That is a bounded PCell engine, not the KLayout-equivalent scripting language the clause above still correctly says is absent. Check: git grep -n "pub struct PCellDef" -- crates/reticle-gen/src/pcell.

Reading this honestly

Everything above is checkable. The capabilities Reticle does have are audited, per-feature, with a command you can run yourself, in the project status report. The subsystems that carry the “verified” weight (geometry booleans, spatial index, DRC, routing, extraction, and CRDT convergence) are each pinned to an independent reference oracle by property tests, which is the strongest evidence in the project that they compute what they claim. When this chapter says Reticle does something, that audit backs it; when it says Reticle does not, that absence is deliberate and stated so the project is not mistaken for a production flow it is not.

Architecture

Reticle is a Cargo workspace of focused crates. The core geometry, indexing, and model crates are deliberately free of GPU, async, and UI code so they stay fast to test and clean to read; the heavier subsystems build on top of them.

Crate graph

graph TD
    geometry[reticle-geometry] --> index[reticle-index]
    geometry --> model[reticle-model]
    geometry --> io[reticle-io]
    proto[reticle-proto] --> io
    index --> render[reticle-render]
    geometry --> render
    model --> render
    index --> drc[reticle-drc]
    model --> drc
    index --> route[reticle-route]
    model --> route
    index --> extract[reticle-extract]
    model --> extract
    model --> sync[reticle-sync]
    sync --> server[reticle-server]
    model --> script[reticle-script]
    render --> app[reticle-app]
    model --> app
    sync --> app
    script --> app
    app --> web[web]
    io --> cli[reticle-cli]
    drc --> cli
    route --> cli

Responsibilities

CrateResponsibility
reticle-geometryExact integer primitives and robust polygon booleans, offsetting, winding, and convex decomposition.
reticle-indexBulk-loaded R-tree, uniform grid, and a tile pyramid that bounds what a streamed viewport must fetch; the .rtla streamed-archive format, its two-pass external builder, and mmap tile sources. Level-of-detail rendering (reducing what a zoomed-out frame draws) lives in reticle-app’s culling module (lod_for_zoom, chunk_lod), not here; see Rendering.
reticle-modelThe hierarchical document: cells, instances, arrays, transforms, per-cell bbox computation, flattening, and a transactional edit history.
reticle-protoThe versioned Protobuf schema and generated types for the document, wire, and collaboration formats.
reticle-ioGDSII, OASIS, CIF, DXF, and Magic import; GDSII and OASIS export; plus a technology-file parser. Also KLayout .lyp and .lyt readers, which are crate-level API with no product entry point (see the note below).
reticle-renderThe wgpu renderer: instanced pipelines, lyon tessellation, GPU-driven cell culling, and offscreen rendering.
reticle-drcA declarative, incremental design-rule checker.
reticle-routeA grid and maze router with rip-up and reroute.
reticle-extractGeometric connectivity extraction across contacts and vias.
reticle-syncReal-time collaboration over a yrs CRDT, with presence and comments.
reticle-serverThe WebSocket collaboration relay.
reticle-scriptAn embedded rhai scripting API over the model.
reticle-appThe interactive egui application, native and in the browser.
reticle-cliThe headless pipeline, 14 subcommands: import, DRC, route, extract, export, convert, render, diff, bool, and the export-svg / export-metrology / export-spice / export-lefdef / waveform-oracle drivers.
webThe WebAssembly harness with a WebGPU capability check and WebGL2 fallback.

The table above shows 15 crates. There are 30, and the 15 it omits are whole subsystems, not details. Added 2026-07-31, because this is the chapter a new reader opens first and reading it as an inventory hides the entire agent, MCP, plugin, generator, verification and interop surface:

CrateResponsibility
reticle-lefdefLEF/DEF import and export, including MACRO and PIN blocks.
reticle-diffLayout diff between two designs, with an overlay.
reticle-metrologyCPU metrology reports.
reticle-genParametric generators and the user PCell engine, retargeted across SKY130, IHP SG13G2 and GF180MCU.
reticle-simA bounded pure-Rust modified-nodal-analysis circuit solver: DC operating point and transient. Not ngspice, never signoff.
reticle-pluginThe embedded wasmi plugin runtime and its host.
reticle-nl-editThe deterministic natural-language edit grammar, with no model call.
reticle-agent-apiThe typed command surface an agent drives.
reticle-agentThe agent harness.
reticle-mcpThe MCP server, 39 tools.
reticle-benchThe agent benchmark driver.
reticle-demo, reticle-demo-serverThe scripted demo harness and its server.
reticle-relay-conformanceConformance vectors for the collaboration relay.
reticle-pyThe Python bindings.
xtaskThe workspace task runner: media capture, dependency and module-use checks, the generated module map, license verification.

This chapter shows the SHAPE, not the inventory. The complete per-crate table covering all 31 workspace members is docs/ARCHITECTURE.md section 3.2.2, machine checked by the dep-check leg of just ci; the per-file map is the generated docs/module-map.md. Re-derive the count with @(Get-ChildItem crates -Directory).Count.

What the crate ships and what the product reaches

These are different facts, and for two of reticle-io’s readers they differ. The KLayout .lyp (layer properties) and .lyt (technology) readers are real: parsed, bounded against malformed input, unit-tested, fuzzed, and public on the crate. Nothing the product ships calls either one. No Reticle build opens a .lyp or .lyt file, and there is no menu item, CLI subcommand, or drop target that accepts one. technology::layers_from_lyp, which merges a parsed .lyp onto a layer table, is likewise uncalled outside the crate’s own tests, and its own doc comment says the call site was never implemented.

Audited at 5c617540 (2026-07-29) over all 14 modules in crates/reticle-io/src/: 12 reachable from a crate outside reticle-io, 2 not. The audit re-runs as a test instead of sitting here as a dated sentence, and it fails in both directions, so giving .lyp an entry point breaks it and so does dropping a working format’s last call site:

cargo nextest run -p reticle-io --test parser_reachability

Design principles

  • Exact integers. Coordinates are database units (DBU), never floating point; see the Geometry chapter and ADR 0002.
  • Contract-first. The cross-crate types and traits are frozen before the subsystems that depend on them are written, so a change to a shared interface is a deliberate, reviewed event.
  • Proven crates for hard problems. Polygon booleans (i_overlay), the R-tree (rstar), GDSII (gds21), rendering (wgpu), the CRDT (yrs), and routing primitives (pathfinding) are delegated to mature libraries; Reticle owns the domain logic that ties them together. The architecture decision records under docs/decisions/ explain each choice.
  • Measure, never guess. Performance targets are backed by benchmarks run on real hardware; see the Performance methodology.

The local build gate

There is no hosted CI. A single just ci recipe runs formatting, Clippy with warnings denied, the test suite, a documentation build, a WebAssembly build, license and advisory checks, and a spell check. It must be green before every commit. See Contributing.

The design system

The v8.1 interface packet gives Reticle one source of visual truth. Every color, size, radius, spacing, font, and shadow comes from a single theme module; a lint makes a hard-coded literal a CI failure; contrast is proven by unit tests, not eyeballed; and one component library is the only widget source the application draws from. This chapter describes that system and the workflow that keeps it honest. The binding token specification is docs/design/tokens.md; the four decisions behind the system are ADRs 0095 to 0098.

Tokens: one source of visual truth

crates/reticle-app/src/theme/ is the only place a color or size value may live. theme/tokens.rs encodes the semantic token table from tokens.md and maps it once onto egui::Style and Visuals, starting from Visuals::dark(). Tokens are named by role, not by value: bg_canvas, bg_panel, bg_raised, bg_input for surfaces; text, text_weak, text_faint for type; accent, danger, warning, success for state; widget_bg, widget_hover, widget_active for interaction; and a small radius, elevation, and spacing set. Chrome is neutral and desaturated on purpose, so layer colors and geometry dominate the canvas and color in a panel always means data or state.

The theme is applied at boot through set_style_of and re-applied only when a dirty flag is set (a density or reduced-motion change), never per frame. Canvas data colors (the layer palette, the DRC heatmap, the diff overlay, presence cursors) are a separate namespace in tokens.rs, so a data color can never masquerade as chrome and the lint still covers it.

This packet ships a single dark theme (ADR 0095). Light is deferred by design: the Theme enum stays and session files carrying theme=light keep parsing and resolve to dark, so a future light variant is a second token table, not an architecture change. Shipping a tokened dark beside an untokened stock light would reintroduce exactly the inconsistency the packet exists to remove.

Contrast proven, not eyeballed

theme/contrast.rs re-proves every contrast pair from the tokens.md table in CI, using the WCAG relative-luminance ratio computed in-crate with no external dependency. The tests assert full opacity first (Color32 is premultiplied), then the ratio floor for each pair: primary text clears 4.5:1 on every surface (for example 14.06:1 on bg_panel), secondary text_weak clears the 3.0:1 large-text floor, the focus ring clears the 3.0:1 non-text UI floor of WCAG 1.4.11, and the accent-on-panel and label-on-accent pairs clear 4.5:1 so links and primary-button labels read as text. A token edit that dropped a pair below its floor would fail the gate.

Typography and icons

Three faces are subset into the bundle (ADR 0097): Inter Regular and Medium (SIL OFL 1.1) for the UI family, JetBrains Mono Regular (SIL OFL 1.1) for coordinates, readouts, and code, and the Lucide icon font (ISC) as a fallback family so any label can inline a glyph constant. theme/fonts.rs installs the FontDefinitions at boot; theme/icons.rs holds the generated Lucide codepoint constants; the subset TTFs live under crates/reticle-app/assets/fonts/ with their license notices. The subsets are regenerated by scripts/subset-fonts.ps1 (fonttools via pip, a documented dev tool, never a build or CI dependency), so the glyph list is part of the reviewable diff. Section headers use Inter Medium at body size in text_weak with no uppercase (deep-tool honesty over label shouting); status-bar and inspector numerals use JetBrains Mono with tabular figures.

The component library

theme/components.rs is the single widget source from Wave 2 forward. Every panel composes from it, so a density or palette change lands in one place. The library provides buttons (primary, secondary, ghost, danger), IconButton (a glyph char plus a tooltip of name and keyboard hint), a toggle chip, a segmented control, a text field, a section header, a collapsible section, the toast and status stack, a progress row, an empty-state block, a keyboard-hint chip, and a modal frame. All styling is tokens-only and every interactive component carries the four token states from tokens.md: widget_hover on hover, widget_active on press, the 1.5px focus ring whenever keyboard focus lands on it, and text_faint disabled. The focus ring is never removed for aesthetics, which is what makes the F6 and Tab traversal (see the shortcuts overlay) visible everywhere.

Lane 1C owns this file and froze its public signatures at the first integration gate; the states-and-motion work extended it additively (new defaulted fields and builder methods) without changing a single existing signature, so the panels that compiled against the gate signatures were never disturbed.

The contribution rule: styling goes through the theme

All styling goes through theme/, and the just check-style lint enforces it. scripts/check-style.ps1 bans raw Color32 constructors and FontId or RichText size literals anywhere under crates/reticle-app/src and crates/web/src except the theme module (ADR 0098). It began as a ratchet: a committed scripts/style-baseline.json grandfathered the 89 legacy literals and its counts could only fall, so CI stayed green while the debt was paid down. Lane 1A drained every literal to zero and the baseline file was deleted, so the ban is now absolute: a new color or size literal outside theme/ fails the gate with a file-and-line list. Adding a visual value means adding a named token, which is the whole point.

The same lint carries the voice rule (no em-dash U+2014 in any tracked text file) and the README banned-word list, so honesty of prose and consistency of style are one gate.

Density and touch

Two density modes ride the 4px spacing rhythm. Comfortable and compact remap the egui Spacing fields and the text scale (for example interact_size.y 28 versus 22, body 13.0 versus 12.0 points); the Settings dialog toggles between them and persists the choice. Touch mode (lane 4B) raises interact_size.y to 40 on top of either density through theme::apply, so a coarse-pointer device gets 40px targets without a second layout. The Settings dialog exposes touch as a tri-state (Auto, On, Off): Auto follows the platform coarse-pointer signal and On/Off override a device whose signal is wrong.

Motion

Motion is functional: transitions communicate a state change and nothing animates for decoration. Style::animation_time is 0.12s comfortable, 0.10s compact, and 0.0 when reduced motion is on, so reduced motion collapses every transition to instant through one shared contract. The animated pieces are the camera tween (camera::CameraTween, ease-out cubic in log-space zoom), the collapsible section openness, the progress-row fill, and the toast fade; each honors the reduced-motion zero-time path.

Visual regression and the frame guard

The gallery is the visual-suite surface. App::gallery() renders every component group at both densities with deterministic, screenshot-stable content; it is reachable with the --gallery native flag and the ?gallery=1 web boot arm.

crates/reticle-app/tests/ui_snapshots.rs is the visual-regression suite, built on egui_kittest 0.35 over the wgpu backend. It has two families: gallery snapshots (each component group crossed with density through the frozen theme::gallery::ui) and full-application snapshots of the real App at 1280x800, 1600x1000, and 900x600 plus a palette-open state. just ui-check runs the suite; just ui-baselines recaptures the committed PNGs under tests/snapshots/. Both families need a GPU adapter (egui_kittest 0.35 with the wgpu feature has no CPU rasterizer), so they skip honestly on an adapterless host and .config/nextest.toml serializes them so concurrent worktrees never contend for the single GPU.

crates/reticle-app/tests/frame_guard.rs guards motion cost. It builds the real App on the kittest wgpu harness, runs untimed warmup frames, then times 120 steps and asserts the median step wall time stays under one 60 Hz budget (16 ms). Median, not mean, so first-layout and font-atlas warmup do not dominate; the guard trips on a per-frame UI-build regression, which is what the states and motion work could regress. just frame-guard runs it, serialized on the single GPU alongside the snapshots. The budget is held at an honest 16 ms and is never widened to force a pass; a flaky GPU check is quarantined instead.

Where to look

  • docs/design/tokens.md: the binding token, contrast, spacing, type, radius, and elevation tables.
  • docs/design/catalog-dispositions.md: the per-item disposition of all 100 Improvement Catalog entries with committed evidence.
  • Decision records: 0095 (semantic token module, one dark theme), 0096 (managed panels over docking), 0097 (typography and the Lucide icon font), and 0098 (the style ratchet and the bundle-size gate).

The redesign: before and after

The v8.1 interface packet rebuilt the chrome around the same engine. This page pairs the v8.0.0 interface (before) with the v8.1.0 interface (after) on the four URL-reachable states, with a short note on what changed and which audit findings and catalog items each pairing closes.

Method and provenance. The before set was captured from the deployed v8.0.0 bundle (web-cc73d6608fe18660) by e2e/baseline-gallery.mjs and lives under docs/design/baseline/ with a manifest.md recording exact provenance; a git commit does not expire, so the before state is reproducible from the tag. The after set uses the same filenames under docs/design/after/ and is captured by the Wave 5 gallery step; where an after image is not yet committed when you read this, its path is the same name in after/. The full findings list is in docs/design/audit.md (25 findings, AUD-01 to AUD-25); the per-item sweep is in docs/design/catalog-dispositions.md.

Each pairing below shows the 1280x800 shot; the 1600x1000 and 900x600 variants (and the phone and tablet variants for the before set) sit beside them in the two directories.

Landing view (home-default)

Before:

v8.0.0 landing view at 1280x800

After:

v8.1.0 landing view at 1280x800

The before landing put the replay theater window, the collapsed 3D stack, and the Cross-section bar over the canvas at fixed positions (AUD-01), behind a single wrapped toolbar of roughly 25 ungrouped controls with no menu bar, no icons, and no shortcut hints (AUD-05), with “Add demo rectangle” as the first panel button (AUD-09) and a smear of overlapping ruler labels at the origin (AUD-08). The after landing has a registry-driven menu bar above a grouped, icon-and-tooltip toolbar (catalog 25, 70), the 3D stack and Cross-section as managed bottom panels that never occlude the canvas (ADR 0096), and an overlay layout manager that keeps the minimap and rulers collision-free by construction (catalog 30, 31, 32, 68).

Editor entry and Start screen (view-editor)

Before:

v8.0.0 editor entry at 1280x800

After:

v8.1.0 editor entry at 1280x800

The before Start surface was a column of undifferentiated gray strips with small right-aligned buttons, no thumbnails, and no technology, size, or license badges, and its “Skip to the editor” link clipped off the bottom at 1280x800 (AUD-11). The after Start screen is rebuilt on the component library with an empty-canvas hero of exactly three primary actions (catalog 16), differentiated gallery cards carrying name, technology, size, source, and license badges plus a per-card landmarks dropdown that answers “what am I looking at” (catalog 14, 96), pinnable recent files (catalog 9, in part), and a skip link inside the scroll.

Streaming an archive (archive-stream)

Before:

v8.0.0 archive streaming at 1280x800

After:

v8.1.0 archive streaming at 1280x800

Streaming the multi-gigabyte live archive over HTTP Range, the before view let the collapsed floating windows cover the streaming HUD, so the flagship demo hid its own proof-of-streaming (AUD-02), and the Layers panel and History still showed the built-in demo document’s layers and shape count while an unrelated die streamed (AUD-04). The after view routes the HUD and the minimap through the overlay manager so they stay legible, shades resident tiles on the minimap and uses the streamed die bounds rather than the stale demo (catalog 30), adds velocity-aware tile prefetch with an honest HUD line (catalog 43), and shows an empty-state in the Layers panel because an archive carries no layer table.

Before:

v8.0.0 viewer chrome at 1280x800

After:

v8.1.0 viewer chrome at 1280x800

The before share-link viewer was the full editor in disguise: History with a debug button, DRC, Layout diff, Comments, Agent, and the draw tools, with the share and follow controls below the fold of the right-panel scroll (AUD-03), and the reconnect state was a line of tiny status-bar text (AUD-14). The after viewer is a distinct chrome selected structurally by is_viewer(), not the editor with panels hidden: canvas, status bar, Layers, presence cursors, a live session chip with state and participant count and avatars (catalog 75), a follow toggle (catalog 87), and one fixed “Open full editor” affordance (catalog 23), with reconnect surfaced as a real toast and offline badge (catalog 74).

Notes

  • These four states are the URL-reachable ones a first visit or a share link can land on. Interior states that need in-app interaction (panels expanded, DRC and diff overlays, comments, the agent panel, the 3D stack, the cross-section) are captured by the native demo-script harness, not by the gallery script.
  • The images render inline when the page is viewed against the repository tree (the paths are relative to docs/design/); the full before and after PNG sets, at every captured resolution, and the before-set provenance manifest live under docs/design/baseline/ and docs/design/after/.

Geometry

reticle-geometry is the exact-integer foundation. It has no GPU, async, or UI code, so it is fast to test and simple to reason about.

Coordinates are integers

Chip layout lives on an integer grid measured in database units (DBU). Reticle uses type Dbu = i32, which matches GDSII and keeps points dense in memory. All area and product arithmetic widens to i64 (or i128 for area sums) to avoid overflow, so for example Rect::area returns an i64 and Polygon::signed_double_area returns an i128. See ADR 0002 for the reasoning and the trade-offs.

Primitives

  • Point { x, y } on the DBU grid, with saturating translation and squared distance in i64.
  • Rect stored as [min, max) corners, with width, height, area, containment, intersection, union, and margin expansion.
  • Polygon, an implicitly-closed ring of vertices, with the shoelace signed double-area (exact in i128), winding classification, and a bounding box.
  • Path, a polyline with a width and an end-cap style (flat, square, round, or a custom extension), with a conservative bounding box.
  • Transform, an orientation from the dihedral group of eight (rotate by a multiple of 90 degrees, optionally reflected), a rational Magnification, and a translation, applied in that order. This matches the GDSII and OASIS placement model.

Robust booleans

Union, intersection, difference, and exclusive-or run on the i_overlay integer engine over DBU coordinates (ADR 0003), wrapped behind polygon_boolean so the dependency stays swappable. Input contours are interpreted by winding under the non-zero fill rule, so a clockwise ring is treated as a hole. Results are flattened to polygons with outer boundaries wound counter-clockwise and holes clockwise, so a caller can tell them apart by Polygon::winding.

Offsetting (growing or shrinking by a delta) runs on i_overlay’s float outline engine with mitered corners and is rounded back to the grid.

Testing

The boolean engine is validated two ways. Exact unit cases check known areas, including a difference that produces a hole. A property test compares the engine against an independent winding-number oracle: for randomized sets of rectangles and every operation, it asserts that a grid of off-edge query points is classified identically by the engine and by the oracle. This catches sign, winding, and hole-handling mistakes that example-based tests miss.

Spatial indexing

reticle-index answers the queries that make browsing a massive layout interactive: which shapes fall in the view, which shape is nearest the cursor, and which tiles to stream at the current zoom.

Indices

  • R-tree. A bulk-loaded R-tree (rstar) is the primary index for rectangle, nearest-edge, and k-nearest queries. Bulk loading packs the tree in one pass, which is far faster than repeated insertion and yields better query performance.
  • Uniform grid. For uniformly distributed geometry, a uniform grid buckets shapes by cell and answers rectangle queries by scanning the covered cells. It is cheap to build and update.
  • Tile and LOD pyramid. Shapes are bucketed into tiles at several levels of detail. A renderer requests only the tiles inside the view at the level appropriate to the zoom, so memory and fetch work scale with what is on screen rather than with the size of the design. It does not reduce draw work, and that distinction is the whole point of the structure: a shape is recorded in every tile its bounding box overlaps at every level, so a coarse tile still names every shape inside it. The pyramid is a coarser tiling, not a coarser representation of the geometry. See Rendering.

All indices implement the shared SpatialIndex trait, so callers are generic over the structure. A brute-force LinearIndex implements the same trait and serves as the oracle the fast indices are property-tested against.

Zero-copy archive (the building block for out-of-core)

An index payload serializes to a zero-copy rkyv archive laid out exactly as its in-memory form, so a caller can read shape rectangles, and index a single entry - straight from the bytes with no parsing or allocation, validated by rkyv’s bytecheck. This is the primitive a memory-mapped, larger-than-RAM layout would sit on. Paging a live, edited document’s own index to disk has no renderer consumer yet: the in-app open funnel’s LoadPlan carried a Streaming band for exactly this, and it was removed after an audit found zero production call sites (crates/reticle-app/src/webopen/load_plan.rs), so every openable document is still built and indexed in memory today. That is a different mmap consumer from the read-only .rtla archive streaming below, which does reach the renderer today; the out-of-core streaming ADR and STATUS.md record both.

Streaming a .rtla archive over a TileSource

The .rtla container (ADR 0062) turns a layout into a network transport for renderable silicon: a header with the world box and per-level grid dimensions, a tile directory of byte ranges, then byte-contiguous tiles, each an independently-validated rkyv TilePayload. A TileSource is the read seam over one such archive: fetch the header, fetch one tile’s bytes by address. Three sources implement it. MmapTileSource maps a local file and serves a tile by slicing the mapped range, reusing the zero-copy archive discipline above (the one documented unsafe, every block validated). HttpRangeTileSource runs in the browser: it fetches the header with two ranged GETs, then a tile per fetch with a Range: bytes=offset-end header, in front of a byte-budgeted in-memory LRU and an OPFS persistent cache so revisiting an archive is instant (ADR 0063). MemTileSource is an in-memory double for tests.

A small query layer resolves a viewport against the archive’s finest level (coarser levels are paint-only), fetches the overlapping tiles, and returns the records that intersect the view. That streamed result is proven equal to the in-RAM R-tree’s answer over 600 randomized layouts. Every count read from a header or directory is untrusted: tile counts are summed with checked arithmetic, a header inconsistent with its directory is rejected, and the header fetch is capped, so a directory claiming billions of tiles errors rather than exhausting memory. The physical byte framing and the OPFS/LRU cache policy are fixed in ADR 0063.

Targets

The bulk index build of one million shapes should complete in well under a second, and point or rubber-band picking over a million shapes should return in under a millisecond. Measured numbers are in the performance chapter.

Document model

reticle-model is the hierarchical document that everything edits and renders.

Hierarchy is the source of scale

A document is a set of named cells. A cell owns flat geometry (shapes on layers) and placements of other cells: single instances and regular arrays (rows and columns with a pitch). Placements carry a transform (orientation, magnification, translation), and they nest, so a modest cell arrayed thousands of times expands to effectively billions of leaf shapes.

Crucially the hierarchy is never flattened for browsing. Each cell caches the bounding box of its own geometry, so the renderer can cull whole instances and arrays that fall outside the view and pay only for what is visible. Flattening is available when a tool genuinely needs the expanded geometry, and so is the inverse.

Transactional editing

Edits are expressed as a small vocabulary of reversible operations (add or remove a shape, add an instance or array, add or remove a cell). Applying an edit records its inverse on an undo stack, so undo and redo are exact and unbounded. This same operation log is what the collaboration layer replicates; see Collaboration.

The trait surface

The model also defines the stable traits the higher subsystems implement: DocumentStore for editable access, RuleSet for design-rule checking, Router for routing, Importer and Exporter for file formats, and Renderer for drawing. Keeping these here lets the core stay free of the GPU, IO, and async stacks while still describing how they plug in.

Drawing and vertex editing

The editor’s drawing tools add geometry to the top cell, and the vertex-edit tool reshapes a shape already there. As with the rest of the app, the interesting logic is window-free and lives in reticle-app’s draw module, unit-tested without a GPU; the egui layer only turns pixels into world points, calls in, and paints the live preview.

Every one of these actions goes through the undo history, so drawing a shape or moving a vertex can be undone and redone like any other edit.

Tools

These tools sit on the toolbar next to Select, Pan, Measure, and Cross-section. They are also in the command palette under Tool:.

  • Rect draws an axis-aligned rectangle by dragging from one corner to the opposite one.
  • Polygon draws a closed polygon by clicking to place each vertex.
  • Path draws a wire: a polyline with a width and an end-cap style.
  • Bus draws several parallel wires at once: click a centerline the way the path tool does, and finishing commits N wires on a fixed pitch (see Buses).
  • Vertices edits the vertices of the selected shape.

Switching to any non-drawing tool discards a half-drawn shape so nothing leaks between tools. The path width and end cap you pick survive the switch.

Rectangle constraints

A plain drag spans the two corners. Two modifiers refine it, and they combine:

  • Shift constrains the rectangle to a square, growing the shorter side to match the longer one while keeping the far corner in the direction you dragged.
  • Alt treats the drag’s start point as the rectangle’s center rather than a corner, so the box grows symmetrically. Held together with shift, a centered square uses the larger half-extent on both axes.

Ctrl and Cmd used to be a second from-center chord and no longer are: they are the app-wide snap-bypass modifier, and the rectangle tool now honours it like every other placement site (see Snapping). The from-center gesture itself is unchanged; only the second chord that reached it is gone. Check: cargo nextest run -p reticle-app -E 'binary(rect_snap)'.

The start and end points go through the same snap seam every other placement site uses, so the committed rectangle lands wherever the status bar and the snap indicator said the cursor was.

Placing polygons and paths

Both tools accumulate vertices click by click, drawing a live preview of the edges placed so far plus a faint segment out to the cursor. An immediate repeat of the last point is ignored, so a closing double-click never leaves a zero-length edge.

  • A double-click, or Enter, finishes the shape. A polygon needs at least three distinct vertices; a path needs at least two points. A finish gesture with too few points is declined rather than committing a degenerate shape.
  • Escape cancels the shape in progress.

A path carries a width in database units and one of three end caps, both set on the toolbar while the path tool is active:

  • Flat ends the wire exactly at its endpoints.
  • Square extends each end by half the width.
  • Round rounds each end by a half-width radius.

Buses

The Bus tool places several parallel wires in one gesture. Its centerline is placed exactly the way the path tool’s polyline is (it uses the same builder, so Escape cancels a half-drawn bus and the angle constraint anchors on it), and the toolbar carries two extra settings while the tool is active: how many wires, and the centerline-to-centerline pitch in database units. The wires take their width and end cap from the path settings beside them.

Finishing (double-click, or Enter) commits every wire as one undo step, so a bus undoes as a bus rather than one wire at a time. The wires are centered on the centerline: an odd count puts the middle wire on it, an even count runs it between the two middle wires, and consecutive wires are exactly one pitch apart either way. A polyline centerline is offset with mitered corners, so every wire keeps the centerline’s vertex structure.

If the document’s technology declares a spacing rule for the layer being drawn on, the status line after a commit says so when the chosen pitch is below the smallest spacing-clean pitch for that layer, which is the wire width plus that spacing minimum. That is a report, not a correction: the tool places the pitch you asked for.

What the bus tool does not do: it does not route. There is no obstacle avoidance, no auto-jogging around existing geometry, no per-wire length matching, and no connection to a netlist. It is a constant-pitch parallel offset of one centerline. At a corner sharper than the miter guard allows, the inner wires clip rather than fan out.

Check: cargo nextest run -p reticle-app -E 'binary(bus)' drives the real tool and asserts the pitch, and runs the committed SKY130 rule subset over the geometry the tool produced at a compliant pitch (zero spacing violations) and at a tightened one (an m1.2 spacing violation naming two of the wires).

Editing vertices

Select a single shape you drew, then switch to the Vertices tool. It ticks every vertex of the shape so you can see what is grabbable. Only shapes owned directly by the top cell are editable; geometry that comes from a placed instance is not.

  • Drag a vertex to move it. The new position goes through the same snap seam every other placement site uses (see Snapping), so a vertex can be dropped exactly onto existing geometry or a guide.
  • Click on an edge to insert a vertex there. The new vertex lands on the point of the segment nearest the click.
  • Alt-click (or ctrl-click) a vertex to delete it. A polygon keeps at least three vertices and a path at least two, so a deletion that would collapse the shape is refused.

A rectangle promotes to a polygon the moment one of its corners is edited off the axis, since a rectangle can no longer describe the result. A path keeps its width and end cap through an edit. Each move, insert, or delete is one undoable step, applied as a remove-then-add of the reshaped shape.

Snapping

Every placed point and moved vertex goes through one shared snap seam before it is committed, so drawn geometry lands exactly where the status bar and the on-canvas snap indicator said the cursor was. That seam tries nearby existing geometry and user guides first, then falls back to the grid; turning grid snapping off (the Snap toggle) places points at the exact cursor position when nothing else catches them. Holding ctrl or cmd drops snapping entirely for as long as it is held.

All six placement sites use it: the polygon, path, and bus centerlines, the vertex-move commit, and both the anchor and the cursor of a rectangle drag. The rectangle was the last one added; before that it read the grid directly, so object snap, guide snap and the bypass modifier moved the indicator without moving the committed corner. Check: cargo nextest run -p reticle-app -E 'binary(rect_snap) or binary(allangle)'.

Testing

The module’s geometry is unit-tested in isolation: the rectangle-from-drag math for each modifier combination, the polygon and path builders’ finish thresholds and deduplication, the bus offset run and its mitered polyline offset, vertex hit-testing by exact squared distance, edge projection for insertion, and the insert, delete, and move operations against their vertex-count floors. Because this logic is pure, the tests need no window and run with the rest of the reticle-app suite.

On top of that, three GPU-free integration suites drive the real editor through egui_kittest and assert against the committed document rather than against the geometry helpers: binary(allangle) for the polygon tool’s placement and the angle constraint, binary(rect_snap) for the rectangle tool’s snap seam and its modifiers, and binary(bus) for the bus tool’s pitch and its DRC spacing behaviour.

Boolean and transform operations

The Operations panel turns the current shape selection into edits: planar booleans, an offset, rotate and mirror, and align and distribute. It lives in reticle-app’s ops module. The heavy geometry is delegated to reticle-geometry (the same robust i_overlay engine the rest of the app uses); ops is the glue that maps selected scene shapes back to editable cell shapes, runs the operation, and records the result as one undo step.

What the selection points at

Selection is a set of indices into the flattened top-cell scene, the same list the canvas hit-tests. Only the top cell’s own shapes can be edited by index, and Document::flatten emits those first, so a scene index below the editable-shape count maps one-to-one onto a top-cell shape index. Selected indices at or above that count come from placed instances and arrays; there is no single shape in the top cell to rewrite, so the operations skip them.

Booleans and offset act on filled geometry, so they consider rectangles and polygons. A path is a stroked wire rather than a fill region, and turning it into a fill needs the render tessellator, so paths are skipped from boolean and offset input. Transforms (rotate, mirror, align, distribute) are coordinate maps and apply to every shape kind.

Booleans, per layer

Union, intersection, difference, and exclusive-or run through reticle_geometry::polygon_boolean. The selection is grouped by layer first: only shapes on the same layer combine, and each group’s result stays on that layer. This keeps a boolean from silently merging, say, metal-1 and metal-2 geometry. A layer group needs at least two fillable shapes to do anything.

Union, intersection, and exclusive-or fold pairwise across the group (they are associative). Difference subtracts every later shape from the first, matching the usual “A minus the rest” editor behavior. When the engine produces no geometry (an empty intersection, for instance) the inputs are left untouched rather than being deleted.

Offset, grow, and shrink

The offset control feeds reticle_geometry::offset, which grows for a positive DBU amount and shrinks for a negative one, with mitered corners. Each selected fillable shape is offset independently and its result replaces it on the same layer. A shrink that collapses a shape to nothing simply drops it. The offset runs on the float outline engine and rounds back to the grid, so results are on-grid but not guaranteed bit-exact for pathological input.

Rotate and mirror

Rotate takes a numeric angle in degrees and turns the selection about its combined bounding-box center, counter-clockwise. Rotation runs on a floating basis and rounds each vertex to the nearest DBU, so a multiple of 90 degrees is exact while an arbitrary angle is on-grid but not exactly reversible. A rotated rectangle is promoted to a polygon, since a non-orthogonal angle tilts it off the axes.

Mirror reflects the selection across its center, either about the vertical center line (left to right) or the horizontal one (top to bottom). An axis mirror is exact integer arithmetic (2 * center - coordinate), so mirroring twice about the same axis returns the original geometry.

Align and distribute

Align moves every selected shape to a shared edge or center of the selection’s combined bounding box: left, right, or horizontal center; top, bottom, or vertical center. A shape already in place does not move. Distribute needs at least three shapes: it fixes the two extreme shapes and respaces the ones between them so the edge-to-edge gaps along the chosen axis are equal.

The alignment and distribution math is pure and unit-tested against hand-computed offsets, independent of the UI.

One edit, one undo step

A single operation is usually several edits: a boolean removes each of its inputs and adds one result. Those must undo together. The frozen Edit vocabulary has no group variant, so the app layer’s History records how many underlying edits make up each logical step (History::apply_group) and steps undo and redo over a whole group at once. Removals within a group are ordered highest-index-first so that removing one input does not shift the index of another before it is removed. The result: a boolean over three rectangles is a single entry on the undo stack, and one undo restores all three.

File formats

reticle-io reads and writes the layout interchange formats and the technology description.

GDSII

GDSII is the long-standing binary interchange format for IC layout. Reticle reads and writes it through gds21, the de facto Rust GDSII library (part of the Layout21 project), mapping its structures, boundaries, paths, and references onto Reticle cells, shapes, instances, and arrays. Round-trip fidelity is tested: a document exported to GDSII and re-imported preserves its geometry, layers, and hierarchy.

The Reticle container format (OASIS-inspired, ADR 0004)

OASIS is the newer, compressed successor to GDSII, but it has no mature Rust library. Reticle’s Oasis type is therefore not a conformant OASIS reader or writer: it is an in-house binary container, OASIS-inspired (it borrows the spirit - a magic string, a START/END frame, and CELL/RECTANGLE/POLYGON/ PATH/TEXT/PLACEMENT/ARRAY records with explicit layer and datatype) but not its wire format. No third-party tool (KLayout, gdstk) can read it. It exists to round-trip Reticle’s own geometry and hierarchy compactly and losslessly for the supported record set; anything unsupported is a clear error rather than silent data loss. See ADR 0004 for the container layout and honest gaps.

Conformant OASIS reader and writer (oasis_std)

Separately, OasisStd is a genuine SEMI P39 OASIS reader and writer for a practical subset - its writer output is read by KLayout as OASIS, and its reader parses that same subset back into a Document, so a document survives a write-then-read round trip. The writer is uncompressed (no CBLOCK), emitting RECTANGLE, POLYGON, PATH, PLACEMENT, and TEXT with fully explicit modal state, CELLNAME+CELL tables, and PLACEMENT records carrying magnification and angle. The reader additionally decodes third-party OASIS constructs real PDKs and KLayout emit that the writer itself never produces: CBLOCK-compressed blocks, repetition records, and the short point-list forms. Documented writer-subset gaps: arrays are expanded to individual placements, a label’s anchor is dropped (OASIS TEXT is a point), and a path’s round end cap is written flush. KLayout reading oasis_std output is verified in-container by the interop harness; see the interop chapter and ADR 0086. The reader is wired into the app’s own open path (crates/reticle-app/src/open.rs), so opening a .oas/.oasis file whose bytes carry the conformant magic uses this reader, not the in-house container above. Check: cargo nextest run -p reticle-io --test oasis_std_read.

CIF

CIF (Caltech Intermediate Format) import reads the classic CIF 2.0 primitive subset used by MOSIS-era mask layouts: symbol definitions (DS/DF), layers (L), boxes (B), polygons (P), wires (W), and symbol calls with transforms (C). Import only; there is no CIF exporter. An unrecognized top-level command is skipped with a warning rather than rejected, so one private extension statement does not sink an otherwise good file. See crates/reticle-io/src/cif.rs for the full grammar and its honest gaps (CIF text/label extensions and rounded-flash geometry are not implemented).

DXF

DXF (Drawing Exchange Format) import reads the layout-relevant 2D subset of the ENTITIES section: LINE, LWPOLYLINE, the classic POLYLINE/VERTEX/SEQEND chain, CIRCLE, ARC, and the common polyline-boundary case of HATCH. Import only; there is no DXF exporter. Every other entity type (TEXT, INSERT, SPLINE, …) is recognized structurally, so it cannot desync the parser, and is skipped with a deduped warning. See crates/reticle-io/src/dxf.rs for the full entity list and its honest gaps (a bulged polyline imports as its straight-edged chord, not its rounded outline).

Magic

Magic (.mag) import reads the classic ASCII per-cell subset: a header, `<< layer

sections carryingrectgeometry, anduseplacements of other (by-name) cells. Import only; there is no Magic exporter. A.magfile describes exactly one cell, so ausebecomes an instance naming the target cell by string, without that cell's own geometry present in the document - real Magic resolves auseat read time from a directory of sibling files, which is out of scope for a reader that sees only one file's bytes. Seecrates/reticle-io/src/mag.rsfor the full grammar and its honest gaps (onlyrectgeometry; notri, polygon`, or label elements).

Technology files

A technology file describes the process: the database resolution, the layer table (numbers, datatypes, names, and display colors), and the design rules. Reticle parses a simple, readable text format into the model’s Technology, which then drives layer display and the design-rule checker.

Streaming a die: the record reader and the .rtla builder

The GDSII importer above reads a whole library into memory under a 256 MiB cap. A full shuttle die is several gigabytes and the in-browser converter runs in a worker, so both need to pull one record at a time without ever holding the whole file. Two pieces make that possible (Wave 2, ADR 0062 and ADR 0063).

GdsRecordReader<R: Read> is a forward-only GDSII reader over any byte source. It hand-rolls the record framing (2-byte length, record type, data type, payload) with no gds21 dependency, so it is wasm-clean, and it yields a small flat vocabulary of GdsEvents (library and struct boundaries, boundaries, paths, references, arrays, text) in document order. It carries the same hardening as the DOM importer: a zero-length string record is rejected before anyone can index data[-1], dates are skipped rather than parsed (so the out-of-range-date panic class cannot fire), and a record length is a 16-bit field, so no count ever drives an allocation past the remaining input. A differential test asserts the streaming reader accepts everything the DOM importer accepts and reports the same cells and per-layer shape counts across the real corpora; a fuzz target (gds_stream) drives it over arbitrary bytes.

build_rtla writes a .rtla streamed archive from a lazy record source using bounded memory. It is external and two-pass: pass 1 streams the records and spills them to sorted run files on disk; pass 2 merges the runs and emits the tiles in directory order, holding at most one sort chunk and one tile in memory. The finest pyramid level is exact (every record reaches it and round-trips); coarser levels are subsampled paint-only approximations. On the 30M-entry generated layout the build peaks at 127 MiB of RSS (far under a 2 GiB budget), and a 120M-record build produces a 2.42 GB archive to completion under the same bound. The on-disk framing (a 32-byte preamble locating the rkyv header and directory blocks, then byte-contiguous tiles) is specified in ADR 0063 so the native and wasm tile sources read to the same layout.

Converting a GDS to a streamable archive

reticle convert <in.gds> <out.rtla> joins those two pieces into one command: it turns a GDSII file into a .rtla archive you can browse over an HTTP range or a memory map without ever loading the whole die. It streams the input twice, holding no whole-file model. Pass one scans every record to find the world bounding box, recover the database scale, and count the drawn elements; pass two reopens the file as a lazy record source and feeds it to build_rtla. Peak memory is the builder’s spill budget, not the file size, so the command scales to the same gigabyte dies the builder targets. The conversion is byte-deterministic: records are emitted in document order, the world box is an order-independent union, the pyramid depth is a pure function of the world span, and the builder writes no timestamps, so the same input always produces identical bytes.

Its v1 flatten scope is deliberately narrow: only directly drawn geometry becomes a record. Each boundary and path bounding box is one tile record, in database units as authored (a path is inflated by half its width). Instance and array references are not composed into world space, because expanding a placement needs random access to the referenced cell’s geometry (a whole-file model), the very thing the streaming path avoids. A referenced cell’s own shapes are still captured where they are drawn, in that cell’s own frame, so a flat or already-flattened GDS (what the tile generator and the export path emit) converts faithfully, while a deeply hierarchical one does not yet reproduce its placements. True hierarchical flattening is a documented follow-up. See ADR 0072 for the flatten and leveling choices.

Robustness

The parsers are fuzzed. A parser must never panic or hang on malformed input; it either produces a document or returns an error. The streaming GdsRecordReader holds the same guarantee: its gds_stream fuzz target seeds from the committed GDS crash fixtures so it cannot reintroduce a fixed panic class, and a native regression test drives it over those fixtures on every platform (libFuzzer cannot link on MSVC).

GDS / OASIS interop

Reticle’s interchange formats are only as good as what other tools make of them. This chapter records how Reticle’s GDS round-trip compares against two independent external tools, and whether KLayout can read Reticle’s conformant-OASIS writer.

The harness

scripts/interop/ drives a comparison headless inside the pinned hpretl/iic-osic-tools container (KLayout 0.29.x + gdspy 1.6), re-runnable with scripts/interop/run-interop.ps1. It:

  1. generates two fixtures with gdspy (1 dbu = 1 nm);
  2. round-trips each fixture (read then re-export) through Reticle (a standalone reticle-roundtrip driver on the host), KLayout, and gdspy;
  3. normalizes every output with a single authoritative reader - KLayout renders each shape to an integer-dbu polygon (so path-vs-box representation differences are neutralized), and instance transforms are read with gdspy, which exposes the raw GDS STRANS rotation/magnification directly; and
  4. exports each fixture as conformant OASIS (oasis_std) and has KLayout read it.

Because one reader normalizes every writer’s output, a divergence is attributable to the tool that wrote the file. The committed report and its machine-generated companion live under docs/interop/ in the repository.

Tooling note. The packet named gdstk; it is not preinstalled in the image and PEP 668 blocks a clean in-container pip install, so the harness uses gdspy (its same-author predecessor, preinstalled) as the second tool and stays reproducible with no network. If Docker is unavailable the runner prints the exact skipped command and exits 3, so the comparison can be recorded as not-run while the writer, the second PDK, and the GenTech refactor still ship.

What the round-trip preserves

On the clean fixture (a rectangle, a Manhattan polygon, a 45° polygon, a path, a label, a cell reference, and a 2×2 array), Reticle, KLayout, and gdspy round-trip the design identically - rendered geometry, labels, and instances all match, and KLayout reads Reticle’s OASIS output.

The documented divergence

On the odd fixture (seeded with a 1 nm sliver, round and custom-extension paths, a degenerate-vertex polygon, and a reference rotated 45° with magnification 2×), the geometry all round-trips cleanly, but one divergence surfaces in instance transforms:

toolrecovered rotationmagnification
source / gdspy / KLayout45°
Reticle90°

Reticle’s placement model, Orientation, encodes only the eight orthogonal orientations (R0/R90/R180/R270 and mirrors). A non-orthogonal STRANS angle has no exact representation, so Reticle’s GDS importer snaps 45° to the nearest orthogonal orientation (90°) rather than dropping it silently; the magnification and origin survive. This is a modelling limitation, not a reader/writer bug - Reticle is a Manhattan-plus-45°-fill layout tool whose instance transforms are orthogonal by design. It is called out here and in the committed report rather than hidden.

See the full report at docs/interop/gds-oasis-divergence-report.md.

LEF/DEF import and export

The reticle-lefdef crate imports the two text formats an OpenROAD/OpenLane run emits, LEF (the technology and the macro cell abstracts) and DEF (the placed, routed design), and lowers them into a reticle-model Document plus the run-level metadata a viewer overlays. The single public result is LefDefDesign, the contract the run viewer consumes. write_lef/write_def write a LefDefDesign back out to LEF/DEF text; see Export below.

It is a new crate, not an extension of reticle-io: reticle-io is hardened and frozen-adjacent, and LEF/DEF are a different concern (a design-and-technology interchange, not a layout binary). The crate has no external dependencies and no native-only code, so it builds for wasm32-unknown-unknown alongside the rest.

Entry points

#![allow(unused)]
fn main() {
pub fn import_lef_def(lef: &[u8], def: &[u8]) -> Result<LefDefDesign, LefDefError>;
pub fn import_run_dir(dir: &Path) -> Result<LefDefDesign, LefDefError>;
}

import_lef_def takes a LEF byte slice (or several LEF files concatenated, which is valid) and a DEF byte slice. import_run_dir walks a flow output directory (bounded in depth and file count), concatenates the *.lef files it finds, and imports the *.def whose name sorts last, which selects the later flow stage (for example 6_final.def over 2_floorplan.def).

The LefDefDesign contract

LefDefDesign keeps the lowered layout separate from the run metadata a viewer overlays:

fieldsourcemeaning
documentLEF macros + DEF placement/routingthe lowered Document: a cell per macro, a top cell of placed instances and routed shapes, and the layer table
design_nameDEF DESIGNthe top cell name
die_areaDEF DIEAREAthe die outline (a rectilinear outline is reduced to its bounding box)
sitesLEF SITEplacement site definitions
rowsDEF ROWplacement rows
netsDEF NETSthe routed net list, each with its wire and via segments
pinsDEF PINSexternal I/O pins
overlaysreports (viewer)congestion, utilization, and timing-critical-net slots, empty after a LEF/DEF import
warningsimportnon-fatal problems (skipped keywords, dropped degenerate shapes, unresolved references)

The layout lives in document because the renderer already draws cells, instances, and shapes. The rest is run-level metadata a viewer treats differently: rows and the die area are chrome, nets are selectable by name, and the overlays slots are filled in later from a run’s report files, which is a separate concern from the layout import.

Supported subset

This is a deliberate subset, chosen to render an OpenROAD run, not a full LEF/DEF implementation. ADR 0082 records the scope and the reasons.

LEF

  • UNITS DATABASE MICRONS sets the database resolution.
  • LAYER <name> with TYPE (ROUTING, CUT, or other) and WIDTH. Each layer name is interned to a LayerId in declaration order and given a palette color; routing widths become the default wire width for that layer.
  • SITE <name> with CLASS and SIZE.
  • MACRO <name> with CLASS, SIZE, PIN (with DIRECTION and PORT/LAYER/ RECT geometry), and OBS. Each macro becomes a Cell; each pin becomes a model Pin and its port rectangles are drawn on their layers; obstructions are drawn.

Via geometry, spacing and antenna tables, and property definitions are skipped with a warning. Only RECT geometry is lowered from ports and obstructions; a POLYGON is skipped with a warning.

DEF

  • DESIGN, UNITS DISTANCE MICRONS, DIEAREA.
  • ROW, including the DO/BY/STEP repeat.
  • COMPONENTS: each PLACED or FIXED component becomes an Instance at its location and orientation. A component whose macro the LEF never defined is skipped with a warning.
  • PINS: NET, DIRECTION, a LAYER rectangle, and PLACED location.
  • NETS: ROUTED (and FIXED) wires and vias, including NEW layer breaks and the * repeated-coordinate shorthand. Each wire is drawn into the top cell as a path and recorded in the net list.

SPECIALNETS, GROUPS, REGIONS, and BLOCKAGES are skipped with a warning. Coordinates are read as they appear: DEF coordinates are already DBU, LEF microns are converted to DBU on the shared resolution.

Orientation mapping

DEF names the eight placements N, S, E, W and their flipped forms FN, FS, FE, FW. The DEF flip is a mirror about the Y axis applied before the rotation; Reticle’s Orientation models a reflect-about-X-then-rotate (the GDSII convention). The exact correspondence, verified in the crate’s tests by comparing the point transforms directly, is:

DEFReticleDEFReticle
NR0FNMirrorX180
WR90FWMirrorX270
SR180FSMirrorX
ER270FEMirrorX90

Robustness

LEF and DEF are untrusted input, so import never panics or hangs on any byte sequence. Inputs over 256 MiB are refused before parsing, so a hostile length cannot force a large allocation (the OASIS out-of-memory lesson). Bytes are decoded lossily, so invalid UTF-8 never panics. The tokenizer and parsers advance by at least one token per step over a finite stream, so no parse loops forever, and no collection is ever pre-sized from a count read out of the input. A statement that cannot be parsed is a clean LefDefError naming its line; a recoverable problem is a LefDefWarning and the rest of the design still imports.

Export

#![allow(unused)]
fn main() {
pub fn write_lef(design: &LefDefDesign) -> String;
pub fn write_def(design: &LefDefDesign) -> String;
}

write_lef emits layers, sites, and macro pins/obstructions; write_def emits the design, units, die area, rows, placed components, pins, and routed nets. Together they are the mirror of crate::lef/crate::def: only keywords the reader models are emitted, so a written file re-imports cleanly.

The invariant is round-trip structural equality, not byte equality: write_lef/ write_def followed by import_lef_def reproduces the same document, design_name, die_area, sites, rows, nets, and pins for every committed fixture (cargo test -p reticle-lefdef --test write, 7 tests). A few fields the reader does not lower onto LefDefDesign (MACRO SIZE/CLASS, DEF component instance names, PIN placement origin, a macro pin with more than one disjoint PORT RECT) are unobservable after re-import, so the writer fills each with a documented placeholder rather than the original value; see crates/reticle-lefdef/src/write.rs for the exact list.

Validated against a tool

A subset importer is only trustworthy if its reading of a file matches what a real EDA tool reads from the same file. The reticle-cli lefdef_oracle module cross-checks the import against OpenROAD, a real place-and-route tool, run over the exact same LEF and DEF inside a pinned Docker container. It follows the external-oracle pattern ADR 0054 set for the TinyTapeout precheck: pin the tool image by digest, run it non-interactively over a mounted work directory, parse its structured output, and skip honestly (never fail) when Docker or the image is absent. ADR 0088 records the choice.

The oracle is OpenROAD, bundled in hpretl/iic-osic-tools:2025.01 (the same image the precheck pins). A short Tcl script does read_lef then read_def and prints four structural facts as ORACLE <key>=<value> lines that the parser reads back into an OracleCounts:

factreticle-lefdefOpenROAD
macroscells other than the top design celllibrary masters
componentstop cell placed instancesblock instances
pinsDesignPin countblock terminals
die areadie_area box in DBUgetDieArea box in DBU

The cross-check is proven both ways. A faithful import matches the oracle on all four facts; a deliberately corrupted DEF (one component deleted) reports one fewer component, so the counts disagree, which proves the oracle actually discriminates rather than always agreeing. Two layers of test carry this: a parser-level test that always runs in the ordinary gate (no Docker) against committed OpenROAD output captured from the pinned image, and a live container test that runs OpenROAD when Docker and the image are present and skips honestly otherwise.

Net-level routing is not compared: it is the richest and least standardized part of DEF, and the four facts above already discriminate a faithful import from a corrupted one. The die area is compared with a documented per-coordinate tolerance for the case where a tool reports it on a different unit grid; here the tolerance is zero, because both sides read DEF database units directly. To stay inside what OpenROAD’s strict reader accepts, the committed fixtures omit the optional BUSBITCHARS/DIVIDERCHAR header lines (OpenROAD rejects the non-standard plural DIVIDERCHARS) and keep their routing via-free (an undefined via is a hard OpenROAD error). reticle-lefdef, being lenient, imports those same files without complaint.

Rendering and scale

reticle-render is the wgpu renderer. It targets WebGPU in the browser and Vulkan, Metal, or DX12 natively, with a WebGL2 fallback for reach (ADR 0009).

GPU-driven culling

The central trick for scale is to keep the hierarchy on the GPU. Rather than the CPU walking billions of leaf shapes, a compute shader tests each cell’s bounding box against the current view and flags the visible ones, the first stage of a GPU-driven draw list. Compacting the survivors into an indirect draw is built rather than pending: CellCompactor is exported from crates/reticle-render/src/lib.rs and the GPU-resident hierarchy below runs expand, cull and compact in one pass every frame. Check: cargo nextest run -p reticle-render -E 'binary(compact_gpu)', which acquires a real GPU adapter and is skipped on a machine without one. The work is proportional to the number of cells considered, not the flattened shape count. The interactive egui canvas currently culls on the CPU with the same R-tree, and the GPU compute cull is validated against that CPU result in a golden test.

GPU-resident hierarchy

The cull stage above flags visible cells; GpuHierarchy closes the loop by keeping the whole arrayed hierarchy resident on the GPU and never touching a per-element draw list on the CPU. A compact table of array placements (one record per array reference, not per element) plus a table of leaf cells is uploaded once. Every frame a single compute pass (expand_cull_compact.wgsl) does three things at once: it expands each array element-by-element (one thread per element, binary-searching the placement table by a precomputed cumulative element offset), culls each element’s transformed bounding box against the viewport with the same half-open rule as the CPU, and compacts the survivors into ready-to-draw RectInstanceT buffers with a per-workgroup prefix scan and a single atomic range reservation, filling an indirect instance count. One draw_indirect per chunk then draws exactly the survivors; the GPU, not the CPU, decides how many.

A single compute dispatch is bounded two ways: at most max_compute_workgroups_per_dimension (65,535) workgroups of 256 threads, and a storage binding no larger than max_storage_buffer_binding_size (128 MiB, so about 2.79M 48-byte survivors on a default-limits device). GpuHierarchy escapes both by splitting the global element space into fixed-size chunks and issuing one dispatch and one draw per chunk; the cap is beaten by chunk count, never by a bigger dispatch, so the design scales to arbitrarily many elements at a fixed per-chunk cost. A 100M-element array (a via, fill, or bit-cell field, routine in real layout) spans 36 chunks; a 30M array spans 11.

Because the per-frame path iterates only the chunk list (a handful of entries) and the scene tables are uploaded once, the CPU does no per-element work per frame; the cpu_expand_ops counter, bumped only by the CPU reference expansion, stays flat across frames, which a test asserts. Measured throughput and the honest 100M shortfall are in the performance chapter and docs/PERF.md: expansion and culling run at 3.4-3.7 billion elements per second, a 100M design pans interactively at 111 fps when culling keeps the on-screen subset, and drawing all 100M sub-pixel quads at once stays fill-bound at 10 fps (an LOD follow-up, for which this GPU-resident expansion is the prerequisite).

Instanced draws and tessellation

Axis-aligned rectangles are drawn as instanced quads; polygons and paths are tessellated once into vertex and index buffers (lyon) and drawn with per-layer style. Colors come from the technology layer table with a fallback palette.

The index carries a tile and level-of-detail pyramid (LodPyramid, crates/reticle-index/src/lod.rs), and it is a coarser tiling, not a coarser representation of the geometry. Its own module doc states the rule: a shape is recorded in every tile its bounding box overlaps at every level, so a coarse tile still names every shape inside it. That bounds how much a streamed archive has to fetch for a given viewport, which is the 188 KiB first-view figure; it does not reduce how much a zoomed-out frame has to draw. Reducing that is level-of-detail rendering, and the app-side switch ships: at or below 0.02 px/DBU the canvas paints a density impression plus cell bounding boxes instead of shapes (culling::lod_for_zoom and chunk_lod, crates/reticle-app/src/culling/lod.rs), dispatched on all three paint paths (app/canvas_ui.rs, app/render.rs, app/render_archive.rs) and pinned by tests in app/tests_view.rs. What is still missing is a coarser representation INSIDE the reticle-render pipeline, which is why the worst case below stands. See Targets for what that costs, measured. Check: git grep -n "lod_for_zoom" -- crates/reticle-app/src.

Offscreen and live rendering

Two paths share the same reticle-render pipeline. An offscreen Rgba8Unorm target with CPU readback (OffscreenTarget, crates/reticle-render/src/target.rs) drives the golden-image tests and the media capture (the hero image and browse GIF); check grep -n "pub fn render_document_offscreen" crates/reticle-render/src/lib.rs. The interactive canvas renders separately, straight onto eframe’s live egui-wgpu surface every frame through a paint callback (App::draw_shapes_gpu, crates/reticle-app/src/app/retained.rs, invoked from crates/reticle-app/src/app/canvas_ui.rs), composited under the egui overlays queued the same frame. Window and surface presentation is not a follow-up: it has shipped since Wave A (docs/STATUS.md).

The overlays this chapter used to list as follow-ups all ship too: the minimap (App::draw_minimap, crates/reticle-app/src/app/render.rs), DRC violation markers (App::draw_drc_markers, crates/reticle-app/src/app/render_overlays.rs), connected-net highlighting (App::highlight_net_of, crates/reticle-app/src/app/verify.rs, wired from a canvas click in crates/reticle-app/src/app/interact.rs), and the 3D layer-stack cross-section (show_view3d_panel, show_xsection_panel, crates/reticle-app/src/app/dialogs_ui.rs). Check: grep -n "fn draw_minimap(\|fn draw_drc_markers\|fn highlight_net_of\|fn show_xsection_panel" crates/reticle-app/src/app/render.rs crates/reticle-app/src/app/render_overlays.rs crates/reticle-app/src/app/verify.rs crates/reticle-app/src/app/dialogs_ui.rs (four matches, one per symbol).

Targets, and where each one stands

One million flat shapes at a sustained 60 fps at typical zoom: met, measured at 295 fps at 1920x1080 on the recorded host (RTX 4060 Ti, Vulkan). Ten million interactive at 30 fps or better: met, measured at 113 fps on the same host. Both figures come from cargo run -p reticle-render --example fps_bench --release.

Hierarchical designs with effectively billions of leaf shapes: architecturally supported, not fps-benchmarked. That is the wording docs/PERF.md’s targets table already carries for this row, and it is the accurate one. Hierarchy is never flattened for browsing, and cell culling plus the compute-shader cull stage are implemented and tested; no frame-rate figure for a billion-leaf design has been measured, so none is claimed here.

What has been measured is a 100M-element arrayed design, on the same host, with cargo run -p reticle-render --example gpu_hierarchy_bench --release: 111 fps panning, where culling keeps the on-screen subset (10,201 of 100M drawn), and 10.0 fps drawing all 100M sub-pixel quads at once. The second number is the honest worst case and it is not interactive. It is fill and vertex bound rather than expansion bound: the expand-and-cull pass alone runs at 37 fps on the same scene. The full table and its measurement context are in docs/PERF.md.

A render-crate level of detail is the fix for that worst case, and only the app-side switch is built. The app decides what to ask for below 0.02 px/DBU (culling::lod_for_zoom); reticle-render has no reduced-geometry level of its own, so a frame that does ask for all the quads still draws all the quads. A coarser representation of the whole design when the whole design is in view is the missing piece, and the GPU-resident expansion described above is its prerequisite rather than a substitute for it. It is wave-6 work in the current plan; no date is promised, and this chapter gains a number for it only when the wiring lands and the number is measured. Every other measured number is in the performance chapter.

GPU DRC heatmap

The GPU DRC heatmap is a compute-shader design-rule overlay: it evaluates two rules, minimum feature width and minimum edge-to-edge spacing, over the visible rectangle instances entirely on the GPU, and paints the result as a coarse violation heatmap on top of the rendered layout, with a live rule-value slider. It is the interactive, at-a-glance companion to the exact CPU DrcEngine: the CPU engine is the source of truth for a full sign-off pass; the heatmap is the cheap, per-frame “where are the hot spots” view of what the eye is currently looking at.

Like the GPU-driven cull stage, it is WebGPU-only (ADR 0009 / ADR 0027); the WebGL2 fallback keeps the CPU DRC path.

Two passes, reusing the cull scan

The heatmap runs as two compute stages over the same instance buffer.

Binning sorts the visible instances into a uniform grid with a counting sort. A first pass computes each instance’s grid cell from the minimum corner of its world bounding box and does an atomicAdd into a per-bin counter. A single 256-thread workgroup then runs an exclusive prefix scan over those counts, the same Hillis-Steele scan the stream compactor uses for the draw list, to turn per-bin counts into per-bin start offsets. A final scatter pass reserves a slot per instance with one more atomicAdd and writes the instance index into its bin’s dense slice. The grid is capped at 256 bins so the whole scan fits one workgroup.

Checking runs one thread per instance. It flags a min-width violation when the smaller side of the instance’s world box is below the width rule, and a min-spacing violation when any instance in its 3x3 bin neighbourhood sits at a strictly positive edge gap below the spacing rule. Each flagged instance is recorded in a per-instance flag buffer and adds one to its bin’s entry in a coarse heatmap buffer, so the heatmap holds the count of violating instances per bin, the field the overlay draws.

Why 3x3 is enough

The neighbourhood search is only correct if no violating partner can hide outside the 3x3 window. The binning grid guarantees that: the bin size on each axis is chosen (on the CPU) to be at least max_instance_extent + min_spacing, so any two instances whose edge gap is below the spacing rule must land in bins at most one apart on each axis. The 3x3 search is therefore exhaustive, the GPU never misses a violation the CPU engine would find. Capping the grid at 256 bins only makes bins larger, never smaller, so it preserves the invariant.

Agreement with the CPU oracle

The compute path works in f32, but its geometry mirrors reticle-drc’s exact integer helpers, the same signed interval gap, the same floor-integer-square-root for the diagonal corner distance, so it agrees with the CPU engine bit-for-bit as long as every coordinate, width, and height is an integer strictly below 2^24 (the range over which f32 represents consecutive integers exactly). Inside that range, a property test generates randomized layouts, runs the CPU DrcEngine restricted to width and spacing as the oracle, reads back the GPU flags, and asserts the GPU-flagged instance set equals the CPU violating-instance set. It skips honestly when no GPU adapter is present, mirroring the other compute gates.

The 2^24 bound is not a limitation in practice: database-unit coordinates for a cell under interactive DRC sit far below it, and the CPU engine remains available for the exact full-database pass.

The overlay and the slider

The per-bin heatmap stays GPU-resident. A small render pipeline draws one alpha-blended quad per grid bin, coloured by a heat ramp scaled by the bin’s violation count; empty bins are discarded so the layout shows through. Because the heatmap never leaves the GPU, dragging the rule-value slider is cheap: it re-runs the two compute passes with the new rule value and redraws, with no CPU readback in the loop. Measured recompute times are in the performance chapter; on the visible working set a recompute stays well under a frame.

Streamed documents

A layout that fits in RAM is opened, edited, and undone through the in-memory document model. A multi-gigabyte die does not fit, and cannot be built in a browser tab at all, so it is streamed: written once into a tiled .rtla archive (the Wave 2 container, ADR 0062) and then fetched a tile at a time over HTTP Range requests as the camera moves. This chapter covers what a streamed document can and cannot do, and how the view stays responsive while tiles are still in flight.

Streamed documents are read-mostly

A streamed document is browse, measure, query, and share only. It is deliberately not editable, and that is enforced by the type system rather than by a runtime check.

An open document in the app is a DocHost, which is one of two things:

  • Edited(History), an in-memory document with its undo/redo history, or
  • Streamed(StreamedScene), a document paged in from an .rtla archive.

Every mutating path in the app (drawing a shape, running a boolean, undo, redo) takes a &mut History. A DocHost hands out a History only when you match its Edited arm. There is no total accessor that returns a &mut History regardless of which arm is active; the only mutable accessor is fallible and returns Option, and a StreamedScene carries no mutation method of any kind. So an editing tool cannot even name a History to mutate on a streamed document: code that tries either fails to compile because it never destructured Edited, or fails to compile because it called a mutator on an Option it did not unwrap. Editing a streamed document is a compile error, not a runtime refusal that a later feature could forget to add.

This is the scope line drawn in ADR 0062 and made concrete in ADR 0068. The payoff is that the read-mostly guarantee holds for free as the app grows: any new edit path, written the ordinary way against &mut History, is automatically inapplicable to a streamed document, with no reviewer needing to remember to guard it.

Coarse-then-fine: painting while tiles stream in

When the camera moves over a streamed document, the tiles that cover the new viewport at the zoom’s level of detail may not be resident yet. Blocking the frame on the network would stutter the view; painting nothing would flash it blank. Instead the scene refines progressively.

The StreamedScene keeps a working set of resident tiles, bounded by an LRU so RAM does not grow without limit, and a viewport-to-tile mapper built from the archive’s level grid. On a camera move:

  1. It computes the tiles that cover the viewport at the target (finest appropriate) level and fetches the ones not already resident, over the archive’s TileSource. Each fetched tile is validated and decoded exactly as the memory-mapped path is, so a truncated or corrupt tile is an error, never undefined behaviour.
  2. Until those fine tiles arrive, it paints the coarsest resident level that still fully covers the viewport. Coarse levels have fewer, larger tiles, so they are far more likely to be complete; the view shows less detail for a moment rather than going blank.
  3. As tiles arrive (on the browser microtask queue in wasm, on a task in native), they are posted to an inbox the UI loop drains, become resident, and the painted level rises to the fine level.

The fetched tiles’ vertices are uploaded into the renderer’s existing paged GPU buffers; streaming changes what is paged into memory, never the silicon, so it is not an edit.

This behaviour is proven headlessly. A residency test stands up an in-memory archive behind a source that injects a per-tile fetch latency and asserts the full sequence: immediately after a zoom-in the scene paints from the coarse resident level with no fine tile resident, and after the injected delay elapses the resident set has transitioned coarse to fine, the painted level is the fine level, and the painted record set matches the fine-level query exactly.

Opening a served archive in the browser: ?archive=

The browser build turns a served .rtla into a browsable die from a URL alone. A page opened with ?archive=<url> (parsed by share::archive_url_from_query, a pure, round-tripped parser distinct from the ?gds= open that imports an editable document) boots into a read-only browse: on the first frame it constructs an HttpRangeTileSource over <url>, reads and validates the header, builds a StreamedScene, and installs it as a DocHost::Streamed. From then on the canvas paints the streamed die with the coarse-then-fine residency described above, driven once per frame from the live camera viewport; browse, measure, and query work, while an edit stays a compile error because the Streamed arm hands out no History.

The streamed source is designed for a Web Worker, whose synchronous OPFS access handles let it persist tiles across reloads. Wired into the main-thread DocHost, that OPFS path is unavailable (the synchronous access handle exists only in a worker), so the browse reports OPFS as absent and falls back to the network plus the source’s in-memory LRU cache. Losing the cross-reload persistence is the only cost; a worker-hosted source (a later lane) regains it without changing this wiring.

The Start-screen die gallery (gallery::show) resolves each verified die’s archive_key against a base host, gallery::DEFAULT_ARCHIVE_BASE_URL by default. A page opened with ?archive_base=<url> (parsed by share::archive_base_from_query) renders every card against <url> instead, so the committed library/gallery-manifest.json can be browsed against a local static server holding the staged .rtla pieces before they are published, with no edit to any archive_key and no second host constant. This is distinct from and composable with ?archive= above: ?archive_base= names a host the library resolves against and opens nothing by itself, while ?archive= names one archive and boots straight into browsing it.

Only a loopback origin (localhost, 127.0.0.1, or [::1], any port, http or https) or a same-origin absolute path is honoured; any other value falls back to the default host (gallery::is_local_archive_base, gallery::resolve_archive_base, both pure and unit-tested). The restriction exists because ?archive_base= is reachable from any link a visitor can be sent, and without it a crafted link would repoint the whole verified-die library at a third-party host while the cards still read as the project’s own verified entries. Pointing the library at a genuinely different published host stays a reviewed code change, threading a manifest-level base through gallery::die_archive_url. Natively, the base comes from the RETICLE_ARCHIVE_BASE environment variable through the same loopback rule. scripts/serve-gallery-local.mjs (just walk-gallery-local) is the server this is designed to be pointed at; it serves the bundle and the manifest’s archive routes from one origin and fails closed on any die the manifest excludes.

The streaming HUD

While a served archive is open, an on-canvas HUD reports the stream in real time: the bytes fetched over the network against the archive’s total size (probed once with a ranged bytes=0-0 GET reading the Content-Range total), the number of tiles currently resident, the records painted this frame, a working-set estimate (resident tiles times the mean fetched tile size), and the frame rate. The counter arithmetic is a pure ArchiveStats value, unit-tested without a window, and is also published each frame to a window.__reticle_stats seam that the served-archive end-to-end test polls to prove tiles stream in over HTTP Range and records actually paint.

In-browser conversion

A .rtla archive is normally produced by the native reticle convert command (see ADR 0072) and then hosted for streaming. The browser can also do the conversion itself: drop a GDS into the page and it becomes a streamable archive stored locally, with no server and no upload. This is the in-browser converter (ADR 0091).

What happens

  1. A Web Worker converts. Picking a GDS runs convert_gds_to_rtla inside a dedicated Web Worker (crates/web/src/bin/convert_worker.rs), off the main thread, so a large file never freezes the UI. The worker streams the bytes through the same frozen Wave 2 surface the native converter uses: the forward-only GdsRecordReader (ADR 0062) pulls one record at a time, and the archive builder assembles the tiled .rtla.

  2. The archive is written to OPFS. The finished bytes are written into the Origin Private File System (OPFS) under archives/<name>.rtla, using a synchronous access handle. OPFS is a per-origin, private, persistent store; the archive stays in the browser and survives a reload.

  3. The app reopens it through the streaming path. The page then opens ?archive=<url> pointing at the OPFS file, and the existing streamed-archive reader (Streamed documents) pages tiles in on demand. The service worker bridges the two: it serves opfs-archive/<path> by reading the OPFS file and answering HTTP Range requests, so the archive streams through the ordinary HttpRangeTileSource with no new reader. The converted die opens read-only, exactly like a hosted one.

Scope (v1)

The browser converter mirrors the native converter’s v1 scope exactly (ADR 0072): only directly drawn geometry becomes a record (each BOUNDARY and PATH bounding box, in authored database units), SREF/AREF placements are not composed into world space, and the pyramid depth follows the world span. A flat GDS (the common tape-out case) converts faithfully; a deeply hierarchical one drops its instanced placements until hierarchical flattening lands.

The one implementation difference from the native path is memory. The native builder spills sorted runs to disk so it scales to multi-gigabyte dies; a browser tab has no filesystem for that, so the browser uses an in-memory builder (build_rtla_to_vec) that holds the finished archive in RAM. For any input that fits in a single sort chunk (every browser-scale layout) the two builders produce byte-identical output, so a die converted in the browser is the same archive the CLI would have written. Very large dies remain a native-converter job.

OPFS availability

Writing a whole archive at once needs a FileSystemSyncAccessHandle, which is only available inside a Worker and only in a secure context (HTTPS or localhost). Where OPFS is unavailable, the conversion is reported as unsupported rather than failing loudly, and the browser end-to-end test skips the convert step honestly (mirroring the streamed-cache OPFS fallback). On a current Chromium or Firefox over a secure origin it runs; the browser-convert e2e (just e2e-convert) exercises the full path headless.

View and export

The editor’s right-hand column ends with a View and export panel that groups four pieces of view polish: switching the colour theme, saving and restoring camera positions, exporting the current view or selection to a file, and a print-style monochrome render mode. The logic that can be pure is pure and lives in reticle-app’s viewexport module, unit-tested without a window; the egui and GPU wiring is a thin layer in the app module.

Theme switching

Reticle opens in a dark theme. The Toggle theme button flips a stored Theme between dark and light, and the app applies it to the egui Visuals every frame at the top of its ui method, calling ctx.set_visuals with egui::Visuals::dark() or egui::Visuals::light(). Applying it each frame keeps the whole UI, panels and canvas chrome alike, consistent after a toggle.

The theme is part of the persisted view state: it is captured into the session snapshot alongside the camera, tool, and grid (see the session module) and serialized as a theme=dark or theme=light line, so the choice survives a restart. An unknown or missing value falls back to the dark default.

View bookmarks

A bookmark records where the camera was looking so you can return to it later. It stores only the world center and the zoom (pixels per DBU), not a snapshot of the document, so it stays a tiny value that reconstructs a live camera exactly.

  • Type a name and press Save view to capture the current camera. A blank name is filled in with an auto-generated View N label so the entry is always clickable.
  • Press a bookmark’s name button to jump back: the app rebuilds the camera with ViewCamera::new(center, pixels_per_dbu), which round-trips the saved position.
  • Press the small x next to a bookmark to remove it.

Because a bookmark is just a (center, zoom) pair, saving and restoring is a pure round-trip through the camera constructor, which the tests pin down directly.

Exporting

Export has two independent choices: a scope and a format.

  • Scope is either the whole View (every shape in the flattened scene) or just the current Selection. Exporting an empty selection is refused with a status message rather than writing a blank file.
  • Format is SVG or PNG.

Files are written into the working directory as reticle-export.svg or reticle-export.png, and the status bar reports the path.

SVG

SVG is the primary export and is generated purely from the shape list by shapes_to_svg, a function of its inputs with no egui or GPU involvement. Each shape kind maps to one SVG element:

  • a rectangle becomes a <rect>;
  • a polygon becomes a <polygon> with its vertices;
  • a path (wire) becomes a stroked <polyline> whose stroke width is the path width scaled into output pixels.

Shapes are placed by an affine fit of the export bounds onto the pixel canvas (the Projection type), preserving aspect ratio and flipping world +y up to image +y down so the picture matches the on-screen canvas. Fill colours come from the live layer table, the same colours the canvas draws with.

PNG

PNG export takes one of two paths:

  • A full-colour export of the whole view reuses the native offscreen GPU renderer (render_document_offscreen) for a pixel-accurate frame that matches the canvas exactly. This is native-only and is skipped with a status note when no GPU is available.
  • A selection export, or any monochrome export, uses the pure rasterizer in the viewexport module: a small scanline filler that paints each shape over a white page and feeds the crate’s dependency-free PNG encoder. It needs no GPU, so it is unit-tested, and it is what makes selection-only and print-mode PNG images possible.

On the web there is no filesystem and no blocking GPU context, so PNG export reports that it is native-only and SVG generation reports the byte count instead of writing a file.

Monochrome (print) mode

The Monochrome (print) mode checkbox switches both export paths to a print-style render: shapes are drawn as pure black on a white page regardless of their layer colour. In SVG, filled shapes become unfilled black outlines and wires stay black strokes; in the raster path every shape is filled black. This produces a clean, ink-friendly rendering suitable for printing or embedding in a document, independent of the on-screen theme.

Snapping and guides

Placing geometry by eye is imprecise. Reticle snaps the cursor onto meaningful positions so a new point lands exactly where it should: on the grid, on existing geometry, or on a guide line the user has pulled out. The snap logic is pure DBU arithmetic and lives in reticle-app’s snap module, unit-tested without a canvas, a camera, or any drawing.

There are three things the cursor can snap to, checked together and resolved to whichever is nearest:

  • The grid. The background grid rounds a point onto the nearest grid intersection. This is the baseline snap and is described under Rendering and scale; it is owned by GridSettings.
  • Nearby geometry. The vertices, edges, edge midpoints, and bounding-box centers of the shapes around the cursor.
  • User guides. Draggable horizontal and vertical lines, pulled off the rulers, that behave like an extra piece of geometry to snap to.

Snapping to geometry

For each shape near the cursor the engine emits a set of snap candidates:

  • a vertex at every corner of a rectangle and every vertex of a polygon or path;
  • an edge for every segment between consecutive vertices (a polygon also closes the loop back to its first vertex; an open path does not);
  • a midpoint at the center of every edge;
  • a center at the middle of the shape’s bounding box.

A discrete candidate (vertex, midpoint, center) snaps to its own position. An edge snaps to the perpendicular foot of the cursor on the segment, clamped to the endpoints, so the cursor slides along the edge rather than only catching its ends.

The nearest resolved candidate within the snap radius wins. When two candidates sit at exactly the same distance the more specific one is chosen, so an exact corner is preferred over the two edges that meet there, and a midpoint is preferred over the edge it lies on. Only shapes on visible layers are considered, so a hidden layer never steals a snap.

The snap radius is set in screen pixels and converted to world units at the current zoom, so the feel is the same whether zoomed in or out. Candidates are gathered only from the shapes whose bounding box lies within that radius of the cursor, so the cost is bounded by what is under the cursor rather than by the size of the design.

User guides

Guides are horizontal or vertical reference lines at a fixed world coordinate. A horizontal guide pins a y value; a vertical guide pins an x value. Drag inward from the top ruler to pull out a horizontal guide, or from the left ruler to pull out a vertical one; the guide drops at the cursor, rounded onto the grid. Guides are listed in the snap panel, where each can be removed individually or the whole set cleared, and guides can also be added at the view center from the panel.

Guide snapping runs per axis: the nearest vertical guide within range pins the cursor’s x, and the nearest horizontal guide pins its y. When a horizontal and a vertical guide are both in range the cursor lands on their intersection, so crossing guides give an exact point to snap to.

Guides are view and session state. They are never written into the document, so adding, moving, or clearing a guide is not an undoable edit and does not touch the layout.

The snap indicator

Whenever the cursor catches something, a small diamond is drawn at the snapped point with a short caption naming what it hit (vertex, edge, midpoint, center, or guide), colored by kind. The indicator rides on top of the geometry so it is never hidden, and disappears the moment the cursor moves off every candidate.

Settings

The snap panel in the right sidebar surfaces every knob:

  • Show grid, Snap to grid: the grid’s visibility and grid snapping.
  • Snap to geometry: whether nearby vertices, edges, midpoints, and centers catch the cursor.
  • Snap to guides: whether guide lines catch the cursor.
  • Grid spacing: the base grid step, in DBU.
  • Snap radius: how close, in screen pixels, a candidate must be to catch the cursor.

Grid visibility and grid snapping are also toggleable from the toolbar. Turning off both geometry and guide snapping leaves only the grid, and turning off grid snapping as well places points exactly where the cursor is.

How it fits together

The canvas routes its cursor through a single snap seam that tries geometry and guide snapping first and falls back to the grid when nothing is in range. That seam returns both the snapped point, which is what a tool places, and the hint that drives the on-canvas indicator. Because the drawing tools place at the point this seam returns, geometry drawn with a tool snaps to existing geometry and guides, not only to the grid.

Search and selection depth

The search panel adds three ways to build and navigate a selection that go beyond clicking shapes one at a time: a filter query bar, saved selection sets with select-similar, and a cell/instance outline that jumps the camera to whatever you click. It sits at the bottom of the right-hand side panel, under the snapping panel.

Like the rest of the editor, the interesting parts are window-free and unit-tested. The filter language lives in the query module (parser plus evaluator) and the saved-set, select-similar, and outline logic in the outline module; the panel itself is thin glue that binds widgets to that logic, unions the results into the live selection, and defers the camera locate to the next canvas pass.

flowchart LR
    bar["Filter query bar"] --> parse["query::Query::parse"]
    parse -->|"Ok(query)"| eval["query.select(shapes, layers, scope)"]
    parse -->|"Err(ParseError)"| msg["error shown under the bar"]
    eval --> sel["Selection"]
    seed["Current selection"] --> sim["outline::select_similar"]
    sim --> sel
    tree["Outline row click"] --> locate["camera.zoom_to_rect"]

The filter query language

The query bar takes a space-separated list of predicates that are combined with a logical and: a shape is selected only when it satisfies every predicate. So

layer:METAL1 width<400 area>1000

selects every shape on the METAL1 layer that is narrower than 400 DBU and whose bounding-box area exceeds 1000 DBU². The grammar is deliberately small:

query      := predicate (WS predicate)*
predicate  := layer-pred | cell-pred | metric-pred
layer-pred := "layer:" NAME
cell-pred  := "cell:" NAME
metric-pred:= metric OP INT
metric     := "area" | "width" | "height"
OP         := "<" | "<=" | ">" | ">=" | "=" | "=="

Names match case-insensitively, so layer:metal1 and layer:METAL1 are the same. The metric comparisons read straight off the shape’s bounding box using the same i64 arithmetic as the geometry crate, so nothing is rounded: area is in DBU², width and height are in DBU.

layer: resolves the name against the current layer table; a name that is not in the table simply matches nothing rather than erroring, so a typo narrows the selection to empty instead of selecting the whole scene.

cell: is a scope assertion. The panel selects over the flattened top cell, so individual shapes carry no cell provenance to filter on; cell:TOP therefore keeps every shape while TOP is the cell being viewed and drops them all otherwise. It lets a query state which cell it targets and read naturally next to the other predicates, but it does not descend into sub-cells.

Malformed queries fail cleanly

Parsing returns a typed error naming the first token at fault, and the panel shows that message under the bar without touching the selection. The cases are:

QueryError
layer:'layer:' needs a value, e.g. layer:METAL1
area1000'area1000' needs a comparator, e.g. width<400 …
area>big'area' expects a number, got 'big'
color:redunknown term 'color:red'

An empty query bar selects nothing rather than silently grabbing everything.

Saved selection sets

Once you have a selection you care about, name it and save it. A saved set is a snapshot of the selected shape indices under a name; restoring it installs those indices back into the live selection. Saving under an existing name replaces it, a blank name is refused, and each set shows its shape count so you can tell them apart. Because a set stores flattened-scene indices, it is meaningful within a session against the scene it was captured from.

Select similar

Select-similar grows the current selection: for every already-selected shape it adds every other shape that is on the same layer and whose area is within a tolerance band (±25% by default) of that shape’s area. It is the “select all the vias like this one” gesture. It only ever adds to the selection, never removes, and an empty selection grows to nothing because there is no seed to be similar to.

The outline tree

The outline lists the document’s cell hierarchy one level deep: each top cell, followed by its instances and arrays as indented child rows labelled with the child cell and the placement origin (for example LEAF @ (1000, 2000) or LEAF [8x6] @ (0, 4000)). Clicking a row locates it: the camera frames the row’s world rectangle with a small margin so the target lands centered on the canvas.

An instance row’s rectangle is the child cell’s bounding box put through the placement transform, so locating an instance frames exactly where that placement sits in the parent, not the child’s own coordinate origin. Rows for empty cells (no geometry to frame) are shown but not clickable. The tree is rebuilt whenever the document changes, so it never points the camera at stale geometry.

Where the camera locate happens

The panel cannot frame a target the instant a row is clicked: the true canvas size in pixels is not known until the central panel lays out, which happens after the side panel is drawn. So a click records the target rectangle, and the deferred zoom is applied on the next canvas pass once the screen rectangle is in hand. This mirrors the deferred zoom the DRC panel uses to frame a selected violation.

Design-rule checking

reticle-drc verifies that a layout obeys the process design rules.

Declarative rules

Rules come from the technology file, not from code. The engine understands width, spacing, enclosure, extension, notch, area, density, and angle constraints, each expressed as a rule over one or two layers with a threshold. Because the rule set is data, a new process is a new technology file rather than a new build.

A built-in deck for SKY130 ships with the crate: sky130_drc_rules() loads the committed subset of the SkyWater periphery rules. It is a subset and passing it is not tape-out clean; see SKY130 rule coverage for exactly which rule ids are checked and which whole rule families are not.

How checks run

A check evaluates each rule against the geometry using the spatial index to find candidate pairs, and the geometry booleans and offsetting to test spacing and enclosure. Violations carry the offending region’s bounding box so the UI can zoom straight to them.

Incremental re-check

Editing a layout should not re-check the whole design. When a shape changes, only the rules touching the affected region are re-run, using the index to bound the work. The target is under a hundred milliseconds for a local edit, so the violation overlay stays live as you draw.

DRC as you type

The editor wires that incremental re-check to live editing, so a violation is underlined the moment its geometry is drawn, the way a text editor squiggles a misspelling as you type. The DRC panel’s “Check as you type” toggle turns it on.

Two costs run on two cadences. The cheap step re-checks only the region an edit dirtied (check_region over the edit’s neighbourhood, measured at microsecond scale even on a million-shape cell) and runs synchronously on every edit. The expensive step rebuilds the whole prepared index and runs on a throttle, off the per-edit hot path, because a PreparedDrc is an immutable snapshot: an edit is reflected only once the index is rebuilt. Between rebuilds the underlines show the last snapshot, so a just-drawn shape enters the checks after a brief, bounded lag, like a spell-checker catching up after a burst of typing.

The dirty region comes from the edit pipeline: a shape add or remove dirties its bounding box, while a structural change or an undo dirties the whole cell (its region is not cheaply bounded). The live underlines are a spell-checker squiggle drawn beneath each violation at constant on-screen size, deliberately distinct from the boxed markers of a full DRC-panel run.

The measured per-edit check_region latency at a million shapes, and the methodology, are recorded in PERF.md.

Testing

The engine is checked against a naive reference implementation that tests every rule the slow, obvious way over randomized inputs, so the fast path cannot silently disagree with the specification.

The live wiring is covered at the app level by crates/reticle-app/tests/drc_live.rs: drawing two rects too close underlines a spacing violation, and moving one apart clears it, driving the real edit pipeline headlessly with no GPU.

SKY130 rule coverage

This deck is a SUBSET of the SKY130 design rules: passing it is NOT tape-out clean, and it does not cover antenna, density, latch-up, or most implant and well rules. It exists so the editor, the agent harness, and the benchmark suite can check the everyday geometry mistakes (too narrow, too close, too small, under-enclosed, short endcap) against real, cited SKY130 values, not so a design can be signed off.

The rules live in tech/sky130-drc-subset.toml, transcribed from the SkyWater SKY130 periphery rules for the digital metal stack. reticle_drc::sky130_drc_rules() embeds that file at compile time and returns it as engine rules ready for DrcEngine::new; see Design-rule checking for how the engine evaluates each kind. Values are database units (1 dbu = 1 nm); areas are dbu squared.

Checked rules

Layer names follow tech/sky130.tech (GDS layer/datatype).

Rule idKindLayer(s)ValueMeaning
li.1widthli1 (67/20)170 (0.17 um)Min width of li1
li.3spacingli1 (67/20)170 (0.17 um)Min li1 to li1 spacing
li.5enclosurelicon1 (66/44) in li1 (67/20)80 (0.08 um)licon1 enclosed by li1
li.6areali1 (67/20)56100 (0.0561 um²)Min li1 area
m1.1widthmet1 (68/20)140 (0.14 um)Min width of met1
m1.2spacingmet1 (68/20)140 (0.14 um)Min met1 to met1 spacing
m1.4enclosuremcon (67/44) in met1 (68/20)30 (0.03 um)mcon enclosed by met1
m1.6areamet1 (68/20)83000 (0.083 um²)Min met1 area
m2.1widthmet2 (69/20)140 (0.14 um)Min width of met2
m2.2spacingmet2 (69/20)140 (0.14 um)Min met2 to met2 spacing
m2.4enclosurevia (68/44) in met2 (69/20)55 (0.055 um)via enclosed by met2
m3.1widthmet3 (70/20)300 (0.3 um)Min width of met3
m3.2spacingmet3 (70/20)300 (0.3 um)Min met3 to met3 spacing
m4.1widthmet4 (71/20)300 (0.3 um)Min width of met4
m4.2spacingmet4 (71/20)300 (0.3 um)Min met4 to met4 spacing
m5.1widthmet5 (72/20)1600 (1.6 um)Min width of met5
m5.2spacingmet5 (72/20)1600 (1.6 um)Min met5 to met5 spacing
poly.1awidthpoly (66/20)150 (0.15 um)Min width of poly
poly.2spacingpoly (66/20)210 (0.21 um)Min poly to poly spacing
poly.8extensionpoly (66/20) past diff (65/20)130 (0.13 um)Poly endcap past diff
difftap.1widthdiff (65/20)150 (0.15 um)Min width of diff or tap
difftap.3spacingdiff (65/20)270 (0.27 um)Min diff to diff spacing
licon.1widthlicon1 (66/44)170 (0.17 um)licon1 size, as min width
ct.1widthmcon (67/44)170 (0.17 um)mcon size, as min width
via.1awidthvia (68/44)150 (0.15 um)via size, as min width
via2.1awidthvia2 (69/44)200 (0.2 um)via2 size, as min width

26 rules: 12 width, 8 spacing, 3 enclosure, 2 area, 1 extension. The loader’s tests pin this count and the per-kind distribution, so the table above and the committed data cannot drift apart silently.

Not covered

Everything not in the table, including but not limited to:

  • Antenna rules. No charge-accumulation checks at all.
  • Density rules. No metal fill or min/max density windows (the engine has a density check, but this deck defines none).
  • Latch-up and well rules. No nwell spacing/width, no tap distance rules (tap.*, nwell.*), no butting rules.
  • Implant and marker layers. nsdm/psdm/npc and friends are in the layer map but carry no rules here; most implant enclosure/spacing rules (nsd.*, psd.*, npc.*) are absent.
  • Most contact/via rules. Sizes are encoded as min width only (the real rules are exact-size), and only three enclosure directions are present; end-of-line, differential enclosure (m1.5, m2.5), array spacing, and licon-on-poly vs licon-on-diff distinctions are absent.
  • Transistor-level rules. Gate spacing to licon, diff extension past poly (difftap.2, poly.7), and everything hvi/hv related.
  • Resistors, capacitors, SRAM, sealring, pad special-case rules.

Two engine caveats also apply (see Design-rule checking): shapes are reduced to axis-aligned bounding boxes, which is exact for rectangles but conservative (may over-report, never under-report) for polygons and paths; and same-layer spacing treats touching or overlapping shapes as merged rather than as violations.

If a layout must be manufacturable, run the full SkyWater deck in a sign-off tool. This subset is a fast, honest first filter, nothing more.

KLayout .lydrc compatibility

Reticle can run a documented subset of a real KLayout .lydrc DRC deck. reticle_drc::parse_lydrc compiles the deck into the same Vec<Rule> the design-rule engine already runs, so a KLayout rule deck (restricted to the subset below) produces Reticle violations. Anything outside the subset fails with a clear error that names the construct and the line; the parser never panics on untrusted input.

The syntax is pinned to the current KLayout DRC reference, not to memory of the DSL: DRC Layer reference and DRC global functions, read against KLayout 0.29.10 (the version in the pinned hpretl/iic-osic-tools:2025.01 container). See ADR 0083 for the scope decision.

The file format

A .lydrc file is KLayout’s DRC macro format: an XML wrapper whose <text> element holds the Ruby DRC DSL script.

<klayout-macro>
 <category>drc</category>
 <interpreter>dsl</interpreter>
 <dsl-interpreter-name>drc-dsl-xml</dsl-interpreter-name>
 <text>
met1 = input(68, 20)
met1.width(0.14).output("m1.1", "met1 minimum width")
 </text>
</klayout-macro>

The parser extracts and XML-unescapes the <text> body; a bare .drc script (no wrapper) is also accepted.

Units

Following the DSL, a floating-point dimension is micrometres and an integer dimension is database units; an explicit .um or .dbu suffix overrides. Reticle’s database units are nanometres (1 dbu = 1 nm, as in the SKY130 table), so a micrometre value is scaled by 1000 (areas by 1_000_000) into dbu. Thus width(0.14), width(0.14.um), and width(140) all mean 140 dbu.

Supported constructs

.lydrc constructCompiles toNotes
name = input(layer) / input(layer, datatype)a layer bindingdatatype defaults to 0
source(...), report(...)ignoredKLayout I/O header, not a rule
layer.width(v)RuleKind::Widthsingle layer
layer.space(v)RuleKind::Spacingsingle layer
layer.notch(v)RuleKind::Notchsingle layer (see divergence note)
layer.separation(other, v) / layer.sep(other, v)RuleKind::Spacingother_layer = other
outer.enclosing(inner, v)RuleKind::Enclosurereceiver is the enclosing layer; the parser swaps it into other_layer and the argument into layer
layer.with_area(0, v) / with_area(0.0, v) / with_area(nil, v)RuleKind::Areabelow-threshold selection only
trailing .output("name"[, "desc"])rule nameoptional; names the reported rule

The enclosing swap matters: KLayout writes outer.enclosing(inner, v) with the receiver being the enclosing (outer) layer, while the engine’s Rule.layer is the enclosed (inner) shape and Rule.other_layer is the enclosing (outer) one. The parser exchanges the two so the verdicts agree.

Not supported

Everything else, which fails with an UnsupportedConstruct error naming the construct and line:

  • Extension, density, and angle rules. No .lydrc construct maps to these unambiguously within a bounding-box engine, so a deck using them is out of the supported subset.
  • Boolean layer algebra (&, |, -, ^), sizing (sized), merging, and the universal drc expression. The engine checks declarative rules, not derived layers.
  • Connectivity (connect, netter, antenna). The engine is connectivity-free.
  • Two-sided with_area(min, max) bands and the aggregate scalar layer.area. Only a minimum-area (below-threshold) selection maps to RuleKind::Area.

Divergence note

The committed fixture (crates/reticle-drc/tests/fixtures/subset.lydrc over subset.gds) is run both through parse_lydrc + DrcEngine and through KLayout headless, and their verdicts are compared. The comparison is at the layout-level verdict granularity: for each rule, did the tool report at least one violation, or none. The two engines agree on every supported-subset rule of the fixture:

RuleKindReticle countKLayout countVerdict agrees
m1.1Width11yes
m1.2Spacing11yes
m1.4Enclosure14yes
li.6Area11yes
m2.1Width (clean)00yes

Raw marker counts are not required to match, and m1.4 shows why: KLayout emits one edge-pair marker per offending edge (four, one per side of the under-enclosed cut), while the engine emits one violation per offending shape. The fired/not-fired verdict is what both tools agree on.

Two constructs are deliberately excluded from the verdict comparison:

  • notch. KLayout’s notch is an intra-polygon concavity check (a narrow gap within one polygon). The engine’s Notch is an inter-shape same-layer gap measured on bounding boxes, and a bounding-box engine cannot see an intra-polygon notch at all. The parser accepts notch and maps it to RuleKind::Notch, but the two tools measure different things, so the fixture does not compare notch verdicts.
  • Non-rectilinear geometry. The engine reduces every shape to its bounding box, exact for rectangles and conservative (never under-reporting) for polygons and paths. The fixture is rectangles only, so bounding-box area equals true area and the comparison is exact.

Reproducing the comparison

The reticle side runs in the normal gate:

cargo nextest run -p reticle-drc --test lydrc_engine

The KLayout side needs Docker and the pinned container (the same image and invocation as just tt-precheck). It mirrors the container run, parses KLayout’s report database, and asserts each supported-subset rule’s fired verdict matches the reticle side:

powershell -File scripts/lydrc-compare.ps1

The exact underlying command, for a WSL or Linux fallback, is:

klayout -b -r crates/reticle-drc/tests/fixtures/subset.lydrc \
  -rd input=crates/reticle-drc/tests/fixtures/subset.gds \
  -rd report=out.lyrdb

SKY130 grounding

Reticle uses real data from the open SkyWater SKY130 PDK so that its technology file, its 3D layer stack, its DRC subset, and its tier-5 benchmark tasks are grounded in cited numbers rather than invented ones. This chapter is the provenance record: where each piece of SKY130 data comes from, exactly which design rules are and are not checked, the explicit statement that passing them is not tape-out clean, and the license attribution for the standard-cell layouts committed as test fixtures.

Nothing here makes Reticle part of a SKY130 sign-off flow. It uses the PDK as a source of truth for values, so that when the editor, the agent harness, or the benchmark reports a measurement it is a real SKY130 number.

The technology stack and its source

The technology file tech/sky130.tech defines the digital metal stack: the drawing layers (nwell, diff/tap, poly, li1, and met1 through met5 with their contacts and vias), the pin and label purposes, and the physical z-height and thickness of each conductor for the 3D layer-stack view. It is a subset sufficient for the digital stack, not a complete SKY130 layer map.

The data is transcribed from the open SkyWater SKY130 documentation, cited in the file header:

  • Layers (GDS layer:datatype): the SkyWater layers reference, generated from docs/rules/gds_layers.csv in the google/skywater-pdk repository.
  • Process stack (z and thickness): the official Process Stack Diagram (docs/_static/metal_stack.svg) on the Criteria and Assumptions page.
  • Units: SKY130 library GDS uses 1 database unit = 1 nm and 1 user unit = 1 um, verified by binary-parsing the UNITS record of sky130_fd_sc_hd__fill_1.gds. The technology file declares dbu_per_micron 1000 accordingly.

In the stack section, the conductor z and thickness values are read from the process stack diagram; contact and via slabs span the gap between the layers they connect. The well and active z-values are approximate substrate features (the diagram sets the silicon surface at z = 0) and are marked as approximate in the file. Those approximations affect only the 3D visualization’s substrate depiction, not any geometric check.

DRC rule subset

The DRC rules live in tech/sky130-drc-subset.toml, transcribed from the SkyWater SKY130 periphery rules (the Al flow, generated from docs/rules/periphery/periphery.csv in google/skywater-pdk) for the digital metal stack. reticle_drc::sky130_drc_rules() embeds that file at compile time and returns it as engine rules ready for DrcEngine::new. Values are in database units (1 dbu = 1 nm); areas are in dbu squared.

The full coverage table, with every rule id, kind, layer, value, and meaning, is in the SKY130 rule coverage chapter and is reproduced here so this grounding record is self-contained. Layer names follow tech/sky130.tech (GDS layer/datatype).

Rule idKindLayer(s)ValueMeaning
li.1widthli1 (67/20)170 (0.17 um)Min width of li1
li.3spacingli1 (67/20)170 (0.17 um)Min li1 to li1 spacing
li.5enclosurelicon1 (66/44) in li1 (67/20)80 (0.08 um)licon1 enclosed by li1
li.6areali1 (67/20)56100 (0.0561 um²)Min li1 area
m1.1widthmet1 (68/20)140 (0.14 um)Min width of met1
m1.2spacingmet1 (68/20)140 (0.14 um)Min met1 to met1 spacing
m1.4enclosuremcon (67/44) in met1 (68/20)30 (0.03 um)mcon enclosed by met1
m1.6areamet1 (68/20)83000 (0.083 um²)Min met1 area
m2.1widthmet2 (69/20)140 (0.14 um)Min width of met2
m2.2spacingmet2 (69/20)140 (0.14 um)Min met2 to met2 spacing
m2.4enclosurevia (68/44) in met2 (69/20)55 (0.055 um)via enclosed by met2
m3.1widthmet3 (70/20)300 (0.3 um)Min width of met3
m3.2spacingmet3 (70/20)300 (0.3 um)Min met3 to met3 spacing
m4.1widthmet4 (71/20)300 (0.3 um)Min width of met4
m4.2spacingmet4 (71/20)300 (0.3 um)Min met4 to met4 spacing
m5.1widthmet5 (72/20)1600 (1.6 um)Min width of met5
m5.2spacingmet5 (72/20)1600 (1.6 um)Min met5 to met5 spacing
poly.1awidthpoly (66/20)150 (0.15 um)Min width of poly
poly.2spacingpoly (66/20)210 (0.21 um)Min poly to poly spacing
poly.8extensionpoly (66/20) past diff (65/20)130 (0.13 um)Poly endcap past diff
difftap.1widthdiff (65/20)150 (0.15 um)Min width of diff or tap
difftap.3spacingdiff (65/20)270 (0.27 um)Min diff to diff spacing
licon.1widthlicon1 (66/44)170 (0.17 um)licon1 size, as min width
ct.1widthmcon (67/44)170 (0.17 um)mcon size, as min width
via.1awidthvia (68/44)150 (0.15 um)via size, as min width
via2.1awidthvia2 (69/44)200 (0.2 um)via2 size, as min width

That is 26 rules: 12 width, 8 spacing, 3 enclosure, 2 area, 1 extension. The loader’s tests pin this count and the per-kind distribution, so the table above and the committed data cannot drift apart silently.

This is a subset, not tape-out clean

Passing this deck does not mean a layout is manufacturable. It is a fast, honest first filter over the everyday geometry mistakes (too narrow, too close, too small, under-enclosed, short endcap) against cited SKY130 values. It exists so the editor, the agent harness, and the benchmark can check real geometry against real numbers, not so a design can be signed off.

What the deck does not cover, including but not limited to:

  • Antenna rules. No charge-accumulation checks at all.
  • Density rules. No metal fill or min/max density windows (the engine has a density check kind, but this deck defines none).
  • Latch-up and well rules. No nwell spacing or width, no tap-distance rules (tap.*, nwell.*), no butting rules.
  • Implant and marker layers. nsdm/psdm/npc and friends are in the layer map but carry no rules here; most implant enclosure and spacing rules (nsd.*, psd.*, npc.*) are absent.
  • Most contact and via rules. Sizes are encoded as min-width only (the real rules are exact-size), and only three enclosure directions are present; end-of-line, differential enclosure (m1.5, m2.5), array spacing, and licon-on-poly versus licon-on-diff distinctions are absent.
  • Transistor-level rules. Gate spacing to licon, diff extension past poly (difftap.2, poly.7), and everything hvi/hv related.
  • Resistor, capacitor, SRAM, sealring, and pad special-case rules.

Two engine caveats also apply (detailed in Design-rule checking): shapes are reduced to axis-aligned bounding boxes, which is exact for rectangles but conservative (it may over-report, never under-report) for polygons and paths; and same-layer spacing treats touching or overlapping shapes as merged rather than as a violation.

If a layout must be manufacturable, run the full SkyWater deck in a sign-off tool. This subset is a fast, honest first filter, nothing more.

Cell provenance and license attribution

Three standard-cell layouts from the SKY130 high-density library are committed as test fixtures and used as the geometry ground truth for tier-5 benchmark tasks. They are unmodified copies, attributed in crates/reticle-io/tests/corpus/sky130/NOTICE.md:

  • Cells: sky130_fd_sc_hd__fill_1, sky130_fd_sc_hd__inv_1, and sky130_fd_sc_hd__tap_1 (a filler, an inverter, and a well tap: the smallest representative cells).
  • Source: the google/skywater-pdk-libs-sky130_fd_sc_hd repository, upstream path cells/<name>/sky130_fd_sc_hd__<name>_1.gds.
  • Version: fetched from branch main at commit ac7fb61f06e6470b94e8afdf7c25268f62fbd7b1 on 2026-07-02.
  • Copyright: Copyright 2020 The SkyWater PDK Authors.
  • License: Apache License, Version 2.0 (the library’s LICENSE; the same license text is included in this repository as LICENSE-APACHE).

The files are used as fixtures for the GDSII importer (tests/sky130_cells.rs) and the SKY130 DRC subset (tests/sky130_drc.rs). scripts/fetch-sky130-cells.ps1 re-fetches the full set, including the larger nand2_1 and dfxtp_1 used by an ignored external round-trip test (those larger cells are not committed).

Honest findings on the committed cells

The three cells round-trip through the importer with no importer gaps. Running the DRC subset over them is deliberately reported honestly: the filler cell is clean, while the tap and inverter flag a handful of li.5, li.3, and poly.8 results. Those come from the engine’s bounding-box conservatism and from the deck being an approximation of the full rules, not from the cells being illegal in the real PDK. This is documented rather than hidden, and it is exactly why the deck is described as a first filter and not a sign-off check.

A second PDK: IHP SG13G2

Reticle’s generators were written against the SKY130 subset, but the process numbers they draw against are data, not baked constants. This chapter describes that data-driven design (GenTech) and the second process it enables: IHP’s open SG13G2 (a 130 nm SiGe BiCMOS process).

GenTech: numbers as data

Every generator needs the same small set of per-process numbers: the interconnect conductors it may route on (each with a minimum width, spacing, and optional minimum area), the cut layers that bridge adjacent conductors (each with an exact drawn size and the enclosure a covering plate owes it), the substrate-tap contact under the base conductor, and a conservative cut pitch for layers whose deck carries no cut-to-cut spacing. GenTech gathers exactly those into one value.

The generators read GenTech from the Technology argument threaded into generate() (GenTech::for_technology, which selects a built-in by process name and defaults to SKY130 so every existing caller is unaffected). The ring / serpentine / fill / via-array topology, the parameter schemas, and the validation stay code; only the numbers are data. GenTech::sky130() is authored from the committed SKY130 subset and cross-checked against reticle_drc::sky130_drc_rules() by test; a derive_gentech() function reconstructs a GenTech from any parsed Technology, proving the values are faithful to the deck (and to the stack ordering). See ADR 0084.

Roles, not layer names

A GenTech is four stacked interconnect conductors (index 0 = base), three cuts where cut[i] bridges conductor[i] and conductor[i+1], and one substrate-tap cut. The generators address these by role (level index), so a generator enum variant like RingLayer::Li1 means “the base interconnect” - li1 on SKY130 and Metal1 on SG13G2. The role binding for the two processes this chapter was written about (a third, GF180MCU, was retargeted the same way afterwards: GenTech::gf180 in crates/reticle-gen/src/gentech.rs, DRC deck crates/reticle-drc/src/gf180.rs, data files tech/gf180.tech and tech/gf180-drc-subset.toml):

roleSKY130SG13G2SG13G2 GDS
conductor 0li1Metal18/0
conductor 1met1Metal210/0
conductor 2met2Metal330/0
conductor 3met3Metal450/0
cut 0 (0↔1)mconVia119/0
cut 1 (1↔2)viaVia229/0
cut 2 (2↔3)via2Via349/0
substrate taplicon1Cont6/0

The SG13G2 data and its provenance

tech/ihp-sg13g2.tech carries the layer table, the physical stack, and the DRC subset inline; tech/sg13g2-drc-subset.toml is the cited source of record. Every number is transcribed from the open IHP-Open-PDK (github.com/IHP-GmbH/IHP-Open-PDK, branch main, Apache-2.0), with the KLayout DRC rule ids preserved (e.g. M1.a, V1.c). The subset mirrors the digital routing stack (Metal1–Metal4 width/spacing, the Cont/Via1–3 sizes and via enclosures) and deliberately omits what the generators do not draw against: the wide-metal and pattern-density spacing variants (which the DRC engine cannot express as width-conditional), the FEOL Activ/GatPoly contact enclosures, and the thick TopMetal stack. Passing it is not tape-out clean. See ADR 0085.

The proof: all three PDKs, clean by construction

The cleanliness oracle runs every generator over all three shipped processes (crates/reticle-gen/tests/second_pdk.rs for SKY130 and SG13G2, crates/reticle-gen/tests/third_pdk.rs for GF180MCU): it samples random valid parameters, generates into a fresh cell using each process’s Technology, and asserts the real DRC engine finds zero violations under that process’s own deck. CORRECTED 2026-07-31: this said “both processes” while README.md already said “three real PDKs”. Check: git ls-files crates/reticle-gen/tests/second_pdk.rs crates/reticle-gen/tests/third_pdk.rs. The same generator code, handed a different technology by name, draws against that process’s own layers and numbers and stays clean. Making this hold for the second process took exactly one generator change - the contact chain now encloses each contact by both bridging conductor levels, so whichever level a process’s deck requires to enclose the cut (met1 encloses mcon on SKY130, Metal1 encloses Via1 on SG13G2) is satisfied. Everything else was already portable once the numbers became data.

Routing

reticle-route connects nets across the layout while respecting obstacles and spacing.

Grid and maze

The router builds a routing grid over the area of interest and runs a maze search (Lee expansion, or A* with a distance heuristic via pathfinding) per net. The grid encodes obstacles derived from existing geometry and from the design-rule spacing, so a route is legal by construction.

Rip-up and reroute

Nets compete for tracks. When a net cannot be routed because earlier nets have filled the channels, the router rips up offending routes and retries in a different order, trading a longer search for a higher completion rate. Cross-layer vias let a route change layers to escape congestion.

Reporting

The router reports how many nets it completed, the total routed length, and where congestion remains, so a designer knows what to relieve.

Connectivity extraction

reticle-extract recovers the electrical connectivity implied by the geometry.

Nets from geometry

Two shapes on the same layer that touch or overlap are connected; shapes on different layers are connected where a contact or via joins them. Extraction walks the geometry with the spatial index and a union-find structure to group all connected shapes into nets. Each net can be highlighted in the renderer so a designer can trace a signal by eye.

Compare against expectation

Given an expected netlist, extraction reports where the geometry and the intent disagree: shapes that should be connected but are not, and shapes that are connected but should not be. This is the geometric half of a layout-versus-schematic check.

Testing

Extraction is validated against an independent union-find oracle over randomized geometry, so the optimized traversal cannot disagree with the definition of connectivity.

Device recognition

Connectivity extraction recovers nets; device recognition goes one level up and recovers the transistors those nets connect. It is a new module in reticle-extract (device), a sibling of the connectivity types, not a change to them.

A gate is poly over diffusion

A MOSFET is where a poly shape crosses a diffusion shape: that overlap is the channel (the gate). The extractor:

  1. Flattens the cell and finds every poly-over-diffusion overlap that fully crosses the diffusion (a partial overlap leaves diffusion on only one side and is not a channel).
  2. Classifies each gate as NMOS or PMOS from the surrounding implant and well: p+ select (psdm) or an nwell means PMOS; n+ select (nsdm) or bare substrate means NMOS. Each of those questions is asked of the diffusion and channel polygons, not of the boxes round them: an implant lying in an L-shaped diffusion’s notch touches the box and not the diffusion, and would flip the device’s polarity.
  3. Measures the channel: gate length is the channel’s extent across the poly, and gate width is the channel’s area divided by that length, which is the width of the rectangle with the same area and the same length. On a rectangular channel that is exactly the diffusion extent under the poly. On any other channel the extent is measured across the box round the channel and counts silicon the gate does not cover, so reading the width off it overstates the transistor’s drive: measured at 36% on the fixture in crates/reticle-extract/tests/device_nonrect.rs.

Why a transistor needs more than connectivity

Pure connectivity sees a single diffusion rectangle as one net: a plain wire and a transistor look identical to a same-layer union-find, so source and drain come out shorted. That is wrong for a transistor, whose channel does not conduct at DC.

Device recognition fixes this by splitting the diffusion by its gate before it assigns terminal nets. Each diffusion is cut into the lobes on either side of every channel, and connectivity is extracted over that cut geometry (reusing the same Extractor, with the SKY130 contact/via stack plus a body-tie path). The result is that a transistor’s source and drain land on distinct nets exactly when the layout wires them apart, and the gate, source, drain, and bulk terminals each bind to a real net.

Device-level LVS-lite

compare_devices compares an extracted device netlist against an expected one by device kind and terminal-net connectivity: each device reduces to its kind, gate, unordered source/drain, and bulk net names, and devices are matched by that signature. It reports the devices each side has that the other does not, catching device-count and terminal-net mismatches. The match is name-based, so both sides name their nets (the layout through label geometry, the schematic directly). This extends the connectivity compare_netlists additively; that function is unchanged.

Scope and limits

This is deliberately “lite” and honest about its edges:

  • Recognizes NMOS and PMOS only: no parasitic devices (diodes, capacitors, bipolars), no JFETs, no ESD structures.
  • No device-parameter matching beyond reporting W/L: compare_devices matches on connectivity, not on W/L tolerance or model name.
  • No series/parallel device folding and no hierarchical device extraction; it works over the flattened cell.
  • Bulk binding is best-effort from the nearest matching body tap; an untapped body is left unbound rather than guessed.
  • Source and drain are geometrically symmetric, so their labelling is a stable low/high convention, not a claim about circuit function.

Oracle

The recognition is checked against an independent tool, but not yet on the same input on both sides, and this section says so rather than implying otherwise. scripts/device-oracle.ps1 runs Magic’s own device extraction on the real production GDS (crates/reticle-app/assets/sky130_fd_sc_hd__inv_1.gds) inside the pinned hpretl/iic-osic-tools container (Magic + the sky130A PDK, the same image the tt-precheck recipe uses): Magic extracts 1 NMOS + 1 PMOS with gate A, drains Y, and sources on VGND / VPWR. The extractor side of the comparison runs over a hand-built, axis-aligned golden fixture that mimics the same topology and net names (crates/reticle-extract/tests/fixtures/inverter.md), not the production GDS itself, and reports the same device count, kinds, and terminal connectivity. The two runs are on different inputs: the agreement shown is that the extractor’s device model matches Magic’s on an equivalent circuit, not yet that it matches Magic on the identical production layout. Running the extractor directly over the imported production GDS and diffing against the same oracle run on that file is an open gap (docs/honest-limits.md). When Docker or the image is unavailable the tests fall back to the golden fixture and state the limitation.

A full multimodal oracle and an oracle-agreement table across more cells are a separate lane; this chapter documents only the device recognition and its Magic device-count agreement on equivalent, not identical, inputs.

What this oracle structurally cannot see. The golden fixture, every other fixture in crates/reticle-extract/tests/, and both shipped gallery designs are built from axis-aligned rectangles, and on a rectangle a shape’s bounding box IS the shape. So no amount of agreement on that corpus can distinguish an extractor that measures the channel polygon from one that measures the box round it, and a real defect of exactly that kind survived every green fixture and both gallery designs, which read byte-identical before and after the fix. The fixtures that CAN see it are crates/reticle-extract/tests/device_nonrect.rs, each one the smallest layout where the polygon answer and the hull answer differ:

cargo nextest run -p reticle-extract --test device_nonrect

A gallery-only or rectangle-only check is not a check of this property. That is a statement about the corpus, not about the extractor.

Metrology

reticle-metrology turns a laid-out Document into a small set of quantitative reports. Every measurement runs on the CPU with exact integer geometry (no GPU, no sampling), over the flattened top cell, and never mutates the document. It reads only the public APIs of reticle-model, reticle-geometry, and reticle-extract.

Reports

Area and perimeter per layer. For each layer that carries geometry, area::report unions that layer’s flattened shapes on the exact i_overlay integer engine (through reticle_geometry::polygon_boolean) and returns a LayerMetrics { layer, area, perimeter, shape_count }. Area is the covered area in DBU squared with overlaps counted once; perimeter is the total union boundary length in DBU, including the boundaries of holes. Both are exact for integer (manhattan) geometry. A property test cross-checks area and perimeter against an independent coordinate-compression oracle over 200 random rectangle layouts.

Connectivity statistics. connectivity::stats extracts nets with reticle-extract using the SKY130 via/contact stack, so conductors on different layers joined by a via count as one net. It reports net_count, net_sizes (shapes per net, largest first), total_shapes, and max_fanout (the largest net’s shape count).

Antenna ratio. antenna::check is a deliberately small screen of the antenna effect: for each net it computes connected_metal_area / gate_area over a SKY130 layer subset and flags nets above a threshold. The semantics and their limits are stated in full in the module documentation and repeated here so no one mistakes the screen for sign-off:

  • gate_area is the union area of polysilicon (poly, 66/20) on the net, approximated as all poly, not poly intersected with diffusion.
  • connected_metal_area is the union area of li1 (67/20) and met1..met4 (68/20, 69/20, 70/20, 71/20) on the net. Contacts and vias join nets but are not counted as metal.
  • A net with no poly has no gate and is never flagged.

This reduces the whole net to a single total-metal-to-gate ratio. It does not model per-metal-layer cumulative ratios across fabrication steps, sidewall or perimeter terms, diffusion-diode protection, or partial-route (as-built) area. Treat a flag as “worth a closer look”, not as a rule violation.

Export

MetrologyReport::generate bundles all three reports; to_csv and to_markdown render the bundle deterministically (newline-only line endings, fixed number formatting), so the output is byte-stable and safe to diff. Golden tests pin both renderings.

Scope

This is the CPU half of the metrology work. A GPU density overlay was scoped and explicitly deferred (see the metrology decision record); it is not built here.

Layout diff

reticle-diff answers “what changed between these two versions of a layout?” It computes a pure geometric diff between two Document snapshots, and the app paints the result over the canvas: shapes added in green, removed in red, changed in amber. Like the metrology and DRC crates, it runs on the CPU over the flattened top cell, reads only the public APIs of reticle-model and reticle-geometry, and never mutates a document.

The pure diff

The one entry point is diff(before, after) -> LayoutDiff, where

LayoutDiff { added: Vec<DiffShape>, removed: Vec<DiffShape>, changed: Vec<DiffShape> }
DiffShape { layer, rect, label }

The comparison runs over each document’s flattened top cell and treats the two as multisets keyed by (layer, exact geometry):

  • A shape present in after with no match in before is added.
  • A shape present in before with no match in after is removed.
  • Matched shapes, including matched duplicates (the counts are compared, not just presence), are reported as neither.

Because the key is the exact geometry, not a bounding box, two shapes match only when they are geometrically identical. A shape that merely moved therefore reads as one removed plus one added. Flattening happens first, so the diff compares leaf geometry regardless of how the two documents structured their cells; DiffShape’s rect is the shape’s bounding box (what the overlay paints) and label names the flattened top cell.

changed is deferred in v1

Telling a moved or resized shape apart from an independent add-plus-remove is a fuzzy match, and emitting it wrongly is worse than not emitting it at all. v1 therefore reports every geometric difference as an add or a remove and always leaves changed empty. The field exists so the overlay and any future revision keep a stable shape; a same-place resize shows today as a red box plus a green box.

Correctness

The diff is pinned by property tests, the same discipline the DRC and metrology crates use:

  • diff(d, d) is empty for any document.
  • diff(empty, d) is all-added, with the added count equal to the flattened shape count of d.
  • diff(d, empty) is all-removed.
  • The single-insertion oracle: appending one rectangle to any base document yields exactly one added shape and zero removed, whatever the base and whatever the rectangle (a duplicate still raises the multiset count by exactly one).

The app overlay

The app consumes the diff through a diff_overlay module (egui-free logic, unit tested, mirroring the DRC panel split) and a “Layout diff” side panel. The comparison document comes from a two-snapshot flow rather than a second file open, since no clean comparison-document loader exists in this build:

  1. Snapshot captures the current document as the baseline (the before).
  2. Edit the layout.
  3. Diff vs snapshot compares the baseline against the now-current document and paints the difference on the canvas.

A Show diff overlay checkbox hides or shows the painted rectangles without discarding the computed diff, and Clear drops the baseline and the diff. Loading a new document clears the baseline, since it snapshotted the previous one.

A comparison-document file loader and a true per-instance (unflattened) diff are open work; both can reuse the LayoutDiff surface unchanged. See ADR 0079.

Collaboration

reticle-sync and reticle-server make a document editable by several people at once, with no central authority resolving conflicts.

A CRDT over the document

The document is mirrored onto a yrs CRDT (the Rust port of Yjs; ADR 0007). Every create, edit, move, and delete becomes a CRDT update, and the CRDT guarantees that peers who see the same set of updates converge to the same document regardless of the order in which the updates arrive. Edits made while offline reconcile automatically on reconnect.

Presence and comments

Alongside the document updates, peers exchange lightweight presence messages (cursor, selection, and viewport) so each person sees where the others are working, and threaded comments anchored to shapes so a review conversation lives next to the geometry.

The relay

reticle-server is a thin axum and tokio WebSocket relay: it holds rooms, broadcasts updates and awareness to the other peers, sends a new peer the current state, and offers a persistence hook. It contains no editing logic of its own, so the same convergence guarantees hold whether peers are connected through it or exchange updates by any other means.

Reconnect and resync

A shared live session runs over a browser WebSocket, and browser sockets drop: a laptop sleeps, Wi-Fi hiccups, a phone changes networks. The live transport (reticle_app::livesync) treats a drop as recoverable rather than terminal (ADR 0063). When a socket closes or errors while the session is still wanted, the transport redials with capped exponential backoff: the wait doubles each attempt from a 500 ms base up to a 30 s ceiling, with deterministic jitter (each wait lands in [ceiling/2, ceiling]) so a fleet of tabs recovering from the same outage does not stampede the relay in lockstep. Attempts are unbounded (only closing the session stops them) because an outage of any length should heal on its own.

While waiting between tries the status line reads “reconnecting to the shared session (attempt N)…”, counting the tries so a person can see progress; it returns to “connected” the moment a redial succeeds.

Two things resync on reconnect, from the two ends:

  • The sharer republishes its whole document as one full-state snapshot the instant its socket reopens, before resuming incremental updates. So any edit made while the socket was down still reaches viewers, carried by that snapshot, not lost with the dropped socket. Because yrs updates are idempotent, a viewer that already saw part of the document applies the snapshot without duplicating anything.
  • A viewer needs no resend logic at all: on rejoining a room the relay replays the room’s full update log before live traffic, so a reconnecting (or freshly arriving) viewer catches up on everything published so far, again idempotently.

The reconnect schedule is pure, cfg-free logic, unit-tested without a browser (attempt growth, the cap, and the jitter bounds), and the resync contract is proven headlessly by a relay integration test that kills a sharer’s socket, edits offline, reconnects on a fresh connection, and asserts the viewer materializes the combined edits exactly once.

Sharing a session

The Share panel turns the current session into something a collaborator can open. The one-click Share this session button does the whole dance at once: it mints a fresh room (the design name plus a short random suffix), goes live so viewers stream this session, and copies the read-only viewer link to the clipboard. The advanced fields (relay host, room name, viewer-page origin) stay below for when you want to name the room yourself or point at a specific relay. A viewer opens the copied link in a browser and joins read-only: they see your live edits, pan and zoom independently, and can follow your view, but never publish (the relay enforces read-only on its side too).

A permalink deep-links a particular view of an opened document, layered on top of the ?gds=<url> open link. Beyond the file, it can carry three independent, optional pieces of view state:

  • ?cell=<name> - the cell to focus (URL-encoded, so spaces and non-ASCII names work).
  • ?view=<x>,<y>,<zoom> - the camera: the world point at the canvas center and the zoom in pixels per DBU.
  • ?layers=<csv> - the visible layers as layer/datatype specs (for example 68/20,69/20); every other layer is hidden. An empty value hides all.

?view= is shared with the start-view selector (?view=viewer|editor|replay); the two are told apart by shape, so a value of exactly three numbers is a camera and anything else is the start view (see ADR 0067). Copy permalink to this view in the Share panel serializes the current cell, camera, and visible layers into such a link; opening it re-applies them once the document has loaded. Malformed values (a bad number, an unknown layer) are ignored rather than failing the open, so a hand-edited link still works as far as it can.

Testing

Convergence is tested directly: concurrent operation sets are applied in different orders across simulated peers, and the final documents must be identical.

The permalink parser and emitter are pure and round-trip tested (emit then parse is the identity), including the encoding edge cases (spaces, unicode cell names, an empty layer list), and an app-level test proves an emitted permalink restores the same cell, camera, and layers on a freshly opened document.

Browser-level proof (ADR 0058, 0093)

The read-only contract and the transport are proven twice. The authority is a headless Rust relay test (crates/reticle-server/tests/share_live.rs): a real publisher and a real viewer connect over an ephemeral port, and it asserts the publisher’s frames materialize in the viewer and that a frame a view-mode client sends is dropped and never enters the room log. On top of that, a two-context Playwright suite (just e2e-share) proves the browser side, reading a wasm instrumentation seam (window.__reticle_stats) because the egui canvas is GPU-painted and has no DOM node to assert on:

  • an edit made in the sharer paints in a read-only viewer (a no-edit control room isolates the scripted edit to exactly one extra applied shape in the viewer);
  • a view-mode socket cannot write: the same captured relay frame is dropped when a browser sends it from a ?mode=view socket but applied when sent from an edit-mode socket, a positive control that keeps the drop assertion from passing vacuously;
  • a phone navigates a design by touch: on a mobile viewport a two-finger pinch changes the zoom and a drag changes the pan, read back from the camera field of the same seam.

What these do not assert is the pixels of the rendered canvas; that stays the job of the Rust relay test (for the read-only contract) and the pure camera unit tests (for the pan/zoom transform). The README’s share clip is captured from this same two-context flow by just capture-share.

Multi-writer collaboration

The collaboration layer began as one-writer-broadcasts: a sharer published, viewers consumed. Multi-writer turns that into true co-editing, where several editors’ edits merge and converge, a read-only viewer cannot write, and each editor’s undo affects only their own edits (ADR 0081).

Convergence

Every editor keeps a SyncDocument backed by a yrs CRDT. Local edits become binary updates; editors exchange them both ways and converge to a byte-identical document regardless of the order updates arrive in. Disjoint edits (each editor working on a different cell) and conflicting edits (both adding to the same cell) both converge: records are keyed by a globally-unique actor:counter id, so two editors never write the same key, and every record simply coexists as a union.

Roles: editor and viewer

A session has two roles, and read-only is a structural property rather than a matter of discipline:

  • An editor joins in Edit mode. It both receives peers’ updates and may publish its own.
  • A viewer joins in View mode (?mode=view). It receives everything but cannot write, enforced in two independent places:
    • The relay drops a view-mode connection’s frames outright: they are neither added to the room log nor broadcast, so a viewer’s attempted edit never reaches another peer. Both the native relay and the Cloudflare Durable Object worker do this, and the conformance suite proves they agree.
    • The client viewer transport exposes no method that sends a document frame, so a viewer is structurally unable to publish even if the relay backstop were removed.

An end-to-end relay test drives two editors and one viewer at once: the editors converge, the viewer materializes exactly the union the editors reached, and the frame the viewer tries to publish is dropped and never reaches either editor.

Selective undo

In a shared document a single global undo stack is wrong: undoing should reverse my last edit, not whatever edit happened most recently, and it must never disturb a concurrent editor’s work.

Each editor tags its local edits with a per-actor origin (a stable actor id on the underlying CRDT transaction) and drives undo through a yrs undo manager scoped to that origin. So:

  • undo reverts only this editor’s own most recent edit; a peer’s edits (applied with no local origin) are never on this editor’s undo stack.
  • The undo is itself an ordinary CRDT change, so after the editors exchange updates again they still converge, now with that one edit removed.
  • redo re-applies only this editor’s undone edit, and likewise reconverges.

So two editors can interleave edits, and each can undo and redo their own work independently while both documents stay in agreement.

What makes it converge, and one sharp edge

Three details are load-bearing:

  • Client ids stay below 2^53. yrs (following Yjs) round-trips client ids through a JavaScript-safe-integer representation; a larger id is silently corrupted on the wire, which makes peers disagree on which client owns a struct. That is invisible to a document comparison but breaks the precise identity undo/redo depends on, so the derived client id is masked to 32 bits.
  • Deleted structs are kept, not garbage-collected. Redo re-inserts a previously-deleted item; keeping tombstones lets that re-insertion converge across peers.
  • The undo manager is Send + Sync. A document is driven from a background task in the live-agent path, so it must cross threads.

Status

The sync-layer contract above (convergence, per-actor selective undo) and the relay’s view-mode enforcement are delivered and tested. The in-app editor still edits a separate document as its source of truth and publishes a reconciled mirror; making the live editor CRDT-backed, so it merges inbound peer edits and routes its undo button through the per-actor undo manager, is a larger app change deferred to a later step.

The relay: two implementations, one protocol

Reticle ships two collaboration relays, and treats the protocol between them as the asset rather than either relay. The first is a native binary (crates/reticle-server): axum and tokio, one broadcast channel and an in-memory log per room, the relay you run yourself. The second is a Cloudflare Durable Object (worker/): a Worker routes GET /ws/{room} to a per-room object that holds the WebSockets, hosted on the free plan with no server to operate. A single conformance suite proves the two are observably interchangeable.

The wire invariant the relay keys off

Every frame on a live session is a reticle_proto::v1::SyncMessage, a protobuf oneof of three payloads. Because a oneof field is encoded length-delimited, the first byte of every frame is the field tag of the active variant:

First byteVariantMeaning
0x0Aupdatea CRDT document delta
0x12presencecursor, selection, viewport
0x1Acommenta threaded comment

This is frozen by a unit test in reticle-proto and a doc comment on SyncMessage. It lets a relay classify a frame with no protobuf decoder at all: the Durable Object reads one byte to tell a presence update (which it coalesces) from a document update (which it never drops). Neither relay ever inspects the rest of the payload; both treat it as opaque bytes to fan out.

The semantics both relays honor

The native relay defines the contract; the Durable Object mirrors it exactly:

  • Join and replay. GET /ws/{room} upgrades to a WebSocket; ?mode=view|viewer|readonly|ro (case-insensitive) joins read-only. On join the room’s full accepted-frame log replays to the joiner, in order, before any live traffic, so a late joiner catches up.
  • Read-only is enforced server-side. A frame from a view-mode connection is dropped: never logged, never broadcast. A viewer cannot mutate the document even if it sends a well-formed edit frame.
  • No self-echo. A sender never receives the echo of its own frame.
  • Binary only. Only binary frames are payloads; text, ping, and pong are not.

Free-tier limits engineering

The free plan rewards doing less work, so the Durable Object adds three things the native relay does not need, each preserving the observable semantics.

Presence coalescing. A live cursor can move at screen refresh rates, but a follower only needs the latest position. The DO keeps only the newest presence per client within a short window (about 10 Hz) and delivers that, driven by the object’s alarm rather than a setTimeout (a timer callback would keep the isolate awake and defeat hibernation). Update frames are never coalesced: a dropped CRDT delta would corrupt the document, while a dropped intermediate cursor is invisible. The newest presence always converges, so read-only follow-mode still lands on the sharer’s current viewport. This is the one place the two relays are not byte-identical: for a burst the DO delivers strictly fewer presence frames than the native relay, by design.

Log chunking. The room log is persisted in Durable Object storage in fixed chunks and replayed to late joiners in order, so a room survives the object being evicted between events. A production host would compact old chunks into snapshots; the raw chunked log keeps the relay free of any payload-format knowledge.

Caps and expiry. The native relay’s per-room broadcast channel is bounded (ROOM_CHANNEL_CAPACITY = 1024); a lagging peer skips dropped messages rather than stalling the room. The DO bounds its own work the same way and reclaims an idle room from the same alarm it uses for coalescing: when the alarm fires with no open sockets, the room’s storage is deleted. Hibernation-critical state (each connection’s mode and id) rides the socket’s attachment, which survives eviction, so a room can hibernate and wake without losing the read-only guarantee or self-echo suppression.

A fourth optimization, client-side packing (batching several pending CRDT updates into one frame per flush in the sharer’s publish path), belongs to the app transport rather than the relay and is tracked as a follow-up.

Proving the two equivalent: the conformance suite

crates/reticle-relay-conformance expresses the whole contract once, as a table of scripted vectors, and runs the identical table against either relay through one tokio-tungstenite driver:

  • Target::native spawns the axum relay in-process on an ephemeral port.
  • Target::external addresses any relay by URL: the Durable Object under wrangler dev --local (miniflare, no Cloudflare auth), or a deployed wss://...workers.dev.

Each vector covers one clause: late-join log replay in order, view-mode frame drop, echo suppression, presence coalescing, uncoalesced updates, full-log replay, two- room isolation, and the binary-only rule. Byte-for-byte delivery is not the invariant, because the DO coalesces presence; the shared invariant is convergence. A presence burst asserts on both relays that the observer receives a strictly increasing run whose newest value arrives last; the coalescing target additionally asserts it received strictly fewer than were sent, and the native target that it received all of them. One vector, both relays PASS, the verdicts identical.

A deliberately-broken vector (one that expects a dropped view-mode frame to be forwarded) must fail against either real relay: that is how the suite proves it would catch a relay that broke the contract, rather than vacuously passing. Run the whole suite with just conformance, which runs the native half in-process always and the Durable Object half against wrangler dev when worker/node_modules exists. just ci stays Node-free.

Comments and annotations

A comment is a note anchored to a piece of a layout: a reviewer marks a shape or a cell and leaves text next to it, so a review conversation lives on the geometry rather than in a separate document. Comments persist inside the layout document through a schema version bump, V1 to V2, with a proven-lossless migration of every pre-V2 document (see ADR 0080).

The comment model

A comment (reticle_sync::Comment) carries a stable id, the thread_id it belongs to, an anchor_ref binding it to a shape or cell (a cell name, or a cell/element-id path), an author, the body text, a creation timestamp, and an in_reply_to pointing at the comment it replies to (empty for a thread root). Replies inherit their parent’s thread and anchor, so a CommentThread groups a root with its replies in creation order for display.

Persistence: schema V1 to V2

Comments are stored on the document itself. The reticle.proto schema is versioned by a SchemaVersion enum, and adding comments is the first real evolution of that schema, so it is also the first exercise of the migration contract.

The change is deliberately additive: SCHEMA_VERSION_V2 = 2 is added to the enum, and a repeated Comment comments field is appended to Document with a new field number. Protobuf never reuses field numbers and skips absent fields, so a document written under V1 still decodes under V2 with an empty comment list, and a V1 document with no comments re-encodes byte-for-byte unchanged.

reticle_proto::migrate::migrate_document upgrades a document to the current version: for V1 to V2 it stamps the version to V2 and leaves the (empty) comment list alone. It never touches the technology, cells, or top-cell list, so all geometry is preserved exactly. An unspecified (version 0) or newer-than-supported document is refused rather than guessed at, and migrating a document already at the current version is a no-op.

Proving the migration is lossless

The migration is proven against a real pre-V2 artifact, not an argument. A representative V1 document, serialized with the pre-V2 build, is committed as a frozen golden fixture (v1_document_golden.bin). It was captured and committed before the schema was edited: had the schema changed first, the fixture would be a V2 document and prove nothing.

The migration test then decodes that committed fixture with the V2 code, confirms the comment list is empty and the geometry is intact, runs migrate_document, and asserts the version becomes V2 while the geometry is byte-for-byte identical before and after. A second, two-way test builds a V2 document carrying a root-and-reply thread, round-trips it through encode and decode, and confirms the comments, their thread structure, and their anchor bindings all survive, and that a migrated V1 document lists zero comments.

Comment pins in the app

The app surfaces comments as pins on the canvas and a list in the side panel, following the same egui-free-logic split the DRC and diff panels use: a comment_pins module holds the comment set, resolves an anchor_ref to a world point (the centre of the anchored cell’s geometry), and formats a comment for the list, all unit-tested without a UI. The “Comments” panel adds a comment on the current top cell, lists the comments, and selects one; the canvas paints a numbered pin at each anchor, with the selected pin drawn larger and highlighted.

The in-app pins are held in memory today. Serializing them into a saved V2 document (and into the collaborative CRDT for live sharing) reuses the same Document.comments field and the to_proto_comments / from_proto_comments converters the persistence tests already exercise; that save/load wiring is open work.

Classroom mode

Classroom mode layers a teaching workflow on the collaboration machinery: an instructor’s roster shows who else is in the session, a student can follow the instructor’s viewport live, and the instructor can broadcast their current view or release (“unlock”) a student to work independently again. It adds no new wire message and no reticle-sync field (ADR 0111): the roster is built from the existing Awareness presence map, and following rides the same live-published Presence.viewport the read-only viewer already uses (ADR 0038).

Roles and the roster

crate::classroom::ClassroomState (crates/reticle-app/src/classroom.rs) is egui-free: a roster of every other known peer, classified instructor or student, each with a locally-tracked follow flag, plus the instructor’s last broadcast viewport. sync_roster rebuilds the roster from Awareness every frame, reusing the same identity/color/name resolution (crate::viewer::participants) the session chip’s avatar row already uses, so a classroom peer reads exactly like any other collaborator’s presence, with a role label layered on top.

Following the instructor

A student’s “Follow instructor” toggle does not add a second camera-follow path: it flips the same ViewerSession follow flag the collaboration chapter’s session chip already drives, so the existing per-frame sync_camera snaps the student’s camera to the instructor’s live viewport (ADR 0038). Turning follow off leaves the student’s camera where it is, free to pan and zoom independently until they follow again.

Bring everyone, and unlock

The instructor’s Bring everyone here records their current camera viewport as the broadcast target and marks every known student as following, so a following student’s next camera sync lands exactly there. Unlock (per student row, or the palette’s classroom.unlock_student, which targets the first currently-following student in roster order) clears one student’s follow flag without touching anyone else’s. Both are ordinary, pure state transitions on ClassroomState, unit-tested against Awareness values built directly in the test (there is no byte-shape contract fixture here, unlike the F1-F6 producer/consumer pairs: nothing downstream depends on a frozen wire record).

What this depends on, honestly

Today the app publishes presence from exactly one identity (crate::livesync::SHARER_ACTOR), and a read-only viewer never publishes at all, by design (ADR 0038). That means an instructor’s live roster is genuinely empty until a future lane wires a write-capable “join and publish my own presence” path; the classroom panel renders an honest empty state naming this rather than a fabricated row. A student’s half already works end to end over whatever relay is configured, because it only depends on the instructor’s already-flowing viewport. Either way, a classroom that spans more than one machine still needs a reachable relay: the share server default stays 127.0.0.1:3030 (crate::share::DEFAULT_SERVER), and a deployed public relay is operator-owned, tracked as backlog item H1 (scratch/campaign/v82-backlog.md). This module does not change that default and does not attempt to work around it.

Scripting

reticle-script embeds a scripting language so a layout can be built, queried, and transformed programmatically.

The API

The engine is rhai, a small embeddable Rust scripting language. Scripts get an API over the model: create cells and shapes, query and transform geometry, run the design-rule checker, invoke the router, and export to a file format. Because the API is the same model the application edits, a script and a hand edit are interchangeable.

Plugins and examples

A plugin folder lets a script extend the application, and a set of worked example scripts shows the common tasks: generating a parameterized cell, sweeping a rule threshold, batch-checking a directory of layouts, and scripting an export pipeline.

Agent API and harness

Reticle exposes its whole editing engine as a small, serializable command surface, so a program (or a language model) can build and check layouts through the same operations a human uses. On top of that surface sits a propose-verify-correct harness that drives a model against objective checks, writes a replayable transcript, and can mirror its edits onto the live collaboration document.

The command surface (reticle-agent-api)

AgentCommand is a tagged, serde-serializable enum of 30 operations over the engine: create a cell, add a rectangle, polygon, or path, add a label or pin, transform or delete shapes, set the technology, run DRC, check a connectivity intent, extract nets, compare against a netlist, export GDS or OASIS, and render a region to PNG. A [Session] owns an editable document and a stable element-id allocator, so a command that adds geometry returns an [ElementId] that later commands and the transcript can refer to even across deletions (ADR 0018).

Five of those operations lift the in-app editor’s productivity actions to the command surface (ADR 0031): BooleanCombine (union, intersection, difference, or xor over a set of shapes, onto a target layer), AlignShapes, DistributeShapes, OffsetShapes (grow or shrink), and BuildViaStack (a cut plus enclosures sized from the technology’s enclosure rules). They run on the same reticle-geometry primitives as the editor, so agent-built and hand-built geometry match exactly.

Every command applied to a session is recorded as a [CommandRecord] in a [Transcript], and the model’s document has a [document_hash]. Replaying a transcript reproduces that hash exactly, so a run is deterministic and auditable: the verify_replay path recomputes the hash and rejects a tampered transcript.

The propose-verify-correct loop (reticle-agent)

flowchart LR
    M[Model] -->|AgentCommands| A[Apply to Session]
    A --> V{Verify: DRC + intent}
    V -->|clean| D[Done, write artifacts]
    V -->|violations| F[Feed violations back]
    F --> M

The harness asks a [ModelClient] for a batch of commands, applies them to a private session, and verifies the result with the SKY130 DRC subset plus, where a task carries an intent spec, the connectivity checker. Violations become correcting context for the next proposal, up to an iteration bound. Success is defined by the checker, not by the model’s say-so, and a failure is recorded as a failure, never retro-edited to a pass. Each run writes four artifacts: the transcript, the final GDS, a rendered PNG, and a result record.

The model is either the real AnthropicModel (an Anthropic-compatible endpoint; the API key is read from the environment only and never printed, serialized, or written to an artifact) or the deterministic MockModel used offline and in tests.

Iterative refinement (mid-session constraints)

A session does not have to be one fixed prompt. A user watching the loop can add a new constraint between iterations (“make the wire wider”, “keep it on met1”, “shrink the guard ring”) and the loop folds it into the very next proposal without tearing the run down and starting over. This is the refinement protocol.

flowchart LR
    U[User constraint] -->|between iterations| C[Fold into context]
    V[Checker feedback] --> C
    C --> M[Model]
    M -->|AgentCommands| A[Apply]
    A --> K{Verify}
    K -->|clean and constraint met| D[Done]
    K -->|violations| V
    K -.->|user adds more| U

The mechanism is deliberately small. run_agent_task_refined takes a RefinementSource: a trait whose one method, drain(iteration), returns the constraint strings that have arrived since the loop last asked. The loop drains it once per iteration, just before it asks the model, and accumulates the constraints. Each iteration it builds the model context from the accumulated user constraints followed by the checker’s own failure reasons, so the model sees both in one place. Because the constraints accumulate rather than replace, a constraint added once keeps conditioning every later proposal; because the checker still runs every iteration, success is still defined by objective verification, not by the model’s agreement that it complied.

Three properties make this safe and testable:

  • No restart. Folding a constraint into the running context is one continuous run: the session, its transcript, and its element ids are never rebuilt. The refinement_is_folded_in_and_loop_converges_without_restart test starts a task, injects a widen constraint before the second iteration, and asserts the final layout carries the wider wire while the run stays a single loop.
  • Bounded on conflict. A constraint that cannot be satisfied (it conflicts with the task, or asks for something no proposal can reach) does not spin forever: the same max_iterations cap applies, and the run is recorded as an honest failure. The conflicting_refinement_is_bounded_by_max_iterations_not_infinite test proves the loop stops at the cap rather than looping.
  • Frozen context type. The refinements ride in the existing Context::feedback channel, so reticle_bench’s Context type is unchanged. A caller with no mid-session constraints uses run_agent_task, which is run_agent_task_refined with a NoRefinements source and behaves exactly as before.

The source is whatever a caller needs: a channel receiver behind a live UI or HTTP endpoint, or a scripted RefinementFn closure in a deterministic test. The loop owns no channel of its own, so the same code path serves an interactive session and a reproducible convergence test on the mock model.

Planning transparency (PlanStep)

Before each iteration’s proposal, the harness records a PlanStep: the iteration’s goal (the task prompt), the intended tools (the op names of the commands the model proposed, in order), and the expected checks (the always-on DRC oracle plus the task’s own checker). These steps accumulate into Transcript::plan, a parallel log that rides alongside the command records, and the agent panel renders them as a Plan section so a viewer can see the agent’s stated intent next to what it did.

A plan step is narration, not a contract: nothing enforces that an iteration used exactly the tools it listed or that its checks passed. That is deliberate, so the stated plan and the recorded outcome can be compared after the fact for failure mining. The field is additive and replay-neutral: it carries #[serde(default)], so a transcript written before the plan log existed still deserializes (as an empty plan), and replay reads only the records and the final hash.

Live collaboration (reticle-agent::collab)

An [AgentCollaborator] mirrors each agent step onto the reticle-sync CRDT under a distinct actor id, as one atomic transaction per step, so a human peer watching the room never sees a half-drawn step and can edit alongside the agent (ADR 0022). The same transcript the harness writes is what the in-app replay theater plays back through a live session.

Id-addressed edits: transform and delete (ADR 0066)

The geometry-creating commands change what a human sees, so those are mirrored. So are the two edits that address existing elements, TransformShapes and DeleteShapes: the gap ADR 0022 documented and ADR 0066 closes. They name the command surface’s stable ElementIds, which are not the CRDT’s actor:counter element ids, so the collaborator drives an authoritative internal session in lockstep with the mirror: applying each command there validates it and hands back the ElementId it assigned, and the bridge records ElementId -> CRDT id for every shape it creates. A later transform resolves each addressed id and moves that record’s geometry in place (the shape keeps its identity); a delete removes it. An id the bridge never learned (a shape created before it attached) is skipped and reported in StepReport.skipped, never applied incorrectly or dropped silently. Convergence tests exchange these edits with a concurrent human peer in both orders and confirm the transform actually moved the shape and the delete removed it.

Joining a real room (reticle-agent::live)

reticle_agent::live::run_in_room connects an AgentCollaborator to a real reticle-server relay room over a native WebSocket (ws://host/ws/{room}). For each step it ships that step’s CRDT delta as one binary frame in the same SyncMessage framing the browser transport uses (ADR 0058): one step is one frame, so a watcher never sees a half-step. It publishes the agent’s presence (cursor at the last placement, an amber color, a display name), and applies inbound peer frames back into its own document. The agent is then indistinguishable from any other participant: its edits reach browser humans, and their concurrent edits reach it. An in-process integration test proves both directions over a real socket.

The agent_live_room example is the small CLI entry: --relay <ws-url> --room <name> joins a room and runs the demo; --emit <path> regenerates the committed transcript.

The DRC-fix demo transcript

examples/collab/agent_drc_fix.transcript.jsonl is a committed, replayable transcript of the agent fixing a seeded design-rule violation in a live room: it seeds two met1 rectangles closer than the SKY130-subset m1.2 spacing (140 DBU), runs DRC (flagged), TransformShapes the second rectangle to a legal gap, and runs DRC again (clean). It replays to a pinned document hash, checked by a test.

This is a deterministic scripted run, not a live model: the commands are the fixed script in reticle_agent::live::scripted_drc_fix_steps, driven by the harness with no LLM, no API key, and no network beyond the relay socket. The transcript trailer says so, and so does this paragraph: the label is honest everywhere.

Scoped sessions and context packs (reticle-agent::context_pack)

A session can be opened on a region of the document rather than the whole thing.

What the DRC error browser’s “ask the agent to fix this” button does, and what it does not. The button assembles the selected violation’s rule, layer, region and measured-against- required numbers into a context string and opens a session with it. It does not constrain the run to that region: the region travels in the prompt as context, and scoped-region enforcement is a separate seam this action does not use. The handler says the same thing about itself at crates/reticle-app/src/app/verify.rs:1080-1082. Check it with:

cargo nextest run -p reticle-app --test qa139_drc_ask_agent

CORRECTED 2026-08-01 (session 36). This paragraph previously read “it hands the harness a rectangle and the specific violation, and the agent works only that corner”, which was false in its second half and was contradicted by the handler’s own doc comment. It is recorded here rather than quietly reworded because this repository does not backdate its record. Found by an independent review, as collateral while confirming that the button exists at all, which it does.

For a scoped run the harness does not condition the model on the whole-document snapshot. Instead a [ContextPack] assembles a minimal, region-local context string:

  1. the scoped region;
  2. the shapes whose bounding box overlaps that region, each as kind, layer, and bounding box, capped so a dense region still yields a bounded prompt;
  3. the violated rule, stated structurally (kind, layers, measured value, required value);
  4. only the technology rules whose layers appear in those shapes or in the violation.

The overlap test is the same inclusive touch-or-overlap test the query_shapes command uses, so a pack and a query_shapes on the same rectangle agree on which shapes are in scope. The pack is a pure function of the document and the region, so it plugs into the same context hook the whole-document path uses: a scoped run sets the model’s document context to the pack instead of the summary.

Why it saves tokens. The compact per-cell summary the unscoped hook sends is already small, but it is lossy: it lists how many shapes a cell holds, not where they are. To reason about a local geometric fix a model needs the coordinates of the nearby shapes, so the honest whole-document baseline is the full per-shape listing (whole_document_context), not the summary. A pack replaces that full listing with a region-scoped slice.

On a synthetic document of one cell with 200 shapes, a met1 width rule and a met2 spacing rule, and a repair region overlapping exactly one shape, the measured estimates (characters over four) are:

ContextTokensCharacters
Whole-document, full per-shape fidelity18787509
Scoped pack (one shape + violation + one relevant rule)62245

That is roughly a 30x reduction, about 97% fewer tokens, and the pack’s size tracks the region rather than the design, so the saving grows with the design. The measurement is pinned by a test in the context_pack module.

Where it runs

  • reticle-mcp exposes this command surface to a model over the Model Context Protocol; see the MCP chapter.
  • reticle-demo-server runs the loop behind a rate-limited public endpoint and streams each step to a watchable room; see Deployment.
  • The benchmark suite scores the loop across 63 graded tasks.

See ADRs 0018, 0021, and 0022.

In-app agent UX

The editor’s agent panel has two modes. With no model backend configured it is a preview: it narrates a fixed, scripted propose-verify-correct run on a built-in demo cell, with no model and no API key, and that narration does not read or edit your open design or its DRC results (see Agent API and harness for how a real run works). With a backend configured, the real plan/approve/execute agent ships and this panel is its front end: agent.plan stages a plan and agent.approve runs the propose-verify-correct loop, through crate::agent_runner::AgentRunner on native or agent_panel::BrowserLive on wasm. On top of the scripted narration the editor adds two affordances that make the preview feel like a collaborator rather than a batch job: a conversation you can steer mid-run and a browser for reopening past runs.

Everything here is UI-side. The panel logic lives in agent_panel and the history browser in agent_history, both window-free and unit-tested without an egui context; the app module owns only the thin drawing glue. Both features build and run on both native and wasm32-unknown-unknown, with the one filesystem-touching seam (scanning for past transcripts) guarded behind cfg with a clean bundled fallback in the browser.

Conversation mode

The agent panel keeps a conversation transcript alongside the raw narration feed: a list of turns, each authored by either the user or the agent. Starting a run opens the conversation with the prompt as the first user turn. While the run is active, a follow-up box lets you send an additional instruction or constraint; submitting it appends the message to the conversation as a user turn, adds an agent acknowledgement, and records the instruction on the panel’s follow-up list.

That follow-up list is the honest seam. On the UI side today the acknowledgement is scripted, because the panel narrates a recorded transcript rather than driving a live model. A live scoped harness (Wave 3) reads the follow-up list and forwards each instruction to the model as a new constraint on the running session; the UI records them regardless, so the affordance is real before that harness exists.

A follow-up is only accepted while a run is active (an instruction has no session to attach to otherwise) and only when its trimmed text is non-empty. The conversation transcript is distinct from the engine transcript: a conversation entry is human-facing text, not a replayable command. It is capped so a long session drops its oldest turns rather than growing without bound, and starting a fresh run clears it.

Each verify the run crosses is also surfaced as an agent turn (verified: DRC clean or verified: N violation(s) remaining), so the conversation reads as a dialogue that tracks the propose-verify-correct loop and not just a command log. Replaying a transcript in the theater does not write into the panel’s conversation; only the panel’s own run does.

Session history browser

A finished agent run leaves a *.transcript.jsonl file next to its other artifacts (reticle-agent names them <task-id>.transcript.jsonl). The history browser lists those transcripts so you can reopen a past run with one click, loading it straight into the replay theater through the same store seam the theater already loads through.

Listing is on demand: pressing Refresh scans, and drawing never touches the disk, so the browser costs nothing per frame. The platform difference is the browser’s only cfg:

  • Native scans a directory (the conventional runs/ by default, editable in the UI) for *.transcript.jsonl files.
  • wasm has no filesystem, so the browser lists the single bundled demo transcript the theater already carries, and selecting it plays that.

The interesting part, turning a set of file names into a sorted, labelled entry list, is the platform-free entries_from_names: it keeps only the transcript files, labels each by its task id (the base name with the suffix and directory stripped, for either path separator), sorts by label, and collapses duplicates. It is unit-tested over a synthetic listing with no disk touched.

Preview status

The agent panel is a preview of a planned capability, not a working agent. It runs a fixed scripted propose-verify-correct loop on a built-in demo cell so you can see the shape of the interaction (plan, narrated steps, verify results, replayable transcript). The narration itself does not read or edit your open design, and it does not write into the DRC panel or the canvas markers, which track your real layout.

CORRECTED 2026-07-31. This paragraph used to end “A real plan/approve/execute agent over the editor’s command tools is planned for a later release; when it lands, this panel becomes its front end.” It has landed. There are six live command ids, agent.plan, agent.approve, agent.approve_step, agent.stop, agent.replay and agent.open_panel, all in the live registry rather than the reserved table; the native runner’s own module doc opens “The real, native-only plan/approve/execute agent runner”; and the browser analog is a real round with a pasted key. The scripted narration is the no-backend fallback, not the whole feature, and README.md already described the agent as shipped while this chapter said it was planned. Check:

git grep -n 'CommandId("agent\.' -- crates/reticle-app/src/commands/feature_cmds.rs
git grep -n "agent_runner:\|agent_web:" -- crates/reticle-app/src/app.rs

Layout generators

A generator turns a few numbers into the repetitive structure a layout engineer would otherwise draw by hand: a guard ring, a via farm, a pad ring, a seal ring, a density fill, or a probe-able test structure. Each one is a pure function from typed parameters plus a technology to geometry, and every generator is DRC-clean by construction against the SKY130 subset (see ADR 0043). The process numbers are data (a GenTech derived from the technology), so the same generators run DRC-clean against a second PDK too - see A second PDK: IHP SG13G2. The generator framework itself, the typed Generator trait and the type-erased Registry that drives it by id, is described in ADR 0042.

This chapter is about the three product surfaces that expose those generators: the Generate panel in the app, the generator tools on the agent and MCP surface, and the generator tasks in the benchmark. All three drive the same registry from one machine-readable schema, so a generator is described once and surfaces everywhere.

One schema, three surfaces

Every generator publishes a ParamSchema: its field names, their types (a bounded Int, a Bool, or an Enum of string variants), per-field ranges, defaults, and one-line docs. That schema is plain serde data with no behaviour, so each surface renders it its own way from the same source.

flowchart TD
    REG["reticle_gen::Registry<br/>with_builtins()"]
    SCHEMA["ParamSchema<br/>(fields, ranges, defaults)"]
    REG -->|infos| SCHEMA

    SCHEMA -->|"Int → DragValue<br/>Bool → checkbox<br/>Enum → combo box"| PANEL["Generate panel<br/>(reticle-app)"]
    SCHEMA -->|"→ JSON Schema<br/>+ required cell"| MCP["MCP generator tools<br/>(reticle-mcp)"]
    SCHEMA -->|"field-by-field<br/>from the checker string"| BENCH["generator checker<br/>(reticle-bench)"]

    PANEL -->|"apply_group"| CMD["RunGenerator command"]
    MCP -->|"to_generator_command"| CMD
    CMD -->|"apply.rs"| GEN["Registry::generate<br/>into the document"]
    BENCH -->|"re-run to fingerprint"| GEN

The one command underneath all of this is RunGenerator { cell, generator_id, params }, the additive Wave 2D amendment to the frozen agent command surface (ADR 0048). Its apply arm generates into a scratch cell, then commits each produced shape as a normal edit, so a generator run is transcript-replayable and undoable exactly like a hand-drawn shape.

The Generate panel

The panel (generate_panel, ADR 0050) lists the generators from the registry and, for the selected one, builds a typed form straight from its schema: an Int field is a drag control clamped to its [min, max], a Bool is a checkbox, an Enum is a combo box over the variants. The form is seeded from the generator’s defaults, so the panel opens on a working example that generates unchanged.

As the parameters change, the panel generates them into a scratch cell and draws the resulting geometry as a live preview overlay on the canvas, in a distinct accent, so you see the structure before committing it. A parameter that is momentarily out of range simply shows no overlay and surfaces the generator’s own message (which names the offending field) as text below the form, rather than flickering an error on the canvas.

Pressing Generate places the whole structure into the document as one undo-integrated edit: the produced shapes are applied together through History::apply_group, so a single Undo removes the entire generated structure. Because reticle-gen is pure geometry that compiles for wasm32-unknown-unknown, the panel and its live preview work in the browser too.

Generator tools for the agent and MCP

On the MCP surface (generators, ADR 0049) each generator is advertised as its own tool, named for the generator id (guard_ring, via_farm, pad_ring, seal_ring, fill, test_structure). The tool’s input schema is the generator’s ParamSchema converted to a tight model-facing JSON Schema: an Int becomes an integer with inclusive minimum/maximum and a default, a Bool a boolean, an Enum a string constrained by enum. A required cell field is prepended (the target is not one of the generator’s parameters). A tool call maps to a RunGenerator command by splitting the cell out and folding the rest into the parameter object; the generator validates the parameters itself, so a bad value is a well-formed invalid_argument tool error.

Because the catalog is derived from the registry’s infos(), adding a seventh generator surfaces a seventh tool automatically, with no reticle-mcp change.

Generator benchmark tasks

The benchmark poses generator constructions in natural language (“place a guard ring around the selected cell”, “drop a 4 by 4 via farm”, “fill this region with decap at 60 percent density”, “add a van der Pauw test structure”) and grades them with a generator checker. Rather than hand-code the expected geometry per structure, the checker re-runs the named generator with the task’s parameters to build a per-layer shape-count fingerprint, and requires the graded document to carry at least that many shapes on each of those layers and be DRC-clean under the SKY130 subset. Because the generators are DRC-clean by construction, a model that reproduced the structure lands the same fingerprint; extra shapes never make the check fail, and a structurally-complete but DRC-dirty answer fails on the cleanliness half.

The v0.5.0 suite adds 8 such tasks spread across all six generators, taking the suite from 75 to 83 graded tasks.

MCP server

reticle-mcp exposes the frozen agent command surface to a language model over the Model Context Protocol, so a model host can drive Reticle with the same operations the agent harness uses, without any custom glue.

Tools from the frozen surface

Every AgentCommand variant becomes one MCP tool with a JSON input schema and a model-facing description, generated from the frozen types rather than hand-maintained, so the tool set cannot drift from what the engine actually accepts. That is 30 command tools (create a cell, add a rectangle, run DRC, check intent, export, render, the editor operations below, and so on), plus six generator tools (one per built-in layout generator, below), plus three read-only context tools the model uses to observe state before it acts. 39 tools in total, matching what a live tools/list call reports:

  • get_technology_rules the active technology’s layers and DRC rules;
  • get_document_summary the current cells, shape counts, and top cells;
  • get_render_region a PNG of a region, so the model can look at what it has drawn.

Editor operations

Five of the command tools lift the in-app editor’s productivity operations to the agent surface, so a model can restructure geometry the way a person would with the Operations panel (ADR 0031):

  • boolean_combine union, intersection, difference, or xor over two or more shapes, writing the result to a target layer and deleting the inputs;
  • align_shapes line a set of shapes up (left, right, top, bottom, or centered) within their combined bounding box;
  • distribute_shapes respace three or more shapes so adjacent gaps are equal;
  • offset_shapes grow or shrink shapes by a database-unit offset;
  • build_via_stack place a square cut plus a lower and upper enclosure sized from the technology’s enclosure rules.

They share the geometry engine with the editor (reticle-geometry’s robust integer booleans and offsetting), so an agent-built union and a hand-built one are bit-for-bit identical.

Generator tools

The built-in reticle-gen layout generators are each advertised as their own tool, named for the generator id, so a model calls via_farm or guard_ring directly with typed, ranged parameters instead of assembling an opaque params blob for a generic run_generator tool (ADR 0049). Every generator tool takes the target cell plus the generator’s own parameters, and maps to a RunGenerator command when applied:

  • guard_ring a closed conductor ring around a rectangular region, optionally lined with a row of substrate-tap contacts;
  • via_farm an array of cuts between two conductor layers, covered by enclosing lower and upper plates;
  • pad_ring a die-size-aware ring of I/O pad structures around the die edge, with corner keep-outs and reinforced power pads;
  • seal_ring a continuous stacked-metal-plus-cut barrier around the die edge;
  • fill a regular grid of fill tiles over a region, approaching a target coverage density;
  • test_structure a probe-able structure (van der Pauw cross, contact chain, comb, or serpentine), selected by a parameter.

Each is DRC-clean by construction against the SKY130 subset the generators target. Every generator tool’s schema (ranges, defaults, enums) is converted directly from the generator’s own parameter schema, so it cannot drift from what the generator actually accepts.

Transport

The server speaks newline-delimited JSON-RPC 2.0 on stdin and stdout, matching the MCP stdio transport, and is hand-rolled over serde_json rather than pulling in an MCP framework, keeping the dependency surface small and the behavior explicit (ADR 0005). A per-session command budget bounds how many mutating tools a session may apply; once exhausted, further command tools are rejected, so a host cannot drive an unbounded number of edits.

Running it

The reticle-mcp binary is a stdio server: a model host launches it and speaks JSON-RPC over the pipe. It is registered alongside the project’s reticle-dev development server in .mcp.json. An integration test drives all 39 tools over a real stdio subprocess and asserts each one, so the wire contract is covered end to end.

See ADR 0005 and the agent chapter for the command surface these tools mirror.

Python bindings

The reticle-py crate exposes the document and layout-generator APIs to Python through PyO3. It is a native extension built with maturin as a stable-ABI (abi3) wheel, so one wheel loads on every CPython 3.9 and later. The package is a mixed Rust and Python project: the compiled module is imported as reticle_py._core, and a small pure Python layer re-exports it and adds a Jupyter inline viewer.

reticle-py is native only and is deliberately not part of the default Cargo workspace, so the just ci gate never needs a Python toolchain. The reasoning is recorded in ADR 0087.

API surface

import reticle_py as rp

rp.version()                       # crate version string
rp.generators()                    # list of {id, title, description} dicts

doc = rp.Document.open("design.gds")   # GDSII, OASIS, CIF, or DXF; content-sniffed, extension is a fallback
doc.cell_names()                   # sorted list of cell names
doc.top_cells()                    # the document's top (root) cells
doc.cell_count()                   # number of cells
doc.summary()                      # dict of counts, top cells, and layers in use
doc.shapes("TOP")                  # list of {layer, datatype, kind, bbox} for a cell

# Place a built-in generator by id with JSON parameters. Empty "{}" uses the
# generator's own defaults. Returns {shapes_added, bbox}.
doc.place_generator("TOP", "guard_ring", "{}")

png = doc.render_png("TOP", 800, 600)   # PNG bytes, or None if no GPU is available
doc.save("out.gds")                     # format inferred, or pass "gds" / "oasis"

Everything that crosses the boundary is treated as untrusted and validated before it reaches native code: render dimensions must be between 1 and 16384 on each axis, cell names are checked against the document (a miss raises KeyError), and generator parameters are parsed and range-checked by the registry, which reports the offending field as a ValueError.

The Jupyter widget

The v1 viewer renders a cell to a PNG with the same offscreen GPU path the CLI uses and displays it inline:

rp.show(doc, "TOP", width=640, height=480)     # inline image, or a note if no GPU

rp.LayoutView(doc, "TOP") is a lazy alternative whose _repr_png_ renders when a notebook displays it, falling back to a text representation when no GPU adapter is present. A richer interactive widget (browser pan and zoom) is a follow-up; an image is enough to see a layout today.

The committed example notebook, crates/reticle-py/examples/reticle_intro.ipynb, opens the bundled basic.gds, lists the generators, places a guard ring, renders the cell, and saves the result.

Building the wheel

The build runs maturin under uv:

uv tool install maturin
cd crates/reticle-py
maturin build --release        # writes a wheel under the shared target's wheels/ dir

For local development against the current interpreter, maturin develop builds and installs the extension into the active virtual environment in one step.

Agent benchmark suite

The benchmark suite measures whether a model, driven through the Reticle agent API, can turn a natural-language layout instruction into geometry that passes an objective check. It is a fixed, versioned set of tasks with machine-graded checkers, so a run produces a comparable, reproducible score rather than a vibe.

What a task is

Each task is a TOML file under benchmarks/layout-tasks/ naming a prompt, the technology, and a checker with its parameters. The checker is the oracle: it accepts a correct document and rejects a broken one. Every checker is two-way tested, so a task cannot pass by luck or by a checker that always returns true; the test proves the checker accepts the intended solution and rejects a deliberately perturbed one.

The suite (benchmarks/layout-tasks/manifest.toml) is version 0.7.0 with 95 tasks across five tiers at 345c2cbe. CORRECTED 2026-07-31: this line said “version 0.4.0 … 75 tasks”, and the same chapter went on to quote 83 twice below, so it contradicted itself before it contradicted the tree. Historical percentages further down are quoted against their own suite version and labelled as such; do not mix denominators. Re-derive:

Select-String -Path benchmarks/layout-tasks/manifest.toml -Pattern '^version'
$m = Get-Content benchmarks/layout-tasks/manifest.toml -Raw
([regex]::Matches([regex]::Match($m,'tasks\s*=\s*\[(.*?)\]','Singleline').Groups[1].Value,'"')).Count / 2

The v0.4.0 tier breakdown that follows is retained as the record of that version:

TierFocusExamples
1Primitive placement and legalityplace a met1 rectangle, clear the min width and min area rules
2Structured geometrycontact stacks, via chains, comb structures
3Larger structured geometry, connectivity intent, and Wave-3 tool opsguard rings, multi-net intent, boolean unions/intersections/differences, arrays with pitch, via stacks
4Compound cells and iterative refinementcells composed of several checked features, tasks with a scripted follow-up constraint
5Real SKY130 PDKnamed periphery rules (m1.1, m1.4, m2.4, li.5, ct.1, licon.1, via.1a) and the measured geometry of the sky130_fd_sc_hd tap and fill cells

Wave-3 task families (v0.4.0)

Version 0.4.0 adds 12 tasks that exercise the Wave-3 command surface (boolean_combine, align_shapes, distribute_shapes, offset_shapes, build_via_stack):

  • Boolean-op constructions (3, tier 3): union, intersection, and difference of the same two overlapping met1 squares. The boolean_result checker pins which op ran by the result area written to met2 (150000 vs 30000 vs 60000 DBU²) and requires the met1 inputs to be consumed, so “drew the wrong op” or “left the inputs behind” both fail.
  • Array-with-pitch (3, tier 3): a row, a column, and a grid placed at a stated pitch. The array_pitch checker verifies both the instance count and the actual column/row step, so an array at the wrong pitch is rejected even with the right count.
  • Via-stack (3, tier 3): a build_via_stack cut bridging met1/met2, li1/met1, and poly/li1. The contact_stack checker verifies the stack joins both conductors on one net and each encloses the cut by a minimum margin.
  • Iterative-refinement (3, tier 4): an initial prompt plus a scripted refinement follow-up (“make it larger”, “add a second shape”). The refinement-aware runner folds the follow-up into the model’s feedback between iterations through the reticle-agent refinement seam (RefinementSource / run_agent_task_refined), so the model reacts on the next proposal without the session being restarted; the checker enforces the tightened, post-refinement bar.

The refinement field is additive on BenchTask (#[serde(default)]), so task TOML written before it existed still parses unchanged.

Phase-3 depth task families (v0.7.0)

Version 0.7.0 adds 7 tasks across three checker families, exercising Phase 2/3 capability that no earlier wave reached:

  • Net-trace queries (3, tier 3): net_trace_connected, net_trace_extent, and net_trace_isolated are built directly on the F3 trace-query API (reticle_extract::net_at_point/net_extent) rather than re-deriving connectivity the way the intent checker does, so they exercise the same click-a-point, read-the-net sequence a trace UI runs: two probe points must resolve to the same net (connected), the net under one probe must span a minimum bounding box (extent), or two probes must resolve to different nets (isolated).
  • PCell params (2, tier 3): pcell_box exercises the Phase 2 user-PCell API (reticle_gen::PCellDef::effective_params/effective_param_hash/validate_params) for a fixed bench.box_pad PCell definition, one task leaving a parameter to the PCell’s schema default and one overriding it explicitly. The checker resolves parameters through the real PCellDef methods; the reference geometry itself is a Rust-native port of the PCell’s script (reticle-bench does not depend on reticle-script, the sandboxed producer, so it cannot run the script directly). See docs/decisions/0113-phase3-benchmark-tasks.md.
  • Multi-step edits (2, tier 4): t4_multistep_grow_enclosure and t4_multistep_reposition_via reuse the existing contact_stack/via_chain checkers but require a genuinely multi-iteration scripted solution that edits previously placed shapes in place (offset_shapes, transform_shapes) rather than the delete-and-redraw pattern every earlier correction script used.

SPICE/netlist export (the fourth Phase-3 depth area named in the campaign brief) was investigated and ledgered rather than built into a task. CORRECTED 2026-07-31: the writer shipped after that note was written. Three of them did: export_spice (crates/reticle-cli/src/export_spice.rs), write_spice and format_spice (crates/reticle-extract/src/spice.rs), and the xschem bridge’s own writer (crates/reticle-app/src/xschem.rs). The command id is live, not reserved (file.export_spice, in commands/feature_cmds.rs rather than reserved_cmds.rs) and there is a reticle export-spice subcommand. SPICE export is a whole chapter about it, so this paragraph contradicted a sibling chapter. What is still true is the reason there is no benchmark task: a two-way-tested checker over SPICE output is unwritten. The writer is not the blocker. Check: git grep -n "pub fn write_spice\|pub fn format_spice" -- crates/reticle-extract/src.

The propose-verify-correct loop

A run drives each task through the same loop the reticle-agent harness uses:

flowchart LR
    P[Model proposes edits] --> A[Apply to the session]
    A --> V[Verify: DRC subset plus intent]
    V -->|clean| D[Pass, record result]
    V -->|violations| F[Feed violations back]
    F --> P

The verifier is the SKY130 DRC subset plus, where a task carries an intent spec, the connectivity checker. Violations are fed back as correcting context for the next proposal, up to an iteration bound. The result of each task is recorded as a JSON record (task_id, model, success, iterations, first and final violation counts, wall time) and rolled up into a Markdown summary.

Running it

just bench-agent                     # the whole suite
just bench-agent --tier 5            # one tier
just bench-agent --task t1_place_met1_rect

The model is chosen by the environment. The deterministic MockModel is the offline default and needs no key or network; the real AnthropicModel (in reticle-agent) runs the same tasks against a live model when ANTHROPIC_API_KEY is set. Every result record carries the model field so mock and live runs are never conflated.

Current results: two local models

The runs below drove two local models through the whole 83-task v0.5.0 suite over Ollama on the host, each task graded by its two-way-tested checker. The raw per-task ResultRecord files and their command transcripts are committed under benchmarks/results/v0.5.0/; the rows here are computed from those records.

ModelQuantizationTier 1Tier 2Tier 3Tier 4Tier 5Overall
gpt-oss:16k (20B)MXFP48/99/1120/425/117/1049/83 (59%)
qwen2.5-coder:16k (14B)Q4_K_M7/98/116/423/115/1029/83 (35%)

These are small quantized local models, so the numbers are a realistic floor, not a ceiling. The gap has a concrete cause: gpt-oss:16k returns native tool calls, while qwen2.5-coder:16k often ignores the forced tool choice and embeds the call in message text, which a text fallback recovers less reliably. Both paths are handled and regression-tested. Local model outputs are not deterministic between runs; the transcript-replay determinism (replaying a recorded transcript to a fixed document_hash) is unaffected and is a committed test.

The deterministic MockModel (no key, no network) solves only the three sample tasks (t1_place_met1_rect, t1_drc_clean_met1, t1_intent_connect) that prove the harness end to end; just bench-agent runs it and reports 3/83, a machinery baseline that shows all 83 tasks and their checkers execute, not a model score.

An agent-system row is not a bare-model row

The two local rows are bare models: Reticle’s own harness owns the propose-verify-correct loop and asks the model for commands one iteration at a time, so the row measures the model against a fixed reasoning scaffold. Claude Code is an agent system: it brings its own loop, planning, and tool-calling scaffold. Reticle drives it through a separate claude-code backend that, per task, launches claude -p against a generated MCP config pointing at reticle-mcp (with the server-side transcript capture of ADR 0051 on), lets Claude Code drive the tools itself, then replays the captured transcript and runs the same two-way-tested checker. Because the loop and scaffold are the agent system’s own, a Claude Code row is labeled “Claude Code (<model>)” and is not comparable head to head with a bare-model row: it measures a different thing (a whole agent system, not a model against our loop). That distinction is the point of the row, not a caveat to hide.

Honesty of the backend: a run that completes but fails the checker is a real success = false record, exactly like the local rows; a run that cannot happen at all (the claude CLI missing, or the session not authenticated, or out of quota) is a distinct NotRunRecord artifact that can never be counted as a pass or a fail.

Status in this environment: a real but partial run. The claude CLI (v2.1.202) is authenticated here, and a claude-sonnet-5 agent-system run over this suite drove the reticle-mcp tools for real: of the 25 tasks that ran (tiers 1 through 3), 24 passed, a 96% rate well above either bare local model. The run was not carried to all 83 tasks: the operator’s Claude subscription rate-limited the back-to-back agentic sessions, recording the rest as honest not-runs (a 401), and it was stopped before tiers 4 and 5. So the Claude Code row is partial, its denominator (the 25 tasks that ran) differs from the full-suite local rows, and it is not published as an 83-task score. The records for the 25 tasks are committed under benchmarks/results/v0.5.0/claude-code/.

Getting the backend to actually drive the tools took four fixes, all real: hand the prompt to claude -p over stdin (a large multi-line prompt is mangled by the Windows cmd /c npm shim when passed as an argument); drop --allowed-tools (an allow-list blocks the deferred-tool path a heavily configured session uses to reach the MCP tools, so it applied nothing); absolutize RETICLE_MCP_TRANSCRIPT (Claude Code launches the MCP server with its own working directory, so a relative path lands the transcript where the harness cannot replay it); and point RETICLE_MCP_BIN at a current reticle-mcp (the transcript capture is ADR 0051, newer than a stale prebuilt binary). To complete the row when the rate window is clear: just bench-agent-claude-code (on Windows set RETICLE_CLAUDE_BIN to the resolved claude.cmd and RETICLE_MCP_BIN to a current reticle-mcp); it consumes the operator’s subscription quota, one agentic session per task.

Growing the suite

Failure mining (reticle-bench’s mining module) turns real run failures into candidate tasks with provenance and two-way vectors; just bench-promote <id> admits a candidate into the live suite only if its checker passes those vectors, and bumps the manifest version. So the suite grows from observed failures without ever admitting a checker that cannot both accept and reject.

The miner clusters failed and struggling runs by a failure signature, so a recurring failure mode becomes one candidate rather than many near-duplicates. A signature has four dimensions:

  • the persistent DRC rule ids no correction attempt ever cleared;
  • a geometric-pattern class (rectangles, a layer stack, a polygon, a path, a placement, or no geometry at all);
  • the connectivity-intent kind the run ended with (an open, a short, both, or none);
  • the tool surface: which of the Wave 3 editing commands the run reached for.

Tool-surface failure mining

The Wave 3 tool surface is the higher-level editing commands added to the agent API after the first tasks were authored: boolean_combine, align_shapes, distribute_shapes, offset_shapes, and build_via_stack. A model can fail a task through one of these tools (a botched boolean merge, a via stack whose enclosure violates the rule) in a way that looks identical, by DRC rule and geometric pattern, to a failure drawn shape by shape. Clustering by tool surface splits those apart, so the miner surfaces a tool-specific cluster (and drafts a candidate whose id and prompt name the tool) instead of hiding the tool failure inside a generic geometry cluster. The tool surface is recorded whether or not the command succeeded: a command the model tried is evidence of intent to use that tool. Every drafted candidate carries its tool surface in its provenance, alongside the backend, model, and quantization of each source run, so a failure mined from a local (Ollama) run is never conflated with a mock or frontier one.

The tool surface is read from a run’s command transcript. The committed local-model sets under benchmarks/results/ include each task’s transcript alongside its result record, so mining them recovers the full DRC, geometric, intent, and tool-surface signature, not just the backend provenance.

Benchmark methodology

This chapter is the credibility account of the agent benchmark: how a run is scored, what the numbers mean, what determinism does and does not cover, and how a local model is driven inside a small context window. It complements the agent benchmark suite chapter, which documents the task format and the failure-mining machinery; this one is about how to read the results honestly.

The one-sentence claim: the benchmark measures whether a model, driven through the Reticle agent API, can turn a natural-language layout instruction into geometry that passes an objective, two-way-tested checker, and every score is labeled with the backend, model, and quantization that produced it so that a machinery baseline, a local model, and a frontier model are never conflated.

Methodology

Each task is a TOML file under benchmarks/layout-tasks/ naming a prompt, the technology, and a checker with its parameters. A run drives each task through the same propose-verify-correct loop the reticle-agent harness uses: the model proposes a batch of commands, the harness applies them to a private session, and a verifier (the SKY130 DRC subset plus, where the task carries an intent spec, the connectivity checker) either accepts the result or feeds the violations back as correcting context for the next proposal, up to an iteration bound.

Two properties make the score meaningful:

  • The checker is the oracle, and it is two-way tested. Every checker has a test that proves it accepts the intended solution and rejects a deliberately perturbed one. A task therefore cannot pass by luck or by a checker that always returns true. Success is defined by the checker, never by the model’s claim that it complied.
  • A failure is recorded as a failure. Each task’s outcome is a JSON record (task_id, model, success, iterations, first and final violation counts, wall time, and the backend and quantization labels) rolled up into a Markdown summary. Nothing is retro-edited to a pass.

The five tiers

The suite is graded into five tiers of increasing difficulty. The suite is version 0.7.0 with 95 tasks at 345c2cbe; the per-tier counts in the table below are for suite version 0.4.0 and have not been re-derived per tier (see suite versioning). Read the current total from the manifest, not from here: Select-String -Path benchmarks/layout-tasks/manifest.toml -Pattern '^version'.

TierTasks (v0.4.0)FocusExamples
19Primitive placement and legalityplace a met1 rectangle; clear the min-width and min-area rules
211Structured geometrycontact stacks, via chains, comb structures
334Larger structured geometry, connectivity intent, and Wave-3 tool opsguard rings, multi-net intent, boolean unions/intersections/differences, arrays at a stated pitch, via stacks
411Compound cells and iterative refinementcells composed of several checked features; tasks with a scripted follow-up constraint
510Real SKY130 PDKnamed periphery rules (m1.1, m1.4, m2.4, li.5, ct.1, licon.1, via.1a) and the measured geometry of the sky130_fd_sc_hd tap and fill cells

Tier 5 is the one grounded in the real PDK: its rules and its cell geometry come from the cited SKY130 data described in SKY130 grounding. Passing tier 5 tasks is still not a tape-out statement; it means the geometry clears the cited subset of everyday rules.

Suite versioning

The suite version is stamped in benchmarks/layout-tasks/manifest.toml and travels in every result record’s suite_version field, so a score is always tied to the exact task set it was measured against. The version history:

  • v0.2.0 added the tier 1 through 4 parameterized geometry tasks (50 tasks).
  • v0.3.0 added the 10 tier-5 real-SKY130 tasks (63 tasks total).
  • v0.4.0 adds 12 Wave-3 tasks that exercise the higher-level editing command surface (boolean_combine, align_shapes, distribute_shapes, offset_shapes, build_via_stack): 3 boolean-op constructions, 3 array-at-pitch placements, 3 via-stack builds, and 3 iterative-refinement tasks. Those 12 landed in tier 3 (+9) and tier 4 (+3), bringing the suite to 75 tasks.
  • v0.5.0 brought it to 83 tasks, v0.6.0 to 88, and v0.7.0 (current) to 95. The manifest’s own header documents what v0.6.0 and v0.7.0 added. CORRECTED 2026-07-31: this list stopped at v0.4.0 and called it “(current)”, which is how three chapters ended up publishing three different suite sizes, none of them the real one. Where a score below is quoted against an older suite, its denominator is labelled with that suite’s version and must not be mixed with 95.

The suite grows only through failure mining: just bench-promote <id> admits a candidate task into the live suite only if its checker passes its two-way vectors, and bumps the manifest version when it does. So the suite can only ever gain a checker that both accepts and rejects.

Baselines: what each number is

There are two distinct kinds of run, and the chapter is careful never to present one as the other.

The mock machinery baseline

The deterministic MockModel needs no key and no network. It is scripted to solve only the three sample tasks (t1_place_met1_rect, t1_drc_clean_met1, t1_intent_connect) that exist to prove the harness end to end; it has no scripted solution for the other 72 authored tasks, so it fails them by construction. Its purpose is to exercise the whole pipeline for every task (each task loads, runs the loop, and is graded by its two-way-tested checker), not to measure a model. On the current 75-task suite that run is 3/75, and it is labeled a machinery baseline, not a model score. Publishing that figure as if it measured a language model would be dishonest.

Local model runs (Ollama)

A real score comes from driving an actual model through the loop. The OllamaModel backend runs the suite against a local, OpenAI-compatible Ollama endpoint, so the numbers come from a model reasoning about geometry rather than from a script. Each record carries the backend (ollama), the model id, and the quantization, so two local models, or a local versus a frontier run, are always distinguishable.

Current local-model results

The committed local-model result sets under benchmarks/results/v0.5.0/ were measured on this host against the local Ollama backend over the full 83-task v0.5.0 suite (the 75 v0.4.0 tasks plus the 8 generator tasks). Each record carries the backend (ollama), the model id, and the quantization, so two local models are always distinguishable.

Two-model comparison, 83-task v0.5.0 suite (local Ollama, honest, labeled by model and quantization):

ModelQuantizationTier 1Tier 2Tier 3Tier 4Tier 5OverallMean iterations
gpt-oss:16kMXFP48/9 (89%)9/11 (82%)20/42 (48%)5/11 (45%)7/10 (70%)49/83 (59%)1.58
qwen2.5-coder:16kQ4_K_M7/9 (78%)8/11 (73%)6/42 (14%)3/11 (27%)5/10 (50%)29/83 (35%)2.06

The gap has a concrete, non-mysterious cause: gpt-oss:16k returns native tool_calls, while qwen2.5-coder:16k ignores the forced tool_choice and embeds the call in the message text, which the backend recovers through a text-array fallback that is less reliable than a native tool call. The backend handles and regression-tests both paths; the lower qwen score reflects that its answers arrive by the weaker channel. These are local models at 16k quantized weights; the numbers are a realistic floor for what a small local model does on this task, not an upper bound on what a model can do.

Determinism scope

The determinism guarantee is precise and worth stating exactly, because it is easy to overclaim.

  • Transcript replay is deterministic. Every run writes a transcript of the commands it applied, and the model’s document carries a document_hash. Replaying that transcript re-applies the same commands and reproduces the same hash bit-for-bit, regardless of which backend originally produced it. A committed test replays every benchmark transcript and asserts the suite is deterministic across runs on that basis.
  • Local model outputs are not deterministic. The same prompt to gpt-oss:16k can yield different command batches across runs; nothing pins a seed. So a live local run is non-reproducible at the proposal step. This does not weaken the replay guarantee: once a run’s transcript is recorded, that transcript still replays exactly.

The two statements are compatible because they are about different things: replay determinism is a property of recorded transcripts, not of live model generation. When you read a backend = "ollama" row, do not expect it to reproduce across fresh runs; do expect its recorded transcript to replay to the same hash.

The mock baseline, by contrast, is deterministic end to end (the MockModel is scripted), which is why it is the right tool for proving the machinery rather than a model.

The 16k context-window and summarization policy

A local model’s binding constraint is a small context window. The runs above use a 16k-token window shared between the tool schema, the injected document snapshot, and a transcript that grows with every correction iteration. Left unmanaged, a long correction run would overflow that window.

The OllamaModel backend manages it with a ConversationBuffer that accumulates the running messages and, when the estimated token count nears the window, compacts the older iterations into a single short summary message while keeping the most recent iteration verbatim. The default compaction threshold is 12,000 tokens, chosen to leave headroom under the 16k window once the tool schema and the reply are accounted for. The policy is to grow the count of summarizations, not the count of iterations dropped, so a long correction run still fits: the latest turn is always present in full, and the earlier history is present in compressed form rather than truncated away.

This is a deliberate, documented policy rather than an implicit truncation, so the reason a local model sees a compacted history (and can still act on the latest checker feedback) is legible when reading the results. For agents that instead want to shrink the context at the source, a scoped run can hand the model a region-local context pack rather than the whole document, which is a different lever on the same constraint.

Multimodal verification

The benchmark methodology chapter makes one thing central: the checker is the oracle, and it is two-way tested. That oracle reads a single modality, the geometry of the document. This subsection describes a second oracle of a different modality: render the layout to an image and ask a local vision model whether the render shows what the task intended. It sits beside the authoritative checker and never replaces it.

Why a second modality

Two oracles that reach a verdict by unrelated means, geometry versus pixels, agreeing on the same faithful-versus-corrupt distinction is stronger evidence than either alone, and a disagreement is a signal worth surfacing rather than a failure. Reticle already renders a document region to a PNG headlessly (the render_png / RenderPng path the run writer uses for its per-run artifact). The vision oracle reuses that path unchanged, base64-encodes the PNG, and posts it with a yes/no question to a local vision model over Ollama’s native {base}/api/generate endpoint.

The authoritative pass/fail is still the deterministic checker’s. The vision verdict and the agreement rate it produces are provenance reported alongside the score, not the verdict of record. This is the same second-reading role the Tiny Tapeout precheck and the LEF/DEF import oracle play: an independent corroboration, never an override.

Honest not-run

A vision model is heavy and may not be installed, or may be VRAM-bound on a given host, so it is never a hard dependency of the gate. The oracle probes availability the same cheap way the container oracles do: the ollama CLI must be on the path and the model must already be pulled. When the model is absent, when the host has no GPU adapter to render, or when any transport or parse step fails, the oracle returns a printable skip reason, never an error and never a panic. The adapter-gated test runs when a model is present and skips honestly otherwise, printing why. Numbers are only ever reported when the oracle actually ran; nothing here fabricates a verdict.

Model and VRAM budget

The default model is llava:7b (about 4.7 GB resident), pulled with ollama pull llava:7b. It fits comfortably in a 16 GB card, well under the ~8 GB resident budget, leaving room for the wgpu render to share the GPU. It is overridable with RETICLE_VISION_MODEL (for example qwen2.5vl:7b or moondream), and the endpoint with RETICLE_VISION_BASE_URL. The request pins temperature: 0 so a given render yields a stable verdict rather than drifting at Ollama’s default sampling temperature.

What the second oracle can and cannot judge

The prompt asks a binary question, does this render contain drawn geometry (filled colored shapes) or is it an empty/blank layout?, with the task intent appended as a trailing hint. This phrasing is deliberate. A 7B llava reliably answers the present-versus-blank question, but on this host it flips to a spurious “no” when the same question is framed as “does it match the intent”, and it hallucinates geometry into a genuinely blank image when it is forced to justify its answer. So the oracle is scoped as a coarse “non-empty layout consistent with the intent” second opinion, not a fine intent-conformance judge. That is the honest ceiling of a small local vision model, and it is enough to corroborate a faithful layout against an empty or corrupt one.

Concretely, the oracle is compared against the authoritative checker over a fixture pair: a faithful layout (a cell with separated metal rectangles, which the RectPresent checker passes) and a corrupt one (the same cell with no geometry, which the checker fails). The AgreementTally records, for each fixture, whether the vision verdict matched the checker’s pass/fail, and reports the fraction. The safety property the pair demonstrates, that a corrupt layout is caught by at least one oracle, holds regardless of the vision model’s answer because the authoritative checker always catches it; the vision oracle’s contribution is the corroboration and the disagreement signal on top of that.

On the development host the live oracle ran (llava:7b) and agreed with the authoritative checker on both fixtures of the pair. That is a demonstration of the mechanism over a small, hand-built fixture pair, not a suite-wide benchmark headline. See ADR 0090 for the model choice, the VRAM budget, and the honest-not-run policy.

Leaderboard

This page is generated deterministically from the committed benchmark result records under benchmarks/results/. It does not run the suite; it aggregates the *.result.json records the runs already wrote. Regenerate it with cargo run -p reticle-bench -- leaderboard. The record format is the API: to add a row, run the suite and open a pull request with your records (see Submitting a run).

It aggregates 284 committed result record(s) into 4 row(s), one per backend / model / quantization triple and suite_version, so a model’s runs against different suite versions stay separate rows and no row blends two suite denominators. The numbers are exactly what the committed records say and grow as more runs are committed.

How to read a row

  • Kind labels a row as a bare model (a model driven through Reticle’s own propose-verify-correct loop), an agent system (a system that brings its own loop and scaffold, such as Claude Code), or a multi-agent system. A bare-model row and an agent-system row measure different things and are not comparable head to head (see the methodology).
  • Quantization is carried where the backend reports one (for example Q4_K_M on a local GGUF model), so a small quantized local model is never conflated with a full-precision or frontier one.
  • PARTIAL marks a row that has no result in one or more tiers, so it did not span the full difficulty range and its denominator is not comparable to a full-tier row.
  • Each Tier cell is passed/total, and Overall is passed/total (rate) over every committed record for that row.

Rankings

KindModelBackendQuantizationSuiteTier 1Tier 2Tier 3Tier 4Tier 5Overall
agent systemclaude-sonnet-5claude-code-0.7.05/58/826/284/75/548/53 (91%)
agent systemclaude-sonnet-5claude-code-adhoc8/910/1138/417/119/972/81 (89%)
bare modelgpt-oss:16kollamaMXFP40.4.09/911/1119/345/118/1052/75 (69%)
bare modelqwen2.5-coder:16kollamaQ4_K_M0.4.06/98/116/343/116/1029/75 (39%)

The labeling rules above are the honest account preserved from the benchmark methodology: a machinery baseline, a local model, and an agent system are always distinguishable, and a partial run is never published as a full-suite score.

Submitting a run

The leaderboard is built from committed result records, and the record format is the whole API. There is no server, no account, and no upload endpoint: you run the suite, you get JSON records, you open a pull request that adds them, and the next time the page is generated your row appears. This chapter is the exact recipe.

1. Run the suite

The suite lives under benchmarks/layout-tasks/ (a manifest.toml and one TOML per task). Pick the backend that matches what you are measuring:

just bench-agent                     # the deterministic mock (machinery baseline, no key)
just bench-agent-ollama              # a local model over an Ollama endpoint
just bench-agent-claude-code         # Claude Code as an agent system (consumes your quota)

Each recipe takes the same scoping flags, so you can run a tier or a single task while you iterate:

just bench-agent-ollama --tier 1
just bench-agent-ollama --task t1_place_met1_rect

The model and its provenance come from the environment and the backend. For a local model over Ollama, set the model name and (where you know it) the quantization so your row is labeled honestly:

$env:RETICLE_MODEL_NAME = 'gpt-oss:16k'
just bench-agent-ollama --quantization MXFP4

The mock is deterministic end to end and solves only the three sample tasks; it is a machinery baseline, not a model score, and the leaderboard labels it as such. A real score comes from a real model.

2. Find your records

A suite run writes an aggregate result file (a JSON array of records) under scratch/agent-suite-results/ by default (suite.json for a whole-suite run, or tier-<n>.json / task-<id>.json for a scoped one). The Claude Code backend also writes one artifact per task under scratch/agent-runs/, including a <task_id>.result.json record and, separately, a <task_id>.notrun.json for any task that could not run at all.

A not-run is never a result. A task that the backend could not even start (the CLI missing, the session unauthenticated, out of quota) is recorded as a distinct *.notrun.json artifact and is never counted as a pass or a fail. Only *.result.json records reach the leaderboard.

3. The record schema

Every record is a ResultRecord with these fields (the shape is frozen; backend and quantization default so older JSON still parses):

FieldTypeMeaning
task_idstringThe task that ran. Must begin with a t<N>_ tier prefix (for example t1_place_met1_rect); the leaderboard reads the tier from it.
modelstringThe model identifier (or mock).
suite_versionstringThe suite version the task came from.
successboolWhether the two-way-tested checker passed.
iterationsnumberPropose-verify-correct iterations used.
first_proposal_violationsnumberDRC violations in the first proposal.
final_violationsnumberDRC violations in the final document.
wall_msnumberWall-clock time for the task, in milliseconds.
backendstringThe client kind: mock, ollama, anthropic, claude-code, …
quantizationstring or nullThe model’s quantization when the backend reports one (for example Q4_K_M), else null.

A file is a JSON array of these records, exactly as the runner writes it.

4. Validate before you submit

Reject a malformed record before it ever reaches a reviewer. The validator reuses the same ResultRecord shape the runner writes, so a record that validates is a record the leaderboard can render:

cargo run -p reticle-bench -- validate-records scratch/agent-suite-results/suite.json

It accepts a single file or a whole directory. A valid set prints a count and exits 0; a malformed record is rejected with a message naming the file, the record index, and the reason (an empty task_id, a task_id with no tier prefix, an empty model or suite_version, or a file that is not a JSON array of records), and exits non-zero.

5. Open the pull request

Commit your records under benchmarks/results/, in a directory that names your run, with a file name ending in .result.json (the extension the leaderboard aggregates; a *.notrun.json is deliberately not aggregated):

benchmarks/results/<your-label>/<model-or-run>.result.json

Then regenerate the page so your row is included, and commit the regenerated page too:

cargo run -p reticle-bench -- leaderboard

Open the pull request with both the records and the regenerated docs/src/leaderboard.md. Your row is aggregated per backend / model / quantization triple and suite_version (so a run against a new suite version is always its own row, never blended into an older one), labeled by kind (a bare model, an agent system, or a multi-agent system), and marked PARTIAL if it does not span all five tiers. A bare-model row and an agent-system row are not comparable head to head; both are welcome, and the labeling keeps them honest.

Circuit simulation

Scaffolded in the v8.2 campaign Phase 3. The bounded circuit simulator (crate reticle-sim) turns an extracted netlist into F4 waveform records (WaveformSet), wall-clock and memory bounded, with deterministic ordering.

Route: a pure-Rust MNA solver

The route is decided in ADR 0109: the simulator is a pure-Rust modified-nodal-analysis (MNA) transient/DC solver, not ngspice and not a SPICE engine compiled to WebAssembly. It is licensed MIT OR Apache-2.0 (the workspace license) and is described as a pure-Rust MNA solver everywhere, never as ngspice.

Two routes were rejected on measured evidence. Vendoring ngspice to WASM ships a multi-megabyte module (eecircuit-engine alone is ~20 MB per build variant) that overruns the sim bundle headroom by roughly two orders of magnitude, and a stock ngspice build statically links LGPL code (numparam) into the wasm, which the project’s no-GPL-linking rule forbids. Building our own SPICE engine through a pinned emscripten toolchain inherits the same bundle overrun and adds a second, non-Rust wasm toolchain. The pure-Rust route instead compiles into the existing wasm module for a small gz cost, keeps GPL/LGPL code out of the link entirely, and is deterministic across native and browser because WebAssembly arithmetic is strict IEEE-754 with no implicit fused-multiply-add.

The solver itself is built by the sim-engine lane, which emits the F4 WaveformSet shape directly. Its scope is bounded small circuits: linear resistors, capacitors, inductors, and sources for DC operating point and transient analysis, with any nonlinear device models added later and labelled generic wherever PDK model cards are unavailable. Nonlinear models route their exp()/log() calls through the pinned libm crate so native and wasm stay bit identical.

SPICE netlist export

Reticle recognises transistors from layout geometry (device recognition: DeviceKind, terminal nets, and channel W/L in DBU) and writes that as a SPICE subcircuit, for exchange with external simulators and schematic-capture tools. The writer lives in reticle-extract (spice.rs), a sibling of device recognition, not a change to it: it consumes an already-built DeviceNetlist and adds no new extraction logic.

The exchange subset

The interchange subset is fixed by the committed contract fixture crates/reticle-extract/tests/fixtures/contracts/spice_exchange_inverter.spice (with its structural companion .json):

  • * full-line comments and blank lines, skipped on read, optional on write.
  • One .subckt NAME <ports...> / .ends wrapper.
  • One X device-instance card per device: Xn <drain> <gate> <source> <bulk> <model> w=<W> l=<L>, n the device’s index in DeviceNetlist::devices.
  • Decimal-micron w=/l= params, and .end.

The netlist lane’s writer (to_spice_subckt, format_spice, write_spice) emits this subset; the xschem lane reads it back. Both lanes build against the committed fixture before the other lane’s half exists (see ADR 0108), so neither blocks on the other.

What is written, and from where

  • Ports are the nets any device terminal references, kept in the extracted netlist’s own stable (lowest-member-index) order – the writer invents no ordering of its own.
  • W/L convert from the Device DBU fields to decimal microns by exact integer long division, never a float: 650 DBU at 1000 DBU/micron is the literal string "0.65", with no 0.6500000001-style formatting drift.
  • The model name comes from SpiceTech, a small table the caller supplies, keyed only on DeviceKind (NMOS/PMOS). Device recognition reads no threshold-voltage flavour, body-bias variant, or other model-selection detail from geometry, so the table cannot honestly derive one either; see ADR 0108 for why this is caller-supplied rather than baked into a single “sky130” default.

What is honestly absent

  • Area/perimeter parameters (ad/pd/as/ps): Device carries no diffusion-area data, so these are never invented.
  • A guessed node name: a terminal extract_devices could not bind to a net (Option::None) is written as the documented placeholder node NC.
  • A general SPICE importer: parse_spice reads back only the subset format_spice writes, enough to round-trip the writer’s own output and validate the committed contract fixture. It never panics on malformed input – every failure is a SpiceParseError – but it is not a hardened importer for arbitrary, untrusted decks, and has not been through a fuzz campaign the way Reticle’s binary-format readers (GDS, OASIS) have.

Cross-test

crates/reticle-extract/tests/spice_writer.rs extracts a hand-built SKY130 inverter (separate VPB/VNB body-tie straps, so the bulk terminals bind to their own nets rather than shorting into the power rails the way the smaller device-recognition fixture does), writes it, and checks the result three ways: the structured output matches the contract JSON’s kind/terminals/model/W/L; the emitted text parses back to the same structure; and the committed .spice fixture itself parses and agrees with the writer’s structural output.

xschem interoperability

Reticle exchanges netlists and probe selections with xschem, the open-source schematic capture and simulation front end: file.export_spice writes the open design’s extracted devices as a SPICE subcircuit, and xschem.import_probe reads back a list of nodes a user chose to plot, so a layout can round-trip through xschem/ngspice for schematic-level verification or simulation setup.

Implemented in crates/reticle-app/src/xschem.rs (ADR 0112), fixture-first against the committed SPICE exchange contract (crates/reticle-extract/tests/fixtures/contracts/spice_exchange_inverter.{spice,json}), so this piece did not block on the parallel netlist and waveform-ui lanes.

SPICE export

file.export_spice (File > Export > Export SPICE netlist…) runs reticle_extract::extract_devices over the open document’s top cell, then writes the recognised devices as one SPICE subcircuit: a .subckt NAME <ports...> header, one X card per device (Xn <drain> <gate> <source> <bulk> <model> w=... l=...), .ends, .end. The interchange subset matches SPICE netlist export.

The decimal-micron formatting is exact integer arithmetic (never a float), and the model names come from the open document’s own technology: spice_netlist_for_technology in xschem.rs resolves the technology name through a short allow-list, so a document whose technology is sky130 or tinytapeout_sky130 exports SKY130’s own primitive model names (reticle_extract::spice::SpiceTech::sky130, the same table reticle export-spice writes). A technology Reticle has no transcribed model-name source for, including the shipped gf180 and sg13g2 technology files, falls through to the bridge’s two-entry default table and therefore exports an honestly-wrong model name rather than a fabricated one; nothing yet lets a user name a table for one of those. See ADR 0112 for the full reasoning and docs/honest-limits.md’s “xschem export bridge” row for the current split. An open design with no recognised devices reports that honestly rather than exporting an empty or invented subcircuit.

Check:

cargo nextest run -p reticle-app -E 'binary(reticle_app) and (test(export_spice_reaches) or test(spice_netlist_for_technology) or test(bridge_tech))'

Probe import

xschem.import_probe reads Reticle’s own minimal probe-list interchange subset, not xschem’s native schematic file format (out of scope for this lane): one probe per line, whitespace-separated <id> <node> <quantity>, # starts a full-line comment, blank lines are skipped.

# probe list: id node quantity
in A voltage
out Y voltage

quantity is voltage, current, or charge, the same three variants and wire strings as reticle_sim::Quantity (the F4 waveform-record contract), so an imported probe already matches the shape a later lane needs to promote it into a real waveform-panel probe once a WaveformSet exists. The parser is capped (input size and probe count checked before any per-line work) and never panics on malformed input; a bad line is a clear, structured error naming the line and the problem. Native only for now: the browser file picker for probe lists is not wired in this lane.

What this lane does not do

xschem’s native schematic file format (the .sch/.sym grammar, symbols, graphical placement) is out of scope entirely; only the two interchange pieces above are implemented. The live SPICE-writer bridge above is deliberately narrow (two device kinds, one technology); it is not a substitute for reticle_extract::spice once that lands.

Waveform viewer

Scaffolded in the v8.2 campaign Phase 3. The waveform panel renders F4 waveform records (WaveformSet: a shared femtosecond time axis, integer nano-unit probe series, and axis bounds) produced by the bounded simulator.

The panel builds fixture-first against the committed F4 contract fixture crates/reticle-sim/tests/fixtures/contracts/f4_rc_transient.json, so it renders real waveforms before the solver exists; the fixture is swapped for live simulator output at Gate 3 if the oracle-feasibility route delivers. This chapter is filled by the waveform-ui lane.

Where it lives

The Inspector’s Automate group gains a “Waveform” section, alongside Agent, Generate, and PCell (the other panels that run something and show a result). Two commands drive it, both palette-reachable and both unbound by default:

  • waveform.run_oracle loads (or reloads) the waveform set and reveals the section.
  • waveform.export_csv dumps the loaded set to a CSV file.

Fixture-first, said plainly

The panel never pretends to simulate. Every time waveform.run_oracle runs, a warning-toned banner across the top of the section states that the data is the committed F4 fixture, not a live run, with the exact fixture path in the hover text. When a bounded solver ships, the one call this banner’s condition and waveform.run_oracle both key off flips from the fixture to the real query; nothing else in the panel changes (see 0110).

What gets rendered

WaveformSet::analysis selects one of two views:

  • Transient. A probe list shows every recorded node; clicking one plots its polyline, scaled into the plot rect by the set’s Bounds on both axes (time on x, value on y). The axis label under the plot names the quantity and its unit (Voltage (V), Current (A), Charge (C)), and the value shown is always the display form: samples_nano divided by 1e9, time in nanoseconds (time_fs divided by 1e6).
  • Operating point. There is no time axis to plot against a single sample, so every probe is shown at once: a marker per probe spread evenly across the plot’s value axis, plus an exact readout line per probe below it (vdd (n_vdd) 1.800000 V). A committed operating-point fixture has not landed yet; the panel’s own tests build a small synthetic one to prove this path renders correctly (ledgered in 0110).

The plotted-point geometry (crate::waveform_panel::transient_trace and operating_point_value) is plain, egui-free math, unit-tested without a window, mirroring the net-trace panel (crate::trace_panel); only the thin glue in App::waveform_section touches egui.

CSV export

waveform.export_csv writes the raw integer record, not the display-divided floats: a header row of time_fs followed by one <probe id>_n<unit> column per probe (out_nV for a nanovolt-scaled probe named out), and one data row per time sample (or a single row, for an operating point). Every value is the exact i64 from the record, so round-tripping the file reproduces the original samples bit for bit, the same byte-stability the F4 contract itself is built on. A spreadsheet user divides by 1e9 (or 1e6 for time_fs) themselves; the column header names the unit so that step is discoverable without reading source.

Testing

crates/reticle-app/src/waveform_panel.rs unit-tests the plotted-point geometry, the probe-list and operating-point formatting, and the CSV round-trip against the committed fixture, all without a window. The thin App::waveform_section/waveform_plot glue is covered by a headless render test (crates/reticle-app/src/app.rs, mirroring trace_section_renders_without_panic) that builds a real egui pass with no GPU, exercising both the empty state and the loaded fixture.

TinyTapeout submission

Reticle exists to open, inspect, share, and generate real IC layout. TinyTapeout is the cheapest real path from a GDS file to a physical chip, so it is the natural proof that Reticle produces layout a fabricator will actually accept. This chapter is the honest plan for a GDS-mode (custom-layout) TinyTapeout submission built with Reticle, grounded in TinyTapeout’s live specifications, plus a clear statement of what Reticle has today and what remains to build.

All TinyTapeout facts below were fetched from tinytapeout.com on 2026-07-06. Their specs move between shuttles, so treat their site and repositories as the source of truth over this page.

Status: what is built, what is planned

  • Built and shipped (earlier waves): the SKY130 technology grounding, GDSII and OASIS import/export, the DRC subset over the cited SKY130 rules, and the parameterized generators (guard ring, via farm, pad ring, seal ring, density fill, probe-able test structures) that are DRC-clean by construction against that subset. Those are what a tile’s content would be made of.
  • Built in this wave: a Reticle technology-plus-template bundle that frames a correctly-shaped TinyTapeout tile (Lane 4A). “New TinyTapeout tile” on the Start screen now opens the tt_um_reticle_tile template: the 1x2 die outline, the six ua[0]..ua[5] analog pins on met4, and the VDPWR/VGND/VAPWR power straps, all at coordinates transcribed from TinyTapeout’s own analog DEF template and init script (see below).
  • Built in this wave (Lane 4B): a wrapper that runs TinyTapeout’s own precheck as an external oracle, just tt-precheck <gds>, with a structured-failure parser and the agent-loop seam (see below). The live Docker run has now been executed to a verdict on this host (ADR 0059); the “Live precheck verdict” section below records the result.
  • Built in this wave (Lane 4C): the worked in-repo example, a generator-built test-structure tile in the TT template, committed with its replayable transcript under examples/tapeout/ (the packet’s proof artifact). It passes every one of TinyTapeout’s own Magic + KLayout DRC and geometry checks (the live run below), not just our SKY130 subset. It is generator-built, not agent-authored.

No shuttle purchase is in scope for this project. A paid submission is the operator’s own decision, which the tooling above is meant to make straightforward at any time.

Live precheck verdict (measured 2026-07-06)

just tt-precheck examples/tapeout/tt_um_reticle_tile.gds was run to completion in the pinned hpretl/iic-osic-tools:2025.01 container against tt-support-tools main, tech sky130A. The raw report is committed at examples/tapeout/precheck-results.md. The tile passes every geometry, DRC, and structural check against TinyTapeout’s own Magic and KLayout decks:

  • Magic DRC; KLayout FEOL, BEOL, offgrid, pin-label-overlap, zero-area; the KLayout (prBoundary) checks; the boundary check; the layer whitelist; the cell-name check; and the urpm/nwell check: all pass.

The first real run earned the oracle its keep: it caught a bug the SKY130 subset could not, the tile drew its outline only on areaid.sc (81/4) (what Magic reads) and lacked prBoundary.boundary (235/4) (what the KLayout checks delimit the project area from). The tile was fixed (it now carries both markers), regenerated, and re-run; the prBoundary and boundary checks then passed (ADR 0059).

Four checks still fail, and none is geometry or DRC. They are the submission artifacts a GDS-geometry generator does not produce, named plainly rather than faked:

  • Pin check: needs a .lef pin abstract. Reticle does write LEF with PIN blocks (write_lef, crates/reticle-lefdef/src/write.rs, reachable as reticle export-lefdef and as the file.export_lefdef command), but the worked tile’s pins are drawn met4 geometry rather than Pin objects, so nothing populates the abstract yet. CORRECTED 2026-07-31. WAS: “Reticle exports GDS, not LEF”. NOW: as stated above, and the old wording was already contradicted by docs/ARCHITECTURE.md’s ratification of both consumers. Check: git grep -n "pub fn write_lef" -- crates/reticle-lefdef/src.
  • Power pin check and Verilog syntax check: need a .v interface view (and yowasp-yosys); a GDS-mode tile still ships a Verilog stub declaring its ports.
  • Analog pin check: the six ua[*] pins are met4 landing pads, not wired to the interior probe structure, because the worked tile is a template plus an isolated test structure, not a wired design. TinyTapeout wants analog pins wired, or analog_pins: 0.

So the honest headline is: Reticle generates a tile whose geometry is clean against TinyTapeout’s own decks, and a complete submission additionally needs a LEF, a Verilog view, and a wired design, which are the operator’s design steps beyond the geometry the generator produces.

What a GDS-mode submission is, and is not

TinyTapeout accepts two kinds of design. The common one is the digital flow: you write HDL (Verilog), and TinyTapeout’s hardened flow (OpenLane and friends) places and routes it into a tile for you. GDS-mode, also called the analog or custom-layout path, is different: you provide the finished GDS layout for the tile, and TinyTapeout only checks it and drops it into the shuttle. That is the path Reticle serves, because Reticle is a layout tool, not an HDL synthesis flow. A GDS-mode tile is therefore fully your geometry: standard cells, generators, hand layout, or a mix, as long as it satisfies the template and passes the checks below.

TTSKY26c: the current open SKY130 shuttle

The current open SKY130 shuttle at the time of writing is TTSKY26c: it launched 2026-05-26, submissions close 2026-09-07, and estimated delivery is 2027-03-27 to 2027-05-12. Designs can be revised up to the closing date; nothing new is accepted after it. (The prior shuttle, TTSKY26b, closed 2026-05-18 with delivery in late 2026.)

Cost

TinyTapeout prices per tile through its live calculator rather than a fixed figure, so the honest answer is to read the current number from https://app.tinytapeout.com/calculator. A submission gets you the design on the shuttle plus a devkit: two boards (a demo board and a breakout board) and one physical chip; extra chips mean extra devkit boards.

The tile template a GDS must satisfy

For a SKY130 GDS-mode tile (numbers approximate, per TinyTapeout’s analog spec; 3.3 V designs are slightly narrower):

  • Footprint: one of two standard sizes, a 1x2 tile at about 160x225 um or a 2x2 tile at about 334x225 um. Larger designs are billed as multiple tiles.
  • Pins: up to six analog pins ua[0] through ua[5], used in order from 0, placed on metal 4 at locations matching a TinyTapeout DEF template. Each pin’s path must stay under 500 ohm, under 5 pF, and 4 mA maximum.
  • Power: VGND, VDPWR (1.8 V digital), and optional VAPWR (3.3 V analog), brought in as vertical met4 stripes at least 1.2 um wide, running from within the bottom 10 um of the tile to within the top 10 um.
  • Forbidden: metal 5 is off limits, TinyTapeout uses it for the power grid. No floating digital output pins.
  • Naming: the top macro name must start with tt_um_ and be unique on the shuttle.

The template bundle (Lane 4A)

“New TinyTapeout tile” on the Start screen loads a document whose frame is transcribed from TinyTapeout’s own files, not this summary. The bundle has two wasm-safe halves:

  • a technology file, tech/tinytapeout-sky130.tech, that names met4 and its pin/label purposes, adds a tt_boundary marker (SKY130 areaid.sc, 81/4) for the tile outline, and puts the met5 prohibition on record; and
  • a pure-Rust builder, reticle_app::tinytapeout::tile_document(), that constructs the tt_um_reticle_tile cell: the 1x2 die outline (( 0 0 )..( 161000 225760 ) DBU), the six ua[0]..ua[5] analog pins on met4 (each a ( -450 -500 )( 450 500 ) port at the DEF’s PLACED x centers), and the VDPWR/VGND/VAPWR met4 power straps (y 5 um to 220.76 um, 2 um wide, at x = 1, 4, and 7 um).

The coordinates come from tt_analog_1x2.def and magic_init_project.tcl in TinyTapeout/tt-support-tools. The Reticle model has no per-shape lock, so the frame is documented as the fixed part the user must not move rather than mechanically locked. The bundle is validated by a test that matches the die area, the six pin rectangles, and the strap geometry against numbers extracted from those TinyTapeout files (committed as small fixtures with their source URLs), and cross-checked against a real published GDS-mode submission, tt_um_analog_mux, for the shared 1x2 height and the met4-top / no-met5 rule.

Submission mechanics and the precheck oracle

The submission steps are: build the tile GDS, run TinyTapeout’s precheck locally until it is clean, then submit through the TinyTapeout app before the shuttle closes.

The precheck is the gate that matters. It lives in TinyTapeout’s own TinyTapeout/tt-support-tools repository (its precheck module) and runs Magic and KLayout checks over the submitted GDS: DRC, the required layers and power straps, pin placement against the template, the top-cell name, and related structural rules. It is Linux-native, so Reticle will run it via a pinned Docker container (WSL is a documented fallback), wrapped as just tt-precheck <gds> (Lane 4B). The plan is to wire its structured failures back into the agent loop like DRC violations, so a tile can be generated, prechecked, and corrected in the same loop, and to prove it with an end-to-end test where a known-good example passes and a seeded violation fails with a parsed, actionable report.

Reticle’s own SKY130 DRC subset is a fast, in-tool approximation, useful while authoring, but it is not the precheck: the precheck is the authoritative external oracle, and only a clean precheck run means a tile is submission-ready. Keeping the two distinct, our subset for speed and their precheck for truth, is the honest arrangement.

The precheck oracle (Lane 4B)

The precheck wrapper and its structured-failure parser are built and committed (ADR 0054). Three pieces:

  • just tt-precheck <gds> runs Tiny Tapeout’s own precheck over a GDS inside a pinned Docker container. The image is hpretl/iic-osic-tools:2025.01 (amd64 digest sha256:a51257b7d85fc75d5a690317539f9787a401d6dd28583d73dceab174ccc9e78f, measured at 3.94 GB compressed on 2026-07-06), which bundles Magic, KLayout, gdstk, and the SKY130 PDK (PDK_ROOT=/foss/pdks). The recipe calls scripts/tt-precheck.ps1, which stages a minimal Tiny Tapeout project (an info.yaml whose top_module equals the GDS filename stem, which the precheck requires and asserts), mounts that project and a pinned tt-support-tools checkout, runs python precheck/precheck.py --gds <gds> --tech sky130A in the container, and copies the reports (results.md, results.xml, magic_drc.txt, drc_*.xml) to an out directory. The container exit code is the precheck’s own (0 = passed). WSL is a documented fallback (the same precheck command against a distro that has the tools and PDK installed). The recipe is additive and not part of just ci, like the nightly-only fuzz/miri recipes, because it needs Docker and a multi-GB image.
  • A structured-failure parser in reticle_cli::tt_precheck (standard-library-only, no new dependency) turns the reports into PrecheckReport { passed, failures: Vec<PrecheckFailure> }, where a PrecheckFailure { rule, layer, location, message } is modeled on a reticle_model::Violation. parse_results_md records each failed Markdown row as a structural failure carrying the precheck’s own message; parse_magic_drc turns each Magic DRC rectangle (four micron floats) into a located failure at its bounding box in database units. passed is the precheck’s own verdict, not “no failures parsed”, and a missing results.md is an error, not a silent pass.
  • The agent-loop seam. PrecheckReport::feedback_lines() returns exactly the Vec<String> the propose-verify-correct loop folds into its model context (the same Context::feedback channel the DRC verifier uses), so a precheck failure reaches the model on the next proposal the way a DRC violation does. A tile can therefore be generated, prechecked, and corrected in one loop.

The oracle is proven both ways by tests/tt_precheck_oracle.rs: a known-good report parses as passed = true with no failures, and a seeded-violation report parses as a failing report with a Magic DRC rectangle located in database units and a boundary failure (Shapes outside project area) with its message, plus the feedback lines that carry them. The fixtures under crates/reticle-cli/tests/fixtures/tt-precheck/ are synthesized from the precheck’s real output format (transcribed from precheck.py, magic_drc.tcl, and pin_check.py, fetched 2026-07-06) and are labeled as synthesized in their NOTICE.md; they are not captured from a live run.

Live-run status, stated plainly: the live Docker precheck was attempted but deliberately not run to completion in this lane. The wrapper ran end to end through the real path (it validated the GDS, cloned tt-support-tools, staged the minimal project, assembled the exact docker run, and started the pull, with real image layers observed downloading from the desktop-linux context), so the daemon is reachable and the invocation is correct. The pull was stopped because the 3.94 GB compressed image expands to well over 10 GB uncompressed (plus the PDK) against about 39.5 GB free disk, and the pull-plus-precheck is slow, so completing it here was out of scope. The pinned image tag and digest, the exact docker run invocation, and the WSL fallback are recorded so that running it to a verdict is an operator step, not a fabricated pass. No tile is claimed to have passed the precheck. When a real run is captured, its results.md and magic_drc.txt drop in beside (or over) the synthesized fixtures and the same parser and test cover the real output unchanged.

The worked example tile (Lane 4C)

The packet’s proof artifact is a real, committed tile: a complete tt_um_reticle_tile made by Reticle’s own generators, framed by the Lane 4A template, and DRC-clean against the SKY130 subset. It is committed under examples/tapeout/:

  • tt_um_reticle_tile.gds, the finished tile exported to GDSII.
  • tt_um_reticle_tile.transcript.jsonl, the replayable transcript of the build (one command per line, then a {"final_hash": ...} trailer, the format the replay theater loads).

How it is built. Starting from the Lane 4A frame (tile_document(): the 1x2 die outline, the six ua[0]..ua[5] met4 pins, and the VDPWR/VGND/VAPWR met4 power straps, met5 clear), the build places a probe-able serpentine from the reticle_gen test_structure generator into the interior, on met2, at width 1.0 um, bar length 140 um, 40 bars. That is a continuous boustrophedon trace (a ~140 um by ~45.5 um band) whose end-to-end resistance a probe station reads. It is translated to (12000, 88000) DBU, which puts it 4 um to the right of the rightmost power strap and far above the analog-pin strip, wholly inside the die, on a layer that is neither the frame’s met4 nor the forbidden met5.

It is built through the command path, so there is a transcript. The build runs as three frozen AgentCommands against a Session (see reticle_app::tinytapeout_example): ImportGds of the frame, then RunGenerator test_structure, then TransformShapes to place it. The transcript replays to the same document_hash the tile exports, which the committed test worked_tile_is_drc_subset_clean (and its siblings, including transcript_replays_to_its_hash) checks; xtask tapeout-example regenerates both artifacts and refuses to write unless the tile is DRC-subset-clean and the transcript replays. The frame is seeded by GDS import because the frozen command set has no pin-or-label create command; a consequence is that the committed GDS carries the frame as drawing metal and labels, not as Reticle Pin objects, because GDSII has no pin element (see ADR 0055).

This is a generator-built tile, not an agent-authored one. The Claude Code agent path is a not-run in this environment (the CLI is unauthenticated), so nothing in this tile was written by a model; the geometry is emitted by Reticle’s test_structure generator and placed by two ordinary commands. The build is deterministic (two runs produce byte-identical GDS).

DRC-subset-clean; precheck RUN to a verdict, four artifact checks failing. The tile passes Reticle’s SKY130 DRC subset, which is necessary but not sufficient. The real TinyTapeout precheck has now been executed on this host (ADR 0059) and its result is recorded in “Live precheck verdict” above: four artifact checks fail (pin, power-pin, Verilog-syntax, analog-pin). To reproduce the verdict, an operator runs:

just tt-precheck examples/tapeout/tt_um_reticle_tile.gds

No tile in this repo is claimed to have passed the precheck. CORRECTED 2026-07-31: this paragraph read “precheck-deferred … It is not verified through the real TinyTapeout precheck; that authoritative check is the operator’s live step (Lane 4B)”, while the same chapter 230 lines above already said the run had happened and enumerated what failed.

Honest limits

  • The tooling makes a submission possible and repeatable; it does not make one. No tile is purchased or submitted as part of this project.
  • Passing Reticle’s DRC subset is necessary but not sufficient; the TinyTapeout precheck is the real bar. Lane 4B has now run it on this host and four artifact checks fail (see “Live precheck verdict”), so “submission-ready” remains a claim this project has not earned for any specific tile. The reason changed from “not yet run” to “run, and it says no”; the conclusion did not.
  • The generators are DRC-clean against the cited SKY130 rule subset, not the full foundry deck; a real tile still has to clear the precheck’s fuller checks.

Performance methodology

Every performance claim in Reticle is a measured number, not an aspiration. This chapter describes how the numbers are produced so they can be reproduced and trusted; the numbers themselves live in docs/PERF.md.

How measurements are taken

  • Microbenchmarks use criterion, which runs each benchmark many times, warms the caches, and reports a statistically meaningful median with a confidence interval. The benchmark inputs come from the deterministic layout generator, so a run is reproducible.
  • Frame timing for the renderer is measured with an in-process profiler (puffin) fed by tracing spans, and with the offscreen render harness, which renders scripted camera paths and records per-frame times.
  • The host is recorded with every number: these are measured on an RTX 4060 Ti 16 GB. Numbers from other machines are not comparable and are not mixed in.

Guarding against regressions

The benchmark results are committed as a history. xtask perf-check compares a fresh run against the committed baseline and fails if a result regresses beyond a threshold, so a performance regression is caught the same way a test failure is.

Honesty

Where a target is missed, the measured number and the bottleneck are recorded plainly. A missed target with an honest explanation is more useful than a number that cannot be reproduced.

User guide

Running

  • Native application: cargo run -p reticle-app --release.
  • Browser demo: just web-serve, then open the printed local URL in a WebGPU-capable browser (current Chrome or Edge). Where WebGPU is unavailable the demo falls back to WebGL2 with a notice.
  • Collaboration relay: cargo run -p reticle-server --release, then point two application instances at it to edit together.
  • Headless pipeline: cargo run -p reticle-cli --release -- --help lists the import, DRC, route, extract, export, and render-to-image commands.
  • Generate a layout: just gen-layout 1000000 8 3 scratch/gen.rgds writes a deterministic chip-like layout with the given shape count, layer count, and hierarchy depth to browse or benchmark.

Editing

The application is a CAD-style editor: pan and zoom the canvas, select and measure, and draw shapes on the active layer. A command palette exposes every action and its rebindable shortcut. The layer manager toggles visibility and style, selection filters and a query bar narrow what you are working on, and rulers, a grid, snap, and guides keep edits on-grid. Multiple viewports show different parts of the design at once.

Sessions are saved and restored, autosave and crash recovery protect in-progress work, and an undo-history panel lets you step through and jump within the edit history.

On a touch device (a phone that opened a shared link, or a tablet), the canvas navigates by touch: a two-finger pinch zooms, anchored at the point between your fingers so what you are pinching stays put, and a two-finger drag pans. A single-finger drag pans when the Pan tool is active. The gesture math is a pure camera helper with the zoom-anchoring invariant unit-tested, so the world point under the pinch centroid stays fixed as the zoom changes.

Checking and routing

Run the design-rule checker to populate the violation overlay, and use the error browser to zoom to each violation in turn. Route selected nets, and inspect the congestion and length report. Highlight a net to trace its connectivity across the layout.

Guided tour

The first time the editor opens on a fresh install it runs a short guided tour: a dismissable overlay that walks through the real panels one at a time. Each step names the actual control it points at, draws a highlight box around that region, and waits for a Next, Skip, or Close. The tour shows once, remembers that it has been shown, and can be relaunched at any time from the Help menu.

What the tour covers

The tour is split into two chapters. The first is the core walkthrough and always runs; it opens with how a design gets in and closes with how a session is shared. The second covers the Wave 2 tools and is optional.

Chapter 1, getting started:

  1. Open a design. Bring in your own layout with Open on the toolbar, or drag a layout file onto the window (GDSII, OASIS, CIF, DXF, Magic, or a .zip or .gz of any of them); the Start screen also loads example chips in one click.
  2. The canvas. Pan by dragging and zoom toward the cursor by scrolling; Fit frames the whole design.
  3. Layers. The left-hand layer manager toggles visibility and filters layers by name.
  4. Measure. Pick the Measure tool from the toolbar and click two points to read a distance in database units and microns.
  5. Design-rule checking. Run DRC and click a violation to zoom straight to it.
  6. Net highlight. Light up every shape connected to a net across the design.
  7. Minimap. The overview panel frames the current view; click inside it to jump the camera.
  8. Agent and replay. The agent panel runs a scripted edit session and the replay theater plays a recorded run back step by step.
  9. Share a session. The Share section mints a relay link so the same design can be opened together in a browser.

Chapter 2, Wave 2 tools:

  1. Drawing tools: rectangles, polygons, paths, and vertex editing.
  2. Boolean and transform: union, intersect, subtract, and transforms, all undoable.
  3. Productivity: copy, duplicate, arrays, move-by-delta, and via stacks.
  4. Snapping and guides: snap to vertices, edges, midpoints, and centers, with draggable ruler guides.
  5. Layer and technology editing: reorder, recolor, restyle, and edit the technology definition.
  6. Search and selection: filter shapes with a query, save selection sets, and navigate the cell outline.
  7. View and export: theme, camera bookmarks, and SVG/PNG export.

At the end of the core chapter, Next advances into the Wave 2 chapter and Skip ends the tour, so a user who only wants the basics is never forced through the second half.

Showing once, and relaunching

Whether the tour has run is a single bit stored with the rest of the view state in the session file, next to the camera, tool, grid, theme, and hidden layers. On the first launch with no saved session that bit is unset, so the tour starts automatically; once it finishes or is dismissed the bit is written, so it never opens unprompted again. A session file written by an older build has no such bit and reads as unset, so an upgrade shows the tour once rather than suppressing it.

The Help menu on the toolbar relaunches the tour on demand. “Take the tour” replays the core chapter followed by the Wave 2 chapter; “Core tour only” replays just the core chapter. Either choice restarts from the first step, and a relaunch is not treated as a first run, so it does not disturb the persisted “seen” bit.

On the web there is no filesystem to persist the bit between page loads, so the tour is treated as already seen and does not reopen on every visit; the Help menu still relaunches it within a session.

How it is built

The tour logic is a pure state machine in reticle-app’s tour module: the ordered list of steps, the current position, and the transitions between them (next, skip, finish), plus the first-run-versus-relaunched distinction and whether the second chapter is included. It holds no egui, GPU, or filesystem types, so it compiles unchanged for the browser and is unit-tested in full without a window. Highlighting a control is deliberately abstract: each step names a target region rather than a pixel rectangle, and the egui layer maps that name to the panel or canvas rectangle it already lays out that frame. Nothing depends on exact coordinates, so the highlight tracks the layout even after the panels are resized.

Worked use cases

Reticle opens on a Start screen that is a first-time visitor’s whole first contact: it can open your own file, load an example chip, show recent files, and offer four worked scenarios. Each scenario drops you straight into a prepared starting point for a different part of the tool, so a capability is one click away rather than behind a blank document. You can skip to a blank editor at any time, and reach the Start screen again with Open on the toolbar.

The scenarios are built by the reticle_app::usecases module and the rest of the screen by reticle_app::startscreen. Everything is deterministic and self-contained: the SKY130 cell, its technology, and the example designs are compiled into the binary, so the Start screen behaves identically in the native application and in the browser, where there is no filesystem. The chooser itself is skipped for the deployed web replay build, which drops a public visitor straight into the theater instead.

Opening a file, examples, and recent files

Above the scenarios the Start screen offers three more ways in:

  • Open a file. Drag a layout file (.gds, .oas, .cif, .dxf, .mag, or a .zip/.gz of any) onto the window, from the Start screen or the editor, and it opens immediately. There is no filesystem behind the browser build, so drag-and-drop is the primary open path there; a dropped file’s bytes are read directly. Anything that cannot open, an unrecognized extension, an unreadable path, or bytes that are not the claimed format, is reported on the notification surface rather than failing in silence.
  • Load an example chip. A gallery of redistribution-cleared real designs, each built into the binary and opened through the same hardened path as any other file: a minimized real Tiny Tapeout 03 sample (a few SkyWater standard cells under a small top, Apache-2.0) and the single SKY130 inv_1 inverter cell. This is the no-install way to open a real chip on the web.
  • Recent files. Files you have opened are listed for one-click return. The list is display-only in the app; a persistence backend supplies it (on the web, from browser storage).

Every one of these routes through the same document-open seam and the same error surface, so opening a real design and never crashing is proven in one place. The file formats chapter covers the import path behind them.

1. Inspect a SKY130 cell

Loads a real SkyWater SKY130 high-density standard cell, the inv_1 inverter, from a bundled GDSII stream. The imported geometry is given the committed SKY130 technology, so its layers arrive named and colored (nwell, diff, poly, li1, met1, and their contacts and vias) rather than as anonymous layer numbers, and the physical layer stack comes along so the 3D view has real elevations to extrude.

From here you can:

  • Toggle layers in the layer manager to isolate the poly gate, the local interconnect, or metal 1.
  • Measure feature widths and spacings with the measure tool, reading values in both database units and microns.
  • Open the 3D layer-stack window to see the metals and contacts extruded to their process heights, and take a cross-section across the cell.

This is the fastest way to see that the importer handles a production layout, not just fixtures, and that the layer, measurement, and 3D machinery all read a real technology. See the file formats and rendering chapters for the underlying import and 3D-stack details.

2. Find and fix a violation

Opens a small layout that deliberately breaks a design rule. The top cell holds two metal-1 wires: one comfortably wider than the SKY130 minimum, and one only 80 nm wide, which violates the SKY130 m1.1 rule (minimum met1 width of 0.14 um, i.e. 140 nm). The SKY130 rule subset is carried in the document’s technology, so a check resolves the real periphery rules rather than a generic fallback.

The intended loop is:

  1. Run the design-rule checker. The violation overlay marks the narrow wire and the error browser lists the m1.1 violation with its measured-versus-required width.
  2. Zoom to the violation, select the offending wire, and widen it to at least 140 nm (for example with the productivity panel’s move-by-delta, or by editing its vertices).
  3. Re-run the checker. With both wires meeting the minimum, the m1.1 violation clears.

This exercises the whole DRC path end to end on rules that mean something. See the design-rule checking chapter and the SKY130 rule coverage table for exactly which rules the subset checks.

3. Watch the agent work

Opens the replay theater and plays a recorded agent run. The transcript is the bundled model-free scripted run in which the agent places a clean met1 wire; it drives a real engine replay, with step, play, pause, and speed controls and a live narration feed alongside the canvas. Each verify step the run crosses feeds its design-rule results back to the overlay, so you watch the check clear as the run progresses.

Because the theater plays a compiled-in transcript, it needs no model, network, or API key, and it runs identically on native and in the browser. See the in-app agent UX chapter for the theater and narration, and the agent API and harness chapter for how such transcripts are produced.

4. Build with the new tools

Loads a small starter layout, sparse on purpose, so there is real geometry to work with but plenty of room to build. It seeds two short metal-1 wires and a metal-2 landing pad on the SKY130 metal layers.

It is a sandbox for the newer editing tools:

  • Draw additional shapes on the active layer, snapping to the existing geometry.
  • Boolean the two met1 wires together, or subtract one shape from another.
  • Array a shape into a repeated block to see hierarchy build up.
  • Via stack from met1 up to the met2 pad, generating the enclosure and cut geometry between the layers.

Every edit is undo-integrated, so you can experiment freely. See the drawing and vertex editing, boolean and transform operations, and productivity editing chapters for the tools themselves.

Productivity editing

The productivity panel gathers the everyday layout-editing shortcuts into one place: an in-app clipboard, an array tool with a live preview, numeric move-by-delta, and a via-stack builder. Every change it makes to the document goes through the undo history, so anything the panel does can be undone and redone from the history panel.

The panel lives at the bottom of the right-hand side panel. Its window-free logic sits in the productivity module and is unit-tested without a GPU or a window; the panel itself is thin glue that binds widgets to that logic and routes the resulting edits through the editing history.

Clipboard: copy, cut, paste, and duplicate

Copy snapshots the current selection into an in-app clipboard as resolved shapes in top-cell coordinates. Because the clipboard holds geometry rather than selection indices, it survives later edits, undo, and selection changes.

Paste stamps the clipboard back into the top cell, shifted by the panel’s offset (dx, dy). Duplicate is copy-plus-paste in one step over the current selection, so it works on instanced geometry too: the duplicate is flat geometry drawn directly in the top cell.

Cut copies the selection and then removes it. Only shapes drawn directly in the top cell can be removed, because the model’s remove operation addresses a cell’s own shape list; any selected geometry that belongs to an instance or an array is copied but left in place, and the status line reports how many were skipped.

Move by delta

Move applies a numeric (dx, dy) shift to the selected direct shapes. A move is a remove of each original followed by an add of its translated copy, both through the history, so it undoes cleanly. As with cut, only directly-owned shapes move; instanced geometry is left in place.

Array tool

The array tool repeats the current selection into a grid of rows by columns at a given row pitch and column pitch. Element (0, 0) reproduces the selection in place, so the originals stay put and the grid grows from them.

With the live-preview checkbox on, the pending elements are outlined on the canvas before you commit, so you can dial in the counts and pitches and see the result first. Element (0, 0) is not drawn in the preview because it coincides with the existing selection.

The tool is for tractable, previewable repeats. The element count (rows times columns) is capped; past the cap the commit is refused and the panel says so. Very large regular repeats belong in a hierarchical array placement instead, which the renderer expands lazily rather than materializing every leaf shape.

On commit, each array element is added as its own edit, so each is individually undoable and the scene rebuilds once at the end.

Via-stack builder

The via-stack builder places a connecting cut between two picked layers together with the enclosure rectangles those layers need around it. You pick a lower layer, an upper layer, and the cut layer, set the cut’s square size, and place it at a chosen center.

The enclosure margin for each picked layer is read from the technology’s enclosure rules: the rule whose enclosing layer is that picked layer and whose enclosed layer is the cut layer. When several such rules apply, the largest margin wins so the drawn geometry satisfies all of them at once. A layer with no matching rule falls back to a default margin you can set in the panel, so the builder still produces a sane stack against a technology that omits the rule.

Each enclosure rectangle is the cut expanded outward by its margin, so it overlaps the cut on every side by at least the required amount. The cut and its two enclosures are placed as three separate edits, so the whole stack is undoable.

Undo integration

Everything here is built on the same contract: geometry helpers return owned shapes, and the panel wraps each in an add or remove edit applied through the editing history, then rebuilds the scene once. Nothing mutates the document directly. That is what keeps copy, paste, duplicate, move, array, and via placement all reversible from the history panel.

Layer and technology editing

The layer-and-technology panel groups two related jobs into the end of the right-hand side panel: an upgraded layer manager for arranging and styling the layer table, and a technology editor for viewing and changing the process description that drives the whole document. Its window-free logic lives in the tech_editor module and the layer state lives in layers, so the interesting behavior is unit-tested without a GPU or a window; the panel itself is thin glue that binds widgets to that logic.

Layer manager

The layer manager is a view over the app’s layer state, the same table the canvas consults when it culls hidden layers. Every action here is a cheap, view-only change: it never mutates the document and never lands on the undo stack.

  • Reorder. The up and down arrows move a layer one position earlier or later in the table. The first row cannot move up and the last cannot move down. Reordering keeps the layer table’s internal id-to-index lookup in sync, so visibility and name queries keep resolving to the right rows after a move.
  • Recolor. The color button opens a picker seeded with the layer’s current color. Choosing a new color repacks it into the layer’s 0xRRGGBBAA value; the canvas palette re-reads layer colors, so the change shows up on the next scene rebuild.
  • Fill style. Each layer carries a fill style (solid, hatch, or outline) chosen from a small drop-down. Fill style is display metadata for the manager preview and the legend; it stays inside the app and does not change the geometry.
  • Solo. Solo shows only the chosen layer and hides every other, the fast way to isolate one layer. Soloing an unknown layer is a no-op rather than a blank canvas, so a stale id never hides everything.
  • Show all / Hide all flip every layer visible or hidden at once, and the per-row checkbox toggles a single layer.

Technology editor

The technology editor edits a working draft of the document’s technology: the database resolution (dbu_per_micron), the layer table, and the DRC rule thresholds. The draft is seeded from the live document the first time the panel is shown, and from then on it is yours to edit; nothing touches the document until you press Apply.

You can edit the technology name and resolution, recolor and renumber layers or rename them, and adjust each rule’s threshold value. The rule rows show each rule’s kind and its layer (or layer pair) for context, with the threshold as the editable field, because retuning a value is the common change.

Validation

Apply validates the whole draft before it commits, and if anything is wrong it applies nothing and lists every problem inline. The checks are the invariants the DRC engine and the file format assume but the in-memory type does not enforce on its own:

  • the resolution must be positive,
  • every rule threshold must be non-negative, and a length-style rule (width, spacing, enclosure, extension, notch) or an area rule must be strictly positive, so a negative or zero width is rejected,
  • a two-layer rule kind (spacing, enclosure, extension) must carry a second layer and a single-layer kind must not, and
  • every physical-stack entry’s thickness must be positive.

Only a clean draft reaches the document. Revert throws the draft away and reloads it from the current document, discarding any in-progress edits.

How the change reaches the document

Setting a technology is not one of the document’s undoable edits: the edit vocabulary is geometry-only (add and remove shapes, cells, instances, and labels), and the undo/redo log records only those. So a validated technology is applied directly to the document rather than pushed onto the undo stack. The document revision still advances, which is what the retained renderer keys its cache invalidation on, so the canvas re-reads the new technology. The practical consequence is that a technology change is not itself undoable, and it leaves the existing shape-edit history intact: undoing a shape edit made before the technology change still works.

Technology file round-trip

The editor round-trips the draft through the line-oriented technology-file text format described in File formats. The collapsible Technology file (text) panel shows the draft serialized to that format; Refresh from draft re-serializes the current draft, and Load from text parses the text box back into the draft, reporting a parse error instead of loading a malformed file.

Serialization is canonical and byte-stable rather than a verbatim copy. Parsing discards comments, blank lines, token spacing, keyword case, and any 0x or # color prefix, so those cannot survive a load; the serializer emits one directive per line in a fixed order, with colors as eight uppercase hex digits. The guarantee the editor relies on is a fixpoint: parsing the serialized draft yields an equal technology, and re-serializing it reproduces the same bytes. Re-saving a file the editor wrote gives back exactly that file; a hand-authored file with comments comes back in canonical form without them.

Deployment

This chapter covers running the public demo: the rate-limited demo server, the collaboration relay a spectator watches, the browser bundle, and the release-time secret scan. It is written for a small VPS behind a reverse proxy.

The demo server is reticle-demo-server, a composition binary (ADR 0024) that brings up three things in one process:

  • the rate-limited HTTP service from reticle-demo (POST /submit, GET /status/{id}, POST /cancel);
  • an in-process reticle-server collaboration relay, so a visitor can watch the room each session draws into;
  • a harness: the real reticle-agent propose-verify-correct loop when a key is present, otherwise a deterministic offline scripted loop (ADR 0025).

Run it locally with just demo-up.

The server cannot be started unbounded

The service is built from a reticle_demo::LimitConfig, and there is no constructor that omits it: DemoServer::new(LimitConfig) and DemoServer::with_harness(LimitConfig, harness) both take the limits by value. The type is defined in crates/reticle-demo/src/limits.rs, and every field is enforced on the wire (the enforcement is covered by the abuse tests in crates/reticle-demo/tests/abuse.rs). This is the point of the demo: it is safe to expose to the open internet because it physically cannot run without limits.

The mandatory limits and why each matters

reticle-demo-server builds a non-permissive LimitConfig (see demo_limits() in crates/reticle-demo-server/src/config.rs). The values, and what each protects against:

FieldValueOn breachWhy it matters
per_ip_rate_per_min6429Caps how fast one source IP can submit, so a single visitor cannot flood the queue.
per_ip_concurrency1409One live session per IP, so one visitor cannot hold multiple agent loops at once.
global_concurrency4503A hard ceiling on concurrent agent loops across the whole server, bounding CPU, memory, and (with a real key) model spend.
token_budget100000session cancelledA runaway loop is cancelled before it can burn tokens.
command_budget200session cancelledBounds how many edits one session can issue, so a loop cannot grow a document without limit.
max_prompt_len400 chars400Bounds the input a visitor can submit.
allowed_vocabularytask words400A prompt straying off the layout task vocabulary is rejected before it reaches a model, so the demo cannot be used as a general-purpose model proxy.

The order of checks matters: cheap input validation (length, then vocabulary) runs before any stateful counter is touched, so a malformed prompt never consumes a rate token or a concurrency slot.

Tune these for the host. A larger VPS can raise global_concurrency; a cost-sensitive deployment can lower token_budget. Keep allowed_vocabulary non-empty in any public deployment so the proxy-abuse guard stays on.

The API key is never baked into the image

The Anthropic API key is read only from the ANTHROPIC_API_KEY environment variable, by reticle_agent::AnthropicModel::from_env. It is held in an ApiKey wrapper that never prints, serializes, or logs the clear value, and it reaches the wire only as the x-api-key header. It is never written to a file, a transcript, or an artifact.

Consequences for deployment:

  • Do not put the key in the Dockerfile, an image layer, a committed .env, or a compose file checked into git. Provide it at run time only.
  • With Docker: -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" (read from the host environment), or a Docker/Compose secret mounted into the environment at start.
  • With systemd: an EnvironmentFile= that is chmod 600, root-owned, and outside the repo.
  • Without a key, the server still runs: it uses the offline scripted harness, so just demo-up and a keyless container both work with no network.

Before every release, run the secret scan (just check-keys) to prove no key or other credential has been committed anywhere in the tree. It scans for the sk-ant- prefix, generic api_key/secret/token assignments, and long high-entropy strings, and exits non-zero on a hit. Pass -History to also scan the full git history.

Running with Docker

The repository ships a multi-stage Dockerfile that builds the binary in release and runs it as a non-root user (reticle, uid 10001). It links rustls (no OpenSSL system dependency) and uses the vendored protoc (ADR 0008), so the runtime image is a slim Debian with only ca-certificates added (needed for the outbound HTTPS call to the Anthropic API when a key is set).

# Build from the repo root (the build context).
docker build -t reticle-demo .

# Offline (no key): the scripted harness runs.
docker run --rm -p 3040:3040 -p 3041:3041 -e HOST=0.0.0.0 reticle-demo

# Live model: the key comes from the host environment, never from the image.
docker run --rm -p 3040:3040 -p 3041:3041 -e HOST=0.0.0.0 \
    -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" reticle-demo

The container binds HOST=0.0.0.0 inside, and the host maps ports 3040 (the demo service) and 3041 (the relay). Override PORT and RETICLE_RELAY_ADDR to change them.

Behind a reverse proxy

Terminate TLS at a reverse proxy (nginx, Caddy, or Traefik) and forward to the demo service. The relay is a WebSocket endpoint (GET /ws/{room}), so the proxy must pass the upgrade headers for that path.

The service reads the real client IP from the x-demo-client-ip request header when present (the header a trusted front proxy sets), falling back to the peer address; this is reticle_demo::CLIENT_IP_HEADER. Set that header at the proxy to the true client address so per-IP limits apply to real clients rather than to the proxy. Do not expose the header to untrusted clients directly, or a client could spoof its source IP; only a trusted proxy should set it.

A minimal nginx sketch (TLS omitted for brevity):

# Demo HTTP service.
location /api/ {
    proxy_pass http://127.0.0.1:3040/;
    proxy_set_header x-demo-client-ip $remote_addr;
}

# Collaboration relay (WebSocket upgrade).
location /ws/ {
    proxy_pass http://127.0.0.1:3041;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

Resource ceilings

The limits above bound the demo’s own work, but set OS and container ceilings too so a bug or an unusually heavy session cannot take down the host:

  • Memory: cap the container (for example docker run --memory=1g). Each agent loop holds a small in-memory document and CRDT; the relay keeps an in-memory log per room (see the reticle-server room notes), so restart the process periodically or run behind an orchestrator that recycles it if rooms accumulate.
  • CPU: cap CPU (for example --cpus=2). global_concurrency already bounds how many loops run at once; the CPU cap is a backstop.
  • Disk: the demo server writes no artifacts (unlike the batch CLI), so its disk use is negligible; still, cap logs.
  • Network egress: with a key, the only outbound traffic is to api.anthropic.com. An egress allowlist to that host is a reasonable hardening step.

Publishing the browser bundle to GitHub Pages

The public link is the crates/web Trunk bundle, which mounts reticle-app. The web entry point reads a ?view= query parameter and defaults a public visitor to the replay theater (ADR 0026): the theater plays a recorded agent transcript back through a live session, so a visitor sees motion and DRC overlays with no key and no setup. The index.html frames the theater with an always-visible link to the full editor (?view=editor) and back.

The theater window itself is native-only today; on the wasm bundle the start view is selected at the entry point and the framing is in place, while the in-page theater window lands once the theater modules are un-gated for wasm (they are model-free; this is tracked as TODO(wave3) in crates/reticle-app/src/lib.rs). The native desktop app opens the theater fully via App::with_start_view(StartView::ReplayTheater).

The release step (Wave 3) publishes the bundle to gh-pages:

# Build the optimized wasm bundle.
just web-build           # -> crates/web/dist

# Build the book.
just book                # -> docs/book

# Publish dist/ (and the book) to the gh-pages branch, then enable Pages to serve
# from that branch. This project uses no GitHub Actions; the release skill builds
# the site locally and pushes the branch.

The published index.html and the web entry point default a public visitor to the replay theater view (with the editor one click away). Nothing about the browser bundle needs a key: the theater replays a committed scripted transcript.

Archive hosting

A .rtla archive (the streamed-archive format, ADR 0062) is a network transport for renderable silicon: a header, a tile directory, and byte-contiguous tiles, so a browser fetches exactly one tile with a single HTTP Range request. Two pieces of infrastructure put those archives on the open web safely: a Cloudflare Worker that serves the bytes, and an xtask gate that decides which archives are allowed to be staged for hosting in the first place.

The serving Worker

worker/archive/ is a Cloudflare Worker with an R2 binding to the private bucket reticle-archives. It is separate from the collaboration relay in worker/ (a Durable Object); the two share nothing and deploy independently.

  • Served through the binding, never the public URL. The bucket is reached only through the Worker’s R2 binding, never the rate-limited public r2.dev URL, so the archives stay off the rate-limited path and behind the Worker’s CORS lock.
  • Range. A Range: bytes=a-b request is answered 206 Partial Content with a Content-Range header and exactly those bytes; a request with no Range returns the whole object as 200. Accept-Ranges: bytes is always advertised. This is the point of the format: one tile is one Range request over its [offset, offset+len) slice of the archive.
  • Cache. The Cache API sits in front, keyed by object key plus requested range, so each distinct byte range is cached as its own entry. Because the Cache API will not store a 206 or a Content-Range response, a ranged body is cached as a normalized 200 that records the real status and range in internal headers, then reconstructed on a hit.
  • CORS. Access-Control-Allow-Origin is locked to the Pages origin https://alpharomerojl.github.io (never *), and an OPTIONS preflight is answered 204 with the allowed methods and headers.

The Range header is untrusted

The Range header is attacker controlled, so the parser (worker/archive/src/range.js) is the trust boundary between the network and the R2 read. It resolves every input to one of three verdicts: serve the whole object, serve a bounded [offset, offset+len) slice already clamped to the object size, or reject. A malformed, backwards, or overflowing range is answered 416 Range Not Satisfiable with Content-Range: bytes */size, never a silent full transfer and never an out-of-range read. Every satisfiable range is clamped to the object, so the Worker can never be driven to read past the object or to request an absurd length. The parser is covered by a node --test unit suite; a wrangler dev local ranged fetch is the hermetic integration check (see worker/archive/README.md).

The redistribution license gate

Before an archive is staged for hosting, xtask verify-licenses <dir> decides whether it may be redistributed at all. For every *.rtla archive in a staged content directory it reads a sibling NOTICE manifest (<archive>.rtla.NOTICE, the provenance style of corpus/tinytapeout/NOTICE.md: a Source: URL and an SPDX-License-Identifier:), and it verifies the SPDX license is on a small redistribution allowlist:

  • Apache-2.0, MIT, CC-BY-4.0
  • the CERN Open Hardware Licence family (CERN-OHL-S, -W, -P, any variant)
  • public-domain dedications (CC0-1.0, Unlicense, public-domain)

The gate fails closed: an archive ships only when its license is positively verified. Any archive whose terms cannot be verified is EXCLUDED, with a printed STATUS EXCLUDED line naming the reason: no manifest, no SPDX line, a compound SPDX expression the gate will not guess at, or a license not on the allowlist. A run that excludes anything exits non-zero, so a staging step that would ship unverifiable content fails loudly:

STATUS VERIFIED good.rtla (Apache-2.0) [https://github.com/TinyTapeout/tinytapeout-03]
STATUS EXCLUDED mystery.rtla: license not on redistribution allowlist: NoSuchLicense-9.9
STATUS EXCLUDED orphan.rtla: no license manifest (expected orphan.rtla.NOTICE)
verify-licenses: 3 archive(s): 1 verified, 2 excluded

Install and offline (PWA)

The browser bundle is a Progressive Web App: it can be installed to the home screen or desktop and it loads its app shell offline. Three pieces make that work, all shipped from crates/web and copied verbatim into the Trunk dist:

  • manifest.json declares the install metadata: the name, the standalone display mode, the #0b0e14 theme and background colors, and 192 and 512 px icons (including a maskable variant). It is linked from index.html with a relative href="manifest.json".
  • sw.js is the service worker. On install it pre-caches the navigation shell; on activate it drops caches from older versions and claims open clients; on fetch it serves navigation network-first (falling back to the cached shell when offline) and static assets cache-first, populating the cache from the network on a miss.
  • An inline registration script in index.html registers ./sw.js on window load, when service workers are supported and the page is not opened from file://.

Subpath correctness

Production serves the site under a subpath, https://alpharomerojl.github.io/reticle/, not at the domain root. So every PWA path is relative:

  • the manifest link is href="manifest.json", and its start_url and scope are both ".";
  • the registration call is register("./sw.js");
  • inside the worker, every cached URL is derived from self.registration.scope (the absolute URL the worker controls), so the same worker is correct at the dev root and under /reticle/ with no hardcoded leading /.

Because the hashed web-<hash>.js and web-<hash>_bg.wasm names are not known at install time, the worker discovers and caches them at runtime on the first controlled load rather than listing them in a pre-cache manifest.

How it reaches the deploy artifact

The Trunk copy-file directives in index.html emit manifest.json, sw.js, and both icons into dist. scripts/deploy-pages.ps1 copies dist/* wholesale into the Pages staging directory, so the PWA files ride along to gh-pages under /reticle/ with no extra deploy step.

Proof

just e2e-pwa runs the pwa Playwright project (e2e/tests/pwa.spec.ts) against the root-served dist. It asserts a linked, parseable manifest with a name, a start_url, and resolvable icons; that the service worker registers and controls the page after a reload; and, best-effort, that the app shell still renders after the network is cut and the page reloaded. The subpath boot gate (just e2e-subpath) separately proves the relative asset paths resolve under /reticle/.

Embedding Reticle

The browser bundle can run inside another page’s <iframe>. ?embed=1 (catalog 94) hides every menu, panel, and dialog and leaves only the canvas, so the frame shows just the layout. It is the same wasm bundle the full app ships: embedding changes only the chrome, never the renderer or the import/streaming data path.

The iframe snippet

<iframe
  src="https://alpharomerojl.github.io/reticle/?embed=1&archive=https://example.com/chip.rtla"
  width="960"
  height="600"
  style="border: 0"
  loading="lazy"
  title="Reticle layout viewer"
></iframe>

The canvas fills whatever box the <iframe> is given (width/height on the element, or CSS); there is no minimum size.

Pointing the frame at a design

?embed=1 alone shows the bundle’s built-in default document, a static canvas with nothing loaded. The published bundle’s own public default view is the replay theater (ADR 0026), but the theater is a separate docked panel that never renders in embed’s minimal chrome, so a bare ?embed=1 link is not useful on its own. Add one of these to load real content:

  • &archive=<url> streams a served .rtla archive read-only over HTTP Range requests, without importing the whole file (see Streamed documents and Archive hosting). Best for a large die.
  • &gds=<url> fetches a layout file from a URL into an editable document: the same set the toolbar’s Open accepts (GDSII, OASIS, CIF, DXF, Magic, or a .zip/.gz of any of them; see File formats), since embedding never changes the import/streaming data path (see above). The query key stays gds for permalink compatibility. Embed’s hidden menus and toolbar make it read-only in practice, since there is nothing to invoke an edit from. Best for a small design.

Either composes with the existing permalink seam (see Permalinks), unaffected by embed mode:

  • &cell=<name> focuses a cell.
  • &view=<x>,<y>,<zoom> sets the initial camera: the world point at the canvas center and the zoom in pixels per DBU.
  • &layers=<csv> shows only the listed layer/datatype pairs (for example 68/20,69/20), hiding every other layer.

A full example, a streamed archive framed on one cell at a fixed zoom with two layers visible:

https://alpharomerojl.github.io/reticle/?embed=1&archive=https://example.com/chip.rtla&cell=top&view=0,0,4.0&layers=68/20,69/20

Minimal chrome

?embed=1 suppresses, unconditionally:

  • the Start screen (the worked-use-case chooser), even on a first-time visit;
  • the menu bar, toolbar, and every docked panel (Layers, Inspector): the canvas layout selection (App::chrome_layout) picks the embed layout ahead of the full editor, presentation mode, and the read-only viewer, so none of their chrome can render while embedded, whatever else is also requested;
  • the command palette, floating windows, and the guided-tour overlay.

What remains is the canvas, plus a small “Open in Reticle” link in the bottom corner that reopens the same URL in a new tab with embed=1 turned off, so a visitor can always reach the full app. Keyboard shortcuts still fire in embed mode; there is no overlay to discover them from, but nothing about embed disables input handling.

This is asserted directly, not just eyeballed: cargo test -p reticle-app embed runs headless tests (no GPU, no window; plain app state) that check the exact gates App::ui reads, including that embed wins the chrome layout even when presentation mode or a read-only viewer session is requested at the same time.

Previewing embed chrome without an iframe

The embed.toggle command (palette-only, no default chord) flips embed mode on and off inside the full app, so the minimal chrome can be previewed without standing up an iframe. It sets the same flag ?embed=1 does; toggling it again, or the corner link, returns the full chrome.

Cross-origin requirements

The embedding page, the Reticle host, and a served design can all be different origins. Two things have to allow it:

  • Framing. The Reticle host must not send X-Frame-Options: DENY or a restrictive Content-Security-Policy: frame-ancestors, or the browser refuses to render the frame. The published demo sends neither header: checked 2026-07-11 with curl -sD - -o /dev/null https://alpharomerojl.github.io/reticle/; re-run that command against the live deploy before relying on it, since GitHub Pages’ response headers are outside this repository’s control.
  • The design fetch. A design loaded over &archive= or &gds= from a third origin must itself answer with a permissive Access-Control-Allow-Origin and, for &archive=, allow Range requests (see Archive hosting). This is the same requirement the non-embedded browse already has; embed mode adds no new cross-origin surface.

What embed mode does not do yet

Embed mode is chrome-only today: there is no postMessage host API to script the frame from the embedding page, and the frame posts no resize or scroll events back out. Both are natural follow-ons, not yet built.

Desktop (Tauri)

desktop/ is a native desktop build: a window on the system webview (WebView2 on Windows) that loads the same UI as the browser build, bundled for fully offline use, and the native-only home for features the browser build honestly defers (ADR 0115): the rhai PCell producer today, and the real agent on the roadmap. See docs/decisions/0119-tauri-desktop.md for the full design rationale.

It is a separate, workspace-excluded crate (like crates/reticle-py, ADR 0087): Tauri’s dependency chain (wry, webview2-com, tao) never enters just ci, just wasm-build, or cargo nextest run --workspace.

Why a desktop app, when the editor already runs in a browser

reticle-script (the rhai PCell producer) and reticle-agent (the real propose-verify-correct agent) are native-only dependencies of reticle-app (ADR 0115): shipping either in the wasm bundle would blow the measured browser-bundle gz budget by an order of magnitude. The browser’s PCell Inspector shows the predicted provenance and an honest disclaimer that live produce runs in the desktop app. This chapter’s crate is that desktop app.

Build

Two steps, in order, from the repo root:

# 1. Build the offline web bundle the shell embeds.
just web-build

# 2. Build the desktop shell itself (its own Cargo.lock and, effectively, its
#    own target dir, since it is workspace-excluded).
cd desktop
cargo build

There is no cargo-tauri CLI step: desktop/build.rs calls tauri_build::build() directly and src/main.rs calls tauri::Builder::default()....run(tauri::generate_context!()), so a plain cargo build is the whole build. Step 1 must run first and must be re-run whenever the web UI changes: desktop/tauri.conf.json’s build.frontendDist points at ../crates/web/dist, and Tauri embeds whatever is in that directory into the compiled binary at compile time. Building the shell without a fresh crates/web/dist ships whatever was there last.

How the offline bundling works

Tauri’s asset-embedding step (driven by tauri::generate_context!()) reads crates/web/dist at compile time and embeds every file’s bytes directly into the binary. At run time, the webview loads tauri://localhost/ and Tauri’s own asset protocol answers from those embedded bytes. No local HTTP server is started, and after compilation nothing depends on crates/web/dist still existing on disk or on any network reachability. This is a stronger guarantee than the browser PWA’s offline story (a service worker cache populated over the network on first load, see Install and offline): the desktop shell needs no service worker at all, because there is no network fetch for one to intercept.

The web bundle’s HTML/CSS/JS already use only relative paths (the work that made the gh-pages subpath deploy correct also makes the bundle safe to serve from Tauri’s asset root), so no changes were needed to crates/web to make it embeddable.

The native-only proof: Regenerate demo PCell

The window’s “Reticle” menu has one action: “Regenerate demo PCell (native, offline).” Choosing it runs the real sandboxed rhai producer (reticle_script::produce) against a small built-in fixture (a parametric pixel array; the same fixture shape used by reticle_script::pcell::tests::sensor_def and the browser PCell panel’s own demo) and shows the result, geometry counts and the stamped provenance, in an alert inside the window. It calls the production sandbox directly; nothing about the result is scripted or replayed.

This runs as a native menu action rather than a button inside the (unmodified) web UI so that no change was needed to reticle-app’s source: a native menu-event handler is fully-trusted Rust code with no ACL/capability surface to configure, unlike a command invoked from the webview’s JS.

What is deferred

  • Installers, code signing, auto-update. desktop/tauri.conf.json leaves bundle.active at its default false; this crate builds a plain executable, not a signed package. Follow-on work.
  • The real agent. reticle-agent is already a dependency of desktop/ (see cargo tree below), but no menu action calls it yet: its live run mode needs a reachable model backend, which does not fit an offline, network-disabled proof. Wiring an agent action is follow-on work.
  • Plugin exposure. After the plugin-host lane (wave B).
  • Live collaboration (“Go live”) from the desktop shell. The bundled CSP’s connect-src does not yet include the Share relay’s wss:// origin. The feature already and correctly requires network; this is a small follow-on CSP amendment when prioritized, not a defect.

Proof

# From desktop/: the shell builds clean, on its own Cargo.lock, and both
# native-only crates are reachable in its dependency graph.
cd desktop
cargo build
cargo tree | grep -E "reticle-script|reticle-agent"
cd ..

# From the repo root: excluding desktop/ from the workspace does not disturb
# the existing gate.
just wasm-build
cargo nextest run --workspace --no-run

The GUI half (opening the window with the network disabled, confirming the bundled UI loads with no request ever leaving the process, and reading a real produce result from the native menu action) is a headed, interactive check.

Its exact launch command and expected observation are NOT RECOVERABLE from this repository, dated 2026-07-25. They were recorded in scratch/lanes/tauri/RESULT.md, which was never committed (scratch/ is gitignored at .gitignore:84) and never archived: the tauri lane’s only commit predates the campaign’s earliest archive-before-prune wave by three days, and a search of the whole lane archive on E: finds no copy. This paragraph previously cited that path as though a reader could follow it. The check itself remains valid and is described above; only its written recipe is lost, and re-deriving it is straightforward from cargo tauri dev plus the network-disabled condition already stated here.

Contributing

The build gate

There is no hosted CI. A single recipe is the gate and must be green before every commit:

just ci

just ci runs, in order: formatting check, Clippy with warnings denied across all targets, the test suite (nextest) and doctests, a documentation build with broken links denied, a WebAssembly build, cargo-deny for licenses and advisories, and a spell check. Individual steps are available as their own recipes (just fmt, just clippy, just test, just doc-build, and so on); just --list shows them all.

Standards

  • Exact integers. Layout coordinates are database units (i32); widen to i64/i128 for products and areas. Never introduce floating-point coordinates into the geometry or model core.
  • Documented public API. Every public item carries rustdoc; this is enforced.
  • Safe Rust by default. Any unsafe is isolated, carries a // SAFETY: justification, and is covered by tests and miri (just miri).
  • Tests before claims. New geometry, indices, and CRDT behavior come with property tests against a brute-force or reference oracle; parsers come with fuzz targets (just fuzz <target>); the renderer comes with golden-image tests.
  • Measured performance. A change that affects performance lands with a benchmark and a real number recorded in PERF.md; xtask perf-check guards against regressions.
  • Decisions are recorded. A choice with real trade-offs gets a short architecture decision record under docs/decisions/.

Commits

Commits are small, coherent, and use conventional messages.

Before your first commit in a fresh clone, run this once:

git config core.hooksPath .githooks

git clone never sets it, because core.hooksPath is per-clone local config. Until you run it, the pre-commit hook (fast formatting and Clippy checks) and the commit-msg hook (the AI-attribution guard) do not run at all, and just check-hook-path fails. This was measured red-then-green on a genuinely fresh clone on 2026-07-29; it had been documented only as a parenthetical, never as a step.