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:
- 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 andPERF.md). Running this in a plain browser tab, with no install, is the part of the problem Reticle pushes hardest on. - 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.
- 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). ItsOasistype 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, fromreticle 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 outsidereticle-extract. The MCPnetlist_comparetool 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 contradictedREADME.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 aswaveform.run_oracleandreticle 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:
PCellDefwith a sandboxed rhai producer (native only) and the livepcell.edit_params/pcell.regeneratecommands. 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
| Crate | Responsibility |
|---|---|
reticle-geometry | Exact integer primitives and robust polygon booleans, offsetting, winding, and convex decomposition. |
reticle-index | Bulk-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-model | The hierarchical document: cells, instances, arrays, transforms, per-cell bbox computation, flattening, and a transactional edit history. |
reticle-proto | The versioned Protobuf schema and generated types for the document, wire, and collaboration formats. |
reticle-io | GDSII, 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-render | The wgpu renderer: instanced pipelines, lyon tessellation, GPU-driven cell culling, and offscreen rendering. |
reticle-drc | A declarative, incremental design-rule checker. |
reticle-route | A grid and maze router with rip-up and reroute. |
reticle-extract | Geometric connectivity extraction across contacts and vias. |
reticle-sync | Real-time collaboration over a yrs CRDT, with presence and comments. |
reticle-server | The WebSocket collaboration relay. |
reticle-script | An embedded rhai scripting API over the model. |
reticle-app | The interactive egui application, native and in the browser. |
reticle-cli | The 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. |
web | The 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:
| Crate | Responsibility |
|---|---|
reticle-lefdef | LEF/DEF import and export, including MACRO and PIN blocks. |
reticle-diff | Layout diff between two designs, with an overlay. |
reticle-metrology | CPU metrology reports. |
reticle-gen | Parametric generators and the user PCell engine, retargeted across SKY130, IHP SG13G2 and GF180MCU. |
reticle-sim | A bounded pure-Rust modified-nodal-analysis circuit solver: DC operating point and transient. Not ngspice, never signoff. |
reticle-plugin | The embedded wasmi plugin runtime and its host. |
reticle-nl-edit | The deterministic natural-language edit grammar, with no model call. |
reticle-agent-api | The typed command surface an agent drives. |
reticle-agent | The agent harness. |
reticle-mcp | The MCP server, 39 tools. |
reticle-bench | The agent benchmark driver. |
reticle-demo, reticle-demo-server | The scripted demo harness and its server. |
reticle-relay-conformance | Conformance vectors for the collaboration relay. |
reticle-py | The Python bindings. |
xtask | The 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 underdocs/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:

After:

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:

After:

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:

After:

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.
Share-link viewer (viewer-empty-room)
Before:

