diff --git a/.gitignore b/.gitignore
index 5e8573d..c4f8e7d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,8 @@ build/
*.iml
local.properties
.DS_Store
+
+# Benchmark runs: results/baseline.json is committed; everything else is per-run.
+benchmarks/results/latest.json
+benchmarks/results/latest.md
+benchmarks/results/fork-*.json
diff --git a/.kilo/plans/1788803586700-website-and-skill-plan.md b/.kilo/plans/1788803586700-website-and-skill-plan.md
new file mode 100644
index 0000000..8f3fd90
--- /dev/null
+++ b/.kilo/plans/1788803586700-website-and-skill-plan.md
@@ -0,0 +1,359 @@
+# Synapse Website + Agent Skill — Implementation Plan
+
+## Goal
+Deliver a deployable Astro + Fumadocs marketing/docs site for the Synapse library, plus an agent skill `synapse-pubsub-ftc`. Both must be AI-friendly.
+
+## Resolved Decisions
+- **Brand palette:** Teal-signal + lime accent on near-black. Primary `#0E7C7B`, accent `#B6FF3C`, surface `#0A0F14`, surface-elevated `#11181F`, text `#E6F1EE`, muted `#7A8A86`. Dark-mode default with a designed light mode (not auto-invert).
+- **Site scope:** `/` (home + CTA), `/docs` (Fumadocs), `/install`, `/changelog`, `/community`. No blog.
+- **Skill name:** `synapse-pubsub-ftc`.
+
+## Content Honesty Rules (hard constraints)
+- **No fabricated information.** Every claim on the site must be backed by either (a) source code in this repo with a `file_path:line_number` reference, or (b) the contents of `README.md` / `CHANGELOG.md`. No invented numbers, benchmarks, download counts, adoption stats, latency figures, or "X teams use this" claims.
+- **No testimonials.** Do not invent quotes, attributions, or endorsements. If real quotes are ever added, they must come from a named person with their explicit written permission and a verifiable source (PR comment, email, etc.).
+- **Known user (only one):** FTC Team 23684 — Tech Titans. They may be acknowledged on `/community` as the one confirmed team using Synapse. Do not extrapolate to "multiple teams", "FTC-wide adoption", or any other usage claim.
+- **ComparisonTable is opinion, not benchmark.** Frame raw-FTC vs. Synapse rows as design choices (e.g., "Synapse provides a single hardware thread; raw FTC does not enforce one") — never as measured numbers like "X% fewer races" or "Y ms faster".
+- **No "social proof" widgets** (download counters, fake testimonial carousels, GitHub star counters unless rendered live from the actual API at build time — and even then, only as optional decoration, not a claim).
+
+## Layout (created inside `website/`)
+
+```
+website/
+├── astro.config.mjs
+├── package.json
+├── tsconfig.json
+├── tailwind.config.ts # only if needed; Fumadocs ships its own token system
+├── Dockerfile
+├── docker-compose.yaml
+├── .dockerignore
+├── README.md
+├── public/
+│ ├── favicon.svg
+│ ├── og-default.png
+│ └── robots.txt
+└── src/
+ ├── env.d.ts
+ ├── styles/
+ │ ├── global.css # imports tailwindcss + fumadocs-ui css variables override
+ │ └── tokens.css # Synapse brand CSS custom properties
+ ├── lib/
+ │ ├── source.ts # Fumadocs loader (mirror of manual install)
+ │ ├── search.ts # Orama index generator
+ │ ├── ai.ts # helpers to generate /llms.txt and per-page .md
+ │ └── changelog.ts # reads CHANGELOG.md from repo root at build time
+ ├── components/
+ │ ├── layout/
+ │ │ ├── BaseLayout.astro #
, JSON-LD, ViewTransitions, theme boot
+ │ │ ├── Nav.astro # logo + links + GitHub star CTA
+ │ │ ├── Footer.astro
+ │ │ └── ThemeToggle.astro
+ │ ├── marketing/
+ │ │ ├── Hero.astro # neural-network animated SVG, install cmd, two CTAs
+ │ │ ├── FeatureGrid.astro # 6 feature cards
+ │ │ ├── SafetyPillars.astro # the 4 properties the README sells
+ │ │ ├── LiveCodePreview.astro # client:visible Shiki + typewriter
+ │ │ ├── ArchitectureDiagram.astro
+ │ │ ├── ComparisonTable.astro # raw FTC vs. Synapse (design choices, no numbers)
+ │ │ └── InstallSnippet.astro # copy-to-clipboard Gradle block
+ │ └── react/
+ │ ├── Docs.tsx # wraps fumadocs-ui DocsLayout + DocsPage
+ │ ├── Search.tsx # orama static-client search dialog
+ │ ├── HeroCanvas.tsx # canvas pulse-line neural network
+ │ └── CopyButton.tsx
+ ├── content/
+ │ └── docs/ # MDX files for Fumadocs
+ │ ├── index.mdx # docs landing
+ │ ├── meta.json
+ │ ├── get-started/
+ │ │ ├── index.mdx
+ │ │ ├── install.mdx
+ │ │ └── first-opmode.mdx
+ │ ├── concepts/
+ │ │ ├── index.mdx
+ │ │ ├── orchestrator.mdx
+ │ │ ├── topics.mdx
+ │ │ ├── nodes.mdx
+ │ │ └── subscriptions.mdx
+ │ ├── annotations/
+ │ │ ├── index.mdx
+ │ │ ├── subscribed-to.mdx
+ │ │ ├── run-periodically.mdx
+ │ │ ├── runnable-action.mdx
+ │ │ └── on-hardware-thread.mdx
+ │ ├── ftc/
+ │ │ ├── index.mdx
+ │ │ ├── safe-opmode.mdx
+ │ │ ├── hardware-actions.mdx
+ │ │ ├── safe-device.mdx
+ │ │ └── gamepad-adaptor.mdx
+ │ ├── recipes/
+ │ │ ├── index.mdx
+ │ │ ├── mecanum-drive.mdx
+ │ │ ├── bulk-read-sensors.mdx
+ │ │ └── two-controller-teleop.mdx
+ │ └── api/
+ │ └── index.mdx # hand-curated API surface for v1
+ └── pages/
+ ├── index.astro # marketing home
+ ├── install.astro
+ ├── changelog.astro # renders src data via lib/changelog.ts
+ ├── community.astro
+ ├── docs/
+ │ └── [...slug].astro # catch-all routing into Fumadocs
+ ├── api/
+ │ └── search.ts # fumadocs-core search endpoint
+ ├── og/
+ │ └── docs/[...slug]/image.webp.ts
+ ├── llms.txt.ts # returns Markdown root llms.txt
+ ├── llms-full.txt.ts # concatenated markdown of every page
+ └── docs.md.ts # returns /docs as concatenated markdown (describedby)
+```
+
+## Tech Stack
+- Astro 5 (`output: 'static'`), `@astrojs/react`, `@astrojs/mdx`, `@astrojs/sitemap`.
+- Tailwind CSS 4 via `@tailwindcss/vite` (Fumadocs requires it). Override the `fumadocs-ui/css/neutral.css` and `preset.css` variables with Synapse tokens in `global.css`.
+- `fumadocs-core`, `fumadocs-ui` (DocsLayout + DocsPage inside a React island).
+- `takumi-js` + `sharp` for OG images (per manual install).
+- `shiki` for code highlighting (Fumadocs handles via `rehypeCode`).
+- `@orama/orama` for the search index (fumadocs-core ships `staticClient`).
+- `gray-matter` to parse `CHANGELOG.md` (Keep-a-Changelog format).
+- No SSR adapter needed — site is fully static so the Docker image is nginx-based (smaller, faster).
+
+## Design System
+- **Type:** Display = `Space Grotesk` (700) for headings; Body = `Inter Variable` (400/500); Mono = `JetBrains Mono Variable` for code. Self-hosted via Fontsource-free CDN with `font-display: swap`.
+- **Logo:** Custom SVG monogram — a stylized synapse: two nodes joined by an animated pulse line. Inline SVG in `Nav.astro`; no raster fallback.
+- **Color tokens** (CSS custom properties in `tokens.css`):
+ - `--signal`: `#0E7C7B`
+ - `--signal-bright`: `#1FBFB6`
+ - `--lime`: `#B6FF3C`
+ - `--surface-0`: `#0A0F14`
+ - `--surface-1`: `#11181F`
+ - `--surface-2`: `#18222A`
+ - `--text`: `#E6F1EE`
+ - `--text-muted`: `#7A8A86`
+ - `--border`: `#1E2A33`
+ - Light mode is a separate token set (surface `#F7FAF9`, text `#0A1417`).
+- **Theme toggle:** controlled by `data-theme` attribute, persisted in `localStorage`, no FOUC via inline boot script in ``.
+- **Motion:**
+ - Hero: canvas-based pulse-line neural network (≈ 12 nodes, lines drawn with spring tension, pulses fire on edges). React island `client:visible`, respects `prefers-reduced-motion`.
+ - Scroll: IntersectionObserver-driven reveal (CSS classes only, no JS lib).
+ - Astro `` for SPA-style transitions between marketing pages.
+ - All non-essential motion disabled when `prefers-reduced-motion: reduce`.
+- **Iconography:** `lucide` via inline SVGs (no icon font).
+
+## Page Content (high-level)
+
+### `/` (home)
+1. **Hero** — H1 "Robots that listen to each other." Subhead explaining Synapse in ≤ 25 words. Two CTAs: "Read the docs →" and "Install for FTC". Below: copy-paste Gradle snippet (`implementation 'com.aaravlabs:synapse:0.3.1'`). Live canvas behind text.
+2. **FeatureGrid** — 6 cards: `@SubscribedTo` typed callbacks, `@RunPeriodically` fixed-rate loops, `@RunnableAction` named commands, `@OnHardwareThread` marker, `SafeOpMode` drop-in base, `GamepadAdaptor` zero-boilerplate controls.
+3. **LiveCodePreview** — split panel: left is annotated `DriveNode.java`, right shows the same code with hover annotations explaining each line.
+4. **SafetyPillars** — the four properties the tests prove: (a) single hardware thread, (b) two-pool isolation, (c) copy-on-write subscribe snapshots, (d) `assertNotHardwareThread` fail-fast.
+5. **ComparisonTable** — raw FTC vs. Synapse, framed as design choices (no benchmark numbers): e.g., "Synapse provides a single hardware thread; raw FTC does not enforce one." Each row cites the source (`README.md`, `OrchestratorImpl.java:93`, etc.).
+6. **Footer** — license, repo, docs, community, install.
+
+### `/install`
+- Three tabs: **Gradle (Groovy)**, **Gradle (Kotlin DSL)**, **Maven**.
+- Step-by-step: 1) add dependency, 2) apply R8 keep rules (link to synapse.pro), 3) extend `SafeOpMode` (one-file copy-paste), 4) verify on `BIND` event, 5) optional: enable `GamepadAdaptor`.
+- "Verify it works" block with a minimal `MyFirstOpMode` and expected logcat lines.
+
+### `/changelog`
+- At build time, `lib/changelog.ts` reads `CHANGELOG.md` copied into the builder from the repository-root build context (Keep-a-Changelog 1.1.0), parses it with `gray-matter` + a tiny regex pass for `## [x.y.z] - date` sections, returns structured data.
+- Renders version cards (badge per SemVer), grouped by Added/Changed/Fixed/Removed. Highlights latest release.
+
+### `/community`
+- Three blocks:
+ 1. **Contribute code** — link to repo, list of "good first issues" fetched at build time via `gh api` CLI (cached as JSON during build). Fall back to static list if offline.
+ 2. **Share projects** — invite FTC teams using Synapse to open a PR adding their team to a showcase list. Include a single confirmed entry as the example: **FTC Team 23684 — Tech Titans** (with whatever public info the team has consented to share: name, number, optional link). No other teams are listed unless explicitly added later.
+ 3. **Improve docs** — link to the `content/docs/` source and the "Edit on GitHub" pattern.
+- Contributor ladder (Inspired by some open-source projects): Contributor → Maintainer → Reviewer.
+- **Explicit on-page disclaimer**: "Synapse is new. FTC Team 23684 — Tech Titans is the one team confirmed to be using it." (No invented "users", "downloads", "stars", or community size claims anywhere on this page.)
+
+### `/docs` (Fumadocs)
+- Sidebar tree: Get Started → Concepts → Annotations → FTC Integration → Recipes → API.
+- Top bar search dialog (Orama) triggered with `⌘K`/`Ctrl K`.
+- Each MDX page renders through `Docs.tsx` React island; TOC generated from headings; "Edit on GitHub" link.
+- Home page of docs (`docs/index.mdx`) is a friendly gateway: 60-second tour, then direct links into sections.
+
+## AI-Friendly Requirements
+- **`/llms.txt`** — generated at build from `pages/llms.txt.ts`. Markdown following llmstxt.org v2 spec: H1, blockquote summary, sections per route, links to `.md` mirror. Updates whenever content collections change.
+- **`/llms-full.txt`** — every page concatenated, plain markdown, ≤ 1 MB.
+- **Per-page `.md` mirror** — pre-rendered at build time as a static `.md` file beside each docs page (MDX body stripped of components → plain markdown via `remark`). Nginx serves the pre-rendered `.md` when the request's `Accept` header prefers `text/markdown` (content negotiation lives in `nginx.conf`; Astro middleware does not run at request time with `output: 'static'`). The `Link: ; rel="alternate"; type="text/markdown"` header is emitted by Nginx `add_header` (see Docker Deployment).
+- **`/docs.md`** — same as llms-full but scoped to `/docs/`.
+- **JSON-LD** on every page (`SoftwareSourceCode` for repo, `TechArticle` for docs, `WebSite` with `SearchAction` for home, `Organization` on `/community`).
+- **Semantic HTML**: single `
` per page, proper landmark roles, `aria-label` on icon-only nav buttons.
+- **``** + OG tags generated per page via a shared helper.
+- **Sitemap.xml** via `@astrojs/sitemap`.
+- **`robots.txt`** allows all and points to `/llms.txt`.
+- **Structured headings** so an LLM can chunk docs naturally.
+
+## Docker Deployment
+- **Multi-stage Dockerfile** (build context = repository root so `CHANGELOG.md` is inside it):
+ - `node:lts-alpine` builder copies `website/` and `CHANGELOG.md`, then runs `npm ci && npm run build`.
+ - `nginx:alpine` runtime copies `dist/` and a custom `nginx.conf` that:
+ - serves on `0.0.0.0:8080`
+ - sets `Cache-Control: public, max-age=31536000, immutable` for hashed assets
+ - sets `Cache-Control: public, max-age=0, must-revalidate` for HTML
+ - emits `Link: ; rel="describedby"` and per-page markdown alternate `Link` headers via `add_header` (`sub_filter` is only for rewriting response-body content, e.g. per-page markdown alternate links inside the HTML)
+ - falls back to `/404.html` for missing routes
+ - gzip + brotli for text
+- **`docker-compose.yaml`** at `website/docker-compose.yaml` (context is the repository root so the builder can copy `CHANGELOG.md`):
+ ```yaml
+ services:
+ web:
+ build:
+ context: ..
+ dockerfile: website/Dockerfile
+ image: synapse-website:local
+ container_name: synapse-website
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
+ interval: 30s
+ timeout: 3s
+ retries: 3
+ ```
+- Adds a `/healthz` endpoint that returns 200 with text `ok`.
+
+## Agent Skill — `synapse-pubsub-ftc`
+Location: `/home/aarav/apps/ftcpubsub/.kilo/agent/synapse-pubsub-ftc/SKILL.md` (plus one `references/` subdir).
+
+### `SKILL.md` outline (target ≈ 280 lines)
+```
+---
+name: synapse-pubsub-ftc
+description: Builds FIRST Tech Challenge (FTC) robot code on the Synapse annotation-driven pub/sub bus. Use when the user asks for FTC robot code, hardware-thread-safe teleop/autonomous logic, or to refactor raw FTC OpModes to Synapse, or mentions Synapse / synapse-pubsub / @SubscribedTo / @RunPeriodically / @OnHardwareThread / SafeOpMode / HardwareActions / SafeDevice / GamepadAdaptor / Synapse orchestrator.
+---
+
+# Synapse on FTC
+
+## When to use
+- Writing a NEW FTC OpMode that should use Synapse primitives.
+- Migrating an existing OpMode off direct `DcMotorEx` calls onto the hardware thread.
+- Debugging a race condition or repeated `RuntimeException` involving `DcMotor` / `Servo` / `IMU`.
+- Wiring gamepads / sensors / actuators via topics.
+
+## When NOT to use
+- Pure FRC / non-FTC code. STOP and recommend the FTC analogue only.
+- Tasks that don't involve hardware (vision pipelines, UI) — Synapse still works but is overkill.
+
+## Mental model
+- One `Orchestrator` per robot. It owns four executors (NOT in the API: scheduler, callback pool, action pool, **single** hardware thread).
+- Annotations are the entry point; classes extend `Node` and methods are bound by `AnnotationBinder` at runtime.
+- All hardware calls must cross the hardware thread. Off-thread access throws.
+- Topics are typed (`Topic`, `Topic`). Subscribers get type-checked callbacks.
+
+## Core workflow (copy/paste this checklist)
+```
+- [ ] 1. Confirm user wants Synapse (not raw FTC) — see When to use above.
+- [ ] 2. Identify OpMode lifecycle hooks needed (init / loop / stop) and whether teleop or auto.
+- [ ] 3. Decide the Topics (name + Java type) BEFORE writing nodes.
+- [ ] 4. Build Node classes (one concern each: drive, intake, shooter, etc.).
+- [ ] 5. Wire hardware via `safeMap.device(...)` and `hardware.call(...)` — never raw.
+- [ ] 6. Validate hardware-thread discipline with `hardware.assertNotHardwareThread()` in `loop()`.
+- [ ] 7. Provide a copy-paste `MyFirstOpMode extends SafeOpMode` if user is new.
+```
+
+## Decision trees
+
+### "Should this run on the hardware thread?"
+- Touches any `DcMotor*`, `Servo*`, `IMU`, `ColorSensor`, `TouchSensor`, `DigitalChannel` → YES via `@OnHardwareThread` or `hardware.call(...)`.
+- Reads gamepad input → NO (gamepad reads are off-thread safe and update via `GamepadAdaptor`).
+- Pure math / state machine → NO.
+
+### "Periodic vs action vs subscription?"
+- Needs to fire every N ms regardless of input → `@RunPeriodically(hz=N)`.
+- One-shot in response to a button → `@RunnableAction("name")` + `orch.runAction("name")`.
+- Reactive to a state change → `@SubscribedTo(topic = "topic")`.
+
+### "Topic type — primitive vs wrapper?"
+- Either works; `double` and `Double` are interchangeable at the topic layer (`boxed(Class)` helper). Prefer primitives in annotations.
+
+## Patterns
+[Use this template when generating a new node]
+```java
+public class DriveNode extends Node {
+ private final HardwareActions hardware;
+ public DriveNode(Orchestrator orch, SafeHardwareMap map) {
+ this.hardware = orch.hardware();
+ orch.registerNode(this);
+ }
+
+ @SubscribedTo(topic = "drive/target")
+ void onTarget(TargetPose t) {
+ hardware.run(() -> /* motor writes */);
+ }
+}
+```
+
+## Common mistakes
+1. Calling `motor.setPower(...)` from `loop()` → race. Use `hardware.run(...)` or `@OnHardwareThread`.
+2. Forgetting `orch.registerNode(this)` → annotations never bind.
+3. Using `int` topic for sensor that produces `double` → type mismatch at publish time.
+4. Re-creating an `Orchestrator` per `loop()` → memory leak and thread churn.
+
+## Reference files (load on demand)
+- `references/annotations.md` — full attribute table for each annotation.
+- `references/safety.md` — the four safety invariants and which tests prove each.
+- `references/recipes.md` — copy-paste recipes: mecanum, bulk reads, two-controller teleop.
+- `references/migration.md` — step-by-step raw-FTC → Synapse refactor.
+```
+
+### `references/annotations.md`
+A small table per annotation: target, attributes, defaults, runtime contract, what happens if you violate it, one example.
+
+### `references/safety.md`
+The four invariants from `SafetyPillars.astro`, each cited with the test file that proves it (`HardwareThreadTest.java`, `ThreadingTest.java`, `SoakTest.java`, `SafeOpMode` `loop()`).
+
+### `references/recipes.md`
+Same content as `content/docs/recipes/*.mdx`, condensed to ~150 lines.
+
+### `references/migration.md`
+Step-by-step: identify hardware writes → wrap in `hardware.call` → extract topic types → create `Node` → bind annotations.
+
+## Implementation Order
+
+1. **Scaffold** — `cd /home/aarav/apps/ftcpubsub && npm create astro@latest website -- --template minimal --typescript strict --no-install --no-git --skip-houston`, then `cd website && npm i` the dependency list above. Verify `npm run dev` boots.
+2. **Astro + Tailwind + React + MDX** — wire `astro.config.mjs` per Fumadocs manual install, add `tailwindcss()` Vite plugin, set `site: 'https://synapse.i-am-coder.dev'` (placeholder).
+3. **Brand tokens + global CSS** — `tokens.css` then `global.css` with `@import 'tailwindcss'; @import 'fumadocs-ui/css/neutral.css'; @import 'fumadocs-ui/css/preset.css';` and override the CSS vars.
+4. **Base layout + nav + footer + theme boot script.**
+5. **Homepage** — Hero + canvas island + FeatureGrid + SafetyPillars + LiveCodePreview + ArchitectureDiagram + ComparisonTable + Footer.
+6. **Install page.**
+7. **Changelog parser + page** (read repo-root `CHANGELOG.md`).
+8. **Community page.**
+9. **Docs (Fumadocs)** — implement `lib/source.ts`, `Docs.tsx`, `Search.tsx`, `[...slug].astro`, `api/search.ts`, `og/docs/[...slug]/image.webp.ts`. Author all MDX files in `content/docs/`.
+10. **AI-friendly plumbing** — pre-rendered per-page `.md` mirrors (served by Nginx on `Accept: text/markdown`), `llms.txt.ts`, `llms-full.txt.ts`, `docs.md.ts`, `Link` headers via Nginx `add_header`, JSON-LD helpers.
+11. **SEO** — `@astrojs/sitemap`, `robots.txt`, OG defaults, canonical URLs.
+12. **Docker** — multi-stage `Dockerfile`, `nginx.conf`, `docker-compose.yaml`, `.dockerignore`.
+13. **Agent skill** — create `.kilo/agent/synapse-pubsub-ftc/SKILL.md` and `references/` files.
+14. **README** in `website/` documenting `npm run dev`, `npm run build`, `docker compose up`.
+
+## Validation
+- `npm run build` produces `dist/` with no errors.
+- `npm run dev` → click every marketing page → every docs link → search dialog returns results.
+- `curl -L http://localhost:8080/llms.txt` returns a valid llms.txt v2 document.
+- `curl -LH "Accept: text/markdown" http://localhost:8080/docs/get-started` returns markdown.
+- `curl http://localhost:8080/sitemap.xml` lists every route.
+- `docker compose up --build` serves the site on `:8080`; `curl :8080/healthz` returns 200.
+- Lighthouse desktop ≥ 95 Performance, ≥ 95 Accessibility on `/` and a docs page.
+- `axe-core` run on `/`, `/docs/get-started/install`, `/community` → zero violations.
+- Skill metadata validates (name ≤ 64, lowercase-hyphen, description ≤ 1024, third person).
+- Skill `SKILL.md` body ≤ 500 lines; references stay one level deep.
+- **Content honesty audit:** grep `dist/` for `["0-9]+ teams?`, `downloads?`, `used by`, `powers`, `trusted by`, fake metrics. Only `FTC Team 23684` and `Tech Titans` may appear as a user name. Any other match is a bug — fix before shipping.
+
+## Risks & Mitigations
+- **Fumadocs Astro quirks** — `RootProvider` + `navigate` from `astro:transitions/client` must be the exact form from the manual-install doc or hydration breaks. Pin versions.
+- **OG image generation** — `takumi-js` has no Linux ARM64 wheels yet; if build fails in CI, switch to a static SVG-derived PNG using `sharp`.
+- **Changelog parser drift** — `gray-matter` handles the YAML frontmatter; the `## [x.y.z] - date` regex must tolerate `- TBD` and `- Unreleased`. Keep parser in one file with a `parse()` unit test invoked via `npm run test:parse`.
+- **AI header/mirror delivery** — `Link` headers are emitted by Nginx `add_header` and `.md` mirrors are pre-rendered at build (static Astro output cannot negotiate per request); verify both in `nginx.conf` and in the built `dist/` tree.
+- **No gradle wrapper in repo** — Docker image is nginx-based so no JDK needed; smaller, faster cold start.
+
+## Explicit Out of Scope
+- Blog (user replaced with `/community`).
+- SSR adapter / server endpoints.
+- Authentication, analytics, telemetry.
+- Auto-generating API docs from Javadoc (hand-curated for v1).
+- Translations / i18n.
+- Real testimonials, social-proof widgets, or fabricated usage statistics.
+- Any user list beyond FTC Team 23684 — Tech Titans, unless explicitly added later with consent.
\ No newline at end of file
diff --git a/.kilo/plans/benchmark-suite-plan.md b/.kilo/plans/benchmark-suite-plan.md
new file mode 100644
index 0000000..615667e
--- /dev/null
+++ b/.kilo/plans/benchmark-suite-plan.md
@@ -0,0 +1,291 @@
+# Plan: Objective comparative benchmarks — Synapse vs raw FTC SDK vs SolversLib
+
+## Problem
+
+Synapse has no way to objectively benchmark itself against idiomatic raw FTC SDK code
+or against a command-based framework (user decision: **SolversLib** `org.solverslib:core`
+— the maintained FTCLib fork — not unmaintained FTCLib). Existing "timing" in the test
+suite (`SoakTest`, `HardwareActionsTest`) is correctness-oriented. The website plan
+(`.kilo/plans/1788803586700-website-and-skill-plan.md`) explicitly forbids invented
+numbers, so today there are zero measured claims anywhere.
+
+Goal: a deterministic, repeatable, CI-runnable benchmark suite that AI agents and humans
+can run with one command to (a) compare the three styles across a complexity ladder from
+tiny programs (raw FTC wins) to heavy multi-subsystem robots with expensive vision and
+PIDF loops (Synapse wins), and (b) regress/optimize Synapse internals against a committed
+baseline.
+
+## Hard requirement: bench the frameworks, not mocks
+
+**Measured code paths must execute the real, unmodified framework classes:**
+
+| Style | Real code that must be on the measured path |
+| --- | --- |
+| Synapse | `OrchestratorImpl.publish/subscribe/getOrCreateTopic`, `Topic.recordLatest`, `SubscriberList` dispatch, `AnnotationBinder` reflective invoke for `@SubscribedTo`/`@RunPeriodically`, `HardwareActions.run/call` on the real hardware executor, real `GamepadAdaptor.poll()` |
+| SolversLib | `com.seattlesolvers.solverslib.command.CommandScheduler.run()` from the published AAR, real `Subsystem.periodic()`, `Command.initialize/execute/isFinished/end`, real `GamepadEx.readButtons()` / `ButtonReader` / `GamepadButton` via `CommandScheduler.addButton`, real `setDefaultCommand` |
+| Raw FTC | Literal `OpMode` lifecycle (`init()`/`loop()` on the stub class) with direct field reads and direct device writes — no harness loop abstraction |
+
+**Fakes are allowed only at the physical I/O boundary** (there is no robot on a desktop JVM):
+
+- `SimMotor`/`SimServo`/`SimEncoder` replace `DcMotorEx`/`Servo` — plain field writes into a
+ shared `SimPlant`. Constant-cost, allocation-free.
+- **The `Gamepad` object is the real stub class** (`com.qualcomm.robotcore.hardware.Gamepad`
+ with its real volatile field set from `libs/ftc-sdk-stub.jar`). The stimulus thread flips
+ those fields; `GamepadAdaptor` and `GamepadEx` read them for real. No gamepad double.
+- `SimCamera` produces frames (timestamped buffers); the *processing* is a shared expensive
+ kernel (`SyntheticVisionPipeline`), identical in all styles.
+- `SimPlant` (1 kHz physics integrator thread) is shared "world" code — it is not part of any
+ framework and must never sit inside a measured dispatch segment except as the device write
+ performed by framework-invoked code.
+
+**Prohibited:**
+
+- Any local reimplementation of a scheduler, bus, subscription list, command loop, or button
+ edge detector in the `solverslib`/`synapse`/`raw` style packages. Style packages may only
+ *use* framework APIs and the shared sim kernel.
+- Recording timestamps in harness glue instead of inside the code the framework invoked.
+ Actuation timestamps are written by `SimMotor.setPower` called **from** a `@SubscribedTo`
+ handler / `Command.execute()` / `loop()` body.
+- Wrapping `publish()` in a measured helper that bypasses `OrchestratorImpl` dispatch.
+
+**Verification gates (automated, run as part of the suite):**
+
+1. `:benchmarks:verifyFrameworkClasses` — runtime class-identity assertions in each style's
+ bootstrap: `CommandScheduler` is loaded from the extracted `org.solverslib:core` AAR
+ `classes.jar` (`getProtectionDomain().getCodeSource()`), `OrchestratorImpl` from the
+ `project(':')` output, stub `Gamepad` from `ftc-sdk-stub.jar`. Fails the run if a class
+ resolves from `benchmarks/build` or an unexpected jar.
+2. `:benchmarks:verifyMockBudget` — `micro.sim.deviceWrite` quantifies `SimMotor` write cost
+ and asserts it is < 5% of the smallest framework-level measurement, so mock noise cannot
+ dominate or mask framework overhead.
+3. Structural review rule (documented in `benchmarks/README.md`): `benchmarks/src/main/java/.../shared`
+ contains zero `com.aaravlabs.synapse.*` and zero `com.seattlesolvers.solverslib.*` dispatch
+ types; style packages contain zero classes named like `*Scheduler`, `*Orchestrator`, `*Bus`.
+
+Synthetic SDK stubs (`com.qualcomm.hardware.lynx.LynxModule` etc.) exist only so SolversLib
+classes can *link* on a desktop JVM (the checked-in `ftc-sdk-stub.jar` lacks `LynxModule`,
+which `CommandScheduler` references in `setBulkReading`/`run`). They are never called on a
+measured path (`setBulkReading` is not used; hardware reads go through `SimPlant`).
+
+## Design principles
+
+1. **Identical work, idiomatic code.** Each scenario is the same workload (same physics, same
+ vision kernel, same PIDF math, same stimulus timeline) hand-written three times in each
+ style's natural idiom. Shared code is only the world/sim, never the dispatch.
+2. **Ladder, not a single number.** S0 (raw FTC wins) → S3 (Synapse wins). Reporting must show
+ the whole curve; cherry-picking is impossible by construction.
+3. **Agent-first.** Deterministic seeds, `--quick` (~60 s) and `--full` modes, machine-readable
+ JSON with stable field names, `compare` subcommand with tolerances and exit codes.
+4. **Zero impact on the published artifact.** `benchmarks/` is a separate Gradle subproject;
+ root `build.gradle` publishing/deps untouched. "No new dependencies" (`CONTRIBUTING.md`)
+ applies to the library; benchmark-only deps stay in `:benchmarks`.
+5. **Honest metrics.** Percentiles, not means. Environment metadata in every result. Known
+ confounds documented next to the numbers.
+
+## Scenario ladder
+
+Shared world for every scenario: `SimPlant` (drive base + lift with gravity/friction + intake
+roller), `SimCamera` (30 Hz frames into a ring buffer), seeded `StimulusTimeline` (gamepad
+events + frame cadence, identical per style per scenario).
+
+### S0 — `S0_MinimalDrive` (raw FTC is expected to win)
+One stick → one motor power, one loop. No subscriptions, no commands, no nodes beyond the
+minimum. Measures fixed overhead tax of each style on the smallest possible program.
+Expected: raw ≪ SolversLib ≤ Synapse on actuation latency and loop Hz.
+
+### S1 — `S1_BasicTeleop` (raw FTC likely still wins)
+Tank drive (2 motors), 1 servo, intake toggle on bumper edges (rising/falling), telemetry
+publish at 10 Hz. This is the common rookie TeleOp.
+Expected: raw wins latency; gap quantifies "what does structure cost on a simple robot".
+
+### S2 — `S2_MultiSubsystem` (crossover)
+Drive + intake + lift (PIDF at 100 Hz) + outtake, two gamepads, mixed rates (drive 50 Hz,
+lift PIDF 100 Hz, telemetry 10 Hz), one moderate "auto-align" computation (~0.5 ms) on a
+gamepad event. Command conflicts (intake vs outtake) exercised via SolversLib requirements
+and Synapse actions.
+Expected: raw loop rate falls as work is serialized; per-task rates begin to slip for the
+single-loop styles; Synapse keeps per-pool rates. Latency may still favor raw.
+
+### S3 — `S3_HeavyRobot` (Synapse is expected to win)
+Everything in S2 plus:
+- **Expensive vision:** `SyntheticVisionPipeline` (~3 ms of real pixel work over a 320×240
+ buffer) per camera frame at 30 Hz, result feeds an alignment controller.
+- **Two PIDF loops** at 100–200 Hz (lift position + drivetrain heading hold) against
+ `SimPlant`; tracking RMSE is a first-class metric.
+- **Slow debug logger** (10–20 ms work per event) subscribed to state updates — the classic
+ "one slow consumer" that stalls a single loop.
+Expected: raw + SolversLib single loop degrade (PIDF rate collapse, tracking error growth,
+latency p99 explosion); Synapse isolates vision/logger on the callback pool and keeps PIDF
+near target on the hardware thread.
+
+### Honesty variant — `rawmt` (S2/S3 only)
+Raw FTC with hand-rolled threads (vision on its own thread, logger on its own thread), the
+best a competent team writes without a framework. Included so the comparison cannot be
+dismissed as "you forced vision into one loop". Expected to narrow the S3 gap substantially;
+this is a feature of the results, not a problem.
+
+## Styles (implementation sketch)
+
+- `shared/` — `SimPlant`, `SimMotor`, `SimServo`, `SimCamera`, `SyntheticVisionPipeline`,
+ `SharedPidf`, `StimulusTimeline`, `Probe`, `Hist` (percentile histogram), `Env`,
+ `Report` (JSON + Markdown writers). No framework imports (gate 3).
+- `raw/` — scenario classes structured as `init()` + `while (active) { loop(); }` on one
+ thread, extending the stub `OpMode` so the lifecycle is real. Direct `gamepad.*` reads,
+ direct `SimMotor` writes.
+- `rawmt/` — same world, explicit `Thread`s for vision/logger as described above.
+- `solverslib/` — idiomatic command-based: `Subsystem` subclasses with `periodic()`,
+ `Command`/`InstantCommand`/`RunCommand` + `setDefaultCommand`, `GamepadEx` +
+ `GamepadButton.whenPressed/whenReleased` registered through real `CommandScheduler.addButton`,
+ one `CommandScheduler.getInstance().run()` pump loop (the idiomatic OpMode loop).
+ PIDF via shared `SharedPidf` inside a subsystem (framework scheduling is what's under test,
+ not controller math). Vision in `VisionSubsystem.periodic()` (idiomatic single-thread),
+ with `rawmt`-style threaded variant only in the honesty variant notes.
+- `synapse/` — idiomatic Synapse: `Node` subclasses with `@SubscribedTo`/`@RunPeriodically`
+ (`hardware = true` for actuation/PIDF), `GamepadAdaptor.attach(...)`, `hardware().run/call`
+ for device writes (real hardware-thread hop), camera results published to
+ `camera/frame` from `SimCamera`'s capture thread and consumed by a `@SubscribedTo` handler
+ (real callback-pool dispatch), `LogSink.SILENT` for quiet runs.
+
+## Metrics (full suite, per scenario × style)
+
+1. **Input→actuation latency** (ns): stimulus write of a `Gamepad` field (or frame-ready) →
+ `SimMotor`/`SimServo` write executed by framework-invoked code. p50/p90/p99/max + count.
+2. **Per-task achieved rate + jitter**: target Hz vs achieved Hz and period-jitter p99 for
+ every periodic task (drive, PIDF loops, telemetry, vision, gamepad poll).
+3. **Control quality under load**: lift position RMSE and heading RMSE vs setpoint trajectory
+ (behavioral proof that a stalled loop is worse than an isolated one).
+4. **Loop/scheduler throughput**: iterations/s of the raw loop / `CommandScheduler.run()` /
+ Synapse scheduler ticks.
+5. **Allocation rate** (bytes/s via `ThreadMXBean.getThreadAllocatedBytes`, optional flag).
+
+Micro layer (Synapse optimization targets + comparators), each with warmup + N iterations +
+volatile blackhole sink:
+
+- `micro.raw.directCall` (floor)
+- `micro.publish.subscribers{0,1,8}` programmatic handlers (real `OrchestratorImpl` dispatch)
+- `micro.publish.annotationSubscriber` (real `AnnotationBinder` `Method.invoke`)
+- `micro.topic.recordLatest` / `latestValue` (synchronized contention, 1P1C and 4P4C)
+- `micro.subscribe.churn` (real `SubscriberList` add/remove/replace)
+- `micro.hardware.run` / `micro.hardware.call` round-trip (real hardware executor)
+- `micro.gamepad.adaptorPoll` (real `GamepadAdaptor.poll`)
+- `micro.solverslib.schedulerRun{1,8}` subsystems (real `CommandScheduler.run`)
+- `micro.solverslib.buttonRead` (real `GamepadEx.readButtons`)
+- `micro.sim.deviceWrite` (mock-budget gate)
+
+**Rejected: JMH.** The interesting paths are cross-thread dispatch (queue handoff, executor
+round-trips, 60 Hz polling), which fits JMH's tight-invocation model poorly; a custom harness
+with warmup rounds, percentile histograms, multi-round medians, and `--forks` (fresh JVM per
+fork) keeps one runner, one report format, and zero plugin risk on Gradle 9. Methodology is
+documented in `benchmarks/README.md` so the numbers are defensible.
+
+## Gradle / dependency strategy
+
+- `settings.gradle`: add `include 'benchmarks'` (root publishing untouched).
+- `benchmarks/build.gradle`:
+ - `implementation project(':')` — the real Synapse classes under test.
+ - `compileOnly files('../libs/ftc-sdk-stub.jar')` + `testRuntimeOnly` equivalent — real stub
+ `Gamepad`/`OpMode`.
+ - SolversLib consumption (benchmark-only):
+ ```gradle
+ repositories { maven { url 'https://repo.dairy.foundation/releases' }; mavenCentral() }
+ configurations { solverslibAar }
+ dependencies {
+ solverslibAar('org.solverslib:core:0.3.6@aar')
+ runtimeOnly 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.10' // only if linked classes need it
+ }
+ ```
+ A `extractSolverslib` task unpacks `classes.jar` from the AAR into
+ `benchmarks/build/solverslib/` and puts it on the compile/runtime classpath — the exact
+ published artifact, no vendored sources.
+ - `benchmarks/sdk-stubs/` source dir: synthetic `com.qualcomm.hardware.lynx.LynxModule`
+ (+ nested `BulkCachingMode`) and any other link-only SDK types discovered while wiring
+ SolversLib; never on a measured path.
+ - Java 11 to match the library; runs on the CI JDK 17 the same way `test` does.
+- CI (optional follow-up): `workflow_dispatch` job running `:benchmarks:run --args='--quick'`
+ and uploading `results/latest.json` as an artifact. Not required for the core deliverable.
+
+## Results & agent workflow
+
+```
+benchmarks/
+ build.gradle
+ README.md # methodology, fairness rules, mock policy, how to interpret
+ sdk-stubs/ # link-only synthetic SDK types
+ src/main/java/com/aaravlabs/synapse/bench/
+ shared/ raw/ rawmt/ solverslib/ synapse/
+ harness/ (Hist, Probe, StimulusTimeline, Env, Report, Main)
+ results/
+ baseline.json # committed reference run (from --full on a quiet machine)
+ latest.json latest.md # outputs of the last run (gitignored except baseline)
+```
+
+CLI (single entrypoint `Main`, driven by `gradle :benchmarks:run --args='...'`):
+
+- `run --quick|--full [--scenarios S0,S3] [--styles raw,solverslib,synapse] [--forks N]`
+ → `results/latest.json` + `results/latest.md`
+- `compare results/baseline.json results/latest.json [--tolerance 0.15]` → per-metric
+ regression table on stdout; exit 0 = within tolerance, exit 1 = regression, exit 2 = missing
+ metrics (so agents can branch on the outcome).
+- JSON schema (stable, versioned `"schema": 1`): env block (OS, JDK, CPU model, git SHA,
+ timestamp, mode, seed), then `scenarios[]` with `latencyActuationNs{p50,p90,p99,max,count}`,
+ `taskRates{...}`, `trackingError{liftRmse,headingRmse}`, `loopHz`, `allocBytesPerSec`, and
+ `micro{...}`.
+
+Agent loop this enables: change Synapse source → `:test` → `:benchmarks:run --quick`
+→ `compare` against baseline → keep/iterate. Determinism: fixed stimulus seeds, fixed
+scenario durations (`--quick` ≈ 3 s measure per pair, `--full` ≈ 15 s), warmup window before
+each measurement, median-of-rounds.
+
+## Implementation order
+
+1. Scaffold `:benchmarks` subproject + extract task + `sdk-stubs`; smoke test that real
+ `OrchestratorImpl` and real SolversLib `CommandScheduler` both load and run a trivial
+ round-trip (this flushes out missing SDK stubs early).
+2. Harness core: `Hist`, `Probe`, `StimulusTimeline`, `Env`, `Report`, `Main` with
+ `--quick/--full`, JSON+MD writers.
+3. Shared world: `SimPlant`, devices, `SimCamera`, `SyntheticVisionPipeline`, `SharedPidf`.
+4. S0 × {raw, solverslib, synapse} end-to-end + `verifyFrameworkClasses` + `verifyMockBudget`
+ gates. Validate expected ordering (raw wins S0).
+5. Micro layer (Synapse internals + SolversLib comparators + `micro.sim.deviceWrite`).
+6. S1, S2 (add `rawmt` at S2).
+7. S3 with vision + dual PIDF + slow logger. Validate expected Synapse win and `rawmt` honesty
+ variant behavior.
+8. `compare` subcommand + tolerances + exit codes; run `--full` twice on a quiet machine,
+ record `results/baseline.json`, document variance observed.
+9. `benchmarks/README.md` (methodology, mock policy, interpretation guide, agent recipe).
+
+## Acceptance criteria
+
+- `gradle test` still green (51 tests); root artifact/publishing unchanged (`git diff
+ build.gradle` empty).
+- One command produces `latest.json` + `latest.md` with all scenarios × styles in `--quick`.
+- **Framework-realness gates pass** (class provenance from AAR/project jars; mock budget <5%).
+- Ladder shape reproduced: S0 raw latency < synapse latency; S3 synapse lift-RMSE and PIDF
+ achieved-Hz better than raw and solverslib; S0–S3 table shows the crossover rather than one
+ style winning everywhere.
+- `compare` exits 1 on an injected 2× slowdown of `OrchestratorImpl.publish` and 0 on a
+ no-op run (self-test of the regression detector).
+- Re-run stability: key p50s within ~15% across two consecutive `--full` runs on an idle
+ machine (documented, with `--forks` available to tighten).
+
+## Risks / mitigations
+
+- **SolversLib AAR linkage** (needs `LynxModule`, possibly `HardwareMap.getAll`, Kotlin
+ stdlib): discovered and fixed at step 1 smoke test via `sdk-stubs`; stubs never measured.
+- **Fairness criticism** ("you strawmanned raw/SolversLib"): identical shared kernels, real
+ framework dispatch, idiomatic style code, `rawmt` honesty variant, and a public
+ methodology doc; S0 explicitly reports the case raw wins.
+- **CI noise**: results always include env metadata; `compare` uses tolerances and medians of
+ rounds; baseline regenerated per machine when needed.
+- **Harness overhead polluting latency**: `Probe` is nanosecond stamps into preallocated
+ rings; `verifyMockBudget` bounds its share.
+- **Gradle 9 quirks with AAR extraction**: plain `Copy`/`unzip` task from a detached
+ configuration — no Android plugin required.
+
+## Out of scope (follow-ups)
+
+- On-robot `BenchmarkOpMode` for Control Hub absolute numbers.
+- Publishing measured numbers to the website (would be a deliberate policy change to the
+ no-numbers ComparisonTable rule; real measured data would make it permissible).
+- Benchmarks of Pedro Pathing/Photon modules or real EasyOpenCV pipelines.
+- Nightly CI trend dashboards.
diff --git a/benchmarks/README.md b/benchmarks/README.md
new file mode 100644
index 0000000..80faa31
--- /dev/null
+++ b/benchmarks/README.md
@@ -0,0 +1,176 @@
+# Synapse benchmark suite
+
+Honest apples-to-apples robot-code benchmarks comparing three ways of writing FTC
+control software on one shared simulated robot:
+
+| style | what it is | what it may use |
+| --- | --- | --- |
+| `raw` | idiomatic FTC: one `OpMode.init()/loop()`, direct field reads and device writes | stub `OpMode`, `Gamepad` fields, `Telemetry` |
+| `rawmt` | the best competent raw team: raw plus hand-rolled worker threads (S2/S3 only) | same as `raw`, plus plain `java.lang.Thread` |
+| `solverslib` | idiomatic command-based FTCLib: `Subsystem.periodic()`, `Command`/`InstantCommand`/`RunCommand` + `setDefaultCommand`, `GamepadEx` + `GamepadButton.whenPressed/whenReleased` via `CommandScheduler.addButton`, one `CommandScheduler.run()` pump | published `org.solverslib:core` AAR |
+| `synapse` | idiomatic Synapse: `OrchestratorImpl`, `Topic`, `Node`/`AnnotationBinder`, `GamepadAdaptor`, `HardwareActions`/`SafeDevice`, `@RunPeriodically`, `@SubscribedTo`, `@OnHardwareThread`, `RunnableAction` | this project's real dispatch |
+
+Every style drives **the same** `SimPlant` (differential drive + lift + intake + servo)
+through the same stub `Gamepad` fields and the same seeded `StimulusTimeline`, and is
+scored on the same metrics. The ladder S0→S3 adds complexity to the *workload*, not
+to the harness.
+
+```
+S0_MinimalDrive one stick → one motor power, one loop
+S1_BasicTeleop tank drive, one servo, intake toggle on bumper edges, telemetry 10 Hz
+S2_MultiSubsystem + lift PIDF 100 Hz, outtake on gamepad 2, 0.5 ms auto-align on a
+ gamepad event, mixed rates (drive 50 Hz / lift 100 Hz / telem 10 Hz)
+S3_HeavyRobot + 30 Hz vision (~3 ms/frame), heading-hold PIDF 200 Hz, slow debug
+ logger (15 ms per 50 Hz state update)
+```
+
+## Running
+
+```bash
+gradle :benchmarks:run --args='run --quick' # ≈60 s ladder
+gradle :benchmarks:run --args='run --full' # 15 s measure, 3 rounds (median)
+gradle :benchmarks:run --args='run --full --forks 2' # fresh-JVM repeats, merged median
+gradle :benchmarks:run --args='run --full --forks 2 --rounds 1' # same, 1 round per fork (faster)
+gradle :benchmarks:run --args='run --quick --scenarios S3 --styles raw,synapse'
+gradle :benchmarks:run --args='run --quick --alloc' # + thread alloc rate
+gradle :benchmarks:run --args='compare results/baseline.json results/latest.json'
+gradle :benchmarks:run --args='compare --self-test'
+gradle :benchmarks:verifyFrameworkClasses # gates 1 + 3
+gradle :benchmarks:verifyMockBudget # gate 2
+```
+
+Exit codes: `run` exits non-zero if a gate fails; `compare` exits `0` within
+tolerance, `1` on a regression beyond tolerance, `2` when a metric is missing.
+
+Outputs land in `benchmarks/results/latest.json` (schema-versioned) and
+`results/latest.md` (human table). `results/baseline.json` is committed and is
+machine-referenced — it is a *local* change-detector baseline, not a universal
+truth. Treat every number here as a ratio on this box, never as an absolute.
+
+## Metrics
+
+| metric | how it is recorded |
+| --- | --- |
+| input→actuation latency | `LatencyProbe`: the stimulus thread stamps t0 immediately before flipping a `Gamepad` volatile field and declares the expected power direction (mapped per style sign convention); `SimMotor.setPower`/`SimServo.setPosition` stamp t1 **inside the framework-invoked device write** and the first write whose power crosses a threshold in that direction pairs with it (stick steps alternate sign, so stale writes cannot pair). Reported p50/p90/p99/max. |
+| per-task rate + jitter | `TaskMeter.tick()` at the top of each periodic body the framework invokes (`loop()` body, `Command.execute()`/`Subsystem.periodic()`, `@RunPeriodically` method): achieved Hz vs target Hz and p99 inter-tick period. |
+| control quality | `SimPlant` scores lift position and drivetrain heading against the same `Setpoints` trajectory plus `headingBias` the styles command — world-side bookkeeping, never inside a dispatch segment. |
+| loop throughput | framework pump iterations/s: the `while (active) { loop(); }` body for `raw`/`rawmt`, one `CommandScheduler.run()` call for `solverslib`, and every framework-invoked handler body (`@RunPeriodically`, `@SubscribedTo`, `@RunnableAction`) for `synapse` — its "scheduler ticks", since it has no single pump |
+| allocation rate | optional (`--alloc`): `com.sun.management.ThreadMXBean` bytes/s summed across all live threads (the measured style threads included). |
+| micro layer | warmup + measured batches (local primitives) or per-op samples (cross-thread dispatch), volatile blackhole sink. |
+
+Timestamps are always taken **inside the code the framework invokes**, never in
+harness glue wrapped around a framework call — otherwise you would time the
+harness, not the framework.
+
+## Fairness rules
+
+1. **Identical work.** Same physics, same stimulus timeline (seed 42, jittered
+ cadence so events cannot phase-lock with fixed task periods), same controller
+ math (`SharedPidf`), same expensive kernels (`SyntheticVisionPipeline`,
+ `BusyWork`). Only dispatch wiring differs. Axis sign conventions differ by API
+ (`GamepadEx.getLeftY()` negates, `getRightY()` does not — styles normalize to
+ forward-positive so matched sticks drive matched wheels).
+2. **Idiomatic code per style.** Each scenario×style is hand-written in the style's
+ natural idiom (raw loop with elapsed-time rate gates; SolversLib subsystems +
+ requirements-based command conflicts; Synapse nodes/annotations/topics/actions).
+ There is no shared "robot program" abstraction.
+3. **The world is not a framework.** `shared/` has zero Synapse/SolversLib dispatch
+ types (gate 3). Device writes are constant-cost volatile stores into `SimPlant`.
+4. **Real code on the measured path.** Synapse runs the real `OrchestratorImpl`
+ dispatch, `AnnotationBinder` reflective invoke, `GamepadAdaptor.poll()`,
+ `HardwareActions.run/call`, `Topic` synchronization. SolversLib runs the real
+ published `CommandScheduler`/`GamepadEx` bytecode from
+ `org.solverslib:core:0.3.6@aar` (extracted, never rebuilt). `raw`/`rawmt`
+ execute a literal stub `OpMode` lifecycle.
+5. **No double-counting.** Recording happens inside probed bodies; histograms are
+ preallocated rings (constant-cost, allocation-free on the path).
+
+## Why not JMH
+
+The interesting paths are cross-thread dispatch (`publish()` → callback pool →
+handler; `HardwareActions.run()` → hardware thread) and scheduling jitter over
+millisecond-scale windows. JMH measures single-thread steady-state ops per second;
+it cannot express "p99 of stimulus→actuation under a 30 Hz vision load", and its
+sample patterns (batched single-thread loops) would hide exactly the queueing and
+priority inversion we need to see. The micro layer here is a small custom harness:
+warmup, per-op nanosecond samples for dispatch, batched samples for local
+primitives, preallocated percentile histograms.
+
+## Verification gates
+
+Automated and enforced by `run`, `verifyFrameworkClasses`, `verifyMockBudget`:
+
+1. **Class provenance.** At runtime, `CommandScheduler` must load from the
+ extracted `org.solverslib:core` AAR `classes.jar`, `Gamepad` from the checked-in
+ `libs/ftc-sdk-stub.jar`, `OrchestratorImpl`/`GamepadAdaptor` from the real
+ project output — and **never** from `benchmarks/build/classes` (which would mean
+ a vendored reimplementation). A smoke dispatch (Orchestrator + GamepadAdaptor +
+ CommandScheduler.run()) must execute.
+2. **Mock budget.** `micro.sim.deviceWrite` measures `SimMotor.setPower` *as used
+ on the measured path* (volatile plant write + `System.nanoTime()` + the
+ latency-probe pairing). It must cost **< 5 % of the smallest framework-level
+ dispatch measurement** — here the end-to-end paths the device write sits inside:
+ `micro.publish.subscribers{1,8}`, `micro.publish.annotationSubscriber`,
+ `micro.hardware.run`, `micro.hardware.call`. Local primitive micros
+ (`recordLatest`, `schedulerRun`, `buttonRead`) are optimization targets and
+ deliberately not budget references: they contain no dispatch hop for the mock
+ to hide in. Current budget use ≈ 1 %.
+3. **Structural review rule** (checked automatically and by reviewers):
+ `shared/` contains zero `com.aaravlabs.synapse.*` / `com.seattlesolvers.solverslib.*`
+ dispatch types, and style packages contain zero classes named like
+ `*Scheduler`, `*Orchestrator`, `*Bus`. A team adding S4 must not quietly
+ reimplement a bus.
+
+## Interpreting the ladder
+
+Expected **shape** (trends, not absolute truths):
+
+* **S0/S1: raw wins latency** (µs) — one tight loop has no dispatch tax. SolversLib
+ pays `CommandScheduler.run()` per iteration (≈2× raw, still µs). Synapse pays the
+ `GamepadAdaptor` 60 Hz poll + callback + hardware-thread hops (ms).
+* **S2: the crossover** — multi-rate work serialized into one loop starts to cost;
+ per-pool rates hold for Synapse. Latencies converge on the 50 Hz drive period.
+* **S3: Synapse wins control quality and p99** — the 15 ms debug logger and 3 ms
+ vision serialize behind `raw`/`solverslib`'s single loop (PIDF achieved Hz halves,
+ p99 actuation latency explodes). Synapse runs PIDF on the hardware thread and the
+ slow consumers on the callback pool, so lift/heading RMSE and achieved rates win.
+* **`rawmt` narrows the S3 gap** (honesty variant): hand-rolled threads give raw
+ code the same isolation Synapse has — vision/logger on their own threads keep the
+ control loop near target. What remains is dispatch architecture, not threading.
+ A study where Synapse beats rawmt by the same margin it beats raw is lying.
+
+If the shape is absent (e.g. Synapse wins S0 latency by 2×), suspect a harness bug
+before concluding anything about the framework.
+
+## Adding a scenario (S4 sketch: micro-autonomy)
+
+S4 would add a fast pure-pursuit segment with 3 sensor inputs at different rates and
+an end-of-match macro sequence (3-way command conflicts). Sketch of the workload —
+**write it three times, in each style's idiom**:
+
+* shared: `PurePursuitKernel` (identical math, preallocated buffers),
+ `Setpoints.path()` waypoint stream, two extra 100 Hz/20 Hz sensor fakes at the
+ I/O boundary writing into `SimPlant`-side state.
+* `raw`: the controller in the single `loop()` with elapsed-time gates; the macro is
+ a hand-rolled state machine.
+* `solverslib`: `PurePursuitSubsystem.periodic()`, `FollowPathCommand` /
+ `MacroCommand` with real requirements for the conflicts.
+* `synapse`: `@SubscribedTo` sensor handlers + `@RunPeriodically(hardware = true)`
+ controller, `RunnableAction` macro steps fired from button edges.
+
+Then: extend `Scenario`, `Registry`, `StimulusTimeline.build`, add three pair
+classes, and let gates 1–3 catch any accidental framework reimplementation.
+
+## Known confounds
+
+* Desktop JVM + simulated plant: absolute numbers are not robot-bus latencies.
+* Vision and logger kernels are duration-targeted busy work (~3 ms / ~15 ms) so the
+ load profile is machine-independent while wall-clock cost is real.
+* The plant integrates at 1 kHz on its own thread and scores against wall-clock
+ setpoints (the same ones the controllers read); thread hiccups appear in every
+ style equally.
+* `GamepadEx` axis sign conventions are asymmetric; styles normalize so matched
+ sticks command matched wheels (see Fairness rule 1).
+* One quick run has ~10 latency samples per pair (seeded stick steps in the 3 s
+ window); `--full` and `--forks` widen the sample and take medians before you
+ trust a <10 % difference.
diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle
new file mode 100644
index 0000000..d40d06e
--- /dev/null
+++ b/benchmarks/build.gradle
@@ -0,0 +1,82 @@
+plugins {
+ id 'java'
+ id 'application'
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
+
+repositories {
+ maven { url 'https://repo.dairy.foundation/releases' }
+ mavenCentral()
+}
+
+configurations {
+ solverslibAar
+}
+
+dependencies {
+ implementation project(':')
+ compileOnly files('../libs/ftc-sdk-stub.jar')
+ runtimeOnly files('../libs/ftc-sdk-stub.jar')
+ solverslibAar 'org.solverslib:core:0.3.6@aar'
+}
+
+sourceSets {
+ main {
+ java {
+ srcDirs = ['src/main/java', 'sdk-stubs']
+ }
+ }
+}
+
+def extractedSolverslibJar = layout.buildDirectory.file('solverslib/classes.jar')
+
+tasks.register('extractSolverslib', Copy) {
+ from {
+ configurations.solverslibAar.collect { it.name.endsWith('.aar') ? zipTree(it) : it }
+ }
+ include 'classes.jar'
+ into layout.buildDirectory.dir('solverslib')
+}
+
+tasks.named('compileJava') {
+ dependsOn 'extractSolverslib'
+}
+
+sourceSets.main.compileClasspath += files(extractedSolverslibJar)
+sourceSets.main.runtimeClasspath += files(extractedSolverslibJar)
+
+application {
+ mainClass = 'com.aaravlabs.synapse.bench.harness.Main'
+}
+
+tasks.named('run', JavaExec) {
+ dependsOn 'extractSolverslib'
+ jvmArgs '-Xms512m', '-Xmx2g'
+ if (project.hasProperty('benchArgs')) {
+ args project.property('benchArgs').toString().split('\\s+')
+ }
+}
+
+tasks.register('verifyFrameworkClasses', JavaExec) {
+ group = 'verification'
+ description = 'Asserts measured framework classes load from the real project/AAR/stub jars.'
+ dependsOn 'extractSolverslib', 'classes'
+ classpath = sourceSets.main.runtimeClasspath
+ mainClass = 'com.aaravlabs.synapse.bench.harness.Main'
+ args 'gates', '--only', 'framework,structure'
+ jvmArgs '-Xms256m', '-Xmx1g'
+}
+
+tasks.register('verifyMockBudget', JavaExec) {
+ group = 'verification'
+ description = 'Asserts SimMotor device-write mock cost is <5% of framework dispatch cost.'
+ dependsOn 'extractSolverslib', 'classes'
+ classpath = sourceSets.main.runtimeClasspath
+ mainClass = 'com.aaravlabs.synapse.bench.harness.Main'
+ args 'gates', '--only', 'mock-budget'
+ jvmArgs '-Xms512m', '-Xmx2g'
+}
diff --git a/benchmarks/results/baseline.json b/benchmarks/results/baseline.json
new file mode 100644
index 0000000..244a277
--- /dev/null
+++ b/benchmarks/results/baseline.json
@@ -0,0 +1,754 @@
+{
+ "schema": 1,
+ "env": {
+ "os": "Linux 7.0.0-31-generic (amd64)",
+ "jdk": "21.0.12.1 (Ubuntu)",
+ "cpu": "Intel(R) Core(TM) i5-8500T CPU @ 2.10GHz",
+ "gitSha": "00151d03c21051c66c61a5ef6c08295b609447ee",
+ "timestamp": "2026-09-24T04:36:47.139567055Z",
+ "mode": "full",
+ "seed": "42",
+ "forks": "2",
+ "rounds": "3"
+ },
+ "scenarios": [
+ {
+ "scenario": "S0_MinimalDrive",
+ "style": "raw",
+ "latencyActuationNs": {
+ "p50": 4600.500000,
+ "p90": 5167.000000,
+ "p99": 25147.000000,
+ "max": 43619.000000,
+ "min": 3780.500000,
+ "mean": 5277.346939,
+ "count": 49
+ },
+ "taskRates": {
+ "loop": {
+ "targetHz": 0.000000,
+ "achievedHz": 11355687.508929,
+ "jitterP99Ns": 94.000000,
+ "count": 170336585
+ }
+ },
+ "trackingError": {
+ "liftRmse": 0.000000,
+ "headingRmse": 0.000000
+ },
+ "loopHz": 11355690.522157,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S0_MinimalDrive",
+ "style": "solverslib",
+ "latencyActuationNs": {
+ "p50": 4364.500000,
+ "p90": 5004.500000,
+ "p99": 11968.000000,
+ "max": 12393.000000,
+ "min": 3295.500000,
+ "mean": 4619.846939,
+ "count": 49
+ },
+ "taskRates": {
+ "schedulerRun": {
+ "targetHz": 0.000000,
+ "achievedHz": 2665546.092017,
+ "jitterP99Ns": 396.500000,
+ "count": 39983493
+ }
+ },
+ "trackingError": {
+ "liftRmse": 0.000000,
+ "headingRmse": 0.000000
+ },
+ "loopHz": 2665548.251931,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S0_MinimalDrive",
+ "style": "synapse",
+ "latencyActuationNs": {
+ "p50": 8974349.500000,
+ "p90": 14722168.500000,
+ "p99": 16055130.000000,
+ "max": 16168277.000000,
+ "min": 310273.000000,
+ "mean": 8587528.357143,
+ "count": 49
+ },
+ "taskRates": {
+ "drive": {
+ "targetHz": 0.000000,
+ "achievedHz": 61.802457,
+ "jitterP99Ns": 16972111.000000,
+ "count": 927
+ }
+ },
+ "trackingError": {
+ "liftRmse": 0.000000,
+ "headingRmse": 0.000000
+ },
+ "loopHz": 61.799421,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S1_BasicTeleop",
+ "style": "raw",
+ "latencyActuationNs": {
+ "p50": 588.000000,
+ "p90": 717.500000,
+ "p99": 965.500000,
+ "max": 984.500000,
+ "min": 430.000000,
+ "mean": 595.061225,
+ "count": 49
+ },
+ "taskRates": {
+ "loop": {
+ "targetHz": 0.000000,
+ "achievedHz": 8060093.965876,
+ "jitterP99Ns": 130.500000,
+ "count": 120902127
+ },
+ "telemetry": {
+ "targetHz": 10.000000,
+ "achievedHz": 9.999972,
+ "jitterP99Ns": 100002696.500000,
+ "count": 150
+ }
+ },
+ "trackingError": {
+ "liftRmse": 0.000000,
+ "headingRmse": 0.000000
+ },
+ "loopHz": 8060095.584808,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S1_BasicTeleop",
+ "style": "solverslib",
+ "latencyActuationNs": {
+ "p50": 740.500000,
+ "p90": 1150.500000,
+ "p99": 6604.000000,
+ "max": 11452.000000,
+ "min": 456.000000,
+ "mean": 1006.142857,
+ "count": 49
+ },
+ "taskRates": {
+ "schedulerRun": {
+ "targetHz": 0.000000,
+ "achievedHz": 1989818.276313,
+ "jitterP99Ns": 551.500000,
+ "count": 29847519
+ },
+ "telemetry": {
+ "targetHz": 10.000000,
+ "achievedHz": 9.999938,
+ "jitterP99Ns": 100010775.000000,
+ "count": 150
+ }
+ },
+ "trackingError": {
+ "liftRmse": 0.000000,
+ "headingRmse": 0.000000
+ },
+ "loopHz": 1989818.942205,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S1_BasicTeleop",
+ "style": "synapse",
+ "latencyActuationNs": {
+ "p50": 8923942.500000,
+ "p90": 14326357.500000,
+ "p99": 15929975.000000,
+ "max": 15992164.000000,
+ "min": 530305.000000,
+ "mean": 8374790.836735,
+ "count": 49
+ },
+ "taskRates": {
+ "drive": {
+ "targetHz": 0.000000,
+ "achievedHz": 61.845828,
+ "jitterP99Ns": 16621410.500000,
+ "count": 928
+ },
+ "telemetry": {
+ "targetHz": 10.000000,
+ "achievedHz": 9.991404,
+ "jitterP99Ns": 100164563.500000,
+ "count": 150
+ }
+ },
+ "trackingError": {
+ "liftRmse": 0.000000,
+ "headingRmse": 0.000000
+ },
+ "loopHz": 136.798741,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S2_MultiSubsystem",
+ "style": "raw",
+ "latencyActuationNs": {
+ "p50": 11461653.500000,
+ "p90": 18454153.000000,
+ "p99": 121708606.000000,
+ "max": 212110660.000000,
+ "min": 234353.500000,
+ "mean": 15144299.083334,
+ "count": 48
+ },
+ "taskRates": {
+ "loop": {
+ "targetHz": 0.000000,
+ "achievedHz": 11820728.046416,
+ "jitterP99Ns": 81.500000,
+ "count": 177312094
+ },
+ "drive": {
+ "targetHz": 50.000000,
+ "achievedHz": 49.999196,
+ "jitterP99Ns": 20002449.500000,
+ "count": 750
+ },
+ "liftPidf": {
+ "targetHz": 100.000000,
+ "achievedHz": 99.997680,
+ "jitterP99Ns": 10006632.500000,
+ "count": 1500
+ },
+ "telemetry": {
+ "targetHz": 10.000000,
+ "achievedHz": 9.999985,
+ "jitterP99Ns": 100002568.000000,
+ "count": 150
+ }
+ },
+ "trackingError": {
+ "liftRmse": 12.445197,
+ "headingRmse": 0.000000
+ },
+ "loopHz": 11820729.614010,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S2_MultiSubsystem",
+ "style": "solverslib",
+ "latencyActuationNs": {
+ "p50": 11315799.000000,
+ "p90": 17956471.000000,
+ "p99": 19101091.500000,
+ "max": 19363593.500000,
+ "min": 263629.000000,
+ "mean": 10324626.166667,
+ "count": 48
+ },
+ "taskRates": {
+ "schedulerRun": {
+ "targetHz": 0.000000,
+ "achievedHz": 1216541.572542,
+ "jitterP99Ns": 906.000000,
+ "count": 18248750
+ },
+ "drive": {
+ "targetHz": 50.000000,
+ "achievedHz": 49.995626,
+ "jitterP99Ns": 20008988.500000,
+ "count": 750
+ },
+ "liftPidf": {
+ "targetHz": 100.000000,
+ "achievedHz": 99.986902,
+ "jitterP99Ns": 10010348.000000,
+ "count": 1500
+ },
+ "telemetry": {
+ "targetHz": 10.000000,
+ "achievedHz": 9.999537,
+ "jitterP99Ns": 100077489.500000,
+ "count": 150
+ }
+ },
+ "trackingError": {
+ "liftRmse": 12.434594,
+ "headingRmse": 0.000000
+ },
+ "loopHz": 1216543.208293,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S2_MultiSubsystem",
+ "style": "synapse",
+ "latencyActuationNs": {
+ "p50": 18445927.000000,
+ "p90": 29532981.000000,
+ "p99": 133206306.000000,
+ "max": 222372278.500000,
+ "min": 3100397.000000,
+ "mean": 22367968.270833,
+ "count": 48
+ },
+ "taskRates": {
+ "drive": {
+ "targetHz": 50.000000,
+ "achievedHz": 49.769792,
+ "jitterP99Ns": 20497130.500000,
+ "count": 747
+ },
+ "liftPidf": {
+ "targetHz": 100.000000,
+ "achievedHz": 99.096121,
+ "jitterP99Ns": 10522434.500000,
+ "count": 1486
+ },
+ "telemetry": {
+ "targetHz": 10.000000,
+ "achievedHz": 9.989760,
+ "jitterP99Ns": 100417551.500000,
+ "count": 150
+ },
+ "align": {
+ "targetHz": 0.000000,
+ "achievedHz": 1.000583,
+ "jitterP99Ns": 1048623698.000000,
+ "count": 15
+ }
+ },
+ "trackingError": {
+ "liftRmse": 12.348435,
+ "headingRmse": 0.000000
+ },
+ "loopHz": 352.663173,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S2_MultiSubsystem",
+ "style": "rawmt",
+ "latencyActuationNs": {
+ "p50": 11517961.000000,
+ "p90": 18432388.500000,
+ "p99": 19689812.500000,
+ "max": 19861047.500000,
+ "min": 123145.000000,
+ "mean": 10912343.572917,
+ "count": 48
+ },
+ "taskRates": {
+ "loop": {
+ "targetHz": 0.000000,
+ "achievedHz": 11833134.031804,
+ "jitterP99Ns": 81.000000,
+ "count": 177498327
+ },
+ "drive": {
+ "targetHz": 50.000000,
+ "achievedHz": 49.999394,
+ "jitterP99Ns": 20004995.000000,
+ "count": 750
+ },
+ "liftPidf": {
+ "targetHz": 100.000000,
+ "achievedHz": 99.997375,
+ "jitterP99Ns": 10007926.000000,
+ "count": 1500
+ },
+ "telemetry": {
+ "targetHz": 10.000000,
+ "achievedHz": 9.999973,
+ "jitterP99Ns": 100006276.000000,
+ "count": 150
+ },
+ "align": {
+ "targetHz": 0.000000,
+ "achievedHz": 1.000787,
+ "jitterP99Ns": 1044874980.000000,
+ "count": 15
+ }
+ },
+ "trackingError": {
+ "liftRmse": 12.389621,
+ "headingRmse": 0.000000
+ },
+ "loopHz": 11833134.220495,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S3_HeavyRobot",
+ "style": "raw",
+ "latencyActuationNs": {
+ "p50": 28335937.000000,
+ "p90": 33413909.500000,
+ "p99": 34630267.000000,
+ "max": 34877784.000000,
+ "min": 11490614.500000,
+ "mean": 26261024.061224,
+ "count": 49
+ },
+ "taskRates": {
+ "loop": {
+ "targetHz": 0.000000,
+ "achievedHz": 1731303.154708,
+ "jitterP99Ns": 89.000000,
+ "count": 25952847
+ },
+ "drive": {
+ "targetHz": 50.000000,
+ "achievedHz": 49.995072,
+ "jitterP99Ns": 20011036.500000,
+ "count": 750
+ },
+ "liftPidf": {
+ "targetHz": 100.000000,
+ "achievedHz": 49.995066,
+ "jitterP99Ns": 20013906.000000,
+ "count": 749
+ },
+ "headingPidf": {
+ "targetHz": 200.000000,
+ "achievedHz": 51.228780,
+ "jitterP99Ns": 20010119.000000,
+ "count": 768
+ },
+ "vision": {
+ "targetHz": 30.000000,
+ "achievedHz": 29.997027,
+ "jitterP99Ns": 40012449.500000,
+ "count": 449
+ },
+ "logger": {
+ "targetHz": 50.000000,
+ "achievedHz": 49.995072,
+ "jitterP99Ns": 20011036.500000,
+ "count": 750
+ },
+ "telemetry": {
+ "targetHz": 10.000000,
+ "achievedHz": 9.999046,
+ "jitterP99Ns": 100150505.000000,
+ "count": 150
+ }
+ },
+ "trackingError": {
+ "liftRmse": 13.140224,
+ "headingRmse": 0.002373
+ },
+ "loopHz": 1730181.301204,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S3_HeavyRobot",
+ "style": "solverslib",
+ "latencyActuationNs": {
+ "p50": 28142213.500000,
+ "p90": 33719079.000000,
+ "p99": 35494141.500000,
+ "max": 35791568.500000,
+ "min": 11220804.500000,
+ "mean": 26585838.959184,
+ "count": 49
+ },
+ "taskRates": {
+ "schedulerRun": {
+ "targetHz": 0.000000,
+ "achievedHz": 166003.282697,
+ "jitterP99Ns": 1055.000000,
+ "count": 2488315
+ },
+ "drive": {
+ "targetHz": 50.000000,
+ "achievedHz": 49.895543,
+ "jitterP99Ns": 20376794.000000,
+ "count": 748
+ },
+ "liftPidf": {
+ "targetHz": 100.000000,
+ "achievedHz": 49.989894,
+ "jitterP99Ns": 20441318.000000,
+ "count": 749
+ },
+ "headingPidf": {
+ "targetHz": 200.000000,
+ "achievedHz": 50.924367,
+ "jitterP99Ns": 20396336.500000,
+ "count": 763
+ },
+ "vision": {
+ "targetHz": 30.000000,
+ "achievedHz": 30.012719,
+ "jitterP99Ns": 40025486.500000,
+ "count": 449
+ },
+ "logger": {
+ "targetHz": 50.000000,
+ "achievedHz": 49.989902,
+ "jitterP99Ns": 20012757.500000,
+ "count": 750
+ },
+ "telemetry": {
+ "targetHz": 10.000000,
+ "achievedHz": 9.997890,
+ "jitterP99Ns": 100560435.500000,
+ "count": 150
+ }
+ },
+ "trackingError": {
+ "liftRmse": 13.221066,
+ "headingRmse": 0.002250
+ },
+ "loopHz": 165886.763417,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S3_HeavyRobot",
+ "style": "synapse",
+ "latencyActuationNs": {
+ "p50": 19278112.500000,
+ "p90": 30411155.000000,
+ "p99": 35561370.500000,
+ "max": 36964905.000000,
+ "min": 7190715.500000,
+ "mean": 20413225.724490,
+ "count": 49
+ },
+ "taskRates": {
+ "drive": {
+ "targetHz": 50.000000,
+ "achievedHz": 49.792840,
+ "jitterP99Ns": 20733155.500000,
+ "count": 747
+ },
+ "liftPidf": {
+ "targetHz": 100.000000,
+ "achievedHz": 99.155537,
+ "jitterP99Ns": 10762154.500000,
+ "count": 1488
+ },
+ "headingPidf": {
+ "targetHz": 200.000000,
+ "achievedHz": 196.614946,
+ "jitterP99Ns": 5776974.000000,
+ "count": 2949
+ },
+ "vision": {
+ "targetHz": 30.000000,
+ "achievedHz": 30.000017,
+ "jitterP99Ns": 34045637.000000,
+ "count": 450
+ },
+ "logger": {
+ "targetHz": 50.000000,
+ "achievedHz": 49.748345,
+ "jitterP99Ns": 20873695.500000,
+ "count": 746
+ },
+ "telemetry": {
+ "targetHz": 10.000000,
+ "achievedHz": 9.989908,
+ "jitterP99Ns": 100716206.000000,
+ "count": 150
+ },
+ "align": {
+ "targetHz": 0.000000,
+ "achievedHz": 0.996110,
+ "jitterP99Ns": 1060832611.000000,
+ "count": 15
+ }
+ },
+ "trackingError": {
+ "liftRmse": 12.322570,
+ "headingRmse": 0.002036
+ },
+ "loopHz": 646.995651,
+ "allocBytesPerSec": 0.000000
+ },
+ {
+ "scenario": "S3_HeavyRobot",
+ "style": "rawmt",
+ "latencyActuationNs": {
+ "p50": 11682709.500000,
+ "p90": 17406666.500000,
+ "p99": 19775135.500000,
+ "max": 20007189.000000,
+ "min": 871243.000000,
+ "mean": 10714644.102041,
+ "count": 49
+ },
+ "taskRates": {
+ "loop": {
+ "targetHz": 0.000000,
+ "achievedHz": 11711294.698859,
+ "jitterP99Ns": 83.000000,
+ "count": 175670830
+ },
+ "drive": {
+ "targetHz": 50.000000,
+ "achievedHz": 49.999336,
+ "jitterP99Ns": 20009062.000000,
+ "count": 750
+ },
+ "liftPidf": {
+ "targetHz": 100.000000,
+ "achievedHz": 99.996199,
+ "jitterP99Ns": 10008811.000000,
+ "count": 1500
+ },
+ "headingPidf": {
+ "targetHz": 200.000000,
+ "achievedHz": 199.988252,
+ "jitterP99Ns": 5008792.500000,
+ "count": 3000
+ },
+ "vision": {
+ "targetHz": 30.000000,
+ "achievedHz": 30.000084,
+ "jitterP99Ns": 34547488.000000,
+ "count": 450
+ },
+ "logger": {
+ "targetHz": 50.000000,
+ "achievedHz": 49.999823,
+ "jitterP99Ns": 20796603.500000,
+ "count": 750
+ },
+ "telemetry": {
+ "targetHz": 10.000000,
+ "achievedHz": 9.999978,
+ "jitterP99Ns": 100006182.000000,
+ "count": 150
+ },
+ "align": {
+ "targetHz": 0.000000,
+ "achievedHz": 0.996295,
+ "jitterP99Ns": 1059342120.000000,
+ "count": 15
+ }
+ },
+ "trackingError": {
+ "liftRmse": 12.434635,
+ "headingRmse": 0.002049
+ },
+ "loopHz": 11711291.978560,
+ "allocBytesPerSec": 0.000000
+ }
+ ],
+ "micro": {
+ "micro.raw.directCall": {
+ "nsPerOp": 10.015000,
+ "p50": 11.000000,
+ "p99": 18.500000,
+ "count": 1000000.000000
+ },
+ "micro.publish.subscribers0": {
+ "nsPerOp": 62.960000,
+ "p50": 60.500000,
+ "p99": 80.500000,
+ "count": 1000000.000000
+ },
+ "micro.publish.subscribers1": {
+ "nsPerOp": 6140.721800,
+ "p50": 4918.000000,
+ "p99": 21774.000000,
+ "count": 20000.000000
+ },
+ "micro.publish.subscribers8": {
+ "nsPerOp": 5324.645850,
+ "p50": 3944.000000,
+ "p99": 22441.000000,
+ "count": 20000.000000
+ },
+ "micro.publish.annotationSubscriber": {
+ "nsPerOp": 6254.629325,
+ "p50": 5000.000000,
+ "p99": 21798.000000,
+ "count": 20000.000000
+ },
+ "micro.topic.recordLatest": {
+ "nsPerOp": 62.657500,
+ "p50": 59.500000,
+ "p99": 82.000000,
+ "count": 1000000.000000
+ },
+ "micro.topic.latestValue": {
+ "nsPerOp": 27.727500,
+ "p50": 27.000000,
+ "p99": 37.000000,
+ "count": 1000000.000000
+ },
+ "micro.topic.recordLatest.1p1c": {
+ "nsPerOp": 365.349193,
+ "p50": 361.000000,
+ "p99": 379.500000,
+ "count": 1099125.500000
+ },
+ "micro.topic.recordLatest.4p4c": {
+ "nsPerOp": 285.326381,
+ "p50": 283.500000,
+ "p99": 326.000000,
+ "count": 1407154.000000
+ },
+ "micro.subscribe.churn": {
+ "nsPerOp": 91.587500,
+ "p50": 76.000000,
+ "p99": 210.500000,
+ "count": 1000000.000000
+ },
+ "micro.hardware.run": {
+ "nsPerOp": 4678.642425,
+ "p50": 4833.000000,
+ "p99": 18823.000000,
+ "count": 20000.000000
+ },
+ "micro.hardware.call": {
+ "nsPerOp": 5382.108625,
+ "p50": 4617.500000,
+ "p99": 14161.000000,
+ "count": 20000.000000
+ },
+ "micro.gamepad.adaptorPoll": {
+ "nsPerOp": 5237.752500,
+ "p50": 5183.000000,
+ "p99": 6008.000000,
+ "count": 1000000.000000
+ },
+ "micro.solverslib.schedulerRun1": {
+ "nsPerOp": 35.100000,
+ "p50": 33.500000,
+ "p99": 44.500000,
+ "count": 1000000.000000
+ },
+ "micro.solverslib.schedulerRun8": {
+ "nsPerOp": 100.142500,
+ "p50": 97.500000,
+ "p99": 124.000000,
+ "count": 1000000.000000
+ },
+ "micro.solverslib.buttonRead": {
+ "nsPerOp": 260.770000,
+ "p50": 254.000000,
+ "p99": 313.000000,
+ "count": 1000000.000000
+ },
+ "micro.sim.deviceWrite": {
+ "nsPerOp": 98.270000,
+ "p50": 96.500000,
+ "p99": 118.000000,
+ "count": 1000000.000000
+ }
+ },
+ "gates": {
+ "frameworkClasses": true,
+ "structure": true,
+ "mockBudget": true,
+ "mockBudgetRatioPct": 2.100396,
+ "ok": true,
+ "messages": [
+ "merged 2 forks (median)"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/benchmarks/sdk-stubs/com/qualcomm/hardware/lynx/LynxModule.java b/benchmarks/sdk-stubs/com/qualcomm/hardware/lynx/LynxModule.java
new file mode 100644
index 0000000..a2c57a8
--- /dev/null
+++ b/benchmarks/sdk-stubs/com/qualcomm/hardware/lynx/LynxModule.java
@@ -0,0 +1,21 @@
+package com.qualcomm.hardware.lynx;
+
+/**
+ * Link-only synthetic SDK type. The published SolversLib {@code CommandScheduler}
+ * references {@code LynxModule} in {@code setBulkReading}/{@code run}; the checked-in
+ * {@code ftc-sdk-stub.jar} does not carry it. This stub exists purely so those methods
+ * can link on a desktop JVM. It is never called on a measured path.
+ */
+public class LynxModule {
+
+ public enum BulkCachingMode {
+ MANUAL,
+ AUTO
+ }
+
+ public void setBulkCachingMode(BulkCachingMode mode) {
+ }
+
+ public void clearBulkCache() {
+ }
+}
diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Compare.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Compare.java
new file mode 100644
index 0000000..062a907
--- /dev/null
+++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Compare.java
@@ -0,0 +1,252 @@
+package com.aaravlabs.synapse.bench.harness;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Regression comparison of two result documents with tolerances and
+ * agent-friendly exit codes: 0 = within tolerance, 1 = regression, 2 = missing
+ * metrics.
+ */
+public final class Compare {
+
+ public static final int EXIT_OK = 0;
+ public static final int EXIT_REGRESSION = 1;
+ public static final int EXIT_MISSING = 2;
+
+ private static final class MetricRef {
+ final String label;
+ final double baseline;
+ final double latest;
+ final boolean lowerIsBetter;
+ final double absFloor;
+
+ MetricRef(String label, double baseline, double latest, boolean lowerIsBetter) {
+ this.label = label;
+ this.baseline = baseline;
+ this.latest = latest;
+ this.lowerIsBetter = lowerIsBetter;
+ // Absolute noise floor only for nanosecond-scale metrics; unitless
+ // metrics (RMSE, Hz) rely on the relative tolerance alone so small
+ // magnitudes cannot hide behind a fixed 1.0-unit floor.
+ this.absFloor = (label.endsWith("Ns") || label.endsWith("nsPerOp")
+ || label.contains(".latency.")) ? 1.0 : 0.0;
+ }
+
+ boolean regressed(double tolerance) {
+ if (lowerIsBetter) {
+ return latest > baseline * (1.0 + tolerance) && latest - baseline > absFloor;
+ }
+ return latest < baseline * (1.0 - tolerance) && baseline - latest > absFloor;
+ }
+
+ double ratio() {
+ if (baseline == 0) return latest == 0 ? 1.0 : Double.POSITIVE_INFINITY;
+ return latest / baseline;
+ }
+ }
+
+ private Compare() {
+ }
+
+ public static int run(Path baselinePath, Path latestPath, double tolerance, boolean quiet) throws IOException {
+ Map baseline = Json.parseObject(
+ new String(Files.readAllBytes(baselinePath), StandardCharsets.UTF_8));
+ Map latest = Json.parseObject(
+ new String(Files.readAllBytes(latestPath), StandardCharsets.UTF_8));
+ return compare(baseline, latest, tolerance, quiet);
+ }
+
+ @SuppressWarnings("unchecked")
+ public static int compare(Map baseline, Map latest,
+ double tolerance, boolean quiet) {
+ List metrics = new ArrayList<>();
+ List missing = new ArrayList<>();
+
+ Map> baseScenarios = indexScenarios(baseline);
+ Map> latestScenarios = indexScenarios(latest);
+
+ for (Map.Entry> e : baseScenarios.entrySet()) {
+ String key = e.getKey();
+ Map b = e.getValue();
+ Map l = latestScenarios.get(key);
+ if (l == null) {
+ missing.add("scenario " + key);
+ continue;
+ }
+ collectScenario(key, b, l, metrics, missing);
+ }
+ for (String key : latestScenarios.keySet()) {
+ if (!baseScenarios.containsKey(key)) missing.add("scenario " + key + " (only in latest)");
+ }
+
+ Map baseMicro = (Map) baseline.getOrDefault("micro", Map.of());
+ Map latestMicro = (Map) latest.getOrDefault("micro", Map.of());
+ for (Map.Entry e : baseMicro.entrySet()) {
+ String name = e.getKey();
+ Map b = (Map) e.getValue();
+ Map l = (Map) latestMicro.get(name);
+ if (l == null) {
+ missing.add("micro " + name);
+ continue;
+ }
+ double bv = num(b.get("nsPerOp"));
+ double lv = num(l.get("nsPerOp"));
+ if (bv <= 0) {
+ missing.add("micro " + name + " (baseline nsPerOp not measurable)");
+ continue;
+ }
+ metrics.add(new MetricRef("micro." + name + ".nsPerOp", bv, lv, true));
+ }
+
+ boolean regressions = false;
+ List rows = new ArrayList<>();
+ rows.add(String.format(Locale.ROOT, "%-52s %12s %12s %8s %s",
+ "metric", "baseline", "latest", "ratio", "status"));
+ for (MetricRef m : metrics) {
+ boolean bad = m.regressed(tolerance);
+ if (bad) regressions = true;
+ rows.add(String.format(Locale.ROOT, "%-52s %12.2f %12.2f %8.3f %s",
+ m.label, m.baseline, m.latest, m.ratio(), bad ? "REGRESSION" : "ok"));
+ }
+ if (!quiet) {
+ for (String row : rows) System.out.println(row);
+ if (!missing.isEmpty()) {
+ System.out.println("missing metrics:");
+ for (String s : missing) System.out.println(" " + s);
+ }
+ }
+
+ if (regressions) return EXIT_REGRESSION;
+ if (!missing.isEmpty()) return EXIT_MISSING;
+ return EXIT_OK;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void collectScenario(String key, Map b, Map l,
+ List metrics, List missing) {
+ Map blat = (Map) b.get("latencyActuationNs");
+ Map llat = (Map) l.get("latencyActuationNs");
+ for (String p : new String[] {"p50", "p90", "p99", "max"}) {
+ double bv = num(blat == null ? null : blat.get(p));
+ if (bv <= 0) {
+ missing.add(key + ".latencyActuationNs." + p);
+ continue;
+ }
+ if (!(llat != null && llat.get(p) instanceof Number)) {
+ missing.add(key + ".latencyActuationNs." + p);
+ continue;
+ }
+ metrics.add(new MetricRef(key + ".latency." + p, bv, num(llat.get(p)), true));
+ }
+
+ Map brates = (Map) b.get("taskRates");
+ Map lrates = (Map) l.get("taskRates");
+ for (Map.Entry e : brates.entrySet()) {
+ String task = e.getKey();
+ Map br = (Map) e.getValue();
+ Map lr = (Map) lrates.get(task);
+ if (lr == null) {
+ missing.add(key + ".taskRates." + task);
+ continue;
+ }
+ double bHz = num(br.get("achievedHz"));
+ if (bHz > 0) {
+ if (!(lr.get("achievedHz") instanceof Number)) {
+ missing.add(key + ".taskRates." + task + ".achievedHz");
+ } else {
+ metrics.add(new MetricRef(key + "." + task + ".achievedHz", bHz, num(lr.get("achievedHz")), false));
+ }
+ }
+ double bJ = num(br.get("jitterP99Ns"));
+ if (bJ > 0) {
+ if (!(lr.get("jitterP99Ns") instanceof Number)) {
+ missing.add(key + ".taskRates." + task + ".jitterP99Ns");
+ } else {
+ metrics.add(new MetricRef(key + "." + task + ".jitterP99Ns", bJ, num(lr.get("jitterP99Ns")), true));
+ }
+ }
+ }
+
+ Map btr = (Map) b.getOrDefault("trackingError", Map.of());
+ Map ltr = (Map) l.getOrDefault("trackingError", Map.of());
+ for (String p : new String[] {"liftRmse", "headingRmse"}) {
+ double bv = num(btr.get(p));
+ if (bv > 0) {
+ if (!(ltr.get(p) instanceof Number)) {
+ missing.add(key + "." + p);
+ continue;
+ }
+ metrics.add(new MetricRef(key + "." + p, bv, num(ltr.get(p)), true));
+ }
+ }
+
+ double bLoop = num(b.get("loopHz"));
+ double lLoop = num(l.get("loopHz"));
+ if (bLoop > 0) {
+ metrics.add(new MetricRef(key + ".loopHz", bLoop, lLoop, false));
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map> indexScenarios(Map doc) {
+ Map> out = new LinkedHashMap<>();
+ Object list = doc.get("scenarios");
+ if (!(list instanceof List)) return out;
+ for (Object o : (List