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) list) { + Map m = (Map) o; + out.put(m.get("scenario") + "/" + m.get("style"), m); + } + return out; + } + + private static double num(Object o) { + return o instanceof Number ? ((Number) o).doubleValue() : 0.0; + } + + /** + * Self-test of the regression detector (acceptance criterion): a synthetic + * 2× slowdown of the {@code OrchestratorImpl.publish} path (represented by + * {@code micro.publish.subscribers1}) must exit 1, and comparing a document + * to itself must exit 0. + */ + @SuppressWarnings("unchecked") + public static int selfTest(Path anyResult) throws IOException { + Map base = Json.parseObject( + new String(Files.readAllBytes(anyResult), StandardCharsets.UTF_8)); + Map mutated = Json.parseObject(Json.write(base)); + Map micro = (Map) mutated.get("micro"); + Map pub = (Map) micro.get("micro.publish.subscribers1"); + if (pub == null) { + System.err.println("self-test: no micro.publish.subscribers1 in " + anyResult); + return EXIT_MISSING; + } + pub.put("nsPerOp", num(pub.get("nsPerOp")) * 2.0); + pub.put("p50", num(pub.get("p50")) * 2.0); + pub.put("p99", num(pub.get("p99")) * 2.0); + + int noOp = compare(base, base, 0.15, true); + int slowed = compare(base, mutated, 0.15, true); + + Map stripped = Json.parseObject(Json.write(base)); + Map strippedMicro = (Map) stripped.get("micro"); + strippedMicro.remove("micro.publish.subscribers1"); + int missing = compare(base, stripped, 0.15, true); + + System.out.println("compare self-test:"); + System.out.println(" no-op run exit code = " + noOp + " (expect 0)"); + System.out.println(" 2x publish slowdown exit = " + slowed + " (expect 1)"); + System.out.println(" missing metric exit code = " + missing + " (expect 2)"); + boolean pass = noOp == EXIT_OK && slowed == EXIT_REGRESSION && missing == EXIT_MISSING; + System.out.println(pass ? "SELF-TEST PASS" : "SELF-TEST FAIL"); + return pass ? EXIT_OK : EXIT_REGRESSION; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/FrameworkProvenance.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/FrameworkProvenance.java new file mode 100644 index 0000000..cc01a3b --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/FrameworkProvenance.java @@ -0,0 +1,192 @@ +package com.aaravlabs.synapse.bench.harness; + +import com.aaravlabs.synapse.Node; +import com.aaravlabs.synapse.OrchestratorImpl; +import com.aaravlabs.synapse.bench.shared.World; +import com.aaravlabs.synapse.ftc.GamepadAdaptor; +import com.qualcomm.robotcore.hardware.Gamepad; +import com.seattlesolvers.solverslib.command.CommandScheduler; + +import java.io.File; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * Gate 1 + gate 3: runtime class-provenance assertions and the structural review + * rule. Measured framework classes must load from the real project output, the + * extracted published SolversLib AAR, and the checked-in FTC SDK stub — never + * from {@code benchmarks/build/classes} (which would mean a vendored + * reimplementation). + */ +public final class FrameworkProvenance { + + public static final class Result { + public final boolean ok; + public final List messages; + + Result(boolean ok, List messages) { + this.ok = ok; + this.messages = messages; + } + } + + private FrameworkProvenance() { + } + + public static Result verify() { + List messages = new ArrayList<>(); + boolean ok = true; + + ok &= check(CommandScheduler.class, "solverslib/classes.jar", + "benchmarks/build/classes", messages); + ok &= check(OrchestratorImpl.class, null, + "benchmarks/build/classes", messages); + ok &= check(Gamepad.class, "ftc-sdk-stub.jar", + "benchmarks/build/classes", messages); + ok &= check(GamepadAdaptor.class, null, + "benchmarks/build/classes", messages); + + URL orchSource = OrchestratorImpl.class.getProtectionDomain().getCodeSource().getLocation(); + URL benchSource = FrameworkProvenance.class.getProtectionDomain().getCodeSource().getLocation(); + if (orchSource != null && orchSource.equals(benchSource)) { + ok = false; + messages.add("FAIL OrchestratorImpl resolved from benchmarks output: " + orchSource); + } else { + messages.add("OK OrchestratorImpl from " + orchSource); + } + + // Touch real dispatch so lazy linkage of the measured path is exercised. + try { + World world = new World(com.aaravlabs.synapse.bench.shared.Scenario.S0_MinimalDrive, 1, + new com.aaravlabs.synapse.bench.shared.Metrics()); + com.aaravlabs.synapse.Orchestrator orch = + com.aaravlabs.synapse.Orchestrator.create("provenance", com.aaravlabs.synapse.LogSink.SILENT); + orch.registerNode("probe", new Node(orch) { + }); + GamepadAdaptor.attach(orch, world.gamepad1(), "g1"); + orch.publish("smoke", 1.0); + CommandScheduler.getInstance().run(); + orch.close(); + world.close(); + messages.add("OK smoke dispatch (Orchestrator + GamepadAdaptor + CommandScheduler) ran"); + } catch (Throwable t) { + ok = false; + messages.add("FAIL smoke dispatch: " + t); + } + + return new Result(ok, messages); + } + + private static boolean check(Class cls, String mustContain, String mustNotContain, + List messages) { + URL loc = cls.getProtectionDomain().getCodeSource().getLocation(); + String s = loc == null ? "null" : loc.toString(); + boolean ok = true; + if (mustContain != null && !s.contains(mustContain)) { + ok = false; + } + if (mustNotContain != null && s.contains(mustNotContain)) { + ok = false; + } + messages.add((ok ? "OK " : "FAIL ") + cls.getName() + " <- " + s); + return ok; + } + + /** + * Gate 3 (structural review rule): {@code shared/} must contain zero Synapse + * or SolversLib dispatch types, and style packages must contain zero classes + * named like {@code *Scheduler}, {@code *Orchestrator}, {@code *Bus}. + */ + public static Result verifyStructure(Path sourceRoot) { + List messages = new ArrayList<>(); + boolean ok = true; + Path shared = sourceRoot.resolve("com/aaravlabs/synapse/bench/shared"); + List styleDirs = new ArrayList<>(); + styleDirs.add("raw"); + styleDirs.add("rawmt"); + styleDirs.add("solverslib"); + styleDirs.add("synapse"); + + try (Stream files = Files.walk(shared)) { + for (Path p : (Iterable) files::iterator) { + if (!p.toString().endsWith(".java")) continue; + String text = new String(Files.readAllBytes(p), StandardCharsets.UTF_8); + if (containsFrameworkDispatchImport(text)) { + ok = false; + messages.add("FAIL shared/ references a framework dispatch type: " + p.getFileName()); + } + } + } catch (Exception e) { + ok = false; + messages.add("FAIL structure scan: " + e); + } + + for (String dir : styleDirs) { + Path base = sourceRoot.resolve("com/aaravlabs/synapse/bench/" + dir); + if (!Files.isDirectory(base)) continue; + try (Stream files = Files.walk(base)) { + for (Path p : (Iterable) files::iterator) { + String name = p.getFileName().toString(); + if (!name.endsWith(".java")) continue; + String simple = name.substring(0, name.length() - 5); + if (simple.endsWith("Scheduler") || simple.endsWith("Orchestrator") || simple.endsWith("Bus")) { + ok = false; + messages.add("FAIL style class looks like a reimplemented dispatch type: " + dir + "/" + name); + } + } + } catch (Exception e) { + ok = false; + messages.add("FAIL structure scan " + dir + ": " + e); + } + } + + if (ok) { + messages.add("OK shared/ has no framework dispatch types; style packages have no *Scheduler/*Orchestrator/*Bus"); + } + return new Result(ok, messages); + } + + public static Path defaultSourceRoot() { + File f = new File(FrameworkProvenance.class.getProtectionDomain() + .getCodeSource().getLocation().getPath()); + // .../benchmarks/build/classes/java/main -> benchmarks/src/main/java + Path p = f.toPath(); + for (int i = 0; i < 6 && p != null; i++) { + Path candidate = p.resolve(Paths.get("src", "main", "java")); + if (Files.isDirectory(candidate.resolve("com/aaravlabs/synapse/bench/shared"))) { + return candidate; + } + p = p.getParent(); + } + return Paths.get("benchmarks", "src", "main", "java"); + } + + /** + * True if the source references a Synapse or SolversLib dispatch type. The + * benchmark's own {@code com.aaravlabs.synapse.bench.*} packages are allowed. + */ + static boolean containsFrameworkDispatchImport(String text) { + String[] banned = { + "com.aaravlabs.synapse.Orchestrator", + "com.aaravlabs.synapse.Node", + "com.aaravlabs.synapse.Topic", + "com.aaravlabs.synapse.Subscription", + "com.aaravlabs.synapse.MessageHandler", + "com.aaravlabs.synapse.LogSink", + "com.aaravlabs.synapse.internal", + "com.aaravlabs.synapse.ftc", + "com.aaravlabs.synapse.annotation", + "com.seattlesolvers.solverslib" + }; + for (String b : banned) { + if (text.contains(b)) return true; + } + return false; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Json.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Json.java new file mode 100644 index 0000000..9380a94 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Json.java @@ -0,0 +1,285 @@ +package com.aaravlabs.synapse.bench.harness; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Minimal JSON writer/parser for the fixed benchmark schema. No third-party deps + * (the library's "no new dependencies" rule keeps the benchmark runner simple too). + */ +public final class Json { + + private Json() { + } + + // ------------------------------------------------------------------ write + + public static String write(Object value) { + StringBuilder sb = new StringBuilder(4096); + writeValue(sb, value, 0); + return sb.toString(); + } + + private static void writeValue(StringBuilder sb, Object value, int indent) { + if (value == null) { + sb.append("null"); + } else if (value instanceof String) { + writeString(sb, (String) value); + } else if (value instanceof Boolean) { + sb.append(value.toString()); + } else if (value instanceof Double || value instanceof Float) { + double d = ((Number) value).doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + sb.append("null"); + } else { + sb.append(String.format(java.util.Locale.ROOT, "%.6f", d)); + } + } else if (value instanceof Number) { + sb.append(value.toString()); + } else if (value instanceof Map) { + writeObject(sb, (Map) value, indent); + } else if (value instanceof List) { + writeArray(sb, (List) value, indent); + } else { + writeString(sb, value.toString()); + } + } + + private static void writeObject(StringBuilder sb, Map map, int indent) { + if (map.isEmpty()) { + sb.append("{}"); + return; + } + sb.append("{\n"); + int i = 0; + for (Map.Entry e : map.entrySet()) { + pad(sb, indent + 1); + writeString(sb, String.valueOf(e.getKey())); + sb.append(": "); + writeValue(sb, e.getValue(), indent + 1); + if (++i < map.size()) sb.append(','); + sb.append('\n'); + } + pad(sb, indent); + sb.append('}'); + } + + private static void writeArray(StringBuilder sb, List list, int indent) { + if (list.isEmpty()) { + sb.append("[]"); + return; + } + sb.append("[\n"); + for (int i = 0; i < list.size(); i++) { + pad(sb, indent + 1); + writeValue(sb, list.get(i), indent + 1); + if (i + 1 < list.size()) sb.append(','); + sb.append('\n'); + } + pad(sb, indent); + sb.append(']'); + } + + private static void writeString(StringBuilder sb, String s) { + sb.append('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + + private static void pad(StringBuilder sb, int indent) { + for (int i = 0; i < indent; i++) sb.append(" "); + } + + // ------------------------------------------------------------------- read + + public static Object parse(String text) { + Parser p = new Parser(text); + Object v = p.parseValue(); + p.skipWs(); + return v; + } + + @SuppressWarnings("unchecked") + public static Map parseObject(String text) { + Object v = parse(text); + if (!(v instanceof Map)) throw new IllegalArgumentException("not a JSON object"); + return (Map) v; + } + + private static final class Parser { + private final String s; + private int i; + + Parser(String s) { + this.s = s; + } + + Object parseValue() { + skipWs(); + char c = peek(); + switch (c) { + case '{': + return parseObj(); + case '[': + return parseArr(); + case '"': + return parseStr(); + case 't': + expect("true"); + return Boolean.TRUE; + case 'f': + expect("false"); + return Boolean.FALSE; + case 'n': + expect("null"); + return null; + default: + return parseNum(); + } + } + + Map parseObj() { + Map m = new LinkedHashMap<>(); + i++; // { + skipWs(); + if (peek() == '}') { + i++; + return m; + } + while (true) { + skipWs(); + String key = parseStr(); + skipWs(); + if (peek() != ':') throw new IllegalArgumentException("expected : at " + i); + i++; + m.put(key, parseValue()); + skipWs(); + char c = next(); + if (c == '}') return m; + if (c != ',') throw new IllegalArgumentException("expected , or } at " + i); + } + } + + List parseArr() { + List list = new ArrayList<>(); + i++; // [ + skipWs(); + if (peek() == ']') { + i++; + return list; + } + while (true) { + list.add(parseValue()); + skipWs(); + char c = next(); + if (c == ']') return list; + if (c != ',') throw new IllegalArgumentException("expected , or ] at " + i); + } + } + + String parseStr() { + if (next() != '"') throw new IllegalArgumentException("expected string at " + i); + StringBuilder sb = new StringBuilder(); + while (true) { + char c = next(); + if (c == '"') return sb.toString(); + if (c == '\\') { + char e = next(); + switch (e) { + case '"': + sb.append('"'); + break; + case '\\': + sb.append('\\'); + break; + case '/': + sb.append('/'); + break; + case 'n': + sb.append('\n'); + break; + case 'r': + sb.append('\r'); + break; + case 't': + sb.append('\t'); + break; + case 'u': + sb.append((char) Integer.parseInt(s.substring(i, i + 4), 16)); + i += 4; + break; + default: + throw new IllegalArgumentException("bad escape " + e); + } + } else { + sb.append(c); + } + } + } + + Object parseNum() { + int start = i; + while (i < s.length()) { + char c = s.charAt(i); + if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E') { + i++; + } else { + break; + } + } + String t = s.substring(start, i); + if (t.contains(".") || t.contains("e") || t.contains("E")) { + return Double.parseDouble(t); + } + long l = Long.parseLong(t); + if (l >= Integer.MIN_VALUE && l <= Integer.MAX_VALUE) return (int) l; + return l; + } + + void skipWs() { + while (i < s.length() && Character.isWhitespace(s.charAt(i))) i++; + } + + char peek() { + return s.charAt(i); + } + + char next() { + return s.charAt(i++); + } + + void expect(String word) { + if (!s.startsWith(word, i)) { + throw new IllegalArgumentException("expected " + word + " at " + i); + } + i += word.length(); + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Main.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Main.java new file mode 100644 index 0000000..b02490a --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Main.java @@ -0,0 +1,573 @@ +package com.aaravlabs.synapse.bench.harness; + +import com.aaravlabs.synapse.bench.shared.Env; +import com.aaravlabs.synapse.bench.shared.Hist; +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.Scenario; +import com.aaravlabs.synapse.bench.shared.World; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Single entrypoint for the benchmark suite. + * + *
+ * run --quick|--full [--scenarios S0,S3] [--styles raw,solverslib,synapse]
+ *     [--forks N] [--alloc] [--seed N]
+ * compare results/baseline.json results/latest.json [--tolerance 0.15]
+ * compare --self-test
+ * gates --only framework,structure,mock-budget
+ * 
+ */ +public final class Main { + + private static final class Config { + boolean full; + boolean alloc; + int forks = 1; + int rounds = 1; + long seed = 42; + List scenarios = new ArrayList<>(); + List styles = new ArrayList<>(); + Path outDir = Paths.get("results"); + String internalForkOut; + + long warmupMs() { + return full ? 2000 : 800; + } + + long measureMs() { + return full ? 15_000 : 3000; + } + } + + public static void main(String[] args) throws Exception { + if (args.length == 0) { + usage(); + System.exit(Compare.EXIT_MISSING); + } + String cmd = args[0]; + switch (cmd) { + case "run": + System.exit(cmdRun(args)); + break; + case "compare": + System.exit(cmdCompare(args)); + break; + case "gates": + System.exit(cmdGates(args)); + break; + default: + usage(); + System.exit(Compare.EXIT_MISSING); + } + } + + private static void usage() { + System.err.println("usage: run --quick|--full [--scenarios S0,S1,S2,S3] [--styles raw,rawmt,solverslib,synapse]"); + System.err.println(" [--forks N] [--alloc] [--seed N] [--out DIR]"); + System.err.println(" compare [--tolerance 0.15]"); + System.err.println(" compare --self-test [results/latest.json]"); + System.err.println(" gates --only framework,structure,mock-budget [--quick|--full]"); + } + + // ---------------------------------------------------------------- run + + private static int cmdRun(String[] args) throws Exception { + Config cfg = parseRunArgs(args); + if (cfg.scenarios.isEmpty()) { + for (Scenario s : Scenario.values()) cfg.scenarios.add(s); + } + + if (cfg.internalForkOut != null) { + Map doc = runSuite(cfg); + Report.writeJson(Paths.get(cfg.internalForkOut), doc); + return Compare.EXIT_OK; + } + + if (cfg.forks > 1) { + List> forks = new ArrayList<>(); + for (int i = 0; i < cfg.forks; i++) { + Path forkOut = cfg.outDir.resolve("fork-" + i + ".json"); + List child = new ArrayList<>(); + child.add(System.getProperty("java.home") + "/bin/java"); + child.add("-Xms512m"); + child.add("-Xmx2g"); + child.add("-cp"); + child.add(System.getProperty("java.class.path")); + child.add(Main.class.getName()); + child.add("run"); + child.add(cfg.full ? "--full" : "--quick"); + child.add("--rounds"); + child.add(Integer.toString(cfg.rounds)); + child.add("--seed"); + child.add(Long.toString(cfg.seed)); + child.add("--out"); + child.add(cfg.outDir.toString()); + child.add("--internal-fork-out"); + child.add(forkOut.toString()); + if (cfg.alloc) child.add("--alloc"); + if (!cfg.scenarios.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (Scenario s : cfg.scenarios) { + if (sb.length() > 0) sb.append(','); + sb.append(s.name()); + } + child.add("--scenarios"); + child.add(sb.toString()); + } + if (!cfg.styles.isEmpty()) { + child.add("--styles"); + child.add(String.join(",", cfg.styles)); + } + System.out.println("fork " + (i + 1) + "/" + cfg.forks + " ..."); + Process p = new ProcessBuilder(child).inheritIO().start(); + if (p.waitFor() != 0) { + System.err.println("fork " + i + " failed"); + return Compare.EXIT_MISSING; + } + forks.add(Json.parseObject( + new String(Files.readAllBytes(forkOut), StandardCharsets.UTF_8))); + } + Map merged = mergeForks(forks, cfg); + writeOutputs(cfg, merged); + printLadder(merged); + return gateExit(merged); + } + + Map doc = runSuite(cfg); + writeOutputs(cfg, doc); + printLadder(doc); + return gateExit(doc); + } + + private static void writeOutputs(Config cfg, Map doc) throws IOException { + Path json = cfg.outDir.resolve("latest.json"); + Path md = cfg.outDir.resolve("latest.md"); + Report.writeJson(json, doc); + Report.writeMarkdown(md, doc); + System.out.println("wrote " + json.toAbsolutePath()); + System.out.println("wrote " + md.toAbsolutePath()); + } + + private static int gateExit(Map doc) { + @SuppressWarnings("unchecked") + Map gates = (Map) doc.get("gates"); + Object ok = gates.get("ok"); + return Boolean.TRUE.equals(ok) ? Compare.EXIT_OK : Compare.EXIT_REGRESSION; + } + + private static Map runSuite(Config cfg) throws Exception { + FrameworkProvenance.Result provenance = FrameworkProvenance.verify(); + FrameworkProvenance.Result structure = + FrameworkProvenance.verifyStructure(FrameworkProvenance.defaultSourceRoot()); + + List pairs = new ArrayList<>(); + for (Scenario scenario : cfg.scenarios) { + List styles = cfg.styles.isEmpty() + ? Registry.stylesFor(scenario) + : filterStyles(Registry.stylesFor(scenario), cfg.styles); + for (String style : styles) { + System.out.println("=== " + scenario.name() + " x " + style + " ==="); + List rounds = new ArrayList<>(); + for (int r = 0; r < cfg.rounds; r++) { + rounds.add(runPair(scenario, style, cfg)); + } + pairs.add(medianPair(rounds)); + } + } + + MicroBench micro = new MicroBench(cfg.full); + Map micros = micro.runAll(); + double budgetPct = MicroBench.mockBudgetRatioPct(micros); + boolean budgetOk = !Double.isNaN(budgetPct) && budgetPct < 5.0; + + Map microMap = new LinkedHashMap<>(); + for (Map.Entry e : micros.entrySet()) { + MicroBench.Result r = e.getValue(); + Map m = new LinkedHashMap<>(); + m.put("nsPerOp", r.nsPerOp); + m.put("p50", r.p50); + m.put("p99", r.p99); + m.put("count", r.count); + microMap.put(e.getKey(), m); + } + + Map gates = new LinkedHashMap<>(); + List messages = new ArrayList<>(); + messages.addAll(provenance.messages); + messages.addAll(structure.messages); + messages.add((budgetOk ? "OK " : "FAIL ") + String.format(Locale.ROOT, + "mock budget: micro.sim.deviceWrite = %.1f%% of smallest framework dispatch (limit 5%%)", + budgetPct)); + gates.put("frameworkClasses", provenance.ok); + gates.put("structure", structure.ok); + gates.put("mockBudget", budgetOk); + gates.put("mockBudgetRatioPct", budgetPct); + gates.put("ok", provenance.ok && structure.ok && budgetOk); + gates.put("messages", messages); + + Map env = Env.collect(cfg.full ? "full" : "quick", cfg.seed, cfg.forks, cfg.rounds); + return Report.document(env, pairs, microMap, gates); + } + + private static Report.Pair runPair(Scenario scenario, String style, Config cfg) throws Exception { + Metrics metrics = new Metrics(); + metrics.setAllocEnabled(cfg.alloc); + World world = new World(scenario, cfg.seed + scenario.ordinal() * 17L, metrics, + (cfg.warmupMs() + cfg.measureMs()) / 1000.0, Registry.drivePowerSign(style)); + PairRunner runner = Registry.create(scenario, style, world); + world.start(); + runner.start(); + Thread.sleep(cfg.warmupMs()); + world.plant().resetTracking(); + metrics.startWindow(); + Thread.sleep(cfg.measureMs()); + metrics.endWindow(); + runner.stop(); + world.plant().freezeTracking(); + double liftRmse = world.plant().liftRmse(); + double headingRmse = world.plant().headingRmse(); + Hist.Snapshot latency = metrics.probeSnapshot("actuation"); + List tasks = metrics.taskSnapshots(); + double loopHz = metrics.loopHz(); + double alloc = metrics.allocBytesPerSec(); + world.close(); + return Report.Pair.fromSnapshot(scenario.name(), style, latency, tasks, + liftRmse, headingRmse, loopHz, alloc); + } + + private static List filterStyles(List available, List wanted) { + for (String w : wanted) { + if (!available.contains(w)) { + throw new IllegalArgumentException( + "requested style '" + w + "' is not available for this scenario"); + } + } + List out = new ArrayList<>(); + for (String s : available) { + if (wanted.contains(s)) out.add(s); + } + return out; + } + + // ------------------------------------------------- median-of-rounds/forks + + private static Report.Pair medianPair(List rounds) { + if (rounds.size() == 1) return rounds.get(0); + Report.Pair first = rounds.get(0); + + Map lat = new LinkedHashMap<>(); + for (String p : new String[] {"p50", "p90", "p99", "max", "min", "mean"}) { + lat.put(p, medianOf(rounds, r -> num(((Map) r.latencyActuationNs).get(p)))); + } + lat.put("count", (long) medianOf(rounds, r -> num(r.latencyActuationNs.get("count")))); + + Map rates = new LinkedHashMap<>(); + for (Object task : first.taskRates.keySet()) { + String name = (String) task; + Map entry = new LinkedHashMap<>(); + entry.put("targetHz", ((Map) first.taskRates.get(name)).get("targetHz")); + entry.put("achievedHz", medianOf(rounds, r -> { + Map t = (Map) r.taskRates.get(name); + return t == null ? 0 : num(t.get("achievedHz")); + })); + entry.put("jitterP99Ns", medianOf(rounds, r -> { + Map t = (Map) r.taskRates.get(name); + return t == null ? 0 : num(t.get("jitterP99Ns")); + })); + entry.put("count", (long) medianOf(rounds, r -> { + Map t = (Map) r.taskRates.get(name); + return t == null ? 0 : num(t.get("count")); + })); + rates.put(name, entry); + } + + Map tracking = new LinkedHashMap<>(); + tracking.put("liftRmse", medianOf(rounds, r -> num(r.trackingError.get("liftRmse")))); + tracking.put("headingRmse", medianOf(rounds, r -> num(r.trackingError.get("headingRmse")))); + + double loopHz = medianOf(rounds, r -> r.loopHz); + double alloc = medianOf(rounds, r -> r.allocBytesPerSec); + return new Report.Pair(first.scenario, first.style, lat, rates, tracking, loopHz, alloc); + } + + private interface PairDouble { + double get(Report.Pair p); + } + + private static double medianOf(List pairs, PairDouble f) { + double[] v = new double[pairs.size()]; + for (int i = 0; i < v.length; i++) v[i] = f.get(pairs.get(i)); + java.util.Arrays.sort(v); + int n = v.length; + return (n % 2 == 1) ? v[n / 2] : 0.5 * (v[n / 2 - 1] + v[n / 2]); + } + + @SuppressWarnings("unchecked") + private static Map mergeForks(List> forks, Config cfg) { + if (forks.size() == 1) return forks.get(0); + Map> grouped = new LinkedHashMap<>(); + Map>> microGrouped = new LinkedHashMap<>(); + for (Map fork : forks) { + for (Object o : (List) fork.get("scenarios")) { + Report.Pair p = Report.Pair.fromMap((Map) o); + grouped.computeIfAbsent(p.scenario + "/" + p.style, k -> new ArrayList<>()).add(p); + } + Map micro = (Map) fork.get("micro"); + for (Map.Entry e : micro.entrySet()) { + microGrouped.computeIfAbsent(e.getKey(), k -> new ArrayList<>()) + .add((Map) e.getValue()); + } + } + List pairs = new ArrayList<>(); + for (List list : grouped.values()) { + pairs.add(medianPair(list)); + } + Map microOut = new LinkedHashMap<>(); + for (Map.Entry>> e : microGrouped.entrySet()) { + Map m = new LinkedHashMap<>(); + m.put("nsPerOp", medianMaps(e.getValue(), "nsPerOp")); + m.put("p50", medianMaps(e.getValue(), "p50")); + m.put("p99", medianMaps(e.getValue(), "p99")); + m.put("count", medianMaps(e.getValue(), "count")); + microOut.put(e.getKey(), m); + } + double budgetPct = MicroBench.mockBudgetRatioPct(toMicroResults(microOut)); + boolean allFramework = true; + boolean allStructure = true; + for (Map fork : forks) { + @SuppressWarnings("unchecked") + Map g = (Map) fork.get("gates"); + allFramework &= Boolean.TRUE.equals(g.get("frameworkClasses")); + allStructure &= Boolean.TRUE.equals(g.get("structure")); + } + Map gates = new LinkedHashMap<>(); + gates.put("frameworkClasses", allFramework); + gates.put("structure", allStructure); + gates.put("mockBudget", !Double.isNaN(budgetPct) && budgetPct < 5.0); + gates.put("mockBudgetRatioPct", budgetPct); + gates.put("ok", allFramework && allStructure && !Double.isNaN(budgetPct) && budgetPct < 5.0); + gates.put("messages", List.of("merged " + forks.size() + " forks (median)")); + + Map env = Env.collect(cfg.full ? "full" : "quick", cfg.seed, cfg.forks, cfg.rounds); + return Report.document(env, pairs, microOut, gates); + } + + private static Map toMicroResults(Map microOut) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : microOut.entrySet()) { + Map m = (Map) e.getValue(); + out.put(e.getKey(), new MicroBench.Result(e.getKey(), + num(m.get("nsPerOp")), + (long) num(m.get("p50")), + (long) num(m.get("p99")), + (long) num(m.get("count")))); + } + return out; + } + + private static double medianMaps(List> maps, String key) { + double[] v = new double[maps.size()]; + for (int i = 0; i < v.length; i++) v[i] = num(maps.get(i).get(key)); + java.util.Arrays.sort(v); + int n = v.length; + return (n % 2 == 1) ? v[n / 2] : 0.5 * (v[n / 2 - 1] + v[n / 2]); + } + + private static double num(Object o) { + return o instanceof Number ? ((Number) o).doubleValue() : 0.0; + } + + /** + * Ladder-shape check (acceptance criterion): S0 raw latency must beat Synapse, + * and S3 Synapse lift-RMSE and PIDF achieved-Hz must beat raw and SolversLib. + */ + @SuppressWarnings("unchecked") + private static void printLadder(Map doc) { + Map index = new LinkedHashMap<>(); + for (Object o : (List) doc.get("scenarios")) { + Report.Pair p = Report.Pair.fromMap((Map) o); + index.put(p.scenario + "/" + p.style, p); + } + Report.Pair s0Raw = index.get(Scenario.S0_MinimalDrive.name() + "/raw"); + Report.Pair s0Syn = index.get(Scenario.S0_MinimalDrive.name() + "/synapse"); + Report.Pair s3Syn = index.get(Scenario.S3_HeavyRobot.name() + "/synapse"); + Report.Pair s3Raw = index.get(Scenario.S3_HeavyRobot.name() + "/raw"); + Report.Pair s3Sol = index.get(Scenario.S3_HeavyRobot.name() + "/solverslib"); + + List notes = new ArrayList<>(); + boolean ok = true; + if (s0Raw != null && s0Syn != null) { + double rawP50 = num(s0Raw.latencyActuationNs.get("p50")); + double synP50 = num(s0Syn.latencyActuationNs.get("p50")); + boolean pass = rawP50 < synP50; + ok &= pass; + notes.add((pass ? "OK " : "FAIL ") + String.format(Locale.ROOT, + "S0 raw actuation p50 %.0f ns < synapse %.0f ns", rawP50, synP50)); + } + if (s3Syn != null && s3Raw != null && s3Sol != null) { + double synLift = num(s3Syn.trackingError.get("liftRmse")); + double rawLift = num(s3Raw.trackingError.get("liftRmse")); + double solLift = num(s3Sol.trackingError.get("liftRmse")); + boolean passLift = synLift < rawLift && synLift < solLift; + ok &= passLift; + notes.add((passLift ? "OK " : "FAIL ") + String.format(Locale.ROOT, + "S3 lift RMSE synapse %.3f < raw %.3f, solverslib %.3f", synLift, rawLift, solLift)); + + double synHz = pidfHz(s3Syn); + double rawHz = pidfHz(s3Raw); + double solHz = pidfHz(s3Sol); + boolean passHz = synHz > rawHz && synHz > solHz; + ok &= passHz; + notes.add((passHz ? "OK " : "FAIL ") + String.format(Locale.ROOT, + "S3 lift PIDF achieved Hz synapse %.1f > raw %.1f, solverslib %.1f", synHz, rawHz, solHz)); + + double synHead = num(s3Syn.trackingError.get("headingRmse")); + double rawHead = num(s3Raw.trackingError.get("headingRmse")); + double solHead = num(s3Sol.trackingError.get("headingRmse")); + notes.add(String.format(Locale.ROOT, + "info S3 heading RMSE synapse %.5f, raw %.5f, solverslib %.5f", synHead, rawHead, solHead)); + } + System.out.println("ladder shape:"); + for (String n : notes) System.out.println(" " + n); + if (!ok) System.out.println(" LADDER SHAPE NOT REPRODUCED (see README interpretation guide)"); + } + + private static double pidfHz(Report.Pair p) { + Map rates = p.taskRates; + Object lift = rates.get("liftPidf"); + return lift == null ? 0 : num(((Map) lift).get("achievedHz")); + } + + // ------------------------------------------------------------- compare + + private static int cmdCompare(String[] args) throws IOException { + if (args.length >= 2 && "--self-test".equals(args[1])) { + Path any = args.length >= 3 + ? Paths.get(args[2]) + : Paths.get("results", "latest.json"); + if (!Files.exists(any)) { + System.err.println("self-test needs an existing result file: " + any); + return Compare.EXIT_MISSING; + } + return Compare.selfTest(any); + } + if (args.length < 3) { + usage(); + return Compare.EXIT_MISSING; + } + double tolerance = 0.15; + for (int i = 3; i < args.length; i++) { + if ("--tolerance".equals(args[i]) && i + 1 < args.length) { + tolerance = Double.parseDouble(args[++i]); + } + } + return Compare.run(Paths.get(args[1]), Paths.get(args[2]), tolerance, false); + } + + // -------------------------------------------------------------- gates + + private static int cmdGates(String[] args) { + String only = "framework,structure,mock-budget"; + boolean full = false; + for (int i = 1; i < args.length; i++) { + if ("--only".equals(args[i]) && i + 1 < args.length) only = args[++i]; + else if ("--full".equals(args[i])) full = true; + } + boolean ok = true; + List messages = new ArrayList<>(); + if (only.contains("framework")) { + FrameworkProvenance.Result r = FrameworkProvenance.verify(); + ok &= r.ok; + messages.addAll(r.messages); + } + if (only.contains("structure")) { + FrameworkProvenance.Result r = + FrameworkProvenance.verifyStructure(FrameworkProvenance.defaultSourceRoot()); + ok &= r.ok; + messages.addAll(r.messages); + } + if (only.contains("mock-budget")) { + MicroBench micro = new MicroBench(full); + Map all = micro.runAll(); + double pct = MicroBench.mockBudgetRatioPct(all); + boolean budgetOk = !Double.isNaN(pct) && pct < 5.0; + ok &= budgetOk; + messages.add(String.format(Locale.ROOT, + "%s mock budget: micro.sim.deviceWrite=%.2f ns vs limit 5%% of smallest dispatch (%.2f%%)", + budgetOk ? "OK " : "FAIL", + all.get("micro.sim.deviceWrite").nsPerOp, pct)); + } + for (String m : messages) System.out.println(m); + System.out.println(ok ? "GATES PASS" : "GATES FAIL"); + return ok ? Compare.EXIT_OK : Compare.EXIT_REGRESSION; + } + + // --------------------------------------------------------------- args + + private static Config parseRunArgs(String[] args) { + Config cfg = new Config(); + boolean sawMode = false; + for (int i = 1; i < args.length; i++) { + String a = args[i]; + switch (a) { + case "--quick": + cfg.full = false; + cfg.rounds = 1; + sawMode = true; + break; + case "--full": + cfg.full = true; + cfg.rounds = 3; + sawMode = true; + break; + case "--scenarios": + for (String s : args[++i].split(",")) { + if (!s.trim().isEmpty()) cfg.scenarios.add(Scenario.parse(s)); + } + break; + case "--styles": + for (String s : args[++i].split(",")) { + if (!s.trim().isEmpty()) cfg.styles.add(s.trim()); + } + break; + case "--forks": + cfg.forks = Integer.parseInt(args[++i]); + break; + case "--seed": + cfg.seed = Long.parseLong(args[++i]); + break; + case "--alloc": + cfg.alloc = true; + break; + case "--out": + cfg.outDir = Paths.get(args[++i]); + break; + case "--internal-fork-out": + cfg.internalForkOut = args[++i]; + break; + case "--rounds": + cfg.rounds = Integer.parseInt(args[++i]); + break; + default: + throw new IllegalArgumentException("unknown arg " + a); + } + } + if (!sawMode) { + cfg.full = false; + cfg.rounds = 1; + } + return cfg; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/MicroBench.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/MicroBench.java new file mode 100644 index 0000000..decae92 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/MicroBench.java @@ -0,0 +1,520 @@ +package com.aaravlabs.synapse.bench.harness; + +import com.aaravlabs.synapse.LogSink; +import com.aaravlabs.synapse.Node; +import com.aaravlabs.synapse.Orchestrator; +import com.aaravlabs.synapse.OrchestratorImpl; +import com.aaravlabs.synapse.Subscription; +import com.aaravlabs.synapse.Topic; +import com.aaravlabs.synapse.annotation.SubscribedTo; +import com.aaravlabs.synapse.bench.shared.Blackhole; +import com.aaravlabs.synapse.bench.shared.Hist; +import com.aaravlabs.synapse.bench.shared.LatencyProbe; +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.Scenario; +import com.aaravlabs.synapse.bench.shared.SimMotor; +import com.aaravlabs.synapse.bench.shared.SimPlant; +import com.aaravlabs.synapse.bench.shared.World; +import com.aaravlabs.synapse.ftc.GamepadAdaptor; +import com.aaravlabs.synapse.ftc.HardwareActions; +import com.seattlesolvers.solverslib.command.CommandScheduler; +import com.seattlesolvers.solverslib.command.SubsystemBase; +import com.seattlesolvers.solverslib.gamepad.GamepadEx; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Micro layer: Synapse optimization targets plus SolversLib comparators and the + * {@code micro.sim.deviceWrite} mock-budget probe. Custom harness (not JMH — + * the interesting paths are cross-thread dispatch). Warmup rounds, percentile + * histograms, volatile blackhole sink. + */ +public final class MicroBench { + + public static final class Result { + public final String name; + public final double nsPerOp; + public final long p50; + public final long p99; + public final long count; + + Result(String name, double nsPerOp, long p50, long p99, long count) { + this.name = name; + this.nsPerOp = nsPerOp; + this.p50 = p50; + this.p99 = p99; + this.count = count; + } + } + + /** + * Framework-level dispatch measurements used by the mock budget gate: paths + * that cross a framework dispatch boundary (callback pool, hardware thread, + * reflective handler invoke) and therefore contain the device write on the + * measured path. Local primitive micros ({@code recordLatest}, + * {@code schedulerRun}, {@code buttonRead}) are optimization targets, not + * budget references — they contain no dispatch hop for the mock to hide in. + */ + public static final List DISPATCH_BENCHES = List.of( + "micro.publish.subscribers1", + "micro.publish.subscribers8", + "micro.publish.annotationSubscriber", + "micro.hardware.run", + "micro.hardware.call"); + + private final int warmupOps; + private final int measureOps; + private final int sampleCount; + + public MicroBench(boolean full) { + this.warmupOps = full ? 200_000 : 20_000; + this.measureOps = full ? 1_000_000 : 100_000; + this.sampleCount = full ? 20_000 : 4_000; + } + + public Map runAll() { + Map out = new LinkedHashMap<>(); + out.put("micro.raw.directCall", batch("micro.raw.directCall", this::rawDirectCall)); + out.putAll(publishBenches()); + out.putAll(topicBenches()); + out.put("micro.subscribe.churn", batch("micro.subscribe.churn", this::subscribeChurn)); + out.putAll(hardwareBenches()); + out.put("micro.gamepad.adaptorPoll", batch("micro.gamepad.adaptorPoll", this::gamepadAdaptorPoll)); + out.putAll(solverslibBenches()); + out.put("micro.sim.deviceWrite", batch("micro.sim.deviceWrite", this::simDeviceWrite)); + return out; + } + + // ------------------------------------------------------------------ raw + + private static final long RAW_SINK_HOLDER = 0; + + private long rawDirectCall() { + long v = RAW_SINK_HOLDER + 1; + Blackhole.consume(v); + return v; + } + + // -------------------------------------------------------------- publish + + private Map publishBenches() { + Map out = new LinkedHashMap<>(); + OrchestratorImpl orch = (OrchestratorImpl) Orchestrator.create("micro-pub", LogSink.SILENT); + try { + out.put("micro.publish.subscribers0", + batch("micro.publish.subscribers0", () -> publishOp(orch, "pub0", 0))); + + out.put("micro.publish.subscribers1", + endToEnd("micro.publish.subscribers1", samples -> publishEndToEnd(orch, "pub1", 1, samples))); + out.put("micro.publish.subscribers8", + endToEnd("micro.publish.subscribers8", samples -> publishEndToEnd(orch, "pub8", 8, samples))); + + AtomicLong annotatedDone = new AtomicLong(); + Node node = new Node(orch) { + @SubscribedTo(topic = "pubAnn") + public void onMsg(Integer v) { + annotatedDone.set(System.nanoTime()); + } + }; + orch.registerNode("micro-ann", node); + out.put("micro.publish.annotationSubscriber", + endToEnd("micro.publish.annotationSubscriber", samples -> { + for (int i = 0; i < samples.length; i++) { + annotatedDone.set(0); + long t0 = System.nanoTime(); + orch.publish("pubAnn", i); + long t1 = awaitStamp(annotatedDone); + samples[i] = t1 - t0; + } + return samples.length; + })); + } finally { + orch.close(); + } + return out; + } + + private long publishOp(Orchestrator orch, String topic, int subscribers) { + orch.publish(topic, 42); + return 42; + } + + private int publishEndToEnd(Orchestrator orch, String topic, int subscribers, long[] samples) { + AtomicLong done = new AtomicLong(); + AtomicInteger remaining = new AtomicInteger(); + List subs = new ArrayList<>(); + for (int s = 0; s < subscribers; s++) { + subs.add(orch.subscribe(topic, Integer.class, v -> { + if (remaining.decrementAndGet() == 0) done.set(System.nanoTime()); + })); + } + for (int i = 0; i < samples.length; i++) { + remaining.set(subscribers); + done.set(0); + long t0 = System.nanoTime(); + orch.publish(topic, i); + long t1 = awaitStamp(done); + samples[i] = t1 - t0; + } + for (Subscription s : subs) s.unsubscribe(); + return samples.length; + } + + private static long awaitStamp(AtomicLong stamp) { + long deadline = System.nanoTime() + 2_000_000_000L; + while (true) { + long v = stamp.get(); + if (v != 0) return v; + if (System.nanoTime() > deadline) { + throw new IllegalStateException("dispatch never completed"); + } + Thread.onSpinWait(); + } + } + + // ---------------------------------------------------------------- topic + + private Map topicBenches() { + Map out = new LinkedHashMap<>(); + Orchestrator orch = Orchestrator.create("micro-topic", LogSink.SILENT); + try { + // micro.topic.recordLatest measures publish() to a zero-subscriber + // topic: the synchronous path is exactly Topic.recordLatest. + orch.getOrCreateTopic("topicMicro0", Integer.class); + Topic topic = orch.getOrCreateTopic("topicMicro", Integer.class); + for (int i = 0; i < 1000; i++) { + orch.publish("topicMicro0", i); + Blackhole.consume(topic.latestValue().orElse(0)); + } + out.put("micro.topic.recordLatest", + batch("micro.topic.recordLatest", () -> publishOp(orch, "topicMicro0", 0))); + out.put("micro.topic.latestValue", + batch("micro.topic.latestValue", () -> { + Integer v = topic.latestValue().orElse(0); + Blackhole.consume(v); + return v; + })); + + out.put("micro.topic.recordLatest.1p1c", contention(orch, 1, 1)); + out.put("micro.topic.recordLatest.4p4c", contention(orch, 4, 4)); + } finally { + orch.close(); + } + return out; + } + + /** + * Publisher-side cost of {@code Topic.recordLatest} (via 0-subscriber publish) + * while consumers hammer {@code latestValue()}. 1P1C and 4P4C contention. + */ + private Result contention(Orchestrator orch, int publishers, int consumers) { + String name = "micro.topic.recordLatest." + publishers + "p" + consumers + "c"; + AtomicLong published = new AtomicLong(); + AtomicLong consumed = new AtomicLong(); + AtomicBoolean run = new AtomicBoolean(true); + List threads = new ArrayList<>(); + for (int c = 0; c < consumers; c++) { + Thread t = new Thread(() -> { + while (run.get()) { + orch.getLatestValue("contend", Integer.class); + consumed.incrementAndGet(); + } + }, "micro-consumer"); + t.setDaemon(true); + threads.add(t); + } + for (int p = 0; p < publishers; p++) { + Thread t = new Thread(() -> { + while (run.get()) { + orch.publish("contend", 1); + published.incrementAndGet(); + } + }, "micro-publisher"); + t.setDaemon(true); + threads.add(t); + } + for (Thread t : threads) t.start(); + try { + Thread.sleep(200); + Hist hist = new Hist(); + long totalOps = 0; + long totalNanos = 0; + long budgetMs = (long) Math.max(200, measureOps / 5000.0); + int intervals = (int) Math.max(8, Math.min(40, budgetMs / 50)); + for (int i = 0; i < intervals; i++) { + long ops0 = published.get(); + long t0 = System.nanoTime(); + Thread.sleep(50); + long ops1 = published.get(); + long t1 = System.nanoTime(); + long ops = ops1 - ops0; + if (ops > 0) { + long ns = t1 - t0; + hist.record(ns / ops); + totalOps += ops; + totalNanos += ns; + } + } + run.set(false); + for (Thread t : threads) t.join(500); + double nsPerOp = totalOps > 0 ? (double) totalNanos / totalOps : 0.0; + Hist.Snapshot s = hist.snapshot(); + Blackhole.consume(consumed.get()); + return new Result(name, nsPerOp, s.p50, s.p99, totalOps); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + run.set(false); + return new Result(name, 0, 0, 0, 0); + } + } + + private long subscribeChurn() { + Orchestrator orch = churnOrch; + Subscription s = orch.subscribe("churn", Integer.class, v -> Blackhole.consume(v)); + s.unsubscribe(); + return 1; + } + + private Orchestrator churnOrch; + + // ------------------------------------------------------------- hardware + + private Map hardwareBenches() { + Map out = new LinkedHashMap<>(); + Orchestrator orch = Orchestrator.create("micro-hw", LogSink.SILENT); + try { + HardwareActions hw = orch.hardware(); + out.put("micro.hardware.run", + endToEnd("micro.hardware.run", samples -> { + AtomicLong done = new AtomicLong(); + for (int i = 0; i < samples.length; i++) { + done.set(0); + long t0 = System.nanoTime(); + hw.run(() -> done.set(System.nanoTime())); + samples[i] = awaitStamp(done) - t0; + } + return samples.length; + })); + out.put("micro.hardware.call", + endToEnd("micro.hardware.call", samples -> { + for (int i = 0; i < samples.length; i++) { + long t0 = System.nanoTime(); + try { + long v = hw.call(() -> System.nanoTime()); + samples[i] = v - t0; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + return samples.length; + })); + } finally { + orch.close(); + } + return out; + } + + private long gamepadAdaptorPoll() { + GamepadAdaptorHolder holder = adaptorHolder; + holder.adaptor.poll(); + return 1; + } + + private static final class GamepadAdaptorHolder { + final GamepadAdaptor adaptor; + final Orchestrator orch; + + GamepadAdaptorHolder() { + this.orch = Orchestrator.create("micro-gp", LogSink.SILENT); + World w = new World(Scenario.S0_MinimalDrive, 1, new Metrics()); + GamepadAdaptor.attach(orch, w.gamepad1(), "g1"); + this.adaptor = (GamepadAdaptor) orch.findNode("GamepadAdaptor:g1") + .orElseThrow(() -> new IllegalStateException("adaptor not registered")); + } + } + + private GamepadAdaptorHolder adaptorHolder; + + // ----------------------------------------------------------- solverslib + + private Map solverslibBenches() { + Map out = new LinkedHashMap<>(); + CommandScheduler scheduler = CommandScheduler.getInstance(); + scheduler.reset(); + scheduler.clearButtons(); + try { + List one = new ArrayList<>(); + one.add(new SubsystemBase() { + }); + scheduler.registerSubsystem(one.get(0)); + out.put("micro.solverslib.schedulerRun1", + batch("micro.solverslib.schedulerRun1", () -> { + scheduler.run(); + return 1; + })); + + scheduler.reset(); + List eight = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + SubsystemBase s = new SubsystemBase() { + }; + eight.add(s); + } + scheduler.registerSubsystem(eight.toArray(new SubsystemBase[0])); + out.put("micro.solverslib.schedulerRun8", + batch("micro.solverslib.schedulerRun8", () -> { + scheduler.run(); + return 1; + })); + scheduler.reset(); + + GamepadEx gamepadEx = new GamepadEx(new com.qualcomm.robotcore.hardware.Gamepad()); + out.put("micro.solverslib.buttonRead", + batch("micro.solverslib.buttonRead", () -> { + gamepadEx.readButtons(); + return 1; + })); + } finally { + scheduler.reset(); + scheduler.clearButtons(); + } + return out; + } + + // ----------------------------------------------------------------- mock + + private long simDeviceWrite() { + SimDeviceHolder holder = simDevice; + holder.probe.stimulus(System.nanoTime(), 1.0); + holder.motor.setPower(1.0); + return 1; + } + + private static final class SimDeviceHolder { + final SimPlant plant; + final LatencyProbe probe; + final SimMotor motor; + + SimDeviceHolder() { + this.plant = new SimPlant(new com.aaravlabs.synapse.bench.shared.Setpoints(0), false, false); + this.probe = new LatencyProbe("micro"); + this.probe.setRecording(true); + this.motor = new SimMotor(plant, SimPlant.LEFT, probe); + } + } + + private SimDeviceHolder simDevice; + + // ------------------------------------------------------------- plumbing + + /** Fixed-work op returning a blackhole sink; timed in batches. */ + private interface BatchOp { + long run(); + } + + /** Fills per-op latency samples; returns the number of samples taken. */ + private interface SampleFiller { + int fill(long[] samples); + } + + private Result batch(String name, BatchOp op) { + // lazy init of per-bench fixtures + switch (name) { + case "micro.subscribe.churn": + churnOrch = Orchestrator.create("micro-churn", LogSink.SILENT); + break; + case "micro.gamepad.adaptorPoll": + adaptorHolder = new GamepadAdaptorHolder(); + break; + case "micro.sim.deviceWrite": + simDevice = new SimDeviceHolder(); + break; + default: + break; + } + try { + int itersPerBatch = Math.max(16, measureOps / 200); + int batches = 200; + for (int i = 0; i < warmupOps / itersPerBatch; i++) { + long acc = 0; + for (int j = 0; j < itersPerBatch; j++) acc += op.run(); + Blackhole.consume(acc); + } + long[] samples = new long[batches]; + for (int b = 0; b < batches; b++) { + long t0 = System.nanoTime(); + long acc = 0; + for (int j = 0; j < itersPerBatch; j++) acc += op.run(); + long dt = System.nanoTime() - t0; + Blackhole.consume(acc); + samples[b] = dt / itersPerBatch; + } + Hist h = new Hist(); + for (long s : samples) h.record(s); + Hist.Snapshot snap = h.snapshot(); + return new Result(name, snap.mean, snap.p50, snap.p99, (long) batches * itersPerBatch); + } finally { + cleanup(name); + } + } + + private Result endToEnd(String name, SampleFiller filler) { + long[] warm = new long[Math.min(500, sampleCount)]; + filler.fill(warm); + long[] samples = new long[sampleCount]; + int n = filler.fill(samples); + Hist h = new Hist(); + long sum = 0; + for (int i = 0; i < n; i++) { + h.record(samples[i]); + sum += samples[i]; + } + Hist.Snapshot snap = h.snapshot(); + double nsPerOp = n > 0 ? sum / (double) n : 0.0; + return new Result(name, nsPerOp, snap.p50, snap.p99, n); + } + + private void cleanup(String name) { + switch (name) { + case "micro.subscribe.churn": + if (churnOrch != null) { + churnOrch.close(); + churnOrch = null; + } + break; + case "micro.gamepad.adaptorPoll": + if (adaptorHolder != null) { + adaptorHolder.orch.close(); + adaptorHolder = null; + } + break; + default: + break; + } + } + + /** + * Gate 2: {@code micro.sim.deviceWrite} must cost less than 5% of the + * smallest framework-level dispatch measurement, so mock noise cannot + * dominate or mask framework overhead. + */ + public static double mockBudgetRatioPct(Map micros) { + Result deviceWrite = micros.get("micro.sim.deviceWrite"); + if (deviceWrite == null) return Double.NaN; + double minDispatch = Double.MAX_VALUE; + for (String name : DISPATCH_BENCHES) { + Result r = micros.get(name); + if (r != null && r.nsPerOp > 0 && r.nsPerOp < minDispatch) { + minDispatch = r.nsPerOp; + } + } + if (minDispatch == Double.MAX_VALUE) return Double.NaN; + return 100.0 * deviceWrite.nsPerOp / minDispatch; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Registry.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Registry.java new file mode 100644 index 0000000..dfe3545 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Registry.java @@ -0,0 +1,119 @@ +package com.aaravlabs.synapse.bench.harness; + +import com.aaravlabs.synapse.bench.raw.RawS0; +import com.aaravlabs.synapse.bench.raw.RawS1; +import com.aaravlabs.synapse.bench.raw.RawS2; +import com.aaravlabs.synapse.bench.raw.RawS3; +import com.aaravlabs.synapse.bench.rawmt.RawMtS2; +import com.aaravlabs.synapse.bench.rawmt.RawMtS3; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.Scenario; +import com.aaravlabs.synapse.bench.shared.World; +import com.aaravlabs.synapse.bench.solverslib.SolversS0; +import com.aaravlabs.synapse.bench.solverslib.SolversS1; +import com.aaravlabs.synapse.bench.solverslib.SolversS2; +import com.aaravlabs.synapse.bench.solverslib.SolversS3; +import com.aaravlabs.synapse.bench.synapse.SynapseS0; +import com.aaravlabs.synapse.bench.synapse.SynapseS1; +import com.aaravlabs.synapse.bench.synapse.SynapseS2; +import com.aaravlabs.synapse.bench.synapse.SynapseS3; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Maps (scenario, style) to the hand-written pair implementation. + */ +public final class Registry { + + public static final String RAW = "raw"; + public static final String RAWMT = "rawmt"; + public static final String SOLVERSLIB = "solverslib"; + public static final String SYNAPSE = "synapse"; + + public static final List ALL_STYLES = + Arrays.asList(RAW, RAWMT, SOLVERSLIB, SYNAPSE); + + private Registry() { + } + + public static List stylesFor(Scenario scenario) { + List styles = new ArrayList<>(); + styles.add(RAW); + styles.add(SOLVERSLIB); + styles.add(SYNAPSE); + if (scenario.hasRawmt()) styles.add(RAWMT); + return styles; + } + + /** + * Sign mapping a forward-positive stick command to the motor-power direction + * each style writes. Raw/rawmt/synapse write the raw gamepad field + * (up-negative) straight to the motor; SolversLib's + * {@code GamepadEx.getLeftY()} negates it back to forward-positive. + */ + public static double drivePowerSign(String style) { + return SOLVERSLIB.equals(style) ? 1.0 : -1.0; + } + + public static PairRunner create(Scenario scenario, String style, World world) { + switch (style) { + case RAW: + switch (scenario) { + case S0_MinimalDrive: + return new RawS0(world); + case S1_BasicTeleop: + return new RawS1(world); + case S2_MultiSubsystem: + return new RawS2(world); + case S3_HeavyRobot: + return new RawS3(world); + default: + break; + } + break; + case RAWMT: + switch (scenario) { + case S2_MultiSubsystem: + return new RawMtS2(world); + case S3_HeavyRobot: + return new RawMtS3(world); + default: + break; + } + break; + case SOLVERSLIB: + switch (scenario) { + case S0_MinimalDrive: + return new SolversS0(world); + case S1_BasicTeleop: + return new SolversS1(world); + case S2_MultiSubsystem: + return new SolversS2(world); + case S3_HeavyRobot: + return new SolversS3(world); + default: + break; + } + break; + case SYNAPSE: + switch (scenario) { + case S0_MinimalDrive: + return new SynapseS0(world); + case S1_BasicTeleop: + return new SynapseS1(world); + case S2_MultiSubsystem: + return new SynapseS2(world); + case S3_HeavyRobot: + return new SynapseS3(world); + default: + break; + } + break; + default: + break; + } + throw new IllegalArgumentException("no implementation for " + scenario + " x " + style); + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Report.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Report.java new file mode 100644 index 0000000..df0e3b6 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/harness/Report.java @@ -0,0 +1,231 @@ +package com.aaravlabs.synapse.bench.harness; + +import com.aaravlabs.synapse.bench.shared.Hist; +import com.aaravlabs.synapse.bench.shared.Metrics; + +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; + +/** + * JSON + Markdown result writers. Schema is versioned and stable so agents can + * diff and compare runs mechanically. + */ +public final class Report { + + /** One scenario × style measurement (possibly a median of rounds/forks). */ + public static final class Pair { + public final String scenario; + public final String style; + public final Map latencyActuationNs; + public final Map taskRates; + public final Map trackingError; + public final double loopHz; + public final double allocBytesPerSec; + + public Pair(String scenario, String style, + Map latencyActuationNs, + Map taskRates, + Map trackingError, + double loopHz, + double allocBytesPerSec) { + this.scenario = scenario; + this.style = style; + this.latencyActuationNs = latencyActuationNs; + this.taskRates = taskRates; + this.trackingError = trackingError; + this.loopHz = loopHz; + this.allocBytesPerSec = allocBytesPerSec; + } + + public static Pair fromSnapshot(String scenario, String style, + Hist.Snapshot latency, + List tasks, + double liftRmse, + double headingRmse, + double loopHz, + double allocBytesPerSec) { + Map lat = new LinkedHashMap<>(); + lat.put("p50", latency.p50); + lat.put("p90", latency.p90); + lat.put("p99", latency.p99); + lat.put("max", latency.max); + lat.put("min", latency.min); + lat.put("mean", latency.mean); + lat.put("count", latency.count); + + Map rates = new LinkedHashMap<>(); + for (Metrics.TaskSnapshot t : tasks) { + Map entry = new LinkedHashMap<>(); + entry.put("targetHz", t.targetHz); + entry.put("achievedHz", t.achievedHz); + entry.put("jitterP99Ns", t.jitterP99Ns); + entry.put("count", t.count); + rates.put(t.name, entry); + } + + Map tracking = new LinkedHashMap<>(); + tracking.put("liftRmse", liftRmse); + tracking.put("headingRmse", headingRmse); + + return new Pair(scenario, style, lat, rates, tracking, loopHz, allocBytesPerSec); + } + + public Map toMap() { + Map m = new LinkedHashMap<>(); + m.put("scenario", scenario); + m.put("style", style); + m.put("latencyActuationNs", latencyActuationNs); + m.put("taskRates", taskRates); + m.put("trackingError", trackingError); + m.put("loopHz", loopHz); + m.put("allocBytesPerSec", allocBytesPerSec); + return m; + } + + @SuppressWarnings("unchecked") + public static Pair fromMap(Map m) { + return new Pair( + (String) m.get("scenario"), + (String) m.get("style"), + (Map) m.get("latencyActuationNs"), + (Map) m.get("taskRates"), + (Map) m.get("trackingError"), + ((Number) m.getOrDefault("loopHz", 0)).doubleValue(), + ((Number) m.getOrDefault("allocBytesPerSec", 0)).doubleValue()); + } + } + + private Report() { + } + + public static Map document(Map env, + List pairs, + Map micro, + Map gates) { + Map doc = new LinkedHashMap<>(); + doc.put("schema", 1); + doc.put("env", env); + List scenarios = new ArrayList<>(); + for (Pair p : pairs) scenarios.add(p.toMap()); + doc.put("scenarios", scenarios); + doc.put("micro", micro); + doc.put("gates", gates); + return doc; + } + + public static void writeJson(Path path, Map doc) throws IOException { + Files.createDirectories(path.getParent()); + Files.write(path, Json.write(doc).getBytes(StandardCharsets.UTF_8)); + } + + public static void writeMarkdown(Path path, Map doc) throws IOException { + Files.createDirectories(path.getParent()); + Files.write(path, markdown(doc).getBytes(StandardCharsets.UTF_8)); + } + + @SuppressWarnings("unchecked") + public static String markdown(Map doc) { + StringBuilder sb = new StringBuilder(); + Map env = (Map) doc.get("env"); + sb.append("# Synapse benchmark results\n\n"); + sb.append("Mode: ").append(env.get("mode")) + .append(" · seed ").append(env.get("seed")) + .append(" · forks ").append(env.get("forks")) + .append(" · rounds ").append(env.get("rounds")).append("\n\n"); + sb.append("- OS: ").append(env.get("os")).append('\n'); + sb.append("- JDK: ").append(env.get("jdk")).append('\n'); + sb.append("- CPU: ").append(env.get("cpu")).append('\n'); + sb.append("- git: ").append(env.get("gitSha")).append('\n'); + sb.append("- timestamp: ").append(env.get("timestamp")).append('\n'); + sb.append("\n## Scenario ladder\n\n"); + sb.append("| scenario | style | actuation p50 (ns) | actuation p99 (ns) | lift RMSE | heading RMSE | loopHz |"); + Map firstRates = new LinkedHashMap<>(); + List scenarios = (List) doc.get("scenarios"); + for (Object o : scenarios) { + Map p = (Map) o; + Map rates = (Map) p.get("taskRates"); + for (Object k : rates.keySet()) { + String key = String.valueOf(k); + if (!firstRates.containsKey(key)) firstRates.put(key, rates.get(k)); + } + } + for (Object k : firstRates.keySet()) { + sb.append(' ').append(k).append(" Hz |"); + } + sb.append('\n'); + sb.append("| --- | --- | ---: | ---: | ---: | ---: | ---: |"); + for (int i = 0; i < firstRates.size(); i++) sb.append(" ---: |"); + sb.append('\n'); + for (Object o : scenarios) { + Map p = (Map) o; + Map lat = (Map) p.get("latencyActuationNs"); + Map tracking = (Map) p.get("trackingError"); + Map rates = (Map) p.get("taskRates"); + sb.append("| ").append(p.get("scenario")) + .append(" | ").append(p.get("style")) + .append(" | ").append(fmt(lat.get("p50"))) + .append(" | ").append(fmt(lat.get("p99"))) + .append(" | ").append(fmt(tracking.get("liftRmse"))) + .append(" | ").append(fmt(tracking.get("headingRmse"))) + .append(" | ").append(fmt(p.get("loopHz"))); + for (Object k : firstRates.keySet()) { + Map rate = (Map) rates.get(k); + sb.append(" | ").append(rate == null ? "—" : fmt(rate.get("achievedHz"))); + } + sb.append(" |\n"); + } + + sb.append("\n## Micro layer (ns/op)\n\n"); + sb.append("| bench | ns/op | p50 | p99 | count |\n"); + sb.append("| --- | ---: | ---: | ---: | ---: |\n"); + Map micro = (Map) doc.get("micro"); + for (Map.Entry e : micro.entrySet()) { + Map r = (Map) e.getValue(); + sb.append("| ").append(e.getKey()) + .append(" | ").append(fmt(r.get("nsPerOp"))) + .append(" | ").append(fmt(r.get("p50"))) + .append(" | ").append(fmt(r.get("p99"))) + .append(" | ").append(fmt(r.get("count"))) + .append(" |\n"); + } + + Map gates = (Map) doc.get("gates"); + sb.append("\n## Gates\n\n"); + for (Map.Entry e : gates.entrySet()) { + if ("messages".equals(e.getKey())) { + sb.append("\n```\n"); + for (Object line : (List) e.getValue()) { + sb.append(line).append('\n'); + } + sb.append("```\n"); + } else { + sb.append("- ").append(e.getKey()).append(": ").append(e.getValue()).append('\n'); + } + } + sb.append("\n## Known confounds\n\n"); + sb.append("- Desktop JVM, simulated plant: absolute numbers are not robot-bus latencies.\n"); + sb.append("- Vision and logger kernels are duration-targeted busy work (~3 ms / ~15 ms),\n"); + sb.append(" so the load profile is machine-independent while absolute times are real.\n"); + sb.append("- Axis sign conventions differ by framework API (raw fields vs GamepadEx);\n"); + sb.append(" workload shape (reads + arithmetic + writes) is identical across styles.\n"); + sb.append("- See `benchmarks/README.md` for methodology and fairness rules.\n"); + return sb.toString(); + } + + private static String fmt(Object v) { + if (v == null) return "—"; + if (v instanceof Double) { + double d = (Double) v; + if (d != 0 && Math.abs(d) < 1) return String.format(Locale.ROOT, "%.5f", d); + return String.format(Locale.ROOT, "%.2f", d); + } + return v.toString(); + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS0.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS0.java new file mode 100644 index 0000000..3728993 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS0.java @@ -0,0 +1,66 @@ +package com.aaravlabs.synapse.bench.raw; + +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.qualcomm.robotcore.eventloop.opmode.OpMode; + +/** + * S0 in idiomatic raw FTC: one stick, one motor, one {@code loop()}. No + * subscriptions, no commands — the floor case where raw FTC is expected to win. + */ +public final class RawS0 extends OpMode implements PairRunner { + + private final World world; + private final Metrics metrics; + private final TaskMeter loopMeter; + + private volatile boolean active; + private Thread thread; + + public RawS0(World world) { + this.world = world; + this.metrics = world.metrics(); + this.loopMeter = metrics.task("loop", 0); + } + + @Override + public void start() { + active = true; + gamepad1 = world.gamepad1(); + gamepad2 = world.gamepad2(); + telemetry = world.telemetry(); + thread = new Thread(() -> { + init(); + while (active) { + loop(); + } + }, "raw-s0"); + thread.setDaemon(true); + thread.start(); + } + + @Override + public void stop() { + active = false; + if (thread != null) { + try { + thread.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + @Override + public void init() { + } + + @Override + public void loop() { + loopMeter.tick(); + metrics.countLoopIteration(); + world.leftMotor().setPower(gamepad1.left_stick_y); + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS1.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS1.java new file mode 100644 index 0000000..febd984 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS1.java @@ -0,0 +1,104 @@ +package com.aaravlabs.synapse.bench.raw; + +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.qualcomm.robotcore.eventloop.opmode.OpMode; + +/** + * S1 in idiomatic raw FTC: the common rookie TeleOp. Tank drive, one servo, + * intake toggle on bumper edges, telemetry at 10 Hz — all in one {@code loop()}. + */ +public final class RawS1 extends OpMode implements PairRunner { + + private static final long TELEMETRY_PERIOD_NANOS = 100_000_000L; + + private final World world; + private final Metrics metrics; + private final TaskMeter loopMeter; + private final TaskMeter telemetryMeter; + + private boolean prevBumper; + private boolean prevX; + private boolean intakeRunning; + private boolean servoOpen; + private long lastTelemetry; + + private volatile boolean active; + private Thread thread; + + public RawS1(World world) { + this.world = world; + this.metrics = world.metrics(); + this.loopMeter = metrics.task("loop", 0); + this.telemetryMeter = metrics.task("telemetry", world.scenario().telemetryTargetHz()); + } + + @Override + public void start() { + active = true; + gamepad1 = world.gamepad1(); + gamepad2 = world.gamepad2(); + telemetry = world.telemetry(); + thread = new Thread(() -> { + init(); + while (active) { + loop(); + } + }, "raw-s1"); + thread.setDaemon(true); + thread.start(); + } + + @Override + public void stop() { + active = false; + if (thread != null) { + try { + thread.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + @Override + public void init() { + } + + @Override + public void loop() { + loopMeter.tick(); + metrics.countLoopIteration(); + + world.leftMotor().setPower(gamepad1.left_stick_y); + world.rightMotor().setPower(gamepad1.right_stick_y); + + boolean bumper = gamepad1.right_bumper; + if (bumper && !prevBumper) { + intakeRunning = true; + world.intakeMotor().setPower(1.0); + } else if (!bumper && prevBumper) { + intakeRunning = false; + world.intakeMotor().setPower(0.0); + } + prevBumper = bumper; + + boolean x = gamepad1.x; + if (x && !prevX) { + servoOpen = !servoOpen; + world.servo().setPosition(servoOpen ? 1.0 : 0.0); + } + prevX = x; + + long now = System.nanoTime(); + if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { + lastTelemetry = now; + telemetryMeter.tick(now); + telemetry.addData("intake", intakeRunning ? "on" : "off"); + telemetry.addData("servo", servoOpen ? "open" : "closed"); + telemetry.update(); + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS2.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS2.java new file mode 100644 index 0000000..44028ae --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS2.java @@ -0,0 +1,142 @@ +package com.aaravlabs.synapse.bench.raw; + +import com.aaravlabs.synapse.bench.shared.BusyWork; +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.SharedPidf; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.qualcomm.robotcore.eventloop.opmode.OpMode; + +/** + * S2 in idiomatic raw FTC: drive + intake + lift PIDF + outtake on two gamepads, + * mixed target rates, one 0.5 ms auto-align computation on a gamepad event — + * everything serialized in the single {@code loop()}. + */ +public final class RawS2 extends OpMode implements PairRunner { + + private static final long DRIVE_PERIOD_NANOS = 20_000_000L; + private static final long LIFT_PERIOD_NANOS = 10_000_000L; + private static final long TELEMETRY_PERIOD_NANOS = 100_000_000L; + + private final World world; + private final Metrics metrics; + private final TaskMeter loopMeter; + private final TaskMeter driveMeter; + private final TaskMeter liftMeter; + private final TaskMeter telemetryMeter; + + private final SharedPidf liftPidf = SharedPidf.forLift(); + + private boolean prevBumper1; + private boolean prevA; + private boolean prevBumper2; + private boolean intakeRunning; + private boolean outtaking; + private double alignOffset; + private long alignSeq; + private long lastDrive; + private long lastLift; + private long lastTelemetry; + + private volatile boolean active; + private Thread thread; + + public RawS2(World world) { + this.world = world; + this.metrics = world.metrics(); + this.loopMeter = metrics.task("loop", 0); + this.driveMeter = metrics.task("drive", world.scenario().driveTargetHz()); + this.liftMeter = metrics.task("liftPidf", world.scenario().liftTargetHz()); + this.telemetryMeter = metrics.task("telemetry", world.scenario().telemetryTargetHz()); + } + + @Override + public void start() { + active = true; + gamepad1 = world.gamepad1(); + gamepad2 = world.gamepad2(); + telemetry = world.telemetry(); + thread = new Thread(() -> { + init(); + while (active) { + loop(); + } + }, "raw-s2"); + thread.setDaemon(true); + thread.start(); + } + + @Override + public void stop() { + active = false; + if (thread != null) { + try { + thread.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + @Override + public void init() { + } + + @Override + public void loop() { + loopMeter.tick(); + metrics.countLoopIteration(); + long now = System.nanoTime(); + + if (now - lastDrive >= DRIVE_PERIOD_NANOS) { + lastDrive = now; + driveMeter.tick(now); + double y = gamepad1.left_stick_y; + double turn = gamepad1.left_stick_x; + double rightY = gamepad1.right_stick_y; + world.leftMotor().setPower(y + turn + alignOffset); + world.rightMotor().setPower(rightY - turn - alignOffset); + } + + if (now - lastLift >= LIFT_PERIOD_NANOS) { + lastLift = now; + liftMeter.tick(now); + double target = world.setpoints().liftTargetAt(now); + double power = liftPidf.update(world.plant().liftPos, target, now); + world.liftMotor().setPower(power); + } + + boolean bumper1 = gamepad1.right_bumper; + if (bumper1 && !prevBumper1) { + intakeRunning = !intakeRunning; + outtaking = false; + world.intakeMotor().setPower(intakeRunning ? 1.0 : 0.0); + } + prevBumper1 = bumper1; + + boolean bumper2 = gamepad2.left_bumper; + if (bumper2) { + outtaking = true; + intakeRunning = false; + world.intakeMotor().setPower(-1.0); + } else if (bumper2 != prevBumper2 && !intakeRunning) { + world.intakeMotor().setPower(0.0); + } + prevBumper2 = bumper2; + + boolean a = gamepad1.a; + if (a && !prevA) { + alignOffset = BusyWork.autoAlign(alignSeq++) * 0.02; + } + prevA = a; + + if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { + lastTelemetry = now; + telemetryMeter.tick(now); + telemetry.addData("lift", world.plant().liftPos); + telemetry.addData("align", alignOffset); + telemetry.update(); + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS3.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS3.java new file mode 100644 index 0000000..da88318 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/raw/RawS3.java @@ -0,0 +1,178 @@ +package com.aaravlabs.synapse.bench.raw; + +import com.aaravlabs.synapse.bench.shared.BusyWork; +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.Setpoints; +import com.aaravlabs.synapse.bench.shared.SharedPidf; +import com.aaravlabs.synapse.bench.shared.SimCamera; +import com.aaravlabs.synapse.bench.shared.SyntheticVisionPipeline; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.qualcomm.robotcore.eventloop.opmode.OpMode; + +/** + * S3 in idiomatic raw FTC: everything from S2 plus 30 Hz vision (~3 ms/frame), + * two PIDF loops (lift 100 Hz, heading hold 200 Hz) and a slow debug logger + * (15 ms per state update) — all serialized in the single {@code loop()}. + * This is the case raw FTC is expected to lose badly. + */ +public final class RawS3 extends OpMode implements PairRunner { + + private static final long DRIVE_PERIOD_NANOS = 20_000_000L; + private static final long LIFT_PERIOD_NANOS = 10_000_000L; + private static final long HEADING_PERIOD_NANOS = 5_000_000L; + private static final long STATE_PERIOD_NANOS = 20_000_000L; + private static final long TELEMETRY_PERIOD_NANOS = 100_000_000L; + + private final World world; + private final Metrics metrics; + private final TaskMeter loopMeter; + private final TaskMeter driveMeter; + private final TaskMeter liftMeter; + private final TaskMeter headingMeter; + private final TaskMeter visionMeter; + private final TaskMeter loggerMeter; + private final TaskMeter telemetryMeter; + + private final SharedPidf liftPidf = SharedPidf.forLift(); + private final SharedPidf headingPidf = SharedPidf.forHeading(); + + private boolean prevBumper1; + private boolean prevA; + private boolean prevBumper2; + private boolean intakeRunning; + private double stickY; + private double stickRightY; + private long alignSeq; + private long stateSeq; + private long lastDrive; + private long lastLift; + private long lastHeading; + private long lastState; + private long lastTelemetry; + + private volatile boolean active; + private Thread thread; + + public RawS3(World world) { + this.world = world; + this.metrics = world.metrics(); + this.loopMeter = metrics.task("loop", 0); + this.driveMeter = metrics.task("drive", world.scenario().driveTargetHz()); + this.liftMeter = metrics.task("liftPidf", world.scenario().liftTargetHz()); + this.headingMeter = metrics.task("headingPidf", world.scenario().headingTargetHz()); + this.visionMeter = metrics.task("vision", SimCamera.HZ); + this.loggerMeter = metrics.task("logger", world.scenario().stateTargetHz()); + this.telemetryMeter = metrics.task("telemetry", world.scenario().telemetryTargetHz()); + } + + @Override + public void start() { + active = true; + gamepad1 = world.gamepad1(); + gamepad2 = world.gamepad2(); + telemetry = world.telemetry(); + thread = new Thread(() -> { + init(); + while (active) { + loop(); + } + }, "raw-s3"); + thread.setDaemon(true); + thread.start(); + } + + @Override + public void stop() { + active = false; + if (thread != null) { + try { + thread.join(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + @Override + public void init() { + } + + @Override + public void loop() { + loopMeter.tick(); + metrics.countLoopIteration(); + long now = System.nanoTime(); + + if (now - lastDrive >= DRIVE_PERIOD_NANOS) { + lastDrive = now; + driveMeter.tick(now); + stickY = gamepad1.left_stick_y; + stickRightY = gamepad1.right_stick_y; + } + + if (now - lastHeading >= HEADING_PERIOD_NANOS) { + lastHeading = now; + headingMeter.tick(now); + double target = world.setpoints().headingTargetAt(now) + world.plant().headingBias; + double corr = headingPidf.update(world.plant().heading, target, now); + world.leftMotor().setPower(stickY - corr); + world.rightMotor().setPower(stickRightY + corr); + } + + if (now - lastLift >= LIFT_PERIOD_NANOS) { + lastLift = now; + liftMeter.tick(now); + double target = world.setpoints().liftTargetAt(now); + double power = liftPidf.update(world.plant().liftPos, target, now); + world.liftMotor().setPower(power); + } + + SimCamera camera = world.camera(); + SimCamera.Frame frame = camera.pollFrame(); + if (frame != null) { + visionMeter.tick(now); + world.plant().headingBias = SyntheticVisionPipeline.process(frame.buf, frame.seq) * 0.002; + } + + boolean bumper1 = gamepad1.right_bumper; + if (bumper1 && !prevBumper1) { + intakeRunning = !intakeRunning; + world.intakeMotor().setPower(intakeRunning ? 1.0 : 0.0); + } + prevBumper1 = bumper1; + + boolean bumper2 = gamepad2.left_bumper; + if (bumper2) { + intakeRunning = false; + world.intakeMotor().setPower(-1.0); + } else if (prevBumper2 && !intakeRunning) { + world.intakeMotor().setPower(0.0); + } + prevBumper2 = bumper2; + + boolean a = gamepad1.a; + if (a && !prevA) { + world.plant().headingBias = BusyWork.autoAlign(alignSeq++) * 0.002; + } + prevA = a; + + if (now - lastState >= STATE_PERIOD_NANOS) { + lastState = now; + stateSeq++; + loggerMeter.tick(now); + // Slow debug logger consuming the state update — in a single-loop + // robot this runs inline and stalls everything else. + BusyWork.slowLog(stateSeq); + } + + if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { + lastTelemetry = now; + telemetryMeter.tick(now); + telemetry.addData("lift", world.plant().liftPos); + telemetry.addData("heading", world.plant().heading); + telemetry.update(); + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS2.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS2.java new file mode 100644 index 0000000..5f31859 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS2.java @@ -0,0 +1,175 @@ +package com.aaravlabs.synapse.bench.rawmt; + +import com.aaravlabs.synapse.bench.shared.BusyWork; +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.SharedPidf; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.qualcomm.robotcore.eventloop.opmode.OpMode; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * S2 honesty variant: the best a competent team writes without a framework — + * raw FTC plus hand-rolled worker threads. The 0.5 ms auto-align kernel runs on + * its own thread so the control loop rate does not slip. + */ +public final class RawMtS2 extends OpMode implements PairRunner { + + private static final long DRIVE_PERIOD_NANOS = 20_000_000L; + private static final long LIFT_PERIOD_NANOS = 10_000_000L; + private static final long TELEMETRY_PERIOD_NANOS = 100_000_000L; + + private final World world; + private final Metrics metrics; + private final TaskMeter loopMeter; + private final TaskMeter driveMeter; + private final TaskMeter liftMeter; + private final TaskMeter telemetryMeter; + private final TaskMeter alignMeter; + + private final SharedPidf liftPidf = SharedPidf.forLift(); + + private boolean prevBumper1; + private boolean prevA; + private boolean prevBumper2; + private boolean intakeRunning; + private volatile double alignOffset; + private final AtomicLong alignRequests = new AtomicLong(); + private final AtomicLong alignCompleted = new AtomicLong(); + + private long lastDrive; + private long lastLift; + private long lastTelemetry; + + private volatile boolean active; + private Thread loopThread; + private Thread alignThread; + + public RawMtS2(World world) { + this.world = world; + this.metrics = world.metrics(); + this.loopMeter = metrics.task("loop", 0); + this.driveMeter = metrics.task("drive", world.scenario().driveTargetHz()); + this.liftMeter = metrics.task("liftPidf", world.scenario().liftTargetHz()); + this.telemetryMeter = metrics.task("telemetry", world.scenario().telemetryTargetHz()); + this.alignMeter = metrics.task("align", 0); + } + + @Override + public void start() { + active = true; + gamepad1 = world.gamepad1(); + gamepad2 = world.gamepad2(); + telemetry = world.telemetry(); + + alignThread = new Thread(this::alignLoop, "rawmt-align"); + alignThread.setDaemon(true); + alignThread.start(); + + loopThread = new Thread(() -> { + init(); + while (active) { + loop(); + } + }, "rawmt-s2"); + loopThread.setDaemon(true); + loopThread.start(); + } + + private void alignLoop() { + while (active) { + long req = alignRequests.get(); + if (req != alignCompleted.get()) { + alignMeter.tick(); + double offset = BusyWork.autoAlign(req) * 0.02; + alignOffset = offset; + alignCompleted.set(req); + } else { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + } + + @Override + public void stop() { + active = false; + joinQuietly(loopThread, 1000); + joinQuietly(alignThread, 1000); + } + + private static void joinQuietly(Thread t, long ms) { + if (t == null) return; + try { + t.join(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public void init() { + } + + @Override + public void loop() { + loopMeter.tick(); + metrics.countLoopIteration(); + long now = System.nanoTime(); + + if (now - lastDrive >= DRIVE_PERIOD_NANOS) { + lastDrive = now; + driveMeter.tick(now); + double y = gamepad1.left_stick_y; + double turn = gamepad1.left_stick_x; + double rightY = gamepad1.right_stick_y; + double align = alignOffset; + world.leftMotor().setPower(y + turn + align); + world.rightMotor().setPower(rightY - turn - align); + } + + if (now - lastLift >= LIFT_PERIOD_NANOS) { + lastLift = now; + liftMeter.tick(now); + double target = world.setpoints().liftTargetAt(now); + double power = liftPidf.update(world.plant().liftPos, target, now); + world.liftMotor().setPower(power); + } + + boolean bumper1 = gamepad1.right_bumper; + if (bumper1 && !prevBumper1) { + intakeRunning = !intakeRunning; + world.intakeMotor().setPower(intakeRunning ? 1.0 : 0.0); + } + prevBumper1 = bumper1; + + boolean bumper2 = gamepad2.left_bumper; + if (bumper2) { + intakeRunning = false; + world.intakeMotor().setPower(-1.0); + } else if (bumper2 != prevBumper2 && !intakeRunning) { + world.intakeMotor().setPower(0.0); + } + prevBumper2 = bumper2; + + boolean a = gamepad1.a; + if (a && !prevA) { + alignRequests.incrementAndGet(); + } + prevA = a; + + if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { + lastTelemetry = now; + telemetryMeter.tick(now); + telemetry.addData("lift", world.plant().liftPos); + telemetry.addData("align", alignOffset); + telemetry.update(); + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS3.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS3.java new file mode 100644 index 0000000..8c13991 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/rawmt/RawMtS3.java @@ -0,0 +1,248 @@ +package com.aaravlabs.synapse.bench.rawmt; + +import com.aaravlabs.synapse.bench.shared.BusyWork; +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.SharedPidf; +import com.aaravlabs.synapse.bench.shared.SimCamera; +import com.aaravlabs.synapse.bench.shared.SyntheticVisionPipeline; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.qualcomm.robotcore.eventloop.opmode.OpMode; + +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicLong; + +/** + * S3 honesty variant: raw FTC with hand-rolled threads — vision on its own + * thread, the slow debug logger on its own thread. The control loop keeps its + * rate, which is exactly the isolation Synapse gets from its callback pool. + */ +public final class RawMtS3 extends OpMode implements PairRunner { + + private static final long DRIVE_PERIOD_NANOS = 20_000_000L; + private static final long LIFT_PERIOD_NANOS = 10_000_000L; + private static final long HEADING_PERIOD_NANOS = 5_000_000L; + private static final long STATE_PERIOD_NANOS = 20_000_000L; + private static final long TELEMETRY_PERIOD_NANOS = 100_000_000L; + + private final World world; + private final Metrics metrics; + private final TaskMeter loopMeter; + private final TaskMeter driveMeter; + private final TaskMeter liftMeter; + private final TaskMeter headingMeter; + private final TaskMeter visionMeter; + private final TaskMeter loggerMeter; + private final TaskMeter telemetryMeter; + private final TaskMeter alignMeter; + + private final SharedPidf liftPidf = SharedPidf.forLift(); + private final SharedPidf headingPidf = SharedPidf.forHeading(); + + private final ConcurrentLinkedQueue logQueue = new ConcurrentLinkedQueue<>(); + private final AtomicLong alignRequests = new AtomicLong(); + private final AtomicLong alignCompleted = new AtomicLong(); + + private boolean prevBumper1; + private boolean prevA; + private boolean prevBumper2; + private boolean intakeRunning; + private volatile double stickY; + private volatile double stickRightY; + private long stateSeq; + private long lastDrive; + private long lastLift; + private long lastHeading; + private long lastState; + private long lastTelemetry; + + private volatile boolean active; + private Thread loopThread; + private Thread visionThread; + private Thread loggerThread; + private Thread alignThread; + + public RawMtS3(World world) { + this.world = world; + this.metrics = world.metrics(); + this.loopMeter = metrics.task("loop", 0); + this.driveMeter = metrics.task("drive", world.scenario().driveTargetHz()); + this.liftMeter = metrics.task("liftPidf", world.scenario().liftTargetHz()); + this.headingMeter = metrics.task("headingPidf", world.scenario().headingTargetHz()); + this.visionMeter = metrics.task("vision", SimCamera.HZ); + this.loggerMeter = metrics.task("logger", world.scenario().stateTargetHz()); + this.telemetryMeter = metrics.task("telemetry", world.scenario().telemetryTargetHz()); + this.alignMeter = metrics.task("align", 0); + } + + @Override + public void start() { + active = true; + gamepad1 = world.gamepad1(); + gamepad2 = world.gamepad2(); + telemetry = world.telemetry(); + + visionThread = new Thread(this::visionLoop, "rawmt-vision"); + visionThread.setDaemon(true); + visionThread.start(); + + loggerThread = new Thread(this::loggerLoop, "rawmt-logger"); + loggerThread.setDaemon(true); + loggerThread.start(); + + alignThread = new Thread(this::alignLoop, "rawmt-align"); + alignThread.setDaemon(true); + alignThread.start(); + + loopThread = new Thread(() -> { + init(); + while (active) { + loop(); + } + }, "rawmt-s3"); + loopThread.setDaemon(true); + loopThread.start(); + } + + private void visionLoop() { + while (active) { + SimCamera.Frame frame = world.camera().pollFrame(); + if (frame == null) { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + continue; + } + visionMeter.tick(); + world.plant().headingBias = SyntheticVisionPipeline.process(frame.buf, frame.seq) * 0.002; + } + } + + private void loggerLoop() { + while (active || !logQueue.isEmpty()) { + Long seq = logQueue.poll(); + if (seq == null) { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + continue; + } + loggerMeter.tick(); + BusyWork.slowLog(seq); + } + } + + private void alignLoop() { + while (active) { + long req = alignRequests.get(); + if (req != alignCompleted.get()) { + alignMeter.tick(); + world.plant().headingBias = BusyWork.autoAlign(req) * 0.002; + alignCompleted.set(req); + } else { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + } + + @Override + public void stop() { + active = false; + joinQuietly(loopThread, 2000); + joinQuietly(visionThread, 2000); + joinQuietly(loggerThread, 2000); + joinQuietly(alignThread, 2000); + } + + private static void joinQuietly(Thread t, long ms) { + if (t == null) return; + try { + t.join(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public void init() { + } + + @Override + public void loop() { + loopMeter.tick(); + metrics.countLoopIteration(); + long now = System.nanoTime(); + + if (now - lastDrive >= DRIVE_PERIOD_NANOS) { + lastDrive = now; + driveMeter.tick(now); + stickY = gamepad1.left_stick_y; + stickRightY = gamepad1.right_stick_y; + } + + if (now - lastHeading >= HEADING_PERIOD_NANOS) { + lastHeading = now; + headingMeter.tick(now); + double target = world.setpoints().headingTargetAt(now) + world.plant().headingBias; + double corr = headingPidf.update(world.plant().heading, target, now); + world.leftMotor().setPower(stickY - corr); + world.rightMotor().setPower(stickRightY + corr); + } + + if (now - lastLift >= LIFT_PERIOD_NANOS) { + lastLift = now; + liftMeter.tick(now); + double target = world.setpoints().liftTargetAt(now); + double power = liftPidf.update(world.plant().liftPos, target, now); + world.liftMotor().setPower(power); + } + + boolean bumper1 = gamepad1.right_bumper; + if (bumper1 && !prevBumper1) { + intakeRunning = !intakeRunning; + world.intakeMotor().setPower(intakeRunning ? 1.0 : 0.0); + } + prevBumper1 = bumper1; + + boolean bumper2 = gamepad2.left_bumper; + if (bumper2) { + intakeRunning = false; + world.intakeMotor().setPower(-1.0); + } else if (prevBumper2 && !intakeRunning) { + world.intakeMotor().setPower(0.0); + } + prevBumper2 = bumper2; + + boolean a = gamepad1.a; + if (a && !prevA) { + alignRequests.incrementAndGet(); + } + prevA = a; + + if (now - lastState >= STATE_PERIOD_NANOS) { + lastState = now; + stateSeq++; + logQueue.add(stateSeq); + } + + if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { + lastTelemetry = now; + telemetryMeter.tick(now); + telemetry.addData("lift", world.plant().liftPos); + telemetry.addData("heading", world.plant().heading); + telemetry.update(); + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Blackhole.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Blackhole.java new file mode 100644 index 0000000..aaa8834 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Blackhole.java @@ -0,0 +1,24 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * Volatile sink that defeats dead-code elimination in micro benchmarks. + */ +public final class Blackhole { + + private static volatile long sink; + + private Blackhole() { + } + + public static void consume(long v) { + sink = v; + } + + public static void consume(double v) { + sink = Double.doubleToRawLongBits(v); + } + + public static long sink() { + return sink; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/BusyWork.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/BusyWork.java new file mode 100644 index 0000000..cd3e29b --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/BusyWork.java @@ -0,0 +1,54 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * Deterministic CPU work used for the scenario "expensive" kernels (auto-align, + * slow debug logger). Busy-spins on real arithmetic for a fixed duration so the + * load profile is machine-independent, and returns a stable blackhole sink. + */ +public final class BusyWork { + + /** Moderate ~0.5 ms auto-align kernel (S2/S3 gamepad event work). */ + public static final long AUTO_ALIGN_NANOS = 500_000L; + + /** Slow debug logger kernel (S3): 15 ms of work per state-update event. */ + public static final long SLOW_LOGGER_NANOS = 15_000_000L; + + private static volatile long sink; + + private BusyWork() { + } + + /** Spin doing integer work for the requested duration. Allocation-free. */ + public static void spinNanos(long nanos) { + long start = System.nanoTime(); + long deadline = start + nanos; + long x = 0x9E3779B97F4A7C15L; + long acc = 0; + long now; + while ((now = System.nanoTime()) - deadline < 0) { + for (int i = 0; i < 64; i++) { + x ^= x << 13; + x ^= x >>> 7; + x ^= x << 17; + acc += x; + } + } + sink = acc; + } + + /** Auto-align kernel: fixed-duration work, deterministic result. */ + public static double autoAlign(long eventSeq) { + spinNanos(AUTO_ALIGN_NANOS); + return Math.sin(eventSeq * 0.37) * 4.0; + } + + /** Slow logger kernel: fixed-duration work per event. */ + public static void slowLog(long eventSeq) { + spinNanos(SLOW_LOGGER_NANOS); + sink = eventSeq; + } + + public static long sink() { + return sink; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Env.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Env.java new file mode 100644 index 0000000..c2ff0cc --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Env.java @@ -0,0 +1,69 @@ +package com.aaravlabs.synapse.bench.shared; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Environment metadata recorded with every result so numbers are interpretable. + */ +public final class Env { + + private Env() { + } + + public static Map collect(String mode, long seed, int forks, int rounds) { + Map m = new LinkedHashMap<>(); + m.put("os", System.getProperty("os.name") + " " + System.getProperty("os.version") + + " (" + System.getProperty("os.arch") + ")"); + m.put("jdk", System.getProperty("java.version") + " (" + System.getProperty("java.vendor") + ")"); + m.put("cpu", cpuModel()); + m.put("gitSha", gitSha()); + m.put("timestamp", java.time.Instant.now().toString()); + m.put("mode", mode); + m.put("seed", Long.toString(seed)); + m.put("forks", Integer.toString(forks)); + m.put("rounds", Integer.toString(rounds)); + return m; + } + + private static String cpuModel() { + try { + Path p = Paths.get("/proc/cpuinfo"); + if (Files.exists(p)) { + for (String line : Files.readAllLines(p, StandardCharsets.UTF_8)) { + if (line.startsWith("model name")) { + int colon = line.indexOf(':'); + if (colon >= 0) return line.substring(colon + 1).trim(); + } + } + } + } catch (Exception ignored) { + // fall through + } + return System.getProperty("os.arch", "unknown"); + } + + private static String gitSha() { + try { + Process proc = new ProcessBuilder("git", "rev-parse", "HEAD") + .redirectErrorStream(true) + .start(); + try (BufferedReader r = new BufferedReader( + new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) { + String line = r.readLine(); + if (proc.waitFor() == 0 && line != null && !line.isEmpty()) { + return line.trim(); + } + } + } catch (Exception ignored) { + // fall through + } + return "unknown"; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Hist.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Hist.java new file mode 100644 index 0000000..a52b6a2 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Hist.java @@ -0,0 +1,89 @@ +package com.aaravlabs.synapse.bench.shared; + +import java.util.Arrays; + +/** + * Preallocated percentile histogram. Single-writer: {@link #record} must only be + * called from one thread at a time (the thread that owns the probed path). + * Constant-cost, allocation-free on the measured path. + */ +public final class Hist { + + public static final class Snapshot { + public final long p50; + public final long p90; + public final long p99; + public final long max; + public final long min; + public final double mean; + public final long count; + + Snapshot(long p50, long p90, long p99, long max, long min, double mean, long count) { + this.p50 = p50; + this.p90 = p90; + this.p99 = p99; + this.max = max; + this.min = min; + this.mean = mean; + this.count = count; + } + + public static Snapshot empty() { + return new Snapshot(0, 0, 0, 0, 0, 0.0, 0); + } + } + + private final long[] buf; + private long total; + private volatile int n; + + public Hist() { + this(1 << 16); + } + + public Hist(int capacity) { + this.buf = new long[Math.max(16, capacity)]; + } + + public void record(long value) { + long i = total++; + buf[(int) (i % buf.length)] = value; + n = (int) Math.min(total, buf.length); + } + + public void reset() { + total = 0; + n = 0; + } + + public int count() { + return n; + } + + public Snapshot snapshot() { + int count = n; + if (count == 0) return Snapshot.empty(); + long[] copy = Arrays.copyOf(buf, count); + Arrays.sort(copy); + long sum = 0; + for (long v : copy) sum += v; + return new Snapshot( + percentile(copy, 0.50), + percentile(copy, 0.90), + percentile(copy, 0.99), + copy[copy.length - 1], + copy[0], + ((double) sum) / count, + count); + } + + private static long percentile(long[] sorted, double p) { + if (sorted.length == 1) return sorted[0]; + double rank = p * (sorted.length - 1); + int lo = (int) Math.floor(rank); + int hi = (int) Math.ceil(rank); + if (lo == hi) return sorted[lo]; + double frac = rank - lo; + return (long) Math.round(sorted[lo] * (1.0 - frac) + sorted[hi] * frac); + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/LatencyProbe.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/LatencyProbe.java new file mode 100644 index 0000000..10b1490 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/LatencyProbe.java @@ -0,0 +1,66 @@ +package com.aaravlabs.synapse.bench.shared; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * Input-to-actuation latency probe. The stimulus side stamps {@link #stimulus} just + * before flipping a {@code Gamepad} field (or declaring a frame ready) and declares + * the expected direction of the motor power that will carry the new input; the + * actuation side stamps {@link #actuation} from {@code SimMotor.setPower}/{@code + * SimServo.setPosition} which run inside framework-invoked code. A pending stimulus + * pairs only with a write that crosses the threshold in the expected direction (so a + * stale or correction-dominated write cannot steal the pairing) and is retained until + * a qualifying write occurs. + */ +public final class LatencyProbe { + + private final String name; + private final AtomicLong pendingT0 = new AtomicLong(); + private final Hist hist = new Hist(); + private volatile boolean recording; + private volatile double sign; + + public LatencyProbe(String name) { + this.name = name; + } + + public String name() { + return name; + } + + public void setRecording(boolean on) { + if (!on) pendingT0.set(0); + recording = on; + } + + public void reset() { + pendingT0.set(0); + hist.reset(); + } + + /** + * Called by the stimulus thread immediately before the input write. + * + * @param expectedSign expected direction of the motor power that carries + * this input (+1 / -1, style sign convention applied) + */ + public void stimulus(long t0, double expectedSign) { + if (recording) { + sign = expectedSign; + pendingT0.set(t0); + } + } + + /** Called by {@code SimMotor.setPower}/{@code SimServo.setPosition}. */ + public void actuation(long t1, double power) { + if (pendingT0.get() == 0 || power * sign < 0.2) return; + long t0 = pendingT0.getAndSet(0); + if (t0 != 0 && recording) { + hist.record(t1 - t0); + } + } + + public Hist hist() { + return hist; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Metrics.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Metrics.java new file mode 100644 index 0000000..e3ca9fb --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Metrics.java @@ -0,0 +1,165 @@ +package com.aaravlabs.synapse.bench.shared; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Named measurement sinks for one scenario × style run. Style code holds direct + * {@link TaskMeter}/{@link LatencyProbe} references and stamps inside the bodies + * the framework invokes — never in harness glue around a framework call. + */ +public final class Metrics { + + public static final class TaskSnapshot { + public final String name; + public final double targetHz; + public final double achievedHz; + public final long jitterP99Ns; + public final long count; + + TaskSnapshot(String name, double targetHz, double achievedHz, long jitterP99Ns, long count) { + this.name = name; + this.targetHz = targetHz; + this.achievedHz = achievedHz; + this.jitterP99Ns = jitterP99Ns; + this.count = count; + } + } + + private final Map tasks = new ConcurrentHashMap<>(); + private final Map probes = new ConcurrentHashMap<>(); + private final List orderedTasks = new ArrayList<>(); + private final List orderedProbes = new ArrayList<>(); + + private final java.util.concurrent.atomic.LongAdder loopIterations = + new java.util.concurrent.atomic.LongAdder(); + private volatile long windowStartNanos; + private volatile long windowEndNanos; + private volatile boolean allocEnabled; + private long allocStartBytes = -1; + private long allocEndBytes = -1; + + public TaskMeter task(String name, double targetHz) { + TaskMeter m = tasks.get(name); + if (m == null) { + m = new TaskMeter(name, targetHz); + TaskMeter prev = tasks.putIfAbsent(name, m); + if (prev != null) return prev; + synchronized (orderedTasks) { + orderedTasks.add(m); + } + } + return m; + } + + public LatencyProbe probe(String name) { + LatencyProbe p = probes.get(name); + if (p == null) { + p = new LatencyProbe(name); + LatencyProbe prev = probes.putIfAbsent(name, p); + if (prev != null) return prev; + synchronized (orderedProbes) { + orderedProbes.add(p); + } + } + return p; + } + + public void setAllocEnabled(boolean on) { + allocEnabled = on; + } + + public void startWindow() { + windowStartNanos = System.nanoTime(); + for (TaskMeter m : snapshotTasks()) { + m.reset(); + m.setRecording(true); + } + for (LatencyProbe p : snapshotProbes()) { + p.reset(); + p.setRecording(true); + } + loopIterations.reset(); + if (allocEnabled) allocStartBytes = threadAllocated(); + } + + public void endWindow() { + windowEndNanos = System.nanoTime(); + for (TaskMeter m : snapshotTasks()) m.setRecording(false); + for (LatencyProbe p : snapshotProbes()) p.setRecording(false); + if (allocEnabled) allocEndBytes = threadAllocated(); + } + + public void countLoopIteration() { + loopIterations.increment(); + } + + public long windowNanos() { + return windowEndNanos - windowStartNanos; + } + + public double loopHz() { + double sec = windowNanos() / 1e9; + return sec > 0 ? loopIterations.sum() / sec : 0.0; + } + + public double allocBytesPerSec() { + if (!allocEnabled || allocStartBytes < 0 || allocEndBytes < 0) return 0.0; + double sec = windowNanos() / 1e9; + return sec > 0 ? (allocEndBytes - allocStartBytes) / sec : 0.0; + } + + public List taskSnapshots() { + List out = new ArrayList<>(); + for (TaskMeter m : snapshotTasks()) { + Hist.Snapshot s = m.periodSnapshot(); + out.add(new TaskSnapshot(m.name(), m.targetHz(), m.achievedHz(), s.p99, m.count())); + } + return out; + } + + public Map probeSnapshots() { + Map out = new LinkedHashMap<>(); + for (LatencyProbe p : snapshotProbes()) { + out.put(p.name(), p.hist().snapshot()); + } + return out; + } + + public Hist.Snapshot probeSnapshot(String name) { + LatencyProbe p = probes.get(name); + return p == null ? Hist.Snapshot.empty() : p.hist().snapshot(); + } + + private List snapshotTasks() { + synchronized (orderedTasks) { + return new ArrayList<>(orderedTasks); + } + } + + private List snapshotProbes() { + synchronized (orderedProbes) { + return new ArrayList<>(orderedProbes); + } + } + + private long threadAllocated() { + try { + java.lang.management.ThreadMXBean bean = java.lang.management.ManagementFactory.getThreadMXBean(); + if (bean instanceof com.sun.management.ThreadMXBean) { + com.sun.management.ThreadMXBean b = (com.sun.management.ThreadMXBean) bean; + long total = 0; + for (long v : b.getThreadAllocatedBytes(b.getAllThreadIds())) { + if (v > 0) total += v; + } + return total; + } + } catch (Throwable ignored) { + // optional metric + } + return -1; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/NoopTelemetry.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/NoopTelemetry.java new file mode 100644 index 0000000..41fe446 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/NoopTelemetry.java @@ -0,0 +1,31 @@ +package com.aaravlabs.synapse.bench.shared; + +import org.firstinspires.ftc.robotcore.external.Telemetry; + +/** + * No-op driver-station telemetry at the I/O boundary (there is no driver station + * on a desktop JVM). Calling it keeps the raw/SolversLib styles on the real + * {@code Telemetry} API shape. + */ +public final class NoopTelemetry implements Telemetry { + + public static final class Item implements Telemetry.Item { + } + + private final Item item = new Item(); + private volatile long updates; + + @Override + public Item addData(String caption, Object value) { + return item; + } + + @Override + public void update() { + updates++; + } + + public long updates() { + return updates; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/PairRunner.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/PairRunner.java new file mode 100644 index 0000000..5e16d46 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/PairRunner.java @@ -0,0 +1,19 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * One measured scenario × style run. The harness owns the measurement window; + * implementations only build the robot program in their style and start/stop it. + */ +public interface PairRunner extends AutoCloseable { + + /** Start the style's loops/threads/nodes. */ + void start(); + + /** Stop the style's loops/threads/nodes. */ + void stop(); + + @Override + default void close() { + stop(); + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Scenario.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Scenario.java new file mode 100644 index 0000000..d33705b --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Scenario.java @@ -0,0 +1,87 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * The complexity ladder. Each scenario is the same workload hand-written in every + * style; feature flags here keep the shared world identical across styles. + */ +public enum Scenario { + S0_MinimalDrive, + S1_BasicTeleop, + S2_MultiSubsystem, + S3_HeavyRobot; + + public static Scenario parse(String s) { + String t = s.trim(); + if (t.equalsIgnoreCase("S0")) return S0_MinimalDrive; + if (t.equalsIgnoreCase("S1")) return S1_BasicTeleop; + if (t.equalsIgnoreCase("S2")) return S2_MultiSubsystem; + if (t.equalsIgnoreCase("S3")) return S3_HeavyRobot; + for (Scenario v : values()) { + if (v.name().equalsIgnoreCase(t)) return v; + } + throw new IllegalArgumentException("unknown scenario " + s); + } + + public boolean hasServo() { + return this == S1_BasicTeleop; + } + + public boolean hasIntake() { + return this != S0_MinimalDrive; + } + + public boolean hasLift() { + return this == S2_MultiSubsystem || this == S3_HeavyRobot; + } + + public boolean hasTwoGamepads() { + return this == S2_MultiSubsystem || this == S3_HeavyRobot; + } + + public boolean hasAutoAlign() { + return this == S2_MultiSubsystem || this == S3_HeavyRobot; + } + + public boolean hasVision() { + return this == S3_HeavyRobot; + } + + public boolean hasSlowLogger() { + return this == S3_HeavyRobot; + } + + public boolean hasHeadingHold() { + return this == S3_HeavyRobot; + } + + /** S2/S3 publish a 50 Hz robot-state stream the slow logger consumes. */ + public boolean hasStatePublisher() { + return this == S3_HeavyRobot; + } + + /** Target rate for the drive task; 0 means "every pump iteration". */ + public double driveTargetHz() { + return (this == S2_MultiSubsystem || this == S3_HeavyRobot) ? 50.0 : 0.0; + } + + public double liftTargetHz() { + return 100.0; + } + + public double headingTargetHz() { + return 200.0; + } + + public double telemetryTargetHz() { + return this == S0_MinimalDrive ? 0.0 : 10.0; + } + + public double stateTargetHz() { + return 50.0; + } + + /** Honesty variant is meaningful once expensive side work exists. */ + public boolean hasRawmt() { + return this == S2_MultiSubsystem || this == S3_HeavyRobot; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Setpoints.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Setpoints.java new file mode 100644 index 0000000..f853e91 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/Setpoints.java @@ -0,0 +1,41 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * Deterministic setpoint trajectories shared by every style. Controllers read these + * and {@link SimPlant} scores tracking error against the very same functions, so the + * control-quality metric cannot be gamed by any style. + */ +public final class Setpoints { + + /** Lift sweep between low and high ticks. */ + public static final double LIFT_LOW = 350.0; + public static final double LIFT_HIGH = 650.0; + private static final long LIFT_HALF_PERIOD_NANOS = 1_500_000_000L; + + private final long originNanos; + + public Setpoints(long originNanos) { + this.originNanos = originNanos; + } + + public long originNanos() { + return originNanos; + } + + /** Triangle sweep of the lift; identical phase for every style. */ + public double liftTargetAt(long nowNanos) { + long phase = Math.floorMod(nowNanos - originNanos, 2 * LIFT_HALF_PERIOD_NANOS); + double frac; + if (phase < LIFT_HALF_PERIOD_NANOS) { + frac = phase / (double) LIFT_HALF_PERIOD_NANOS; + } else { + frac = 1.0 - (phase - LIFT_HALF_PERIOD_NANOS) / (double) LIFT_HALF_PERIOD_NANOS; + } + return LIFT_LOW + (LIFT_HIGH - LIFT_LOW) * frac; + } + + /** Heading-hold target: keep yaw at 0 while disturbances hit the plant. */ + public double headingTargetAt(long nowNanos) { + return 0.0; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SharedPidf.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SharedPidf.java new file mode 100644 index 0000000..c5754ce --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SharedPidf.java @@ -0,0 +1,63 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * Identical PIDF math for every style — controller code is deliberately not what + * the suite benchmarks. Output is clamped to [-1, 1] motor power. + */ +public final class SharedPidf { + + private final double kP; + private final double kI; + private final double kD; + private final double kF; + private final double iLimit; + + private double integ; + private double prevError; + private long prevNanos; + + public SharedPidf(double kP, double kI, double kD, double kF, double iLimit) { + this.kP = kP; + this.kI = kI; + this.kD = kD; + this.kF = kF; + this.iLimit = iLimit; + } + + /** Lift position controller (ticks -> power), feedforward cancels gravity. */ + public static SharedPidf forLift() { + return new SharedPidf(0.05, 0.001, 0.0025, 0.08, 80.0); + } + + /** Drivetrain heading-hold controller (radians -> differential power). */ + public static SharedPidf forHeading() { + return new SharedPidf(3.0, 0.0, 0.15, 0.0, 0.0); + } + + public double update(double measurement, double setpoint, long nowNanos) { + double dt = 0.005; + if (prevNanos != 0) { + double real = (nowNanos - prevNanos) * 1e-9; + if (real > 0.0001 && real < 0.1) dt = real; + } + prevNanos = nowNanos; + + double error = setpoint - measurement; + integ += error * dt; + if (integ > iLimit) integ = iLimit; + else if (integ < -iLimit) integ = -iLimit; + double d = (error - prevError) / dt; + prevError = error; + + double out = kP * error + kI * integ + kD * d + kF; + if (out > 1.0) out = 1.0; + else if (out < -1.0) out = -1.0; + return out; + } + + public void reset() { + integ = 0; + prevError = 0; + prevNanos = 0; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimCamera.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimCamera.java new file mode 100644 index 0000000..5ab03bf --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimCamera.java @@ -0,0 +1,111 @@ +package com.aaravlabs.synapse.bench.shared; + +import java.util.function.Consumer; + +/** + * Simulated camera at the physical I/O boundary: a capture thread produces + * timestamped frames at 30 Hz into a ring of reusable buffers. Styles either pull + * the latest unconsumed frame or receive push callbacks (Synapse publishes the + * frame onto its bus from this thread). Frame generation is world-side; only the + * processing kernel ({@link SyntheticVisionPipeline}) runs on a measured path. + */ +public final class SimCamera implements AutoCloseable { + + public static final int HZ = 30; + public static final int RING = 8; + private static final long PERIOD_NANOS = 1_000_000_000L / HZ; + + /** A single reusable frame buffer. */ + public static final class Frame { + public final byte[] buf = new byte[SyntheticVisionPipeline.WIDTH * SyntheticVisionPipeline.HEIGHT]; + public volatile long seq = -1; + public volatile long tNanos; + + public Frame copy() { + Frame f = new Frame(); + System.arraycopy(buf, 0, f.buf, 0, buf.length); + f.seq = seq; + f.tNanos = tNanos; + return f; + } + } + + private final Frame[] ring = new Frame[RING]; + private volatile int latestSlot = -1; + private volatile long consumedSeq = -1; + private volatile Consumer listener; + private volatile boolean running; + private Thread thread; + + public SimCamera() { + for (int i = 0; i < RING; i++) ring[i] = new Frame(); + } + + /** Push mode: invoked on the capture thread for every produced frame. */ + public void setListener(Consumer listener) { + this.listener = listener; + } + + public void start() { + running = true; + thread = new Thread(this::captureLoop, "sim-camera"); + thread.setDaemon(true); + thread.start(); + } + + private void captureLoop() { + long seq = 0; + long next = System.nanoTime(); + while (running) { + int slot = (int) (seq % RING); + Frame f = ring[slot]; + fill(f.buf, seq); + f.seq = seq; + f.tNanos = System.nanoTime(); + latestSlot = slot; + Consumer l = listener; + if (l != null) l.accept(f); + seq++; + next += PERIOD_NANOS; + long now; + while ((now = System.nanoTime()) - next < 0) { + java.util.concurrent.locks.LockSupport.parkNanos(Math.min(next - now, 50_000L)); + } + } + } + + private static void fill(byte[] buf, long seq) { + int s = (int) (seq * 2654435761L); + for (int i = 0; i < buf.length; i += 7) { + buf[i] = (byte) (s + i); + } + } + + /** Pull mode: latest not-yet-consumed frame, or null. */ + public Frame pollFrame() { + int slot = latestSlot; + if (slot < 0) return null; + Frame f = ring[slot]; + long seq = f.seq; + if (seq <= consumedSeq) return null; + consumedSeq = seq; + return f; + } + + public Frame latestFrame() { + int slot = latestSlot; + return slot < 0 ? null : ring[slot]; + } + + @Override + public void close() { + running = false; + if (thread != null) { + try { + thread.join(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimMotor.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimMotor.java new file mode 100644 index 0000000..5fcee57 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimMotor.java @@ -0,0 +1,49 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * Simulated {@code DcMotorEx} at the physical I/O boundary. The only thing a + * measured actuation path does here is a plain volatile field write into + * {@link SimPlant} plus the actuation stamp the plan requires to live inside the + * framework-invoked device write. Constant-cost, allocation-free. + */ +public final class SimMotor { + + private final SimPlant plant; + private final int channel; + private final LatencyProbe probe; + private volatile double lastPower; + + public SimMotor(SimPlant plant, int channel, LatencyProbe probe) { + this.plant = plant; + this.channel = channel; + this.probe = probe; + } + + /** Mirror of {@code DcMotorEx.setPower}. Runs on the framework-invoked thread. */ + public void setPower(double power) { + lastPower = power; + switch (channel) { + case SimPlant.LEFT: + plant.leftPower = power; + break; + case SimPlant.RIGHT: + plant.rightPower = power; + break; + case SimPlant.LIFT: + plant.liftPower = power; + break; + case SimPlant.INTAKE: + plant.intakePower = power; + break; + default: + throw new IllegalStateException("bad channel " + channel); + } + if (probe != null) { + probe.actuation(System.nanoTime(), power); + } + } + + public double getPower() { + return lastPower; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimPlant.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimPlant.java new file mode 100644 index 0000000..773713f --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimPlant.java @@ -0,0 +1,216 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * Shared physics "world": a differential drive base, a lift with gravity/friction, + * and an intake roller, integrated at 1 kHz on its own thread. Device writes into + * this plant are plain volatile field stores (constant cost, allocation-free). + * The integrator also scores tracking error against {@link Setpoints}, which is + * world-side bookkeeping and never part of a measured dispatch segment. + */ +public final class SimPlant implements AutoCloseable { + + public static final int LEFT = 0; + public static final int RIGHT = 1; + public static final int LIFT = 2; + public static final int INTAKE = 3; + + private static final double DT = 0.001; + private static final double WHEEL_VMAX = 1.4; + private static final double WHEEL_TAU = 0.045; + private static final double LIFT_ACC_GAIN = 2500.0; + private static final double LIFT_GRAVITY = 200.0; + private static final double LIFT_DAMP = 1.0; + private static final double LIFT_MAX = 1150.0; + private static final double ROLLER_TAU = 0.08; + /** Halved because the drive law splits ±corr across two motors (differential = 2·corr). */ + private static final double HEADING_ACC_GAIN = 17.5; + private static final double HEADING_DAMP = 4.0; + + // ---- device inputs (written by framework-invoked code) ---------------- + public volatile double leftPower; + public volatile double rightPower; + public volatile double liftPower; + public volatile double intakePower; + public volatile double servoPos; + + /** + * Yaw nudge commanded by auto-align/vision (radians added to the heading + * setpoint). Written exactly like a device command and scored by this plant, + * so every style tracks the same reference. + */ + public volatile double headingBias; + + // ---- integrator state (written by the physics thread) ----------------- + public volatile double leftWheelVel; + public volatile double rightWheelVel; + public volatile double x; + public volatile double y; + public volatile double heading; + public volatile double headingVel; + public volatile double liftPos = Setpoints.LIFT_LOW; + public volatile double liftVel; + public volatile double rollerVel; + + private final Setpoints setpoints; + private final boolean trackLift; + private final boolean trackHeading; + + private double liftSse; + private double headingSse; + private long trackSamples; + private volatile boolean scoring = true; + + private volatile boolean running; + private Thread thread; + + public SimPlant(Setpoints setpoints, boolean trackLift, boolean trackHeading) { + this.setpoints = setpoints; + this.trackLift = trackLift; + this.trackHeading = trackHeading; + } + + public void start() { + running = true; + thread = new Thread(this::run, "sim-plant"); + thread.setDaemon(true); + thread.start(); + } + + private void run() { + long next = System.nanoTime(); + long simNanos = 0L; + while (running) { + step(simNanos); + simNanos += 1_000_000L; + next += 1_000_000L; + long now; + while ((now = System.nanoTime()) - next < 0) { + java.util.concurrent.locks.LockSupport.parkNanos(Math.min(next - now, 50_000L)); + } + } + } + + private void step(long simNanos) { + double lp = clampPower(leftPower); + double rp = clampPower(rightPower); + double ip = clampPower(intakePower); + double fp = clampPower(liftPower); + + leftWheelVel += (lp * WHEEL_VMAX - leftWheelVel) * (DT / WHEEL_TAU); + rightWheelVel += (rp * WHEEL_VMAX - rightWheelVel) * (DT / WHEEL_TAU); + + double v = 0.5 * (leftWheelVel + rightWheelVel); + x += v * Math.cos(heading) * DT; + y += v * Math.sin(heading) * DT; + + // Right wheel faster = yaw left (counter-clockwise, heading increases). + headingVel += ((rp - lp) * HEADING_ACC_GAIN - HEADING_DAMP * headingVel + + headingDisturbance(simNanos)) * DT; + heading += headingVel * DT; + + double liftAcc = fp * LIFT_ACC_GAIN - LIFT_GRAVITY - LIFT_DAMP * liftVel + + liftDisturbance(simNanos); + liftVel += liftAcc * DT; + liftPos += liftVel * DT; + if (liftPos < 0) { + liftPos = 0; + liftVel = 0; + } else if (liftPos > LIFT_MAX) { + liftPos = LIFT_MAX; + liftVel = 0; + } + + rollerVel += (ip * 12.0 - rollerVel) * (DT / ROLLER_TAU); + + scoreTracking(simNanos); + } + + /** + * Deterministic broadband load disturbances (sum of sines, identical for every + * style). Frequency content sits at 7-55 Hz: a control loop stalled behind a + * slow consumer (≈50 Hz or worse) cannot reject the upper bands, while a + * 100-200 Hz loop can. See benchmarks/README.md for the offline calibration. + */ + private static double liftDisturbance(long simNanos) { + double t = simNanos * 1e-9; + return 5000.0 * Math.sin(2 * Math.PI * 7.0 * t) + + 15000.0 * Math.sin(2 * Math.PI * 16.0 * t) + + 15000.0 * Math.sin(2 * Math.PI * 33.0 * t) + + 12000.0 * Math.sin(2 * Math.PI * 55.0 * t); + } + + /** Deterministic yaw torque noise at 9/20/40 Hz. */ + private static double headingDisturbance(long simNanos) { + double t = simNanos * 1e-9; + return 4.0 * Math.sin(2 * Math.PI * 9.0 * t) + + 4.0 * Math.sin(2 * Math.PI * 20.0 * t) + + 4.0 * Math.sin(2 * Math.PI * 40.0 * t); + } + + private void scoreTracking(long simNanos) { + if (!scoring) return; + if (!trackLift && !trackHeading) return; + // Score against the same wall-clock setpoint the style code tracks; a + // sim-clock reference would drift from the controllers' System.nanoTime() + // and invent tracking error. + long now = System.nanoTime(); + synchronized (this) { + if (trackLift) { + double err = liftPos - setpoints.liftTargetAt(now); + liftSse += err * err; + } + if (trackHeading) { + double err = heading - (setpoints.headingTargetAt(now) + headingBias); + headingSse += err * err; + } + trackSamples++; + } + } + + /** Stop accumulating tracking error (called at the end of the measurement window). */ + public void freezeTracking() { + scoring = false; + } + + /** Zero the tracking accumulators at the start of a measurement window. */ + public void resetTracking() { + synchronized (this) { + liftSse = 0; + headingSse = 0; + trackSamples = 0; + } + scoring = true; + } + + public double liftRmse() { + synchronized (this) { + if (!trackLift || trackSamples == 0) return 0; + return Math.sqrt(liftSse / trackSamples); + } + } + + public double headingRmse() { + synchronized (this) { + if (!trackHeading || trackSamples == 0) return 0; + return Math.sqrt(headingSse / trackSamples); + } + } + + private static double clampPower(double p) { + if (p > 1.0) return 1.0; + if (p < -1.0) return -1.0; + return Math.abs(p) < 0.03 ? 0.0 : p; + } + + @Override + public void close() { + running = false; + if (thread != null) { + try { + thread.join(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimServo.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimServo.java new file mode 100644 index 0000000..fb94317 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SimServo.java @@ -0,0 +1,30 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * Simulated {@code Servo} at the physical I/O boundary. Position writes are plain + * volatile stores plus the actuation stamp inside the framework-invoked call. + */ +public final class SimServo { + + private final SimPlant plant; + private final LatencyProbe probe; + private volatile double position; + + public SimServo(SimPlant plant, LatencyProbe probe) { + this.plant = plant; + this.probe = probe; + } + + /** Mirror of {@code Servo.setPosition}. */ + public void setPosition(double pos) { + position = pos; + plant.servoPos = pos; + if (probe != null) { + probe.actuation(System.nanoTime(), pos); + } + } + + public double getPosition() { + return position; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/StimulusTimeline.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/StimulusTimeline.java new file mode 100644 index 0000000..646bd27 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/StimulusTimeline.java @@ -0,0 +1,150 @@ +package com.aaravlabs.synapse.bench.shared; + +import com.qualcomm.robotcore.hardware.Gamepad; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.locks.LockSupport; + +/** + * Seeded, deterministic stimulus: gamepad field flips on a fixed timeline, + * identical per style per scenario. The stimulus thread stamps + * {@link LatencyProbe#stimulus} immediately before the write to the real stub + * {@link Gamepad} volatile fields — that stamp is the "input" end of + * input-to-actuation latency. + */ +public final class StimulusTimeline implements AutoCloseable { + + private static final class Event { + final long atNanos; + final Runnable apply; + + Event(long atNanos, Runnable apply) { + this.atNanos = atNanos; + this.apply = apply; + } + } + + private final List events = new ArrayList<>(); + private volatile boolean running; + private Thread thread; + + private void at(double seconds, Runnable apply) { + events.add(new Event((long) (seconds * 1e9), apply)); + } + + /** + * Build the workload timeline covering the full warmup-plus-measure span. + * Stick steps are latency-probe events (the measured input→actuation path); + * button/edge traffic exercises toggles and command conflicts. + * + * @param spanSec total warmup-plus-measure duration the timeline must cover + * @param leftPowerSign style sign convention mapping a forward-positive stick + * command to the expected motor-power direction + * (−1 raw/rawmt/synapse write the raw gamepad field, + * +1 solverslib's {@code GamepadEx.getLeftY()} negates it) + */ + public static StimulusTimeline build(Scenario scenario, long seed, + Gamepad g1, Gamepad g2, + LatencyProbe actuation, + double spanSec, double leftPowerSign) { + StimulusTimeline t = new StimulusTimeline(); + Random rnd = new Random(seed); + int steps = (int) Math.floor((spanSec - 0.41) / 0.30); + for (int i = 0; i < steps; i++) { + double mag = 0.4 + 0.6 * rnd.nextDouble(); + // Alternate the sign every step so a stale (previous-step) write has + // the wrong direction and cannot pair with the new stimulus. + final double leftY = ((i & 1) == 0 ? 0.85 : -0.85) * mag; + // Heading-hold scenarios command translation only (matched sticks) so + // the hold loop is fighting disturbances, not the driver. + final double rightY = scenario.hasHeadingHold() ? leftY + : (rnd.nextBoolean() ? 0.85 : -0.85) * (0.4 + 0.6 * rnd.nextDouble()); + final double leftX = scenario.hasHeadingHold() ? 0.0 + : (rnd.nextBoolean() ? 0.3 : -0.3) * rnd.nextDouble(); + // Jittered cadence keeps stimulus events from phase-locking with the + // styles' fixed task periods (a 300 ms cadence is exactly 15× 20 ms + // and would measure a constant phase offset instead of latency). + final double atSec = 0.30 + i * 0.30 + rnd.nextDouble() * 0.11; + final double expectedSign = leftPowerSign * Math.signum(leftY); + t.at(atSec, () -> { + actuation.stimulus(System.nanoTime(), expectedSign); + g1.left_stick_y = (float) -leftY; + g1.right_stick_y = (float) -rightY; + g1.left_stick_x = (float) leftX; + }); + } + + if (scenario.hasIntake()) { + for (int i = 0; 0.55 + i * 0.45 + 0.07 < spanSec; i++) { + final boolean down = (i % 2 == 0); + final double atSec = 0.55 + i * 0.45 + rnd.nextDouble() * 0.07; + t.at(atSec, () -> g1.right_bumper = down); + } + } + + if (scenario.hasServo()) { + for (int i = 0; 0.80 + i * 0.55 + 0.09 < spanSec; i++) { + final boolean down = (i % 2 == 0); + final double atSec = 0.80 + i * 0.55 + rnd.nextDouble() * 0.09; + t.at(atSec, () -> g1.x = down); + } + } + + if (scenario.hasAutoAlign()) { + for (int i = 0; 0.65 + i * 0.50 + 0.08 < spanSec; i++) { + final boolean down = (i % 2 == 0); + final double atSec = 0.65 + i * 0.50 + rnd.nextDouble() * 0.08; + t.at(atSec, () -> g1.a = down); + } + } + + if (scenario.hasTwoGamepads()) { + for (int i = 0; 0.70 + i * 0.55 + 0.06 < spanSec; i++) { + final boolean down = (i % 2 == 0); + final double atSec = 0.70 + i * 0.55 + rnd.nextDouble() * 0.06; + t.at(atSec, () -> g2.left_bumper = down); + } + } + + t.events.sort((a, b) -> Long.compare(a.atNanos, b.atNanos)); + return t; + } + + public void start() { + running = true; + List plan = new ArrayList<>(events); + thread = new Thread(() -> { + long t0 = System.nanoTime(); + for (Event e : plan) { + if (!running) return; + long due = t0 + e.atNanos; + long now; + while ((now = System.nanoTime()) - due < 0) { + LockSupport.parkNanos(Math.min(due - now, 100_000L)); + if (!running) return; + } + e.apply.run(); + } + }, "stimulus"); + thread.setDaemon(true); + thread.start(); + } + + public int eventCount() { + return events.size(); + } + + @Override + public void close() { + running = false; + if (thread != null) { + try { + thread.join(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SyntheticVisionPipeline.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SyntheticVisionPipeline.java new file mode 100644 index 0000000..715a35e --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/SyntheticVisionPipeline.java @@ -0,0 +1,42 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * Deterministic ~3 ms of real pixel work over a 320x240 buffer. Identical kernel + * for every style: repeated gradient passes over the frame until the target + * duration is reached (so the load profile does not depend on CPU speed), with a + * deterministic alignment result that is a pure function of the frame sequence. + */ +public final class SyntheticVisionPipeline { + + public static final int WIDTH = 320; + public static final int HEIGHT = 240; + public static final long TARGET_NANOS = 3_000_000L; + + private SyntheticVisionPipeline() { + } + + /** + * Process one frame. Returns the alignment offset the robot must apply — + * a deterministic function of {@code seq} so every style sees identical output. + */ + public static double process(byte[] frame, long seq) { + long start = System.nanoTime(); + long deadline = start + TARGET_NANOS; + double acc = 0; + int w = WIDTH; + int h = HEIGHT; + do { + for (int yRow = 1; yRow < h - 1; yRow++) { + int row = yRow * w; + for (int x = 1; x < w - 1; x++) { + int i = row + x; + int g = (frame[i - 1] & 0xff) + (frame[i + 1] & 0xff) + + (frame[i - w] & 0xff) + (frame[i + w] & 0xff); + acc += (g * 0.25) * 0.001 + (frame[i] & 0xff) * 0.0005; + } + } + } while (System.nanoTime() - deadline < 0); + Blackhole.consume(acc); + return Math.sin(seq * 0.17) * 3.0; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/TaskMeter.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/TaskMeter.java new file mode 100644 index 0000000..ec220af --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/TaskMeter.java @@ -0,0 +1,68 @@ +package com.aaravlabs.synapse.bench.shared; + +/** + * Per-task achieved-rate / period-jitter meter. {@link #tick} is called from inside + * the periodic body the framework invokes ({@code loop()} body, + * {@code Command.execute()}, {@code @RunPeriodically} method). Single-writer. + */ +public final class TaskMeter { + + private final String name; + private final double targetHz; + private final Hist periods = new Hist(); + private volatile boolean recording; + private long last; + private long count; + private long firstTick; + + public TaskMeter(String name, double targetHz) { + this.name = name; + this.targetHz = targetHz; + } + + public String name() { + return name; + } + + public double targetHz() { + return targetHz; + } + + public void setRecording(boolean on) { + recording = on; + } + + public void reset() { + periods.reset(); + last = 0; + count = 0; + firstTick = 0; + } + + public void tick() { + tick(System.nanoTime()); + } + + public void tick(long now) { + if (!recording) return; + if (firstTick == 0) firstTick = now; + if (last != 0) periods.record(now - last); + last = now; + count++; + } + + public long count() { + return count; + } + + public Hist.Snapshot periodSnapshot() { + return periods.snapshot(); + } + + /** Achieved Hz over the recording window, measured between first and last tick. */ + public double achievedHz() { + if (count < 2 || firstTick == 0 || last <= firstTick) return 0.0; + double seconds = (last - firstTick) / 1e9; + return (count - 1) / seconds; + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/World.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/World.java new file mode 100644 index 0000000..d5d7ac8 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/shared/World.java @@ -0,0 +1,138 @@ +package com.aaravlabs.synapse.bench.shared; + +import com.qualcomm.robotcore.hardware.Gamepad; + +/** + * Per-run shared world: real stub {@link Gamepad}s, {@link SimPlant} devices, + * optional {@link SimCamera}, setpoints and the seeded stimulus timeline. Style + * packages consume this and their framework APIs only. + */ +public final class World implements AutoCloseable { + + private final Scenario scenario; + private final long seed; + private final Metrics metrics; + + private final Gamepad gamepad1 = new Gamepad(); + private final Gamepad gamepad2 = new Gamepad(); + private final Setpoints setpoints; + private final SimPlant plant; + private final SimMotor leftMotor; + private final SimMotor rightMotor; + private final SimMotor liftMotor; + private final SimMotor intakeMotor; + private final SimServo servo; + private final SimCamera camera; + private final NoopTelemetry telemetry = new NoopTelemetry(); + private final StimulusTimeline stimulus; + private final LatencyProbe actuation; + + public World(Scenario scenario, long seed, Metrics metrics) { + this(scenario, seed, metrics, 8.0, -1.0); + } + + /** + * @param spanSec total warmup-plus-measure duration the stimulus covers + * @param leftPowerSign style sign convention mapping a forward-positive stick + * command to the expected left-motor power direction + */ + public World(Scenario scenario, long seed, Metrics metrics, + double spanSec, double leftPowerSign) { + this.scenario = scenario; + this.seed = seed; + this.metrics = metrics; + this.actuation = metrics.probe("actuation"); + this.setpoints = new Setpoints(System.nanoTime() + 50_000_000L); + this.plant = new SimPlant(setpoints, scenario.hasLift(), scenario.hasHeadingHold()); + // Only the drive-left write carries the actuation latency probe: the measured + // path is the stick stimulus -> drive actuation. Other device writes stay + // un-instrumented so they cannot steal the pairing. + this.leftMotor = new SimMotor(plant, SimPlant.LEFT, actuation); + this.rightMotor = new SimMotor(plant, SimPlant.RIGHT, null); + this.liftMotor = new SimMotor(plant, SimPlant.LIFT, null); + this.intakeMotor = new SimMotor(plant, SimPlant.INTAKE, null); + this.servo = new SimServo(plant, null); + this.camera = scenario.hasVision() ? new SimCamera() : null; + this.stimulus = StimulusTimeline.build(scenario, seed, gamepad1, gamepad2, actuation, + spanSec, leftPowerSign); + } + + public Scenario scenario() { + return scenario; + } + + public long seed() { + return seed; + } + + public Metrics metrics() { + return metrics; + } + + public Gamepad gamepad1() { + return gamepad1; + } + + public Gamepad gamepad2() { + return gamepad2; + } + + public Setpoints setpoints() { + return setpoints; + } + + public SimPlant plant() { + return plant; + } + + public SimMotor leftMotor() { + return leftMotor; + } + + public SimMotor rightMotor() { + return rightMotor; + } + + /** Only the left motor carries the actuation latency probe (S0 uses it alone). */ + public SimMotor liftMotor() { + return liftMotor; + } + + public SimMotor intakeMotor() { + return intakeMotor; + } + + public SimServo servo() { + return servo; + } + + public SimCamera camera() { + return camera; + } + + public NoopTelemetry telemetry() { + return telemetry; + } + + public StimulusTimeline stimulus() { + return stimulus; + } + + public LatencyProbe actuationProbe() { + return actuation; + } + + /** Start plant, camera capture and stimulus. */ + public void start() { + plant.start(); + if (camera != null) camera.start(); + stimulus.start(); + } + + @Override + public void close() { + stimulus.close(); + if (camera != null) camera.close(); + plant.close(); + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS0.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS0.java new file mode 100644 index 0000000..ce1fe67 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS0.java @@ -0,0 +1,83 @@ +package com.aaravlabs.synapse.bench.solverslib; + +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.seattlesolvers.solverslib.command.CommandScheduler; +import com.seattlesolvers.solverslib.command.RunCommand; +import com.seattlesolvers.solverslib.command.SubsystemBase; +import com.seattlesolvers.solverslib.gamepad.GamepadEx; + +/** + * S0 in idiomatic SolversLib: one stick → one motor power via a default + * {@link RunCommand} on a {@link SubsystemBase}, pumped by + * {@code CommandScheduler.run()} (the idiomatic OpMode loop). + */ +public final class SolversS0 implements PairRunner { + + private final World world; + private final Metrics metrics; + private final TaskMeter pumpMeter; + + private GamepadEx gamepadEx; + private DriveSubsystem drive; + private volatile boolean active; + private Thread thread; + + public SolversS0(World world) { + this.world = world; + this.metrics = world.metrics(); + this.pumpMeter = metrics.task("schedulerRun", 0); + } + + @Override + public void start() { + active = true; + CommandScheduler.getInstance().reset(); + + gamepadEx = new GamepadEx(world.gamepad1()); + drive = new DriveSubsystem(); + + CommandScheduler.getInstance().setDefaultCommand(drive, + new RunCommand(() -> drive.setPower(gamepadEx.getLeftY()), drive)); + + thread = new Thread(this::pump, "solvers-s0"); + thread.setDaemon(true); + thread.start(); + } + + private void pump() { + CommandScheduler scheduler = CommandScheduler.getInstance(); + while (active) { + gamepadEx.readButtons(); + scheduler.run(); + pumpMeter.tick(); + metrics.countLoopIteration(); + } + } + + @Override + public void stop() { + active = false; + if (thread != null) { + try { + thread.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + CommandScheduler.getInstance().reset(); + CommandScheduler.getInstance().clearButtons(); + } + + private final class DriveSubsystem extends SubsystemBase { + void setPower(double power) { + world.leftMotor().setPower(power); + } + + @Override + public void periodic() { + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS1.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS1.java new file mode 100644 index 0000000..84a44fd --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS1.java @@ -0,0 +1,130 @@ +package com.aaravlabs.synapse.bench.solverslib; + +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.seattlesolvers.solverslib.command.CommandScheduler; +import com.seattlesolvers.solverslib.command.InstantCommand; +import com.seattlesolvers.solverslib.command.RunCommand; +import com.seattlesolvers.solverslib.command.SubsystemBase; +import com.seattlesolvers.solverslib.gamepad.GamepadEx; +import com.seattlesolvers.solverslib.gamepad.GamepadKeys; + +/** + * S1 in idiomatic SolversLib: the common rookie TeleOp. Tank drive default + * command, intake toggle and servo on real {@code GamepadButton.whenPressed} + * bindings (through {@code CommandScheduler.addButton}), telemetry at 10 Hz. + */ +public final class SolversS1 implements PairRunner { + + private static final long TELEMETRY_PERIOD_NANOS = 100_000_000L; + + private final World world; + private final Metrics metrics; + private final TaskMeter pumpMeter; + private final TaskMeter telemetryMeter; + + private GamepadEx gamepadEx; + private DriveSubsystem drive; + private IntakeSubsystem intake; + private ServoSubsystem servo; + private TelemetrySubsystem telemetrySubsystem; + + private boolean intakeRunning; + private boolean servoOpen; + private long lastTelemetry; + + private volatile boolean active; + private Thread thread; + + public SolversS1(World world) { + this.world = world; + this.metrics = world.metrics(); + this.pumpMeter = metrics.task("schedulerRun", 0); + this.telemetryMeter = metrics.task("telemetry", world.scenario().telemetryTargetHz()); + } + + @Override + public void start() { + active = true; + CommandScheduler.getInstance().reset(); + + gamepadEx = new GamepadEx(world.gamepad1()); + drive = new DriveSubsystem(); + intake = new IntakeSubsystem(); + servo = new ServoSubsystem(); + telemetrySubsystem = new TelemetrySubsystem(); + + CommandScheduler.getInstance().setDefaultCommand(drive, new RunCommand(() -> { + drive.setPower(gamepadEx.getLeftY(), -gamepadEx.getRightY()); + }, drive)); + + gamepadEx.getGamepadButton(GamepadKeys.Button.RIGHT_BUMPER).whenPressed(new InstantCommand(() -> { + intakeRunning = !intakeRunning; + intake.setPower(intakeRunning ? 1.0 : 0.0); + }, intake)); + + gamepadEx.getGamepadButton(GamepadKeys.Button.X).whenPressed(new InstantCommand(() -> { + servoOpen = !servoOpen; + servo.setPosition(servoOpen ? 1.0 : 0.0); + }, servo)); + + thread = new Thread(this::pump, "solvers-s1"); + thread.setDaemon(true); + thread.start(); + } + + private void pump() { + CommandScheduler scheduler = CommandScheduler.getInstance(); + while (active) { + gamepadEx.readButtons(); + scheduler.run(); + pumpMeter.tick(); + metrics.countLoopIteration(); + long now = System.nanoTime(); + if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { + lastTelemetry = now; + telemetryMeter.tick(now); + world.telemetry().addData("intake", intakeRunning ? "on" : "off"); + world.telemetry().update(); + } + } + } + + @Override + public void stop() { + active = false; + if (thread != null) { + try { + thread.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + CommandScheduler.getInstance().reset(); + CommandScheduler.getInstance().clearButtons(); + } + + private final class DriveSubsystem extends SubsystemBase { + void setPower(double left, double right) { + world.leftMotor().setPower(left); + world.rightMotor().setPower(right); + } + } + + private final class IntakeSubsystem extends SubsystemBase { + void setPower(double power) { + world.intakeMotor().setPower(power); + } + } + + private final class ServoSubsystem extends SubsystemBase { + void setPosition(double pos) { + world.servo().setPosition(pos); + } + } + + private final class TelemetrySubsystem extends SubsystemBase { + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS2.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS2.java new file mode 100644 index 0000000..9e12451 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS2.java @@ -0,0 +1,232 @@ +package com.aaravlabs.synapse.bench.solverslib; + +import com.aaravlabs.synapse.bench.shared.BusyWork; +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.SharedPidf; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.seattlesolvers.solverslib.command.CommandBase; +import com.seattlesolvers.solverslib.command.CommandScheduler; +import com.seattlesolvers.solverslib.command.RunCommand; +import com.seattlesolvers.solverslib.command.SubsystemBase; +import com.seattlesolvers.solverslib.gamepad.GamepadEx; +import com.seattlesolvers.solverslib.gamepad.GamepadKeys; + +/** + * S2 in idiomatic SolversLib: drive + intake + lift PIDF + outtake on two + * gamepads, mixed target rates, 0.5 ms auto-align on a button command. Intake + * and outtake are commands with the same requirement so the real scheduler + * interruption machinery is exercised. + */ +public final class SolversS2 implements PairRunner { + + private static final long DRIVE_PERIOD_NANOS = 20_000_000L; + private static final long TELEMETRY_PERIOD_NANOS = 100_000_000L; + + private final World world; + private final Metrics metrics; + private final TaskMeter pumpMeter; + private final TaskMeter driveMeter; + private final TaskMeter liftMeter; + private final TaskMeter telemetryMeter; + + private final SharedPidf liftPidf = SharedPidf.forLift(); + + private GamepadEx gamepad1Ex; + private GamepadEx gamepad2Ex; + private DriveSubsystem drive; + private LiftSubsystem lift; + private IntakeSubsystem intake; + private AutoAlignCommand autoAlign; + private IntakeCommand intakeCommand; + private OuttakeCommand outtakeCommand; + private TeleopDriveCommand teleopDrive; + + private volatile double alignOffset; + private long alignSeq; + private long lastTelemetry; + + private volatile boolean active; + private Thread thread; + + public SolversS2(World world) { + this.world = world; + this.metrics = world.metrics(); + this.pumpMeter = metrics.task("schedulerRun", 0); + this.driveMeter = metrics.task("drive", world.scenario().driveTargetHz()); + this.liftMeter = metrics.task("liftPidf", world.scenario().liftTargetHz()); + this.telemetryMeter = metrics.task("telemetry", world.scenario().telemetryTargetHz()); + } + + @Override + public void start() { + active = true; + CommandScheduler.getInstance().reset(); + + gamepad1Ex = new GamepadEx(world.gamepad1()); + gamepad2Ex = new GamepadEx(world.gamepad2()); + drive = new DriveSubsystem(); + lift = new LiftSubsystem(); + intake = new IntakeSubsystem(); + autoAlign = new AutoAlignCommand(); + intakeCommand = new IntakeCommand(); + outtakeCommand = new OuttakeCommand(); + teleopDrive = new TeleopDriveCommand(); + + CommandScheduler.getInstance().registerSubsystem(drive, lift, intake); + CommandScheduler.getInstance().setDefaultCommand(drive, teleopDrive); + + gamepad1Ex.getGamepadButton(GamepadKeys.Button.RIGHT_BUMPER).toggleWhenPressed(intakeCommand); + gamepad2Ex.getGamepadButton(GamepadKeys.Button.LEFT_BUMPER).whileHeld(outtakeCommand); + gamepad1Ex.getGamepadButton(GamepadKeys.Button.A).whenPressed(autoAlign); + + thread = new Thread(this::pump, "solvers-s2"); + thread.setDaemon(true); + thread.start(); + } + + private void pump() { + CommandScheduler scheduler = CommandScheduler.getInstance(); + while (active) { + gamepad1Ex.readButtons(); + gamepad2Ex.readButtons(); + scheduler.run(); + pumpMeter.tick(); + metrics.countLoopIteration(); + long now = System.nanoTime(); + if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { + lastTelemetry = now; + telemetryMeter.tick(now); + world.telemetry().addData("lift", world.plant().liftPos); + world.telemetry().update(); + } + } + } + + @Override + public void stop() { + active = false; + if (thread != null) { + try { + thread.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + CommandScheduler.getInstance().reset(); + CommandScheduler.getInstance().clearButtons(); + } + + private final class DriveSubsystem extends SubsystemBase { + void drive(double leftY, double rightY, double turn, double align) { + world.leftMotor().setPower(leftY + turn + align); + world.rightMotor().setPower(rightY - turn - align); + } + } + + private final class LiftSubsystem extends SubsystemBase { + private long last; + + void setPower(double power) { + world.liftMotor().setPower(power); + } + + @Override + public void periodic() { + long now = System.nanoTime(); + if (now - last < 10_000_000L) return; + last = now; + liftMeter.tick(now); + double target = world.setpoints().liftTargetAt(now); + setPower(liftPidf.update(world.plant().liftPos, target, now)); + } + } + + private final class IntakeSubsystem extends SubsystemBase { + void setPower(double power) { + world.intakeMotor().setPower(power); + } + } + + private final class TeleopDriveCommand extends CommandBase { + private long last; + + TeleopDriveCommand() { + addRequirements(drive); + } + + @Override + public void execute() { + long now = System.nanoTime(); + if (now - last < DRIVE_PERIOD_NANOS) return; + last = now; + driveMeter.tick(now); + drive.drive(gamepad1Ex.getLeftY(), -gamepad1Ex.getRightY(), gamepad1Ex.getLeftX(), alignOffset); + } + + @Override + public boolean isFinished() { + return false; + } + } + + private final class IntakeCommand extends CommandBase { + IntakeCommand() { + addRequirements(intake); + } + + @Override + public void initialize() { + intake.setPower(1.0); + } + + @Override + public boolean isFinished() { + return false; + } + + @Override + public void end(boolean interrupted) { + intake.setPower(0.0); + } + } + + private final class OuttakeCommand extends CommandBase { + OuttakeCommand() { + addRequirements(intake); + } + + @Override + public void initialize() { + intake.setPower(-1.0); + } + + @Override + public void execute() { + intake.setPower(-1.0); + } + + @Override + public boolean isFinished() { + return false; + } + + @Override + public void end(boolean interrupted) { + intake.setPower(0.0); + } + } + + private final class AutoAlignCommand extends CommandBase { + @Override + public void initialize() { + alignOffset = BusyWork.autoAlign(alignSeq++) * 0.02; + } + + @Override + public boolean isFinished() { + return true; + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS3.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS3.java new file mode 100644 index 0000000..2045295 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/solverslib/SolversS3.java @@ -0,0 +1,287 @@ +package com.aaravlabs.synapse.bench.solverslib; + +import com.aaravlabs.synapse.bench.shared.BusyWork; +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.SharedPidf; +import com.aaravlabs.synapse.bench.shared.SimCamera; +import com.aaravlabs.synapse.bench.shared.SyntheticVisionPipeline; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.seattlesolvers.solverslib.command.CommandBase; +import com.seattlesolvers.solverslib.command.CommandScheduler; +import com.seattlesolvers.solverslib.command.SubsystemBase; +import com.seattlesolvers.solverslib.gamepad.GamepadEx; +import com.seattlesolvers.solverslib.gamepad.GamepadKeys; + +/** + * S3 in idiomatic SolversLib: everything from S2 plus 30 Hz vision (~3 ms/frame + * in {@code VisionSubsystem.periodic()}) and a slow debug logger (15 ms per + * state update) — all serialized by the single {@code CommandScheduler.run()} + * pump. Expected to degrade like raw FTC. + */ +public final class SolversS3 implements PairRunner { + + private static final long HEADING_PERIOD_NANOS = 5_000_000L; + private static final long DRIVE_PERIOD_NANOS = 20_000_000L; + private static final long STATE_PERIOD_NANOS = 20_000_000L; + private static final long TELEMETRY_PERIOD_NANOS = 100_000_000L; + + private final World world; + private final Metrics metrics; + private final TaskMeter pumpMeter; + private final TaskMeter driveMeter; + private final TaskMeter liftMeter; + private final TaskMeter headingMeter; + private final TaskMeter visionMeter; + private final TaskMeter loggerMeter; + private final TaskMeter telemetryMeter; + + private final SharedPidf liftPidf = SharedPidf.forLift(); + private final SharedPidf headingPidf = SharedPidf.forHeading(); + + private GamepadEx gamepad1Ex; + private GamepadEx gamepad2Ex; + private DriveSubsystem drive; + private LiftSubsystem lift; + private HeadingSubsystem heading; + private IntakeSubsystem intake; + private VisionSubsystem vision; + private DebugLogSubsystem debugLog; + private TeleopDriveCommand teleopDrive; + private IntakeCommand intakeCommand; + private OuttakeCommand outtakeCommand; + private AutoAlignCommand autoAlign; + + private volatile double stickY; + private volatile double stickRightY; + private long alignSeq; + private long stateSeq; + private long lastTelemetry; + + private volatile boolean active; + private Thread thread; + + public SolversS3(World world) { + this.world = world; + this.metrics = world.metrics(); + this.pumpMeter = metrics.task("schedulerRun", 0); + this.driveMeter = metrics.task("drive", world.scenario().driveTargetHz()); + this.liftMeter = metrics.task("liftPidf", world.scenario().liftTargetHz()); + this.headingMeter = metrics.task("headingPidf", world.scenario().headingTargetHz()); + this.visionMeter = metrics.task("vision", SimCamera.HZ); + this.loggerMeter = metrics.task("logger", world.scenario().stateTargetHz()); + this.telemetryMeter = metrics.task("telemetry", world.scenario().telemetryTargetHz()); + } + + @Override + public void start() { + active = true; + CommandScheduler.getInstance().reset(); + + gamepad1Ex = new GamepadEx(world.gamepad1()); + gamepad2Ex = new GamepadEx(world.gamepad2()); + drive = new DriveSubsystem(); + lift = new LiftSubsystem(); + heading = new HeadingSubsystem(); + intake = new IntakeSubsystem(); + vision = new VisionSubsystem(); + debugLog = new DebugLogSubsystem(); + teleopDrive = new TeleopDriveCommand(); + intakeCommand = new IntakeCommand(); + outtakeCommand = new OuttakeCommand(); + autoAlign = new AutoAlignCommand(); + + CommandScheduler.getInstance().registerSubsystem(drive, lift, heading, intake, vision, debugLog); + CommandScheduler.getInstance().setDefaultCommand(drive, teleopDrive); + + gamepad1Ex.getGamepadButton(GamepadKeys.Button.RIGHT_BUMPER).toggleWhenPressed(intakeCommand); + gamepad2Ex.getGamepadButton(GamepadKeys.Button.LEFT_BUMPER).whileHeld(outtakeCommand); + gamepad1Ex.getGamepadButton(GamepadKeys.Button.A).whenPressed(autoAlign); + + thread = new Thread(this::pump, "solvers-s3"); + thread.setDaemon(true); + thread.start(); + } + + private void pump() { + CommandScheduler scheduler = CommandScheduler.getInstance(); + while (active) { + gamepad1Ex.readButtons(); + gamepad2Ex.readButtons(); + scheduler.run(); + pumpMeter.tick(); + metrics.countLoopIteration(); + long now = System.nanoTime(); + if (now - lastTelemetry >= TELEMETRY_PERIOD_NANOS) { + lastTelemetry = now; + telemetryMeter.tick(now); + world.telemetry().addData("lift", world.plant().liftPos); + world.telemetry().addData("heading", world.plant().heading); + world.telemetry().update(); + } + } + } + + @Override + public void stop() { + active = false; + if (thread != null) { + try { + thread.join(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + CommandScheduler.getInstance().reset(); + CommandScheduler.getInstance().clearButtons(); + } + + private final class DriveSubsystem extends SubsystemBase { + } + + private final class LiftSubsystem extends SubsystemBase { + private long last; + + @Override + public void periodic() { + long now = System.nanoTime(); + if (now - last < 10_000_000L) return; + last = now; + liftMeter.tick(now); + double target = world.setpoints().liftTargetAt(now); + world.liftMotor().setPower(liftPidf.update(world.plant().liftPos, target, now)); + } + } + + private final class HeadingSubsystem extends SubsystemBase { + private long last; + + @Override + public void periodic() { + long now = System.nanoTime(); + if (now - last < HEADING_PERIOD_NANOS) return; + last = now; + headingMeter.tick(now); + double target = world.setpoints().headingTargetAt(now) + world.plant().headingBias; + double corr = headingPidf.update(world.plant().heading, target, now); + world.leftMotor().setPower(stickY - corr); + world.rightMotor().setPower(stickRightY + corr); + } + } + + private final class IntakeSubsystem extends SubsystemBase { + void setPower(double power) { + world.intakeMotor().setPower(power); + } + } + + private final class VisionSubsystem extends SubsystemBase { + @Override + public void periodic() { + SimCamera.Frame frame = world.camera().pollFrame(); + if (frame == null) return; + visionMeter.tick(); + world.plant().headingBias = SyntheticVisionPipeline.process(frame.buf, frame.seq) * 0.002; + } + } + + private final class DebugLogSubsystem extends SubsystemBase { + private long last; + + @Override + public void periodic() { + long now = System.nanoTime(); + if (now - last < STATE_PERIOD_NANOS) return; + last = now; + stateSeq++; + loggerMeter.tick(now); + BusyWork.slowLog(stateSeq); + } + } + + private final class TeleopDriveCommand extends CommandBase { + private long last; + + TeleopDriveCommand() { + addRequirements(drive); + } + + @Override + public void execute() { + long now = System.nanoTime(); + if (now - last < DRIVE_PERIOD_NANOS) return; + last = now; + driveMeter.tick(now); + stickY = gamepad1Ex.getLeftY(); + // GamepadEx.getLeftY() is forward-positive (it negates the raw field) but + // getRightY() is raw-gamepad-signed — normalize so matched sticks drive + // matched wheels in every style's plant. + stickRightY = -gamepad1Ex.getRightY(); + } + + @Override + public boolean isFinished() { + return false; + } + } + + private final class IntakeCommand extends CommandBase { + IntakeCommand() { + addRequirements(intake); + } + + @Override + public void initialize() { + intake.setPower(1.0); + } + + @Override + public boolean isFinished() { + return false; + } + + @Override + public void end(boolean interrupted) { + intake.setPower(0.0); + } + } + + private final class OuttakeCommand extends CommandBase { + OuttakeCommand() { + addRequirements(intake); + } + + @Override + public void initialize() { + intake.setPower(-1.0); + } + + @Override + public void execute() { + intake.setPower(-1.0); + } + + @Override + public boolean isFinished() { + return false; + } + + @Override + public void end(boolean interrupted) { + intake.setPower(0.0); + } + } + + private final class AutoAlignCommand extends CommandBase { + @Override + public void initialize() { + world.plant().headingBias = BusyWork.autoAlign(alignSeq++) * 0.002; + } + + @Override + public boolean isFinished() { + return true; + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS0.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS0.java new file mode 100644 index 0000000..5095444 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS0.java @@ -0,0 +1,61 @@ +package com.aaravlabs.synapse.bench.synapse; + +import com.aaravlabs.synapse.LogSink; +import com.aaravlabs.synapse.Node; +import com.aaravlabs.synapse.Orchestrator; +import com.aaravlabs.synapse.annotation.SubscribedTo; +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.aaravlabs.synapse.ftc.GamepadAdaptor; + +/** + * S0 in idiomatic Synapse: one stick → one motor. {@link GamepadAdaptor} publishes + * the real stub {@code Gamepad} fields onto the bus; a {@code @SubscribedTo} + * handler hops to the real hardware thread through {@code hardware().run} and + * writes the device. Minimum nodes, real dispatch end to end. + */ +public final class SynapseS0 implements PairRunner { + + private final World world; + private final Metrics metrics; + private final TaskMeter driveMeter; + + private Orchestrator orchestrator; + + public SynapseS0(World world) { + this.world = world; + this.metrics = world.metrics(); + this.driveMeter = metrics.task("drive", 0); + } + + @Override + public void start() { + orchestrator = Orchestrator.create("bench-s0", LogSink.SILENT); + orchestrator.registerNode("drive", new DriveNode(orchestrator)); + GamepadAdaptor.attach(orchestrator, world.gamepad1(), "g1"); + } + + @Override + public void stop() { + if (orchestrator != null) { + orchestrator.close(); + orchestrator = null; + } + } + + private final class DriveNode extends Node { + DriveNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @SubscribedTo(topic = "g1/left_stick_y") + public void onStick(Float value) { + metrics.countLoopIteration(); + driveMeter.tick(); + double power = value; + orchestrator.hardware().run(() -> world.leftMotor().setPower(power)); + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS1.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS1.java new file mode 100644 index 0000000..76046b1 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS1.java @@ -0,0 +1,132 @@ +package com.aaravlabs.synapse.bench.synapse; + +import com.aaravlabs.synapse.LogSink; +import com.aaravlabs.synapse.Node; +import com.aaravlabs.synapse.Orchestrator; +import com.aaravlabs.synapse.annotation.OnHardwareThread; +import com.aaravlabs.synapse.annotation.RunPeriodically; +import com.aaravlabs.synapse.annotation.SubscribedTo; +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.aaravlabs.synapse.ftc.GamepadAdaptor; + +/** + * S1 in idiomatic Synapse: the common rookie TeleOp. Tank drive off the + * {@code GamepadAdaptor} axis topics, intake toggle on bumper rising/falling + * edges, servo on button press, telemetry publish at 10 Hz. + */ +public final class SynapseS1 implements PairRunner { + + private final World world; + private final Metrics metrics; + private final TaskMeter driveMeter; + private final TaskMeter telemetryMeter; + + private Orchestrator orchestrator; + + public SynapseS1(World world) { + this.world = world; + this.metrics = world.metrics(); + this.driveMeter = metrics.task("drive", 0); + this.telemetryMeter = metrics.task("telemetry", world.scenario().telemetryTargetHz()); + } + + @Override + public void start() { + orchestrator = Orchestrator.create("bench-s1", LogSink.SILENT); + orchestrator.registerNode("drive", new DriveNode(orchestrator)); + orchestrator.registerNode("intake", new IntakeNode(orchestrator)); + orchestrator.registerNode("servo", new ServoNode(orchestrator)); + orchestrator.registerNode("telemetry", new TelemetryNode(orchestrator)); + GamepadAdaptor.attach(orchestrator, world.gamepad1(), "g1"); + } + + @Override + public void stop() { + if (orchestrator != null) { + orchestrator.close(); + orchestrator = null; + } + } + + private final class DriveNode extends Node { + DriveNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @SubscribedTo(topic = "g1/left_stick_y") + public void onLeftY(Float value) { + metrics.countLoopIteration(); + driveMeter.tick(); + double power = value; + orchestrator.hardware().run(() -> world.leftMotor().setPower(power)); + } + + @SubscribedTo(topic = "g1/right_stick_y") + public void onRightY(Float value) { + metrics.countLoopIteration(); + double power = value; + orchestrator.hardware().run(() -> world.rightMotor().setPower(power)); + } + } + + private final class IntakeNode extends Node { + private boolean running; + + IntakeNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @SubscribedTo(topic = "g1/right_bumper/rising") + @OnHardwareThread + public void onPress(Boolean ignored) { + metrics.countLoopIteration(); + running = true; + world.intakeMotor().setPower(1.0); + } + + @SubscribedTo(topic = "g1/right_bumper/falling") + @OnHardwareThread + public void onRelease(Boolean ignored) { + metrics.countLoopIteration(); + running = false; + world.intakeMotor().setPower(0.0); + } + + boolean isRunning() { + return running; + } + } + + private final class ServoNode extends Node { + private boolean open; + + ServoNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @SubscribedTo(topic = "g1/x/rising") + @OnHardwareThread + public void onToggle(Boolean ignored) { + metrics.countLoopIteration(); + open = !open; + world.servo().setPosition(open ? 1.0 : 0.0); + } + } + + private final class TelemetryNode extends Node { + TelemetryNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @RunPeriodically(hz = 10) + public void publishTelemetry() { + metrics.countLoopIteration(); + telemetryMeter.tick(); + orchestrator.publish("telemetry/servo", world.servo().getPosition()); + world.telemetry().update(); + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS2.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS2.java new file mode 100644 index 0000000..8a69b69 --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS2.java @@ -0,0 +1,206 @@ +package com.aaravlabs.synapse.bench.synapse; + +import com.aaravlabs.synapse.LogSink; +import com.aaravlabs.synapse.Node; +import com.aaravlabs.synapse.Orchestrator; +import com.aaravlabs.synapse.annotation.OnHardwareThread; +import com.aaravlabs.synapse.annotation.RunnableAction; +import com.aaravlabs.synapse.annotation.RunPeriodically; +import com.aaravlabs.synapse.annotation.SubscribedTo; +import com.aaravlabs.synapse.bench.shared.BusyWork; +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.SharedPidf; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.aaravlabs.synapse.ftc.GamepadAdaptor; + +/** + * S2 in idiomatic Synapse: drive + intake + lift PIDF + outtake on two gamepads, + * per-pool rates (drive 50 Hz and lift PIDF 100 Hz on the hardware thread, + * auto-align and telemetry on the scheduler/callback pools). Intake and outtake + * are real {@code RunnableAction}s fired from button edges. + */ +public final class SynapseS2 implements PairRunner { + + private final World world; + private final Metrics metrics; + private final TaskMeter driveMeter; + private final TaskMeter liftMeter; + private final TaskMeter telemetryMeter; + private final TaskMeter alignMeter; + + private final SharedPidf liftPidf = SharedPidf.forLift(); + + private Orchestrator orchestrator; + + public SynapseS2(World world) { + this.world = world; + this.metrics = world.metrics(); + this.driveMeter = metrics.task("drive", world.scenario().driveTargetHz()); + this.liftMeter = metrics.task("liftPidf", world.scenario().liftTargetHz()); + this.telemetryMeter = metrics.task("telemetry", world.scenario().telemetryTargetHz()); + this.alignMeter = metrics.task("align", 0); + } + + @Override + public void start() { + orchestrator = Orchestrator.create("bench-s2", LogSink.SILENT); + orchestrator.registerNode("drive", new DriveNode(orchestrator)); + orchestrator.registerNode("lift", new LiftNode(orchestrator)); + orchestrator.registerNode("intake", new IntakeNode(orchestrator)); + orchestrator.registerNode("align", new AlignNode(orchestrator)); + orchestrator.registerNode("telemetry", new TelemetryNode(orchestrator)); + GamepadAdaptor.attach(orchestrator, world.gamepad1(), "g1"); + GamepadAdaptor.attach(orchestrator, world.gamepad2(), "g2"); + } + + @Override + public void stop() { + if (orchestrator != null) { + orchestrator.close(); + orchestrator = null; + } + } + + private final class DriveNode extends Node { + private volatile double leftY; + private volatile double leftX; + private volatile double rightY; + private volatile double align; + + DriveNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @SubscribedTo(topic = "g1/left_stick_y") + public void onLeftY(Float v) { + metrics.countLoopIteration(); + leftY = v; + } + + @SubscribedTo(topic = "g1/left_stick_x") + public void onLeftX(Float v) { + metrics.countLoopIteration(); + leftX = v; + } + + @SubscribedTo(topic = "g1/right_stick_y") + public void onRightY(Float v) { + metrics.countLoopIteration(); + rightY = v; + } + + @SubscribedTo(topic = "align/offset") + public void onAlign(Double v) { + metrics.countLoopIteration(); + align = v; + } + + @RunPeriodically(hz = 50, hardware = true) + public void drive() { + metrics.countLoopIteration(); + driveMeter.tick(); + double turn = leftX; + world.leftMotor().setPower(leftY + turn + align); + world.rightMotor().setPower(rightY - turn - align); + } + } + + private final class LiftNode extends Node { + LiftNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @RunPeriodically(hz = 100, hardware = true) + public void update() { + metrics.countLoopIteration(); + liftMeter.tick(); + long now = System.nanoTime(); + double target = world.setpoints().liftTargetAt(now); + double power = liftPidf.update(world.plant().liftPos, target, now); + world.liftMotor().setPower(power); + } + } + + private final class IntakeNode extends Node { + private boolean running; + private long actionSeq; + + IntakeNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @SubscribedTo(topic = "g1/right_bumper/rising") + public void onIntakeToggle(Boolean ignored) { + metrics.countLoopIteration(); + orchestrator.runAction("intake"); + } + + @SubscribedTo(topic = "g2/left_bumper/rising") + public void onOuttakeStart(Boolean ignored) { + metrics.countLoopIteration(); + orchestrator.runAction("outtake"); + } + + @SubscribedTo(topic = "g2/left_bumper/falling") + public void onOuttakeStop(Boolean ignored) { + metrics.countLoopIteration(); + orchestrator.runAction("intake-off"); + } + + @RunnableAction("intake") + public void intake() { + metrics.countLoopIteration(); + actionSeq++; + running = true; + orchestrator.hardware().run(() -> world.intakeMotor().setPower(1.0)); + } + + @RunnableAction("outtake") + public void outtake() { + metrics.countLoopIteration(); + actionSeq++; + running = false; + orchestrator.hardware().run(() -> world.intakeMotor().setPower(-1.0)); + } + + @RunnableAction("intake-off") + public void intakeOff() { + metrics.countLoopIteration(); + actionSeq++; + running = false; + orchestrator.hardware().run(() -> world.intakeMotor().setPower(0.0)); + } + } + + private final class AlignNode extends Node { + private long seq; + + AlignNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @SubscribedTo(topic = "g1/a/rising") + public void onAlignRequest(Boolean ignored) { + metrics.countLoopIteration(); + alignMeter.tick(); + double offset = BusyWork.autoAlign(seq++) * 0.02; + orchestrator.publish("align/offset", offset); + } + } + + private final class TelemetryNode extends Node { + TelemetryNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @RunPeriodically(hz = 10) + public void publishTelemetry() { + metrics.countLoopIteration(); + telemetryMeter.tick(); + orchestrator.publish("telemetry/lift", world.plant().liftPos); + world.telemetry().update(); + } + } +} diff --git a/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS3.java b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS3.java new file mode 100644 index 0000000..de30e2d --- /dev/null +++ b/benchmarks/src/main/java/com/aaravlabs/synapse/bench/synapse/SynapseS3.java @@ -0,0 +1,248 @@ +package com.aaravlabs.synapse.bench.synapse; + +import com.aaravlabs.synapse.LogSink; +import com.aaravlabs.synapse.Node; +import com.aaravlabs.synapse.Orchestrator; +import com.aaravlabs.synapse.annotation.RunnableAction; +import com.aaravlabs.synapse.annotation.RunPeriodically; +import com.aaravlabs.synapse.annotation.SubscribedTo; +import com.aaravlabs.synapse.bench.shared.BusyWork; +import com.aaravlabs.synapse.bench.shared.Metrics; +import com.aaravlabs.synapse.bench.shared.PairRunner; +import com.aaravlabs.synapse.bench.shared.SharedPidf; +import com.aaravlabs.synapse.bench.shared.SimCamera; +import com.aaravlabs.synapse.bench.shared.SyntheticVisionPipeline; +import com.aaravlabs.synapse.bench.shared.TaskMeter; +import com.aaravlabs.synapse.bench.shared.World; +import com.aaravlabs.synapse.ftc.GamepadAdaptor; + +/** + * S3 in idiomatic Synapse: everything from S2 plus 30 Hz vision on the callback + * pool, two PIDF loops on the hardware thread (lift 100 Hz, heading hold 200 Hz) + * and a slow debug logger subscribed to 50 Hz state updates on the callback pool. + * The classic "one slow consumer" is isolated from control here. + */ +public final class SynapseS3 implements PairRunner { + + private final World world; + private final Metrics metrics; + private final TaskMeter driveMeter; + private final TaskMeter liftMeter; + private final TaskMeter headingMeter; + private final TaskMeter visionMeter; + private final TaskMeter loggerMeter; + private final TaskMeter telemetryMeter; + private final TaskMeter alignMeter; + + private final SharedPidf liftPidf = SharedPidf.forLift(); + private final SharedPidf headingPidf = SharedPidf.forHeading(); + + private Orchestrator orchestrator; + + public SynapseS3(World world) { + this.world = world; + this.metrics = world.metrics(); + this.driveMeter = metrics.task("drive", world.scenario().driveTargetHz()); + this.liftMeter = metrics.task("liftPidf", world.scenario().liftTargetHz()); + this.headingMeter = metrics.task("headingPidf", world.scenario().headingTargetHz()); + this.visionMeter = metrics.task("vision", SimCamera.HZ); + this.loggerMeter = metrics.task("logger", world.scenario().stateTargetHz()); + this.telemetryMeter = metrics.task("telemetry", world.scenario().telemetryTargetHz()); + this.alignMeter = metrics.task("align", 0); + } + + @Override + public void start() { + orchestrator = Orchestrator.create("bench-s3", LogSink.SILENT); + orchestrator.registerNode("drive", new DriveNode(orchestrator)); + orchestrator.registerNode("lift", new LiftNode(orchestrator)); + orchestrator.registerNode("vision", new VisionNode(orchestrator)); + orchestrator.registerNode("logger", new DebugLogNode(orchestrator)); + orchestrator.registerNode("intake", new IntakeNode(orchestrator)); + orchestrator.registerNode("align", new AlignNode(orchestrator)); + orchestrator.registerNode("telemetry", new TelemetryNode(orchestrator)); + GamepadAdaptor.attach(orchestrator, world.gamepad1(), "g1"); + GamepadAdaptor.attach(orchestrator, world.gamepad2(), "g2"); + world.camera().setListener(frame -> orchestrator.publish("camera/frame", frame.copy())); + } + + @Override + public void stop() { + if (orchestrator != null) { + world.camera().setListener(null); + orchestrator.close(); + orchestrator = null; + } + } + + private final class DriveNode extends Node { + private volatile double leftY; + private volatile double rightY; + private volatile double cmdY; + private volatile double cmdRightY; + + DriveNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @SubscribedTo(topic = "g1/left_stick_y") + public void onLeftY(Float v) { + metrics.countLoopIteration(); + leftY = v; + } + + @SubscribedTo(topic = "g1/right_stick_y") + public void onRightY(Float v) { + metrics.countLoopIteration(); + rightY = v; + } + + @SubscribedTo(topic = "align/offset") + public void onAlign(Double v) { + metrics.countLoopIteration(); + world.plant().headingBias = v; + } + + @RunPeriodically(hz = 50, hardware = true) + public void sampleSticks() { + metrics.countLoopIteration(); + driveMeter.tick(); + cmdY = leftY; + cmdRightY = rightY; + } + + @RunPeriodically(hz = 200, hardware = true) + public void headingHold() { + metrics.countLoopIteration(); + headingMeter.tick(); + long now = System.nanoTime(); + double target = world.setpoints().headingTargetAt(now) + world.plant().headingBias; + double corr = headingPidf.update(world.plant().heading, target, now); + world.leftMotor().setPower(cmdY - corr); + world.rightMotor().setPower(cmdRightY + corr); + } + } + + private final class LiftNode extends Node { + LiftNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @RunPeriodically(hz = 100, hardware = true) + public void update() { + metrics.countLoopIteration(); + liftMeter.tick(); + long now = System.nanoTime(); + double target = world.setpoints().liftTargetAt(now); + double power = liftPidf.update(world.plant().liftPos, target, now); + world.liftMotor().setPower(power); + } + } + + private final class VisionNode extends Node { + VisionNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @SubscribedTo(topic = "camera/frame") + public void onFrame(SimCamera.Frame frame) { + metrics.countLoopIteration(); + visionMeter.tick(); + double offset = SyntheticVisionPipeline.process(frame.buf, frame.seq) * 0.002; + orchestrator.publish("align/offset", offset); + } + } + + private final class DebugLogNode extends Node { + DebugLogNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @SubscribedTo(topic = "robot/state") + public void onState(Long seq) { + metrics.countLoopIteration(); + loggerMeter.tick(); + BusyWork.slowLog(seq); + } + } + + private final class IntakeNode extends Node { + IntakeNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @SubscribedTo(topic = "g1/right_bumper/rising") + public void onIntakeToggle(Boolean ignored) { + metrics.countLoopIteration(); + orchestrator.runAction("intake"); + } + + @SubscribedTo(topic = "g2/left_bumper/rising") + public void onOuttakeStart(Boolean ignored) { + metrics.countLoopIteration(); + orchestrator.runAction("outtake"); + } + + @SubscribedTo(topic = "g2/left_bumper/falling") + public void onOuttakeStop(Boolean ignored) { + metrics.countLoopIteration(); + orchestrator.runAction("intake-off"); + } + + @RunnableAction("intake") + public void intake() { + metrics.countLoopIteration(); + orchestrator.hardware().run(() -> world.intakeMotor().setPower(1.0)); + } + + @RunnableAction("outtake") + public void outtake() { + metrics.countLoopIteration(); + orchestrator.hardware().run(() -> world.intakeMotor().setPower(-1.0)); + } + + @RunnableAction("intake-off") + public void intakeOff() { + metrics.countLoopIteration(); + orchestrator.hardware().run(() -> world.intakeMotor().setPower(0.0)); + } + } + + private final class AlignNode extends Node { + private long seq; + + AlignNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @SubscribedTo(topic = "g1/a/rising") + public void onAlignRequest(Boolean ignored) { + metrics.countLoopIteration(); + alignMeter.tick(); + double offset = BusyWork.autoAlign(seq++) * 0.002; + orchestrator.publish("align/offset", offset); + } + } + + private final class TelemetryNode extends Node { + private long stateSeq; + + TelemetryNode(Orchestrator orchestrator) { + super(orchestrator); + } + + @RunPeriodically(hz = 50) + public void publishState() { + metrics.countLoopIteration(); + orchestrator.publish("robot/state", ++stateSeq); + } + + @RunPeriodically(hz = 10) + public void publishTelemetry() { + metrics.countLoopIteration(); + telemetryMeter.tick(); + orchestrator.publish("telemetry/lift", world.plant().liftPos); + world.telemetry().update(); + } + } +} diff --git a/settings.gradle b/settings.gradle index 38c78ba..6b0af16 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,6 +4,8 @@ plugins { rootProject.name = 'synapse' +include 'benchmarks' + // Publishes to Maven Central via the Central Portal publisher API // (https://central.sonatype.org/publish/publish-portal-api/). The legacy // OSSRH staging API compatibility shim used by gradle-nexus/publish-plugin