After:

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 underdocs/design/baseline/anddocs/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 ini64.Rectstored 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 ini128), 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 rationalMagnification, 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:
- generates two fixtures with gdspy (1 dbu = 1 nm);
- round-trips each fixture (read then re-export) through Reticle (a standalone
reticle-roundtripdriver on the host), KLayout, and gdspy; - 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
STRANSrotation/magnification directly; and - 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:
| tool | recovered rotation | magnification |
|---|---|---|
| source / gdspy / KLayout | 45° | 2× |
| Reticle | 90° | 2× |
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:
| field | source | meaning |
|---|---|---|
document | LEF macros + DEF placement/routing | the lowered Document: a cell per macro, a top cell of placed instances and routed shapes, and the layer table |
design_name | DEF DESIGN | the top cell name |
die_area | DEF DIEAREA | the die outline (a rectilinear outline is reduced to its bounding box) |
sites | LEF SITE | placement site definitions |
rows | DEF ROW | placement rows |
nets | DEF NETS | the routed net list, each with its wire and via segments |
pins | DEF PINS | external I/O pins |
overlays | reports (viewer) | congestion, utilization, and timing-critical-net slots, empty after a LEF/DEF import |
warnings | import | non-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 MICRONSsets the database resolution.LAYER <name>withTYPE(ROUTING,CUT, or other) andWIDTH. Each layer name is interned to aLayerIdin declaration order and given a palette color; routing widths become the default wire width for that layer.SITE <name>withCLASSandSIZE.MACRO <name>withCLASS,SIZE,PIN(withDIRECTIONandPORT/LAYER/RECTgeometry), andOBS. Each macro becomes aCell; each pin becomes a modelPinand 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 theDO/BY/STEPrepeat.COMPONENTS: eachPLACEDorFIXEDcomponent becomes anInstanceat its location and orientation. A component whose macro the LEF never defined is skipped with a warning.PINS:NET,DIRECTION, aLAYERrectangle, andPLACEDlocation.NETS:ROUTED(andFIXED) wires and vias, includingNEWlayer 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:
| DEF | Reticle | DEF | Reticle |
|---|---|---|---|
N | R0 | FN | MirrorX180 |
W | R90 | FW | MirrorX270 |
S | R180 | FS | MirrorX |
E | R270 | FE | MirrorX90 |
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:
| fact | reticle-lefdef | OpenROAD |
|---|---|---|
| macros | cells other than the top design cell | library masters |
| components | top cell placed instances | block instances |
| pins | DesignPin count | block terminals |
| die area | die_area box in DBU | getDieArea 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, orStreamed(StreamedScene), a document paged in from an.rtlaarchive.
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:
- 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. - 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.
- 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.
Pointing the die gallery at a local archive host: ?archive_base=
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
-
A Web Worker converts. Picking a GDS runs
convert_gds_to_rtlainside 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-onlyGdsRecordReader(ADR 0062) pulls one record at a time, and the archive builder assembles the tiled.rtla. -
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. -
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 servesopfs-archive/<path>by reading the OPFS file and answering HTTPRangerequests, so the archive streams through the ordinaryHttpRangeTileSourcewith 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 Nlabel 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
xnext 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
viewexportmodule: 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:
| Query | Error |
|---|---|
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:red | unknown 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 id | Kind | Layer(s) | Value | Meaning |
|---|---|---|---|---|
li.1 | width | li1 (67/20) | 170 (0.17 um) | Min width of li1 |
li.3 | spacing | li1 (67/20) | 170 (0.17 um) | Min li1 to li1 spacing |
li.5 | enclosure | licon1 (66/44) in li1 (67/20) | 80 (0.08 um) | licon1 enclosed by li1 |
li.6 | area | li1 (67/20) | 56100 (0.0561 um²) | Min li1 area |
m1.1 | width | met1 (68/20) | 140 (0.14 um) | Min width of met1 |
m1.2 | spacing | met1 (68/20) | 140 (0.14 um) | Min met1 to met1 spacing |
m1.4 | enclosure | mcon (67/44) in met1 (68/20) | 30 (0.03 um) | mcon enclosed by met1 |
m1.6 | area | met1 (68/20) | 83000 (0.083 um²) | Min met1 area |
m2.1 | width | met2 (69/20) | 140 (0.14 um) | Min width of met2 |
m2.2 | spacing | met2 (69/20) | 140 (0.14 um) | Min met2 to met2 spacing |
m2.4 | enclosure | via (68/44) in met2 (69/20) | 55 (0.055 um) | via enclosed by met2 |
m3.1 | width | met3 (70/20) | 300 (0.3 um) | Min width of met3 |
m3.2 | spacing | met3 (70/20) | 300 (0.3 um) | Min met3 to met3 spacing |
m4.1 | width | met4 (71/20) | 300 (0.3 um) | Min width of met4 |
m4.2 | spacing | met4 (71/20) | 300 (0.3 um) | Min met4 to met4 spacing |
m5.1 | width | met5 (72/20) | 1600 (1.6 um) | Min width of met5 |
m5.2 | spacing | met5 (72/20) | 1600 (1.6 um) | Min met5 to met5 spacing |
poly.1a | width | poly (66/20) | 150 (0.15 um) | Min width of poly |
poly.2 | spacing | poly (66/20) | 210 (0.21 um) | Min poly to poly spacing |
poly.8 | extension | poly (66/20) past diff (65/20) | 130 (0.13 um) | Poly endcap past diff |
difftap.1 | width | diff (65/20) | 150 (0.15 um) | Min width of diff or tap |
difftap.3 | spacing | diff (65/20) | 270 (0.27 um) | Min diff to diff spacing |
licon.1 | width | licon1 (66/44) | 170 (0.17 um) | licon1 size, as min width |
ct.1 | width | mcon (67/44) | 170 (0.17 um) | mcon size, as min width |
via.1a | width | via (68/44) | 150 (0.15 um) | via size, as min width |
via2.1a | width | via2 (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/npcand 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 construct | Compiles to | Notes |
|---|---|---|
name = input(layer) / input(layer, datatype) | a layer binding | datatype defaults to 0 |
source(...), report(...) | ignored | KLayout I/O header, not a rule |
layer.width(v) | RuleKind::Width | single layer |
layer.space(v) | RuleKind::Spacing | single layer |
layer.notch(v) | RuleKind::Notch | single layer (see divergence note) |
layer.separation(other, v) / layer.sep(other, v) | RuleKind::Spacing | other_layer = other |
outer.enclosing(inner, v) | RuleKind::Enclosure | receiver 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::Area | below-threshold selection only |
trailing .output("name"[, "desc"]) | rule name | optional; 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
.lydrcconstruct 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 universaldrcexpression. 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 scalarlayer.area. Only a minimum-area (below-threshold) selection maps toRuleKind::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:
| Rule | Kind | Reticle count | KLayout count | Verdict agrees |
|---|---|---|---|---|
m1.1 | Width | 1 | 1 | yes |
m1.2 | Spacing | 1 | 1 | yes |
m1.4 | Enclosure | 1 | 4 | yes |
li.6 | Area | 1 | 1 | yes |
m2.1 | Width (clean) | 0 | 0 | yes |
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’snotchis an intra-polygon concavity check (a narrow gap within one polygon). The engine’sNotchis 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 acceptsnotchand maps it toRuleKind::Notch, but the two tools measure different things, so the fixture does not comparenotchverdicts.- 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 fromdocs/rules/gds_layers.csvin 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 declaresdbu_per_micron 1000accordingly.
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 id | Kind | Layer(s) | Value | Meaning |
|---|---|---|---|---|
li.1 | width | li1 (67/20) | 170 (0.17 um) | Min width of li1 |
li.3 | spacing | li1 (67/20) | 170 (0.17 um) | Min li1 to li1 spacing |
li.5 | enclosure | licon1 (66/44) in li1 (67/20) | 80 (0.08 um) | licon1 enclosed by li1 |
li.6 | area | li1 (67/20) | 56100 (0.0561 um²) | Min li1 area |
m1.1 | width | met1 (68/20) | 140 (0.14 um) | Min width of met1 |
m1.2 | spacing | met1 (68/20) | 140 (0.14 um) | Min met1 to met1 spacing |
m1.4 | enclosure | mcon (67/44) in met1 (68/20) | 30 (0.03 um) | mcon enclosed by met1 |
m1.6 | area | met1 (68/20) | 83000 (0.083 um²) | Min met1 area |
m2.1 | width | met2 (69/20) | 140 (0.14 um) | Min width of met2 |
m2.2 | spacing | met2 (69/20) | 140 (0.14 um) | Min met2 to met2 spacing |
m2.4 | enclosure | via (68/44) in met2 (69/20) | 55 (0.055 um) | via enclosed by met2 |
m3.1 | width | met3 (70/20) | 300 (0.3 um) | Min width of met3 |
m3.2 | spacing | met3 (70/20) | 300 (0.3 um) | Min met3 to met3 spacing |
m4.1 | width | met4 (71/20) | 300 (0.3 um) | Min width of met4 |
m4.2 | spacing | met4 (71/20) | 300 (0.3 um) | Min met4 to met4 spacing |
m5.1 | width | met5 (72/20) | 1600 (1.6 um) | Min width of met5 |
m5.2 | spacing | met5 (72/20) | 1600 (1.6 um) | Min met5 to met5 spacing |
poly.1a | width | poly (66/20) | 150 (0.15 um) | Min width of poly |
poly.2 | spacing | poly (66/20) | 210 (0.21 um) | Min poly to poly spacing |
poly.8 | extension | poly (66/20) past diff (65/20) | 130 (0.13 um) | Poly endcap past diff |
difftap.1 | width | diff (65/20) | 150 (0.15 um) | Min width of diff or tap |
difftap.3 | spacing | diff (65/20) | 270 (0.27 um) | Min diff to diff spacing |
licon.1 | width | licon1 (66/44) | 170 (0.17 um) | licon1 size, as min width |
ct.1 | width | mcon (67/44) | 170 (0.17 um) | mcon size, as min width |
via.1a | width | via (68/44) | 150 (0.15 um) | via size, as min width |
via2.1a | width | via2 (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/npcand 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, andsky130_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
mainat commitac7fb61f06e6470b94e8afdf7c25268f62fbd7b1on 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 asLICENSE-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):
| role | SKY130 | SG13G2 | SG13G2 GDS |
|---|---|---|---|
| conductor 0 | li1 | Metal1 | 8/0 |
| conductor 1 | met1 | Metal2 | 10/0 |
| conductor 2 | met2 | Metal3 | 30/0 |
| conductor 3 | met3 | Metal4 | 50/0 |
| cut 0 (0↔1) | mcon | Via1 | 19/0 |
| cut 1 (1↔2) | via | Via2 | 29/0 |
| cut 2 (2↔3) | via2 | Via3 | 49/0 |
| substrate tap | licon1 | Cont | 6/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:
- 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).
- Classifies each gate as NMOS or PMOS from the surrounding implant and
well: p+ select (
psdm) or annwellmeans 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. - 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_devicesmatches 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_areais the union area of polysilicon (poly, 66/20) on the net, approximated as all poly, not poly intersected with diffusion.connected_metal_areais the union area ofli1(67/20) andmet1..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
afterwith no match inbeforeis added. - A shape present in
beforewith no match inafteris 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 ofd.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:
- Snapshot captures the current document as the baseline (the before).
- Edit the layout.
- 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
yrsupdates 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).
Permalinks
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 aslayer/datatypespecs (for example68/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=viewsocket 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:
undoreverts 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.
redore-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 byte | Variant | Meaning |
|---|---|---|
0x0A | update | a CRDT document delta |
0x12 | presence | cursor, selection, viewport |
0x1A | comment | a 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::nativespawns the axum relay in-process on an ephemeral port.Target::externaladdresses any relay by URL: the Durable Object underwrangler dev --local(miniflare, no Cloudflare auth), or a deployedwss://...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_restarttest 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_iterationscap applies, and the run is recorded as an honest failure. Theconflicting_refinement_is_bounded_by_max_iterations_not_infinitetest proves the loop stops at the cap rather than looping. - Frozen context type. The refinements ride in the existing
Context::feedbackchannel, soreticle_bench’sContexttype is unchanged. A caller with no mid-session constraints usesrun_agent_task, which isrun_agent_task_refinedwith aNoRefinementssource 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:
- the scoped region;
- 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;
- the violated rule, stated structurally (kind, layers, measured value, required value);
- 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:
| Context | Tokens | Characters |
|---|---|---|
| Whole-document, full per-shape fidelity | 1878 | 7509 |
| Scoped pack (one shape + violation + one relevant rule) | 62 | 245 |
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-mcpexposes this command surface to a model over the Model Context Protocol; see the MCP chapter.reticle-demo-serverruns 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.jsonlfiles. - 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_rulesthe active technology’s layers and DRC rules;get_document_summarythe current cells, shape counts, and top cells;get_render_regiona 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_combineunion, intersection, difference, or xor over two or more shapes, writing the result to a target layer and deleting the inputs;align_shapesline a set of shapes up (left, right, top, bottom, or centered) within their combined bounding box;distribute_shapesrespace three or more shapes so adjacent gaps are equal;offset_shapesgrow or shrink shapes by a database-unit offset;build_via_stackplace 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_ringa closed conductor ring around a rectangular region, optionally lined with a row of substrate-tap contacts;via_farman array of cuts between two conductor layers, covered by enclosing lower and upper plates;pad_ringa die-size-aware ring of I/O pad structures around the die edge, with corner keep-outs and reinforced power pads;seal_ringa continuous stacked-metal-plus-cut barrier around the die edge;filla regular grid of fill tiles over a region, approaching a target coverage density;test_structurea 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:
| Tier | Focus | Examples |
|---|---|---|
| 1 | Primitive placement and legality | place a met1 rectangle, clear the min width and min area rules |
| 2 | Structured geometry | contact stacks, via chains, comb structures |
| 3 | Larger structured geometry, connectivity intent, and Wave-3 tool ops | guard rings, multi-net intent, boolean unions/intersections/differences, arrays with pitch, via stacks |
| 4 | Compound cells and iterative refinement | cells composed of several checked features, tasks with a scripted follow-up constraint |
| 5 | Real SKY130 PDK | named 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_resultchecker 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_pitchchecker 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_stackcut bridging met1/met2, li1/met1, and poly/li1. Thecontact_stackchecker 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
refinementfollow-up (“make it larger”, “add a second shape”). The refinement-aware runner folds the follow-up into the model’s feedback between iterations through thereticle-agentrefinement 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, andnet_trace_isolatedare built directly on the F3 trace-query API (reticle_extract::net_at_point/net_extent) rather than re-deriving connectivity the way theintentchecker 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_boxexercises the Phase 2 user-PCell API (reticle_gen::PCellDef::effective_params/effective_param_hash/validate_params) for a fixedbench.box_padPCell definition, one task leaving a parameter to the PCell’s schema default and one overriding it explicitly. The checker resolves parameters through the realPCellDefmethods; the reference geometry itself is a Rust-native port of the PCell’s script (reticle-benchdoes not depend onreticle-script, the sandboxed producer, so it cannot run the script directly). Seedocs/decisions/0113-phase3-benchmark-tasks.md. - Multi-step edits (2, tier 4):
t4_multistep_grow_enclosureandt4_multistep_reposition_viareuse the existingcontact_stack/via_chaincheckers 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.
| Model | Quantization | Tier 1 | Tier 2 | Tier 3 | Tier 4 | Tier 5 | Overall |
|---|---|---|---|---|---|---|---|
gpt-oss:16k (20B) | MXFP4 | 8/9 | 9/11 | 20/42 | 5/11 | 7/10 | 49/83 (59%) |
qwen2.5-coder:16k (14B) | Q4_K_M | 7/9 | 8/11 | 6/42 | 3/11 | 5/10 | 29/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 thebackendandquantizationlabels) 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'.
| Tier | Tasks (v0.4.0) | Focus | Examples |
|---|---|---|---|
| 1 | 9 | Primitive placement and legality | place a met1 rectangle; clear the min-width and min-area rules |
| 2 | 11 | Structured geometry | contact stacks, via chains, comb structures |
| 3 | 34 | Larger structured geometry, connectivity intent, and Wave-3 tool ops | guard rings, multi-net intent, boolean unions/intersections/differences, arrays at a stated pitch, via stacks |
| 4 | 11 | Compound cells and iterative refinement | cells composed of several checked features; tasks with a scripted follow-up constraint |
| 5 | 10 | Real SKY130 PDK | named 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):
| Model | Quantization | Tier 1 | Tier 2 | Tier 3 | Tier 4 | Tier 5 | Overall | Mean iterations |
|---|---|---|---|---|---|---|---|---|
gpt-oss:16k | MXFP4 | 8/9 (89%) | 9/11 (82%) | 20/42 (48%) | 5/11 (45%) | 7/10 (70%) | 49/83 (59%) | 1.58 |
qwen2.5-coder:16k | Q4_K_M | 7/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:16kcan 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_Mon 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 ispassed/total (rate)over every committed record for that row.
Rankings
| Kind | Model | Backend | Quantization | Suite | Tier 1 | Tier 2 | Tier 3 | Tier 4 | Tier 5 | Overall | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| agent system | claude-sonnet-5 | claude-code | - | 0.7.0 | 5/5 | 8/8 | 26/28 | 4/7 | 5/5 | 48/53 (91%) | |
| agent system | claude-sonnet-5 | claude-code | - | adhoc | 8/9 | 10/11 | 38/41 | 7/11 | 9/9 | 72/81 (89%) | |
| bare model | gpt-oss:16k | ollama | MXFP4 | 0.4.0 | 9/9 | 11/11 | 19/34 | 5/11 | 8/10 | 52/75 (69%) | |
| bare model | qwen2.5-coder:16k | ollama | Q4_K_M | 0.4.0 | 6/9 | 8/11 | 6/34 | 3/11 | 6/10 | 29/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):
| Field | Type | Meaning |
|---|---|---|
task_id | string | The task that ran. Must begin with a t<N>_ tier prefix (for example t1_place_met1_rect); the leaderboard reads the tier from it. |
model | string | The model identifier (or mock). |
suite_version | string | The suite version the task came from. |
success | bool | Whether the two-way-tested checker passed. |
iterations | number | Propose-verify-correct iterations used. |
first_proposal_violations | number | DRC violations in the first proposal. |
final_violations | number | DRC violations in the final document. |
wall_ms | number | Wall-clock time for the task, in milliseconds. |
backend | string | The client kind: mock, ollama, anthropic, claude-code, … |
quantization | string or null | The 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...>/.endswrapper. - One
Xdevice-instance card per device:Xn <drain> <gate> <source> <bulk> <model> w=<W> l=<L>,nthe device’s index inDeviceNetlist::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
DeviceDBU fields to decimal microns by exact integer long division, never a float:650DBU at1000DBU/micron is the literal string"0.65", with no0.6500000001-style formatting drift. - The model name comes from
SpiceTech, a small table the caller supplies, keyed only onDeviceKind(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):Devicecarries no diffusion-area data, so these are never invented. - A guessed node name: a terminal
extract_devicescould not bind to a net (Option::None) is written as the documented placeholder nodeNC. - A general SPICE importer:
parse_spicereads back only the subsetformat_spicewrites, enough to round-trip the writer’s own output and validate the committed contract fixture. It never panics on malformed input – every failure is aSpiceParseError– 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_oracleloads (or reloads) the waveform set and reveals the section.waveform.export_csvdumps 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
Boundson 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_nanodivided by1e9, time in nanoseconds (time_fsdivided by1e6). - 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_tiletemplate: the 1x2 die outline, the sixua[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
.lefpin abstract. Reticle does write LEF withPINblocks (write_lef,crates/reticle-lefdef/src/write.rs, reachable asreticle export-lefdefand as thefile.export_lefdefcommand), but the worked tile’s pins are drawn met4 geometry rather thanPinobjects, 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 bydocs/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
.vinterface view (andyowasp-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, oranalog_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]throughua[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 optionalVAPWR(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 att_boundarymarker (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 thett_um_reticle_tilecell: the 1x2 die outline (( 0 0 )..( 161000 225760 )DBU), the sixua[0]..ua[5]analog pins on met4 (each a( -450 -500 )( 450 500 )port at the DEF’sPLACEDx 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 ishpretl/iic-osic-tools:2025.01(amd64 digestsha256: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 callsscripts/tt-precheck.ps1, which stages a minimal Tiny Tapeout project (aninfo.yamlwhosetop_moduleequals the GDS filename stem, which the precheck requires and asserts), mounts that project and a pinnedtt-support-toolscheckout, runspython precheck/precheck.py --gds <gds> --tech sky130Ain 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 ofjust ci, like the nightly-onlyfuzz/mirirecipes, 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 intoPrecheckReport { passed, failures: Vec<PrecheckFailure> }, where aPrecheckFailure { rule, layer, location, message }is modeled on areticle_model::Violation.parse_results_mdrecords each failed Markdown row as a structural failure carrying the precheck’s own message;parse_magic_drcturns each Magic DRC rectangle (four micron floats) into a located failure at its bounding box in database units.passedis the precheck’s own verdict, not “no failures parsed”, and a missingresults.mdis an error, not a silent pass. - The agent-loop seam.
PrecheckReport::feedback_lines()returns exactly theVec<String>the propose-verify-correct loop folds into its model context (the sameContext::feedbackchannel 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 bytracingspans, 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 -- --helplists the import, DRC, route, extract, export, and render-to-image commands. - Generate a layout:
just gen-layout 1000000 8 3 scratch/gen.rgdswrites 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:
- 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.
- The canvas. Pan by dragging and zoom toward the cursor by scrolling; Fit frames the whole design.
- Layers. The left-hand layer manager toggles visibility and filters layers by name.
- Measure. Pick the Measure tool from the toolbar and click two points to read a distance in database units and microns.
- Design-rule checking. Run DRC and click a violation to zoom straight to it.
- Net highlight. Light up every shape connected to a net across the design.
- Minimap. The overview panel frames the current view; click inside it to jump the camera.
- Agent and replay. The agent panel runs a scripted edit session and the replay theater plays a recorded run back step by step.
- 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:
- Drawing tools: rectangles, polygons, paths, and vertex editing.
- Boolean and transform: union, intersect, subtract, and transforms, all undoable.
- Productivity: copy, duplicate, arrays, move-by-delta, and via stacks.
- Snapping and guides: snap to vertices, edges, midpoints, and centers, with draggable ruler guides.
- Layer and technology editing: reorder, recolor, restyle, and edit the technology definition.
- Search and selection: filter shapes with a query, save selection sets, and navigate the cell outline.
- 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/.gzof 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_1inverter 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:
- Run the design-rule checker. The violation overlay marks the narrow wire and the
error browser lists the
m1.1violation with its measured-versus-required width. - 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).
- Re-run the checker. With both wires meeting the minimum, the
m1.1violation 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
0xRRGGBBAAvalue; 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-servercollaboration relay, so a visitor can watch the room each session draws into; - a harness: the real
reticle-agentpropose-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:
| Field | Value | On breach | Why it matters |
|---|---|---|---|
per_ip_rate_per_min | 6 | 429 | Caps how fast one source IP can submit, so a single visitor cannot flood the queue. |
per_ip_concurrency | 1 | 409 | One live session per IP, so one visitor cannot hold multiple agent loops at once. |
global_concurrency | 4 | 503 | A hard ceiling on concurrent agent loops across the whole server, bounding CPU, memory, and (with a real key) model spend. |
token_budget | 100000 | session cancelled | A runaway loop is cancelled before it can burn tokens. |
command_budget | 200 | session cancelled | Bounds how many edits one session can issue, so a loop cannot grow a document without limit. |
max_prompt_len | 400 chars | 400 | Bounds the input a visitor can submit. |
allowed_vocabulary | task words | 400 | A 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 ischmod 600, root-owned, and outside the repo. - Without a key, the server still runs: it uses the offline scripted harness, so
just demo-upand 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 thereticle-serverroom 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_concurrencyalready 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.devURL, so the archives stay off the rate-limited path and behind the Worker’s CORS lock. - Range. A
Range: bytes=a-brequest is answered206 Partial Contentwith aContent-Rangeheader and exactly those bytes; a request with noRangereturns the whole object as200.Accept-Ranges: bytesis 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
206or aContent-Rangeresponse, a ranged body is cached as a normalized200that records the real status and range in internal headers, then reconstructed on a hit. - CORS.
Access-Control-Allow-Originis locked to the Pages originhttps://alpharomerojl.github.io(never*), and anOPTIONSpreflight is answered204with 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.jsondeclares the install metadata: the name, the standalone display mode, the#0b0e14theme and background colors, and 192 and 512 px icons (including a maskable variant). It is linked fromindex.htmlwith a relativehref="manifest.json".sw.jsis 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.htmlregisters./sw.json window load, when service workers are supported and the page is not opened fromfile://.
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 itsstart_urlandscopeare 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.rtlaarchive 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/.gzof any of them; see File formats), since embedding never changes the import/streaming data path (see above). The query key staysgdsfor 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 listedlayer/datatypepairs (for example68/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: DENYor a restrictiveContent-Security-Policy: frame-ancestors, or the browser refuses to render the frame. The published demo sends neither header: checked 2026-07-11 withcurl -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 permissiveAccess-Control-Allow-Originand, for&archive=, allowRangerequests (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.jsonleavesbundle.activeat its defaultfalse; this crate builds a plain executable, not a signed package. Follow-on work. - The real agent.
reticle-agentis already a dependency ofdesktop/(seecargo treebelow), 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-srcdoes not yet include the Share relay’swss://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 toi64/i128for 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
unsafeis isolated, carries a// SAFETY:justification, and is covered by tests andmiri(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-checkguards 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.