diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index bdfa2a5..c417fb0 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -27,6 +27,7 @@ interface NavLink {
const links: NavLink[] = [
{ to: '/product', label: 'How it works' },
{ to: '/why', label: 'Why Statewave' },
+ { to: '/benchmarks', label: 'Benchmarks' },
{ to: '/use-cases', label: 'Use Cases' },
{ to: '/connectors', label: 'Connectors' },
{ to: '/developers', label: 'Developers' },
diff --git a/src/content/blog/agent-memory-provenance-audit-trails.mdx b/src/content/blog/agent-memory-provenance-audit-trails.mdx
index 3d0be75..447f845 100644
--- a/src/content/blog/agent-memory-provenance-audit-trails.mdx
+++ b/src/content/blog/agent-memory-provenance-audit-trails.mdx
@@ -4,6 +4,8 @@ slug: agent-memory-provenance-audit-trails
date: "2026-05-25T09:00:00+02:00"
description: When an AI agent answers from memory, you need to know where it learned the answer. This post explains the provenance model in Statewave — what we store, what it costs, and what it enables for compliance, debugging, and trust.
author: Statewave team
+image: /images/blog/agent-memory-provenance-audit-trails/og.png
+headerImage: /images/blog/agent-memory-provenance-audit-trails/header.png
tags:
- provenance
- audit
diff --git a/src/content/blog/ai-agent-memory-vs-rag.mdx b/src/content/blog/ai-agent-memory-vs-rag.mdx
index 5c37271..cbd2d3c 100644
--- a/src/content/blog/ai-agent-memory-vs-rag.mdx
+++ b/src/content/blog/ai-agent-memory-vs-rag.mdx
@@ -4,6 +4,8 @@ slug: ai-agent-memory-vs-rag
date: "2026-05-25T09:00:00+02:00"
description: Retrieval-augmented generation and an agent memory runtime overlap at the embedding store, but they answer different questions. Here's where the two diverge and which to reach for.
author: Statewave team
+image: /images/blog/ai-agent-memory-vs-rag/og.png
+headerImage: /images/blog/ai-agent-memory-vs-rag/header.png
tags:
- memory
- rag
diff --git a/src/content/blog/episodic-vs-semantic-memory.mdx b/src/content/blog/episodic-vs-semantic-memory.mdx
index b7c8d13..4d521df 100644
--- a/src/content/blog/episodic-vs-semantic-memory.mdx
+++ b/src/content/blog/episodic-vs-semantic-memory.mdx
@@ -4,6 +4,8 @@ slug: episodic-vs-semantic-memory
date: "2026-08-07T09:00:00+02:00"
description: Episodic memory stores what happened; semantic memory stores what's true. Here's how AI agents use each, when to convert one to the other, and how to store it.
author: Statewave team
+image: /images/blog/episodic-vs-semantic-memory/og.png
+headerImage: /images/blog/episodic-vs-semantic-memory/header.png
tags:
- memory
- episodic-memory
diff --git a/src/content/blog/persistent-memory-for-ai-support-agents.mdx b/src/content/blog/persistent-memory-for-ai-support-agents.mdx
index a484a0f..24f65db 100644
--- a/src/content/blog/persistent-memory-for-ai-support-agents.mdx
+++ b/src/content/blog/persistent-memory-for-ai-support-agents.mdx
@@ -4,6 +4,8 @@ slug: persistent-memory-for-ai-support-agents
date: "2026-05-25T09:00:00+02:00"
description: A practical walkthrough of giving a support agent durable memory of customers across sessions — what to record, how to compile it, how to retrieve it, and what to expect from the support workflow benchmark.
author: Statewave team
+image: /images/blog/persistent-memory-for-ai-support-agents/og.png
+headerImage: /images/blog/persistent-memory-for-ai-support-agents/header.png
tags:
- support
- tutorial
diff --git a/src/content/blog/self-hosted-memory-postgres-pgvector.mdx b/src/content/blog/self-hosted-memory-postgres-pgvector.mdx
index 7aab251..8e8e6b9 100644
--- a/src/content/blog/self-hosted-memory-postgres-pgvector.mdx
+++ b/src/content/blog/self-hosted-memory-postgres-pgvector.mdx
@@ -4,6 +4,8 @@ slug: self-hosted-memory-postgres-pgvector
date: "2026-05-25T09:00:00+02:00"
description: Why Statewave is Postgres-only by design, what pgvector buys you over a dedicated vector database, and what the deployment shape looks like in production.
author: Statewave team
+image: /images/blog/self-hosted-memory-postgres-pgvector/og.png
+headerImage: /images/blog/self-hosted-memory-postgres-pgvector/header.png
tags:
- postgres
- pgvector
diff --git a/src/index.css b/src/index.css
index 6e99659..0513b6d 100644
--- a/src/index.css
+++ b/src/index.css
@@ -79,7 +79,7 @@
*
* Border opacity stays at 0.06: against #fafbfc the contrast is gentler
* than against #fff but still defines edges cleanly. Text-primary stays at
- * slate-900 — the issue was the bg, not the foreground.
+ * slate-900. The issue was the bg, not the foreground.
*/
[data-theme="light"] {
--theme-surface-0: #FAFBFD;
@@ -97,7 +97,7 @@
--theme-selection: rgba(99, 102, 241, 0.15);
--theme-hero-particle: #4f46e5;
--theme-hero-opacity: 0.35;
- /* Slightly stronger in light mode — the near-white surface needs the
+ /* Slightly stronger in light mode: the near-white surface needs the
texture more than the dark theme does. */
--theme-hero-dot: rgba(2, 6, 23, 0.06);
--theme-hero-overlay-from: rgba(250, 251, 252, 0.5);
@@ -146,6 +146,16 @@
--viz-green: var(--color-success);
--viz-amber: #F2D16B;
+ /* Benchmark chart series (/benchmarks scoreboard, /vs/mem0). Categorical:
+ * each hue identifies one system, never its rank. Dark-mode steps are
+ * chosen against the --theme-surface-1 card (#0C1833), not flipped from
+ * the light set, and validated for the OKLCH lightness band, chroma
+ * floor, adjacent-pair CVD separation (worst pair ΔE 14.3 deutan, target
+ * >= 8), and 3:1 contrast. Re-run the validator if you change these. */
+ --series-statewave: #5E8CFF;
+ --series-mem0-cloud: #CC7A0D;
+ --series-mem0-oss: #12A594;
+
--viz-code-text: #C7D6F2;
--viz-code-muted: #7385A8;
--viz-code-keyword: #8FA5FF;
@@ -183,6 +193,12 @@
--viz-green: #059669;
--viz-amber: #B45309;
+ /* Benchmark chart series, light-mode steps, validated against the
+ * --theme-surface-1 card (#F2F6FC). See the dark block for the contract. */
+ --series-statewave: #4A72E8;
+ --series-mem0-cloud: #B86A08;
+ --series-mem0-oss: #0D9488;
+
--viz-code-text: #334155;
--viz-code-muted: #64748B;
--viz-code-keyword: #4F46E5;
@@ -198,7 +214,7 @@ html {
scroll-behavior: smooth;
/* Anchor links (e.g. /product#privacy) and programmatic scrolls land
* below the 60px fixed navbar instead of being clipped by it. No snap
- * here — proximity-based scroll-snap fought trackpad momentum and made
+ * here. Proximity-based scroll-snap fought trackpad momentum and made
* the page feel sticky. */
scroll-padding-top: 80px;
/* Prevents iOS Safari bouncing the body (and its rubber-band background)
@@ -290,7 +306,7 @@ html[data-scroll-lock="true"] body {
* element with a `transition` on a property tied to those variables (body
* bg, card backgrounds, borders) starts an interpolation. The custom
* scrollbar thumb is alpha-tinted, so as the body background transitions
- * underneath it, the thumb appears to change color through wrong shades —
+ * underneath it, the thumb appears to change color through wrong shades,
* reading as "flicker."
*
* Standard fix: ThemeProvider toggles `theme-switching` on for one
@@ -345,7 +361,7 @@ textarea:disabled {
cursor: not-allowed;
}
-/* Hero hint chip — attention bounce + subtle scale pulse */
+/* Hero hint chip: attention bounce + subtle scale pulse */
@keyframes heroHintBounce {
0%,
@@ -396,7 +412,7 @@ textarea:disabled {
z-index: 1;
}
-/* For elements that already have a corner radius — keep theirs. */
+/* For elements that already have a corner radius, keep theirs. */
.tour-pulse--inherit-radius {
border-radius: inherit;
}
@@ -444,7 +460,7 @@ textarea:disabled {
*
* Theme-switch flicker fix: the scrollbar properties live on `html` (they're
* inherited, so a global `*` selector is wasteful and causes unnecessary
- * style recalc on every theme swap). The thumb has NO transition either —
+ * style recalc on every theme swap). The thumb has NO transition either,
* WebKit pseudo-element transitions are unreliable and produce a visible
* flash when interpolating between the dark and light alpha values during a
* theme change. Snappier is better here; the body's bg fade carries the
@@ -633,6 +649,17 @@ h6 {
opacity: .45;
}
+/* For sections that bring their own color — the /benchmarks scoreboard and
+ /vs/mem0 comparison charts run saturated categorical series hues — so the
+ ambient wash stays behind the data instead of washing over it. */
+.section-glow--soft {
+ opacity: .4;
+}
+
+[data-theme="light"] .section-glow--soft {
+ opacity: .2;
+}
+
.install-cmd-field {
box-shadow: 0 12px 35px rgba(0, 0, 0, .14);
}
@@ -641,6 +668,17 @@ h6 {
box-shadow: 0 6px 18px rgba(30, 41, 59, .06);
}
+/* For sections that bring their own color — the /benchmarks scoreboard runs
+ three saturated categorical hues — so the ambient wash stays behind the data
+ instead of washing over it. */
+.section-glow--soft {
+ opacity: .4;
+}
+
+[data-theme="light"] .section-glow--soft {
+ opacity: .2;
+}
+
/* ==========================================================================
Home page
========================================================================== */
diff --git a/src/lib/blog.ts b/src/lib/blog.ts
index b842650..5adcf3b 100644
--- a/src/lib/blog.ts
+++ b/src/lib/blog.ts
@@ -28,6 +28,12 @@ export interface BlogPostFrontmatter {
* "/blog/my-post/cover.png"). Optional — falls back to the site-wide
* default OG image (DEFAULT_OG_IMAGE in lib/seo-meta.ts) when unset. */
image?: string
+ /** Site-relative path to this post's banner image, shown inline at the
+ * top of the post itself (below the title card, above the article
+ * body). Separate from `image` (the OG/social-share card) because the
+ * two can reasonably differ — this one renders on the page, `image`
+ * never does. Optional — posts without one just skip the banner. */
+ headerImage?: string
}
export interface BlogPost {
diff --git a/src/lib/seo-meta.ts b/src/lib/seo-meta.ts
index 6d897e2..d117495 100644
--- a/src/lib/seo-meta.ts
+++ b/src/lib/seo-meta.ts
@@ -41,10 +41,13 @@ export type RouteKey =
| '/'
| '/product'
| '/why'
+ | '/benchmarks'
| '/use-cases'
| '/use-cases/multi-agent-memory'
| '/use-cases/personal-assistant-memory'
| '/use-cases/multi-agent-shared-context'
+ | '/use-cases/grounded-shop-assistant'
+ | '/vs/mem0'
| '/connectors'
| '/developers'
| '/about'
@@ -58,10 +61,13 @@ export const PUBLIC_ROUTES: readonly RouteKey[] = [
'/',
'/product',
'/why',
+ '/benchmarks',
'/use-cases',
'/use-cases/multi-agent-memory',
'/use-cases/personal-assistant-memory',
'/use-cases/multi-agent-shared-context',
+ '/use-cases/grounded-shop-assistant',
+ '/vs/mem0',
'/connectors',
'/developers',
'/about',
@@ -117,6 +123,18 @@ export const PAGE_META: Record = {
priority: 0.8,
changefreq: 'monthly',
},
+ '/benchmarks': {
+ title: 'Statewave Benchmarks: LoCoMo & LongMemEval vs. mem0',
+ description:
+ "Statewave beats mem0 OSS on both LoCoMo and LongMemEval, run on mem0's own harness at gpt-4o, and edges the paid mem0 cloud tier while staying free and self-hosted. Apache-2.0, fully reproducible.",
+ breadcrumbLabel: 'Benchmarks',
+ // Long-form editorial with a methodology and a scope section, not a
+ // landing page: 'article' is what tells social scrapers and answer
+ // engines to treat it as the writeup it is.
+ ogType: 'article',
+ priority: 0.9,
+ changefreq: 'monthly',
+ },
'/use-cases': {
title: 'Use Cases — Memory for Support Agents, Copilots, and AI Apps',
description:
@@ -153,6 +171,24 @@ export const PAGE_META: Record = {
priority: 0.7,
changefreq: 'monthly',
},
+ '/use-cases/grounded-shop-assistant': {
+ title: 'Grounded Shop Assistant — Cited Answers with a Closed-Loop Coverage Gap',
+ description:
+ 'A shopper and ops assistant pair that answers strictly from retrieved evidence, validates every citation against what was actually retrieved, and turns ungrounded questions into coverage-gap episodes the content team resolves.',
+ breadcrumbLabel: 'Grounded Shop Assistant',
+ ogType: 'article',
+ priority: 0.7,
+ changefreq: 'monthly',
+ },
+ '/vs/mem0': {
+ title: 'Statewave vs. Mem0 — Deterministic Context vs. Ranked Retrieval',
+ description:
+ 'How Statewave compares to Mem0: deterministic, token-bounded context assembly with policy enforcement and integrity-hashed receipts vs. ranked similarity retrieval — plus LoCoMo and LongMemEval benchmark results measured on Mem0’s own harness.',
+ breadcrumbLabel: 'vs Mem0',
+ ogType: 'article',
+ priority: 0.7,
+ changefreq: 'monthly',
+ },
'/connectors': {
title: 'Connectors — Feed GitHub, Docs, Slack, and More into Statewave Memory',
description:
diff --git a/src/lib/use-case-pages.ts b/src/lib/use-case-pages.ts
index c521a8a..2adbe60 100644
--- a/src/lib/use-case-pages.ts
+++ b/src/lib/use-case-pages.ts
@@ -30,6 +30,11 @@ export const USE_CASE_DETAIL_PAGES: readonly UseCaseDetailPage[] = [
label: 'Shared Context',
path: '/use-cases/multi-agent-shared-context',
},
+ {
+ slug: 'grounded-shop-assistant',
+ label: 'Grounded Shop Assistant',
+ path: '/use-cases/grounded-shop-assistant',
+ },
] as const
export function useCaseDetailPage(
diff --git a/src/pages/BenchmarksPage.tsx b/src/pages/BenchmarksPage.tsx
new file mode 100644
index 0000000..ef84a58
--- /dev/null
+++ b/src/pages/BenchmarksPage.tsx
@@ -0,0 +1,2094 @@
+import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
+import {
+ animate,
+ motion,
+ useMotionValue,
+ useReducedMotion,
+ useTransform,
+ type Transition,
+ type Variants,
+} from 'framer-motion'
+import { Section } from '../components/Section'
+import { Heading } from '../components/Heading'
+import { Button } from '../components/Button'
+import { CodeCopyButton } from '../components/CodeCopyButton'
+import { usePageSEO } from '../lib/seo'
+
+/*
+ * /benchmarks: head-to-head LoCoMo + LongMemEval results against mem0,
+ * run on mem0's own eval harness (statewave-memory-benchmarks, a fork of
+ * mem0ai/memory-benchmarks). Same repo and claims already cited on
+ * /about, /press, and /whitepaper; this page is the detailed writeup.
+ *
+ * gpt-4o is used as a shared answerer + judge across all three backends so
+ * the memory layer is the only variable, not a reproduction of mem0's
+ * published gpt-5 + Qwen figures.
+ */
+
+const REPO_URL = 'https://github.com/smaramwbc/statewave-memory-benchmarks'
+const UPSTREAM_REPO = 'https://github.com/mem0ai/memory-benchmarks'
+/** Diffs the fork against the upstream repo it forked from (not against itself). */
+const UPSTREAM_DIFF_URL = `${UPSTREAM_REPO}/compare/main...smaramwbc:statewave-memory-benchmarks:main`
+
+/* ─── Data ──────────────────────────────────────────────────────────────────
+ * Every number rendered on this page derives from SYSTEMS. Deltas, bar
+ * lengths, and prose figures are computed, never retyped, so correcting a
+ * score here corrects it everywhere.
+ */
+
+type SeriesKey = 'statewave' | 'mem0-cloud' | 'mem0-oss'
+type Metric = 'locomo' | 'lme'
+
+interface SystemScore {
+ key: SeriesKey
+ name: string
+ /** Licensing/hosting category, shown in the chart legend. */
+ tag: string
+ locomo: number
+ lme: number
+}
+
+const SYSTEMS: readonly SystemScore[] = [
+ { key: 'statewave', name: 'Statewave', tag: 'OSS · self-hosted', locomo: 0.905, lme: 0.967 },
+ { key: 'mem0-cloud', name: 'mem0 cloud', tag: 'paid · closed', locomo: 0.899, lme: 0.933 },
+ { key: 'mem0-oss', name: 'mem0 OSS', tag: 'OSS · self-hosted', locomo: 0.866, lme: 0.833 },
+] as const
+
+const [STATEWAVE, MEM0_CLOUD, MEM0_OSS] = SYSTEMS
+
+/* Categorical series colors. One hue per system (identity, never rank), held
+ * in CSS vars so light/dark each get their own validated step. See the
+ * `--series-*` block in index.css for the palette contract. */
+const SERIES_COLOR: Record = {
+ statewave: 'var(--series-statewave)',
+ 'mem0-cloud': 'var(--series-mem0-cloud)',
+ 'mem0-oss': 'var(--series-mem0-oss)',
+}
+
+/* `grade` is the old "robust signal" / "directional only" prose promoted to a
+ * colored badge: how much weight a reader should put on the panel is a signal,
+ * so it's carried by a token instead of a line of mono text that wrapped. */
+
+/* Bars are flat and unglowed, on purpose. They used to be a left-to-right ramp
+ * of the series hue with a halo on the leader; that put the first half of every
+ * bar in a color its own legend swatch never shows, and left the panel with
+ * three separate bloom layers competing with the numbers. Emphasis is carried
+ * by the rank chip, type weight, and the leader datum line instead — none of
+ * which cost the reader any accuracy. */
+
+const METRICS: Record = {
+ locomo: { label: 'LoCoMo', n: 'n = 1,540', grade: 'Robust signal', tone: 'success' },
+ lme: { label: 'LongMemEval', n: 'n = 30', grade: 'Directional', tone: 'amber' },
+}
+
+const GRADE_TONE: Record<'success' | 'amber', string> = {
+ success:
+ 'border-[color:var(--color-success)]/30 bg-[color:var(--color-success)]/10 text-success',
+ amber:
+ 'border-[color:var(--viz-amber)]/35 bg-[color:var(--viz-amber)]/10 text-[color:var(--viz-amber)]',
+}
+
+const fmt = (n: number) => n.toFixed(3)
+const delta = (a: number, b: number) => `+${(a - b).toFixed(3)}`
+
+/* Axis scale. The zoomed 0.80–1.00 view is the default because at full
+ * scale three scores within 0.07 of each other are visually identical, but
+ * a zoomed axis is also the classic way to inflate a small lead, so the
+ * reader can flip to the honest 0–1.00 view and judge for themselves. */
+type Scale = 'zoom' | 'full'
+
+const AXIS: Record = {
+ zoom: { min: 0.8, max: 1.0, ticks: [0.8, 0.9, 1.0], label: '0.80–1.00' },
+ full: { min: 0, max: 1.0, ticks: [0, 0.25, 0.5, 0.75, 1.0], label: '0–1.00' },
+}
+
+const axisPct = (v: number, scale: Scale) => {
+ const { min, max } = AXIS[scale]
+ return ((v - min) / (max - min)) * 100
+}
+
+/* Chart motion vocabulary. Two transitions, and which one runs says what
+ * happened: REVEAL is the once-per-page entrance, RESPONSE is what a bar does
+ * when the reader flips the axis. Replaying the entrance on every axis flip —
+ * stagger delays and all — was the thing that made the toggle feel broken. */
+const BAR_REVEAL: Transition = { duration: 0.8, ease: [0.16, 1, 0.3, 1] }
+const BAR_RESPONSE: Transition = { duration: 0.5, ease: [0.4, 0, 0.2, 1] }
+const ROW_STAGGER = 0.08
+/* Beats measured from the start of the panel's reveal, so the sequence reads
+ * ranking → datum → margins rather than everything arriving at once. */
+const DATUM_AT = 0.8
+const CONNECTOR_AT = 0.95
+const DELTA_LABEL_AT = 1.2
+
+/* ─── Content ───────────────────────────────────────────────────────────── */
+
+/* Each fix names the backend it helps by SeriesKey rather than by a typed-out
+ * label, so the card carries the same hue and the same name the chart already
+ * uses for that system instead of a second, hand-maintained vocabulary. */
+const FIXES: readonly { helps: SeriesKey; title: string; body: string }[] = [
+ {
+ helps: 'mem0-cloud',
+ title: 'Cloud v3 add URL',
+ body: 'Without it cloud ingested nothing. This fix is what lets it score at all.',
+ },
+ {
+ helps: 'mem0-oss',
+ title: 'OSS v2 search-filter',
+ body: 'Corrected so queries return the intended memories, not an over-filtered subset.',
+ },
+ {
+ helps: 'mem0-oss',
+ title: 'OSS date grounding',
+ body: 'Grounds the session date back into message content so time-anchored questions resolve.',
+ },
+]
+
+const systemName = (key: SeriesKey) => SYSTEMS.find((s) => s.key === key)?.name ?? key
+
+const CLAIMS = [
+ `Beats its open-source peer on both: LoCoMo ${delta(STATEWAVE.locomo, MEM0_OSS.locomo)}, LongMemEval ${delta(STATEWAVE.lme, MEM0_OSS.lme)}.`,
+ `Edges the paid cloud tier too, ${fmt(STATEWAVE.locomo)} vs ${fmt(MEM0_CLOUD.locomo)}, while staying free and self-hosted.`,
+ 'Holds against mem0’s best config; our client fixes are applied to their backends, not withheld.',
+ 'Reproduces from one public, Apache-2.0 code path with mem0’s judge unchanged.',
+]
+
+const NON_CLAIMS = [
+ 'Not a reproduction of mem0’s published gpt-5 + Qwen figures.',
+ 'No category-level or per-type breakdowns beyond the aggregate scores.',
+ 'No long-context BEAM score. The harness runs, but no number is claimed.',
+ 'LongMemEval (n=30) is directional, not a significance test.',
+]
+
+/* The governance bridge is a comparison, so it is stored as one. Icons were
+ * dropped with the card grid: a shield beside "Access policies" carried no
+ * information the title didn't already carry. */
+const GOVERNANCE_ROWS = [
+ {
+ title: 'Access policies',
+ body: 'Scope what each agent and tenant can read or write, enforced at retrieval time.',
+ },
+ {
+ title: 'Sensitivity labels',
+ body: 'Tag memories by sensitivity and keep classified content out of the wrong context.',
+ },
+ {
+ title: 'Tamper-evident audit',
+ body: 'Every write and read leaves a verifiable receipt you can replay after the fact.',
+ },
+ {
+ title: 'Provenance',
+ body: 'Trace any retrieved memory back to the exact source turn it came from.',
+ },
+]
+
+/* Retrieval half of the same table. Scores read from SYSTEMS so this can
+ * never drift from the scoreboard above; mem0 cloud is the paid tier, i.e.
+ * their strongest showing, which is the fair column to sit beside. */
+const RETRIEVAL_ROWS = [
+ {
+ title: 'LoCoMo',
+ body: 'Aggregate score on mem0’s own harness, gpt-4o answerer and judge.',
+ mem0: fmt(MEM0_CLOUD.locomo),
+ statewave: fmt(STATEWAVE.locomo),
+ },
+ {
+ title: 'LongMemEval',
+ body: 'Same harness, same judge, 30-question matched subset.',
+ mem0: fmt(MEM0_CLOUD.lme),
+ statewave: fmt(STATEWAVE.lme),
+ },
+]
+
+const FAQS = [
+ {
+ q: 'Why gpt-4o and not gpt-5?',
+ a: "mem0's headline figures use gpt-5 + Qwen. We standardized on gpt-4o as a shared answerer and judge across all three backends so the only variable is the memory layer. A cleaner comparison, not a reproduction of their numbers.",
+ },
+ {
+ q: "Isn't n=30 too small on LongMemEval?",
+ a: "Yes, treat it as directional. It's a matched 30-question subset with wide error bars. LoCoMo at n=1,540 is the robust signal, and Statewave leads both.",
+ },
+ {
+ q: 'Did you tune Statewave and handicap mem0?',
+ a: "The opposite. Three client fixes we shipped raise mem0's own scores; without the cloud v3 add-URL fix, cloud ingested nothing. We beat their best config, not a strawman.",
+ },
+ {
+ q: 'Why run on mem0’s harness instead of your own?',
+ a: "So the framing isn't ours to bend. The judge and scoring code are unchanged from upstream; only the memory backend swaps. You can diff the fork against upstream line by line.",
+ },
+ {
+ q: "It's one run. Can I trust it?",
+ a: "Don't take our word for it. The harness is Apache-2.0 and copy-pasteable: clone it and re-run every number yourself. LoCoMo's margin is stable across runs.",
+ },
+ {
+ q: 'What is Statewave, exactly?',
+ a: 'An open-source memory runtime for AI agents: the layer that ingests, stores, and retrieves what an agent needs to remember. These benchmarks measure that retrieval quality head-to-head.',
+ },
+]
+
+const NAV_SECTIONS = [
+ { id: 'results', label: 'Scoreboard' },
+ { id: 'methodology', label: 'Methodology' },
+ { id: 'run', label: 'Run it' },
+ { id: 'scope', label: 'Scope' },
+ { id: 'governance', label: 'Governance' },
+ { id: 'faq', label: 'FAQ' },
+]
+
+/* ─── Shared motion ─────────────────────────────────────────────────────────
+ * Same cadence as the homepage hero stagger (staggerChildren 0.12) so the
+ * page's motion reads as part of the site rather than its own dialect.
+ */
+const STAGGER: Variants = {
+ hidden: {},
+ show: { transition: { staggerChildren: 0.09, delayChildren: 0.04 } },
+}
+const FADE_UP: Variants = {
+ hidden: { opacity: 0, y: 16 },
+ show: { opacity: 1, y: 0, transition: { duration: 0.5, ease: [0.22, 0.61, 0.36, 1] } },
+}
+
+/** Card grid that staggers its children in on first scroll into view. */
+function StaggerGrid({ className, children }: { className: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+/**
+ * Panel card with the site's standard hover-lift treatment (same recipe as
+ * the /about principle cards). Padding is deliberately not set here; callers
+ * pass their own, since two utilities of equal specificity would otherwise
+ * resolve by stylesheet order rather than by class-attribute order.
+ */
+function LiftCard({ className = '', children }: { className?: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function BenchmarksPage() {
+ // Title, description, og:type and the breadcrumb all come from the route
+ // table in lib/seo-meta.ts. Passing literals here instead left /benchmarks
+ // out of PUBLIC_ROUTES, which is what generates sitemap.xml — the page read
+ // as correct while being absent from the sitemap entirely.
+ usePageSEO()
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+/* ─── Hero ──────────────────────────────────────────────────────────────────
+ * Ambient treatment mirrors the homepage hero: a radial brand glow, a masked
+ * dot-grid, and a fade into surface-0 at the bottom. All three layers read
+ * from theme vars, so light mode needs no separate handling.
+ */
+
+function Hero() {
+ return (
+
+
+
+
+
+
+
+
+ {/* The badge is uppercase site-wide, but uppercasing `mem0` renders
+ MEM0 — at 11px with 0.18em tracking the zero reads as a letter O,
+ so the whole claim lands as "memo's own harness". The brand token
+ opts out of the transform; the rest of the badge keeps the
+ site pattern. */}
+ Open source · Apache-2.0 · mem0's own harness
+
+
+ {/* The non-breaking space keeps the article bound to its noun. At
+ `max-w-[16ch]` the balancer broke this as "Statewave tops a /
+ memory benchmark", stranding "a" at the end of the first line. */}
+
+ Statewave tops a memory benchmark{' '}
+ we didn't write.
+
+
+ {/* Deliberately smaller than the site's usual hero subhead: the h1 is
+ already three lines, and at 18px this paragraph competed with it
+ instead of supporting it. */}
+
+ An open-source, self-hosted memory runtime for AI agents. It clears mem0 OSS on
+ both LoCoMo and LongMemEval, run on mem0's own harness at{' '}
+ gpt-4o, same eval loop,
+ their judge unchanged, and edges the paid mem0 cloud tier too.
+
+
+
+
+
+
+
+
+
+
+ Not a reproduction of mem0's published gpt-5 + Qwen figures.
+
+
+
+ )
+}
+
+/** Compact "who leads" summary: the headline result before the full chart. */
+function HeroLeaderboard() {
+ return (
+
+ {SYSTEMS.map((s) => {
+ const lead = s.key === 'statewave'
+ return (
+
+
+
+
+ {s.name}
+
+
+ {/* Label left, figure right. Leading with the numbers put the two
+ metric labels at different x-positions in every card, because
+ the LoCoMo figure is set larger than the LongMemEval one — so
+ nothing lined up either within a card or across the three.
+ Anchoring the figures to the right edge aligns both columns
+ and lets the scores be read down the row. */}
+
+
+ LoCoMo
+
+ {fmt(s.locomo)}
+
+
+
+ LongMemEval
+
+ {fmt(s.lme)}
+
+
+
+
+ )
+ })}
+
+ )
+}
+
+/* ─── Sticky section nav ────────────────────────────────────────────────────
+ * The page is long and every section is anchored; this is the wayfinding.
+ * Scroll-spy uses one IntersectionObserver over the section elements rather
+ * than a scroll listener, so it costs nothing per frame.
+ */
+
+function SectionNav() {
+ const [active, setActive] = useState(NAV_SECTIONS[0].id)
+
+ useEffect(() => {
+ const els = NAV_SECTIONS.map((s) => document.getElementById(s.id)).filter(
+ (el): el is HTMLElement => el !== null,
+ )
+ if (els.length === 0) return
+
+ const io = new IntersectionObserver(
+ (entries) => {
+ // Pick the entry nearest the top of the viewport among those visible;
+ // "last one that crossed" alone flickers when two sections overlap.
+ const visible = entries
+ .filter((e) => e.isIntersecting)
+ .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
+ if (visible[0]) setActive(visible[0].target.id)
+ },
+ { rootMargin: '-20% 0px -70% 0px', threshold: 0 },
+ )
+ els.forEach((el) => io.observe(el))
+ return () => io.disconnect()
+ }, [])
+
+ return (
+
+ )
+}
+
+/* ─── Scoreboard ────────────────────────────────────────────────────────── */
+
+function Scoreboard() {
+ const [scale, setScale] = useState('zoom')
+ // Which series the reader has isolated, if any. Null = all shown equally.
+ const [focus, setFocus] = useState(null)
+
+ return (
+
+
+ {/* Softened: this section is the one place on the site carrying three
+ saturated series hues, and at full strength the ambient wash sat on
+ top of them as haze. */}
+
+
+
+
+
+ The scoreboard
+
+
+ Three systems, one eval loop
+
+
+ The margins are small on purpose: a fair fight, not an inflated one.
+
+
+
+ {/* Chart toolbar. Legend and axis switch were two loose rows of small
+ text floating above the panels; bound into one bar they read as
+ controls belonging to the chart. */}
+
+
+
+
+
+ Click a system to isolate it · switch the axis for full scale
+
+
+ {/* Hero and support, not twins. The two panels used to sit in an even
+ 50/50 split, which told the eye they carried equal weight — the
+ one thing this page's own copy says they don't. LoCoMo at n=1,540
+ is the robust signal, so it takes the full width and the long
+ bars; LongMemEval at n=30 is explicitly directional and sits in
+ the row below, next to the fine print it belongs with. */}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * Series legend: identity is never carried by color alone, and each entry
+ * doubles as an isolate toggle: pressing one dims the other two across both
+ * panels so a single system can be read against the axis on its own.
+ */
+function Legend({
+ focus,
+ onFocus,
+}: {
+ focus: SeriesKey | null
+ onFocus: (key: SeriesKey | null) => void
+}) {
+ return (
+
+ )
+}
+
+/**
+ * Axis-scale switch. A zoomed axis is the standard way to make a small lead
+ * look big, so rather than only disclosing the zoom in prose we let the
+ * reader collapse it back to 0–1.00 and watch the bars converge.
+ */
+function ScaleToggle({ scale, onChange }: { scale: Scale; onChange: (s: Scale) => void }) {
+ return (
+
+ )
+}
+
+const CAVEATS = [
+ {
+ title: 'Shared stack',
+ body: 'gpt-4.1 extraction · text-embedding-3-small · gpt-4o answer + judge.',
+ },
+ {
+ title: 'Single run',
+ body: 'One run, not an average. LoCoMo (n=1,540) is the robust read.',
+ },
+ {
+ title: 'n=30 on LME',
+ body: 'A 30-question matched set with wide error bars. Directional only.',
+ },
+ {
+ title: 'Asymmetry',
+ body: "mem0 cloud's extractor/embedder isn't configurable, a product-inherent asymmetry.",
+ },
+]
+
+/**
+ * Fine print, kept in full but given less voice than the chart: each label is
+ * demoted to a muted dot-and-caps line so the set reads as an apparatus note
+ * rather than four more paragraphs competing with the panels.
+ *
+ * It stacks vertically now instead of running four-across under the charts.
+ * As a full-width strip it was the last thing on the section and read as a
+ * conclusion; beside the directional panel it reads as what it is — the
+ * conditions both runs were made under.
+ */
+function Caveats() {
+ return (
+
+
+ Conditions of the run
+
+
+ {CAVEATS.map((c, i) => (
+
0 ? 'border-t border-theme-border' : ''}`}
+ >
+
+
+ {c.title}
+
+
+ {c.body}
+
+
+ ))}
+
+
+ )
+}
+
+/**
+ * One benchmark = one panel of three bars on a shared zoomed axis.
+ *
+ * Bars and counters are driven by a single `onViewportEnter` on the panel so
+ * they resolve together; independent per-element observers made the numbers
+ * and their bars finish at visibly different times.
+ */
+function ScorePanel({
+ metric,
+ scale,
+ focus,
+}: {
+ metric: Metric
+ scale: Scale
+ focus: SeriesKey | null
+}) {
+ const reduced = useReducedMotion() ?? false
+ const [shown, setShown] = useState(false)
+ const meta = METRICS[metric]
+ const headline = delta(STATEWAVE[metric], MEM0_OSS[metric])
+ const ticks = AXIS[scale].ticks
+ // Every non-leader row draws its gap back to this position, so the panel
+ // owns it rather than each row recomputing it.
+ const leaderPct = axisPct(STATEWAVE[metric], scale)
+
+ return (
+ setShown(true)}
+ viewport={{ once: true, margin: '-60px' }}
+ className="relative overflow-hidden rounded-2xl border border-theme-border bg-surface-1 p-6 shadow-sm sm:p-7"
+ >
+ {/* One decorative layer, not three: a hairline in the leader's hue across
+ the top, held at 55% so the bars stay the brightest thing in the
+ panel. The blurred bloom that used to sit behind the headline delta is
+ gone — stacked against the section wash it was reading as haze. */}
+
+
+
+
+
+ {meta.label}
+
+
+
+ {meta.n}
+
+
+ {meta.grade}
+
+
+
+ {/* The panel's focal point. The old second line here also carried the
+ cloud delta, which wrapped into the title; that number is now drawn
+ on the cloud row itself as the gap back to Statewave. */}
+
+
+ {headline}
+
+
+ vs mem0 OSS
+
+
+
+
+ {/* Plot area. Gridlines sit behind the bars at the labeled axis ticks so
+ the current zoom is legible rather than implied. */}
+
+ {/* Same pr-14 gutter the bars reserve, so gridlines land on the track
+ rather than on the delta-label column. Keyed by scale: the two axes
+ have different tick counts, so there is nothing to tween between —
+ the set is crossfaded while the bars glide to their new lengths. */}
+
+
+ {ticks.map((t) => (
+
+ ))}
+
+
+
+
+ {SYSTEMS.map((s, i) => (
+
+ ))}
+
+
+ {/* The leader's score as a datum line across all three rows, drawn
+ top-down once the bars have landed. It replaces the three separate
+ end-ticks the delta connectors each used to draw: they sat at one x
+ and were therefore one line, and as one line every gap below it is
+ measured against a single edge. Painted after the rows so it caps
+ the leader's bar instead of hiding behind it. */}
+
+
+ )
+}
+
+/**
+ * One system's row: name + score on a label line, bar on the line below.
+ *
+ * The score sits right-aligned in a shared column rather than riding the end
+ * of its own bar. Bar-end labels staircased across three x-positions, so the
+ * three figures a reader most wants to compare could not be read as a column.
+ *
+ * The lead over Statewave is drawn, not written: a dashed span from this bar's
+ * end to the leader's end, which *is* the delta at the current axis scale. It
+ * replaces a `SW +0.039` label that floated in dead space far from the bar it
+ * described, and it collapses honestly when the axis flips to full scale.
+ */
+function ScoreRow({
+ system,
+ metric,
+ rank,
+ shown,
+ reduced,
+ delay,
+ scale,
+ leaderPct,
+ dimmed,
+}: {
+ system: SystemScore
+ metric: Metric
+ rank: number
+ shown: boolean
+ reduced: boolean
+ delay: number
+ scale: Scale
+ leaderPct: number
+ dimmed: boolean
+}) {
+ const [hovered, setHovered] = useState(false)
+ const value = system[metric]
+ const isLeader = system.key === 'statewave'
+ const swDelta = isLeader ? null : delta(STATEWAVE[metric], value)
+ const color = SERIES_COLOR[system.key]
+ const target = axisPct(value, scale) / 100
+
+ /* One motion value per row drives both the bar's scaleX and the connector's
+ * left edge, so the connector stays welded to the bar tip through the reveal
+ * and through an axis flip. scaleX rather than width keeps all six bars on
+ * the compositor instead of relaying out the panel every frame.
+ *
+ * The score itself is deliberately *not* on this value. It used to count up
+ * on its own rAF loop, finishing visibly out of step with its bar; the
+ * obvious repair — derive the digits from the bar tip's position on the axis
+ * — is worse. An axis flip re-renders with the new axis bounds immediately
+ * while the bar is still travelling, so the digits read out scores that were
+ * never measured (0.905 rendering as 0.981 mid-flip), and they stick there
+ * for as long as the tab is backgrounded and rAF is paused. On this page the
+ * figure is the claim and the bar is the illustration: the illustration
+ * animates, the claim holds still and stays true in every frame. */
+ const grow = useMotionValue(0)
+ const revealed = useRef(false)
+
+ useEffect(() => {
+ if (reduced) {
+ grow.set(target)
+ return
+ }
+ if (!shown) return
+ const entering = !revealed.current
+ revealed.current = true
+ const controls = animate(grow, target, entering ? { ...BAR_REVEAL, delay } : BAR_RESPONSE)
+ return () => controls.stop()
+ }, [grow, shown, reduced, target, delay])
+
+ const barEnd = useTransform(grow, (g) => `${g * 100}%`)
+
+ return (
+
+
+ {/* Bar track + fill.
+ `pr-14` reserves a gutter for the delta label at the end of the gap
+ connector, so the track can never grow long enough to push it out of
+ the panel. Without it every bar overflows on the full 0–1.00 scale,
+ where all three sit past 83%. */}
+
+
+
+ {/* Flat series hue, squared off to 3px. A 6px pill on a 24px bar read
+ as a UI control; at 3px it reads as a measurement. */}
+
+
+ {/* The gap, drawn: this bar's tip to the datum line at the leader's
+ score, labeled where it meets that line. `left` rides the same
+ motion value as the bar, so the connector is pinned to the tip
+ through the reveal and through an axis flip; only its far end is
+ fixed, because the far end *is* the datum. The dashed rule wipes
+ in from that end, back toward this bar — the direction the margin
+ is actually read in. */}
+ {swDelta && (
+
+
+
+ {swDelta}
+
+
+ )}
+
+ A fork of mem0's harness, not a rewrite. Every run travels the same path; only the
+ memory backend changes.
+
+
+
+
+
+
+
+ Three fixes, all in mem0's favor
+
+
+ Bugs in mem0's own client code. Every one of them{' '}
+ raises the mem0 backends' scores, and
+ every one is applied in the fork rather than withheld — so the scoreboard above runs
+ against their best config, not a strawman.
+
+
+
+
+ {FIXES.map((f) => (
+
+
{f.title}
+
{f.body}
+ {/* Which backend the fix helps, carried by the chart's hue for that
+ system instead of a badge — two of the three fixes target the
+ same backend, so a repeated pill led every card with its least
+ distinguishing line. */}
+
+
+ applied to {systemName(f.helps)}
+
+
+ ))}
+
+
+ {/* Both links leave for the repo, so neither takes the gradient — the
+ page's primary action is the "Run it" section directly below. */}
+
+
+
+
+
+ )
+}
+
+/* Answer and judge are one node because they are one model: gpt-4o scores its
+ * own answers across all three backends, which is a methodology fact worth
+ * showing rather than two identical boxes at the tail of the diagram. */
+const PIPELINE_STEPS = [
+ { label: 'Dataset', value: 'LoCoMo · LME' },
+ { label: 'Stage 1', value: 'Ingest' },
+ { label: 'swap', value: '' },
+ { label: 'Stage 2', value: 'Search', hint: 'top-200' },
+ { label: 'Stage 3', value: 'Answer + judge', hint: 'gpt-4o' },
+]
+
+/**
+ * The eval loop, drawn. Steps resolve left-to-right on scroll so the diagram
+ * reads as a flow rather than a row of boxes.
+ *
+ * Border style carries the section's whole claim: dashed nodes are identical
+ * in all three runs, the one solid accent node is what gets swapped. That was
+ * previously only stated in the mono footnotes underneath, which left the
+ * diagram illustrating the argument instead of making it.
+ */
+function Pipeline() {
+ const step: Variants = {
+ hidden: { opacity: 0, y: 10 },
+ show: { opacity: 1, y: 0, transition: { duration: 0.45, ease: [0.22, 0.61, 0.36, 1] } },
+ }
+
+ return (
+
+
+ {PIPELINE_STEPS.map((s, i) => (
+
+ {i > 0 && (
+
+ {/* Stacked, a column of arrow glyphs is louder than the nodes it
+ joins; a hairline reads as the same connector at a whisper. */}
+
+ →
+
+ )}
+
+ {s.label === 'swap' ? (
+
+ {/* No pulse ring here. The border weight, the fill and the badge
+ already say this is the node that matters; a loop animating
+ on top of the section's own claim is decoration asking to be
+ mistaken for meaning. */}
+
+ Only this swaps
+
+
+
+ ) : (
+ /* min-h keeps the four shared stages the same height as each
+ other while leaving them visibly shorter than the swap node.
+ Stretching every box to the tallest one, as before, spent the
+ diagram's emphasis on padding. */
+
+
+ {s.label}
+
+ {s.value}
+ {s.hint && (
+ {s.hint}
+ )}
+
+ )}
+
+ ))}
+
+
+ {/* Legend for the border treatment above. Without it the dashes are
+ decoration; with it they are the argument. */}
+
+
+
+ identical in all three runs
+
+
+
+ the only stage that changes
+
+
+
+ {/* Solid rule, not dashed: dashes now mean "unchanged from upstream" a
+ few pixels above, and a divider borrowing that stroke would read as
+ part of the legend. */}
+
+
+ › Statewave adds{' '}
+ statewave_client.py + a{' '}
+ --backend statewave dispatch
+
+
+ › gpt-4.1 extraction shared by Statewave &
+ mem0 OSS
+
+
+ › judge & scoring code untouched from
+ upstream
+
+
+
+ )
+}
+
+/* ─── Run it ────────────────────────────────────────────────────────────── */
+
+const INSTALL_SNIPPET = `git clone ${REPO_URL}.git
+cd statewave-memory-benchmarks
+pip install -r requirements.txt
+export OPENAI_API_KEY=sk-... # answerer + judge`
+
+/* `note` is the retrieval caveat that used to sit as one line of mono text
+ * under both panels, where it was noise on the Statewave tab and easy to miss
+ * on the tab it actually qualifies. Per backend, it renders with the command
+ * it applies to. */
+const BACKEND_SNIPPETS: readonly {
+ label: string
+ series: SeriesKey
+ code: string
+ note: string
+}[] = [
+ {
+ label: 'Statewave',
+ series: 'statewave',
+ code: `export STATEWAVE_URL=https://your-instance
+export STATEWAVE_API_KEY=sw-...
+python -m benchmarks.locomo.run \\
+ --backend statewave \\
+ --answerer-model gpt-4o \\
+ --judge-model gpt-4o`,
+ note: 'Honors the harness top-200 retrieval request.',
+ },
+ {
+ label: 'mem0 cloud',
+ series: 'mem0-cloud',
+ code: `export MEM0_API_KEY=m0-...
+python -m benchmarks.locomo.run \\
+ --backend cloud \\
+ --mem0-api-key "$MEM0_API_KEY" \\
+ --answerer-model gpt-4o \\
+ --judge-model gpt-4o`,
+ note: 'Honors the harness top-200 retrieval request.',
+ },
+ {
+ label: 'mem0 OSS',
+ series: 'mem0-oss',
+ code: `docker compose up -d # Mem0 + Qdrant
+python -m benchmarks.locomo.run \\
+ --backend oss \\
+ --mem0-host http://localhost:8888 \\
+ --answerer-model gpt-4o \\
+ --judge-model gpt-4o`,
+ note: 'Caps retrieval at ≤20 memories/query by library default, where Statewave and cloud honor the top-200 request.',
+ },
+]
+
+/**
+ * Three steps on a numbered rail, capped at `max-w-3xl`.
+ *
+ * The panels used to run the full `max-w-7xl` measure while the longest shell
+ * line is ~62 characters, so every block was a slab with text in its left
+ * third. Narrowing them also pulls the copy control back next to the code it
+ * copies, which is the whole point of the section.
+ *
+ * `active` lives here rather than inside the tablist because step 3 answers
+ * "did it work?" for whichever backend step 2 is showing.
+ */
+function RunIt() {
+ const [active, setActive] = useState('statewave')
+ const current = BACKEND_SNIPPETS.find((b) => b.series === active) ?? BACKEND_SNIPPETS[0]
+
+ return (
+
+
+
+
+ Reproduce it
+
+
+ Reproduce every number here
+
+
+ Copy-pasteable, straight from the harness README. LoCoMo shown; for LongMemEval swap in{' '}
+ benchmarks.longmemeval.run with{' '}
+ --per-type 5.
+
+
+ )
+}
+
+/**
+ * One step: number badge on a rail, title, content.
+ *
+ * The step number used to live as 11.5px mono inside the terminal's window
+ * chrome — the structure of the section was its least visible element. The
+ * rail carries the sequence so the chrome can go back to saying `bash`.
+ */
+function Step({
+ n,
+ title,
+ children,
+ last = false,
+}: {
+ n: number
+ title: string
+ children: ReactNode
+ last?: boolean
+}) {
+ return (
+
+ {!last && (
+
+ )}
+
+ {n}
+
+
{title}
+
{children}
+
+ )
+}
+
+/**
+ * Step 2: the three backend invocations, tabbed rather than tiled.
+ *
+ * Side by side these were three cramped columns of wrapped shell text. Only
+ * one is ever relevant to a given reader (you run the backend you have), so
+ * a tablist gives each command the full width and makes the section
+ * something you use rather than read.
+ *
+ * The tabs render *inside* the panel's window chrome. Floating above it they
+ * sat on `--theme-surface-2`, a lighter fill than the `--viz-code-bg` panel
+ * they controlled, so the selected tab detached from its own content instead
+ * of belonging to it.
+ */
+function BackendTerminal({
+ active,
+ setActive,
+ current,
+}: {
+ active: SeriesKey
+ setActive: (s: SeriesKey) => void
+ current: (typeof BACKEND_SNIPPETS)[number]
+}) {
+ const tabRefs = useRef<(HTMLButtonElement | null)[]>([])
+
+ // Roving focus: ←/→ move between tabs, per the WAI-ARIA tabs pattern.
+ const onKeyDown = (e: React.KeyboardEvent, index: number) => {
+ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
+ e.preventDefault()
+ const dir = e.key === 'ArrowRight' ? 1 : -1
+ const next = (index + dir + BACKEND_SNIPPETS.length) % BACKEND_SNIPPETS.length
+ setActive(BACKEND_SNIPPETS[next].series)
+ tabRefs.current[next]?.focus()
+ }
+
+ const tabs = (
+
+ )
+}
+
+/**
+ * Step 3: what a correct run lands on.
+ *
+ * The section promised "reproduce every number" but stopped at launching the
+ * harness, so a reader had no way to tell a matching run from a broken one.
+ * Figures come from SYSTEMS like everywhere else on the page, and the bars sit
+ * on the same zoomed axis as the scoreboard so the two read as one measurement.
+ *
+ * Nothing here animates: these are the figures the page's credibility rests
+ * on, and a width transition on a tab switch would redraw the claim.
+ */
+function ExpectedResult({ series }: { series: SeriesKey }) {
+ const system = SYSTEMS.find((s) => s.key === series) ?? STATEWAVE
+ const color = SERIES_COLOR[series]
+
+ return (
+
+
+
+ Published result · {system.name}
+
+
+ axis {AXIS.zoom.label}
+
+
+
+
+ {(Object.keys(METRICS) as Metric[]).map((m) => (
+
+
+
+ {METRICS[m].label}
+ {METRICS[m].n}
+
+
+ {fmt(system[m])}
+
+
+
+
+
+
+ ))}
+
+
+
+ The same figures charted on{' '}
+
+ the scoreboard
+
+ . Answerer and judge are both gpt-4o, so a rerun can land slightly either side of these.
+
+
+ )
+}
+
+/**
+ * Terminal-style snippet panel.
+ *
+ * Surface is `--viz-code-bg` (a deep navy in the same family as the page)
+ * rather than `--theme-code-bg` (a near-black); the latter read as a flat
+ * black slab dropped onto the navy page. Window chrome, a colored prompt,
+ * and dimmed comments make it read as a shell session.
+ *
+ * The copied text is always the raw `code` string: the prompt glyph and the
+ * highlighting are presentation only and never reach the clipboard.
+ *
+ * Border weight is uniform across steps. Step 2 used to carry a full-saturation
+ * series stroke while step 1 sat on a 10%-alpha hairline, which read as step 1
+ * being the optional one — it is the prerequisite. Series identity now rides
+ * the header rule and the selected tab's swatch instead of the panel outline.
+ */
+function Terminal({
+ title,
+ code,
+ accent,
+ tabs,
+}: {
+ title: string
+ code: string
+ accent?: string
+ tabs?: ReactNode
+}) {
+ return (
+
+
+
+
+
+
+
+
+ {tabs ?? (
+
+ {title}
+
+ )}
+
+
+
+
+
+ {code.split('\n').map((line, i) => (
+
+ ))}
+
+
+ )
+}
+
+/**
+ * One rendered shell line: prompt, command body, trailing comment.
+ *
+ * Continuation lines (the wrapped half of a `\`-broken command) are indented
+ * in the source and get no prompt, so a multi-line invocation reads as one
+ * command rather than several.
+ */
+function ShellLine({ line }: { line: string }) {
+ const isContinuation = line.startsWith(' ')
+ const hash = line.indexOf('#')
+ const body = hash === -1 ? line : line.slice(0, hash)
+ const comment = hash === -1 ? null : line.slice(hash)
+
+ return (
+
+
+ )
+}
+
+/* ─── Governance bridge ─────────────────────────────────────────────────── */
+
+/**
+ * The section makes a comparison, so it is drawn as one.
+ *
+ * Four icon cards stated the contrast in prose and then showed a feature list
+ * that never mentioned mem0 again — the headline's claim was carried entirely
+ * by the headline. As a matrix the argument is the shape of the table: the
+ * retrieval band is a near-tie, the governance band is empty down one side.
+ * That also retires the trailing "governance is where Statewave pulls ahead"
+ * line, which restated the intro paragraph 60 words after it.
+ */
+function GovernanceBridge() {
+ return (
+
+
+
+ Beyond retrieval
+
+
+ {/* The heading face sets `0` with no slash or dot, so at 36px "mem0"
+ reads as "memO" and the sentence lands as a memo. The brand token
+ takes the mono face, whose zero is unambiguous. */}
+ What mem0 doesn't do
+
+
+ Retrieval is table stakes, and the benchmark above settles it. Statewave's real
+ difference is governance: the controls a memory layer needs before it touches
+ production data.
+
+
+
+
+
+
+ Statewave compared with mem0 cloud: benchmark retrieval scores, then governance
+ capabilities.
+
+ )
+}
+
+/**
+ * Present / not-offered mark. The glyph is decorative and duplicated as
+ * visually-hidden text, so the row's meaning never rests on a symbol alone.
+ */
+function MarkCell({ present }: { present: boolean }) {
+ return (
+
+ )
+}
+
+/* ─── FAQ ───────────────────────────────────────────────────────────────── */
+
+/* Native disclosure, the same pattern as the homepage FAQ, so the
+ * section is keyboard- and AT-navigable for free and the collapsed answers
+ * stay in the DOM for crawlers. First item open so the section reads as
+ * content on first paint rather than a stack of closed bars. */
+function Faq() {
+ return (
+
+
+
+
+ FAQ
+
+
+ Questions you're right to ask
+
+
+ No spin. The awkward questions, answered directly.
+
+
+ {post.meta.headerImage && (
+
+ )}
diff --git a/src/pages/GroundedShopAssistantPage.tsx b/src/pages/GroundedShopAssistantPage.tsx
new file mode 100644
index 0000000..864e36f
--- /dev/null
+++ b/src/pages/GroundedShopAssistantPage.tsx
@@ -0,0 +1,1227 @@
+import { motion } from 'framer-motion'
+import { Fragment, useState } from 'react'
+import { Section } from '../components/Section'
+import { Heading } from '../components/Heading'
+import { Button } from '../components/Button'
+import { CodeCopyButton } from '../components/CodeCopyButton'
+import { UseCaseSwitcher } from '../components/UseCaseSwitcher'
+import { usePageSEO } from '../lib/seo'
+import { breadcrumbJsonLd } from '../lib/seo-meta'
+
+const REPO_URL = 'https://github.com/smaramwbc/statewave-grounded-shop-assistant'
+
+/* Mockups below use the `--viz-*` tokens (src/index.css) so their neutrals
+ * flip with the light/dark theme while the grounded/gap accents stay
+ * branded in both, same convention as PersonalAssistantMemoryPage. */
+
+/* ─── Hero ───────────────────────────────────────────────────────────────── */
+
+function HeroSection() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Grounded Answers,
+
+ Not Guesses
+
+
+
+ Every answer traces to retrieved evidence and is cited back to its
+ source. When the assistant can't find grounded evidence, it says
+ so instead of guessing, and files the gap for the content
+ team to resolve.
+
+ Real suite from the repo:{' '}
+ node --test across
+ chat-core, statewave-core, and server, green on every Node version in CI.
+
+
+ )
+}
+
+/* ─── Two outcomes, both leave a trace ───────────────────────────────────── */
+
+function TwoOutcomesSection() {
+ return (
+
+
+
+
+
+ Why Statewave
+
+
+
+ Two Outcomes, Both{' '}
+ Leave a Trace
+
+
+
+ Grounding is enforced by the runtime, not the prompt. Either an
+ answer survives citation validation, or the question becomes a
+ coverage gap the Ops Assistant can resolve.
+
+
+
+
+
+
+
+
+
+ Grounded and cited
+
+
+
+
+
+ The model answers from retrieved evidence, and at least one
+ citation ID survives validation.
+
+ )
+}
+
+const WHY_CARDS = [
+ {
+ title: 'Grounded answers only',
+ body: "The model answers strictly from retrieved evidence, and is told to say it doesn't know rather than fill the gap with a guess.",
+ Visual: QADemoVisual,
+ },
+ {
+ title: 'Every claim is citable',
+ body: 'Citation IDs from the model are validated against the evidence actually retrieved. Unknown IDs are dropped and flagged as a warning.',
+ Visual: CitationDemoVisual,
+ },
+ {
+ title: 'Content gaps are an object',
+ body: 'An ungrounded question writes a Coverage gap Episode. The Ops Assistant reads gaps beside the catalog and FAQs, so the content team fixes them from the same UI.',
+ Visual: GapDemoVisual,
+ },
+ {
+ title: 'Append-only memory',
+ body: 'Nothing is mutated in place. Updating a product or resolving a gap appends a new Episode with the same sourceId, so facts supersede instead of editing history.',
+ Visual: AppendOnlyVisual,
+ },
+]
+
+function WhyGridSection() {
+ return (
+
+
+ The closed loop. A question the
+ shopper assistant can't ground becomes an{' '}
+
+ ops:coverage-gaps
+ {' '}
+ Episode the Ops Assistant reads as evidence. Resolving it appends
+ another Episode with the same{' '}
+
+ sourceId
+
+ , superseding the gap instead of editing history.
+
+
+
+ One Pipeline,{' '}
+ Episode to Citation
+
+
+
+ Source content flows through ingestion into an append-only store,
+ compiles into Subjects, and is read by both assistants over the
+ same completion path.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/* ─── The content team works the gaps ────────────────────────────────────── */
+
+const OPS_BULLETS = [
+ { color: 'bg-amber-500', text: 'Open gaps carry the unanswered question verbatim' },
+ { color: 'bg-accent', text: 'Resolved gaps supersede, never overwrite' },
+ { color: 'bg-brand-500', text: <>Read over GET /api/ops/gaps> },
+]
+
+const OPS_GAPS = [
+ { status: 'OPEN', id: 'GAP-2381', meta: '2 shoppers', text: 'Which planters are safe for a rooftop with a weight limit?' },
+ { status: 'OPEN', id: 'GAP-2379', meta: '1 shopper', text: 'Do you ship bare-root roses in winter?' },
+ { status: 'RESOLVED', id: 'GAP-2361', meta: '+ Episode', text: "What's your return window on live plants?" },
+]
+
+function OpsConsoleMockup() {
+ const tabs = ['Overview', 'Subjects', 'Coverage gaps', 'Conversations', 'Catalog']
+
+ return (
+
+
+
+ The Content Team{' '}
+ Works the Gaps
+
+
+
+ Every ungrounded question shows up in the Ops console as an open
+ coverage gap, with the shopper's exact wording. Resolving one
+ appends an Episode under the same sourceId, so the next shopper
+ gets a grounded answer.
+
+ Boots with a deterministic offline responder, so grounding,
+ citations, and the coverage-gap loop all work with zero setup.
+ Point it at any OpenAI-compatible gateway for real generated
+ answers.
+
+ Mem0 ranks by relevance and hands you the result. Statewave assembles
+ a deterministic, token-bounded context bundle and returns an
+ integrity-hashed receipt of exactly what the agent saw.
+
+ )
+}
+
+/* ─── The gap ─────────────────────────────────────────────────────────────── */
+
+function GapSection() {
+ return (
+
+ {/* --soft: this section's Statewave panel carries its own saturated
+ chart series color below, so the ambient wash stays behind the
+ data instead of washing over it — see .section-glow--soft. */}
+
+
+
+
+ Ranked by similarity, or assembled by policy
+
+
+
+ Mem0 partitions memory by an id you pass, then ranks what it finds by a
+ blend of vector similarity, keyword overlap and entity matches.
+ Statewave ingests each event as an immutable episode, compiles those
+ into typed memories with confidence and validity, and assembles a
+ ranked bundle the same way on every call.
+
+ vector search →
+ candidate pool, re-scored before return
+
+
+
+
+
Retrieval is non-deterministic
+
+ The caller picks an id and the store ranks by relevance. Expiry
+ exists but is opt-in with no default, and there is no kind
+ priority and no recency decay — so an address the customer
+ changed months ago, with no expiry set, can still rank above the
+ current one.
+
+
+
+
+ {/* statewave side */}
+
+
+
+
+
+
+ Statewave · Record → Compile → Context → Govern
+
+ Given the same subject, task, and token budget, the assembler
+ returns the identical bundle every run. Four additive signals
+ set the order: kind priority (3–10), recency (0–5),
+ task relevance (0–8), and temporal validity (−4 to +3).
+
+ How order is decided
+ score = priority + recency + relevance + validity
+
+
+ {SIGNALS.map((s, i) => (
+
+
{s.label}
+
{s.range}
+
{s.note}
+
+ ))}
+
+
+
+ )
+}
+
+/* ─── Full comparison table ──────────────────────────────────────────────── */
+
+interface CompareRow {
+ cap: string
+ m0: string
+ sw: string
+}
+
+const RETRIEVAL_ROWS: CompareRow[] = [
+ { cap: 'How context is selected', m0: 'Top-k embedding-nearest as the candidate pool, then re-scored', sw: 'Deterministic assembly, additively scored to a token budget' },
+ { cap: 'Ranking signals', m0: 'Vector similarity fused with keyword and entity boosts; optional reranker', sw: 'Kind priority, recency, task relevance, temporal validity' },
+ { cap: 'Stale / expired facts', m0: 'Optional expiry, off by default; no confidence score or decay', sw: 'Penalised −4 and dropped before assembly' },
+ { cap: 'Same query, same result', m0: 'Varies with the index', sw: 'Byte-identical bundle every run' },
+]
+
+const GOVERNANCE_ROWS: CompareRow[] = [
+ { cap: 'Proof of what the agent saw', m0: 'None', sw: 'Immutable, ULID-addressable receipt with an integrity hash' },
+ { cap: 'Policy on the read path', m0: 'Implement it in your app', sw: 'Declarative bundles: deny or redact by label and caller' },
+ { cap: 'Provenance to source', m0: 'Session ids automatic; links to source documents by hand', sw: 'Source episode ids, confidence, and validity per memory' },
+ { cap: 'Subject deletion (GDPR)', m0: 'One call per subject; change history is retained', sw: 'One call clears episodes, memories, and receipts' },
+]
+
+const OPS_ROWS: CompareRow[] = [
+ { cap: 'Storage', m0: 'Pluggable vector stores', sw: 'Postgres and pgvector, nothing else to run' },
+ { cap: 'Graph / relationship memory', m0: 'OSS: graph config removed, relations no longer returned. Platform: graph affects the score, no graph payload to query', sw: 'Typed memories with provenance, no graph tier' },
+ { cap: 'Interface', m0: 'Python and TypeScript SDKs, REST, CLI, hosted MCP server', sw: 'REST, Python and TypeScript SDKs, MCP server, connectors' },
+ { cap: 'License', m0: 'Apache 2.0 core, paid platform', sw: 'Apache 2.0 throughout, runs fully offline' },
+]
+
+function CompareGroup({ title, rows }: { title: string; rows: CompareRow[] }) {
+ return (
+ <>
+
+ {title}
+
+
+ {/* Desktop: table. Mobile: stacked cards (see block below) — same
+ rationale as WhyPage's comparison table: a 3-column grid on a
+ phone either clips the Statewave column or shrinks everything
+ to unreadable widths. */}
+
+ Both are memory layers for agents. They diverge on what the runtime
+ enforces and what you have to build or pay for.
+
+
+
+
+
+
CAPABILITY
+
+
+ Mem0
+
+
+
+ Statewave
+
+
+
+
+
+
+
+
+
+ Mem0 v3 deleted the open-source graph-store drivers (Neo4j, Memgraph,
+ Kuzu, Apache AGE, Neptune); the graph_store config block
+ is no longer read, and search results no longer carry a{' '}
+ relations field. On the managed Platform there is no
+ graph store to configure and no graph payload in the response —
+ entity connections reach the caller only through the combined{' '}
+ score. Rows reflect each product's public docs and
+ source as of August 2026.
+
+
+
+
+
+
+ Reach for Mem0 when
+
+
+ You are giving a single assistant persistent memory, want a mature
+ SDK with broad framework integrations, and one identity scope
+ — user, agent, run, or app — describes how your data
+ partitions.
+
+
+
+
+
+
+
+ Reach for Statewave when
+
+
+ Agents run in production across many sessions and you need
+ deterministic context, provenance back to source episodes, policy
+ enforced on the read path, and an auditable receipt for every
+ decision.
+
+ A support agent resumes a customer thread three weeks later. In
+ between, the customer moved house and pasted a card number into an
+ earlier message. The same episode history runs through each system.
+
+
+
+ }
+ title="Mem0 · search by id"
+ calloutTone="muted"
+ lines={[
+ # retrieve context for the reply,
+ › client.search("where do I ship it",,
+ user_id="cust_5521"),
+ # ranked by relevance,
+
+
+ old address · Elm St
+ stale, no expiry set
+
+
+ card 4242 4242 ····
+ pii, no policy gate
+
+
,
+ ]}
+ callout="Expiry, redaction and any policy on the read path are left to the application to build and keep correct."
+ />
+
+ }
+ title="Statewave · assemble + govern"
+ calloutTone="accent"
+ lines={[
+ # assemble a ranked, bounded bundle,
+ › assemble(subject="cust_5521",,
+ task="where do I ship it", budget=1500),
+
+
+ new address · Oak Ave
+ valid +3 · ranked #1
+
+
+ old address · Elm St
+ −4 expired · dropped
+
+
+ card ●●●● ●●●● ····
+ label:pii · redacted
+
+
,
+ ]}
+ callout="The runtime decides: the superseded address scores out, the card is redacted by its policy label, and the receipt stores an integrity hash of exactly what was delivered."
+ />
+
+
+ )
+}
+
+/* ─── Every call leaves a receipt ────────────────────────────────────────── */
+
+const MECHANISM_CARDS = [
+ { icon: '{}', title: 'Policy engine', body: 'Content-hashed YAML or JSON bundles. Deny or redact by sensitivity label and caller identity; log_only records each decision so you can audit a policy before enforcing it.' },
+ { icon: '#', title: 'Sensitivity labels', body: 'Per-memory pii, financial, and secret tags in a GIN-indexed array, so policy filters run inside the query rather than after it.' },
+ { icon: '←', title: 'Full provenance', body: 'Every compiled memory keeps the source episode ids, confidence score, and validity window it was derived from.' },
+ { icon: '⌫', title: 'Subject deletion', body: 'One GDPR-style call erases every episode, memory, and receipt for a subject, leaving no orphaned rows behind.' },
+]
+
+function ReceiptCard() {
+ const rows = [
+ { label: 'profile_fact', meta: 'conf 0.92 · valid', src: '[ep_4, ep_9]', dim: false },
+ { label: 'procedure', meta: 'conf 0.88 · valid', src: '[ep_2]', dim: false },
+ { label: 'episode_summary', meta: 'superseded', src: 'dropped', dim: true },
+ ]
+
+ return (
+
+ Mem0 leaves auditability to your application. In Statewave every
+ assembly is governed and recorded in the core, on the read path,
+ under Apache 2.0.
+
+
+
+
+
+
+
+ {MECHANISM_CARDS.map((card) => (
+
+
+ {card.icon}
+
+
{card.title}
+
{card.body}
+
+ ))}
+
+
+
+ )
+}
+
+/* ─── Benchmarks ──────────────────────────────────────────────────────────── */
+
+/* Flat categorical fills, not a rank gradient — each hue names one system
+ * (see --series-* in index.css), and length alone carries the score. */
+const LOCOMO_SERIES: Record<'sw' | 'm0-cloud' | 'm0-oss', string> = {
+ sw: '--series-statewave',
+ 'm0-cloud': '--series-mem0-cloud',
+ 'm0-oss': '--series-mem0-oss',
+}
+
+function LoCoMoBar({ label, value, pct, tone }: { label: React.ReactNode; value: string; pct: number; tone: 'sw' | 'm0-cloud' | 'm0-oss' }) {
+ const seriesColor = `var(${LOCOMO_SERIES[tone]})`
+ return (
+
+ )
+}
+
+/* ─── Moving over from Mem0 ───────────────────────────────────────────────── */
+
+interface MigrationRow {
+ m0: string
+ sw: string
+ note: string
+}
+
+const MIGRATION_ROWS: MigrationRow[] = [
+ { m0: 'client.add("…", user_id="cust_5521")', sw: 'record(subject="cust_5521", event="…")', note: 'Ingested as an immutable episode; compilers extract typed memories.' },
+ { m0: 'client.search("…", user_id="cust_5521")', sw: 'assemble(subject="cust_5521", task="…", budget=1500)', note: 'Ranked, token-bounded bundle plus a receipt of what was delivered.' },
+ { m0: 'client.get_all(user_id="cust_5521")', sw: 'memories(subject="cust_5521", kind="profile_fact")', note: 'Browse by kind, subject, or validity; provenance attached.' },
+ { m0: 'client.delete_all(user_id="cust_5521")', sw: 'delete_subject("cust_5521")', note: 'Removes every episode, memory, and receipt in one call — receipts included.' },
+]
+
+const INSTALL_CMD = 'npx @statewavedev/statewave'
+
+function MigrationSection() {
+ return (
+
+
+
+ Moving over from Mem0
+
+
+ One command boots the whole runtime. Every Mem0 call has a direct
+ counterpart, and each write lands as an immutable episode.
+
+
+
+
+
+
+
+ one command · auto-wires Claude Code, Cursor, Codex
+
+
+
+
+
+
$ npx @statewavedev/statewave
+
→ API + admin console + Postgres up via Docker · healthy in under 2 min · no account
+
+
+
+
+
+
MEM0 SDK
+
+
STATEWAVE
+
+
+ {MIGRATION_ROWS.map((row) => (
+
+
{row.m0}
+
→
+
+
{row.sw}
+
{row.note}
+
+
+ ))}
+
+
+
+ )
+}
+
+/* ─── FAQ ─────────────────────────────────────────────────────────────────── */
+
+const FAQS = [
+ { q: 'How is Statewave different from Mem0?', a: 'Mem0 ranks memories by relevance for an id you pass. Statewave compiles raw episodes into typed memories, ranks them with a fixed scoring model to a token budget, applies policy on the read path, and returns an integrity-hashed receipt of exactly what was delivered.' },
+ { q: 'What makes retrieval deterministic?', a: 'A fixed scoring model: kind priority (3–10), recency (0–5), task relevance (0–8), and temporal validity (−4 to +3). The same subject, task, and budget produce the same bundle every time.' },
+ { q: 'What is a state-assembly receipt?', a: 'An immutable, ULID-addressable record of one context call. It carries a byte-level integrity hash of what was delivered and references the policy bundle hash, so ‘what did the agent see, under which policy’ is answerable forever.' },
+ { q: 'Does it work with Claude, Cursor, or Codex?', a: 'Yes. One command (npx @statewavedev/statewave) boots the runtime and auto-wires Claude Code, Claude Desktop, Cursor, VS Code Copilot, and Codex CLI. Any MCP-compatible client connects too.' },
+ { q: 'Can I run it fully offline?', a: 'Yes. Storage is Postgres-only and self-hosted. The heuristic compiler keeps everything on your network; nothing leaves unless you configure an LLM compiler or hosted embeddings.' },
+]
+
+function FaqSection() {
+ return (
+
+
+ Frequently asked
+
+
+
+ Self-host the Apache 2.0 runtime, wire it to your MCP client, and
+ every context call comes back with a receipt.
+
+
+
+
+
+
+
+
+ "mem0" is a trademark of its respective owner. References
+ to mem0 on this page are nominative, for benchmark-comparison
+ purposes only, and do not imply any affiliation with or
+ endorsement by mem0ai.
+
+ )
+}
diff --git a/src/pages/UseCasesPage.tsx b/src/pages/UseCasesPage.tsx
index 4f89e21..794d501 100644
--- a/src/pages/UseCasesPage.tsx
+++ b/src/pages/UseCasesPage.tsx
@@ -422,6 +422,16 @@ const USE_CASES: UseCase[] = [
description: 'A careful, audit-grade application — patient as subject, encounters as episodes, compiled with provenance.',
category: 'domain', status: 'future', tags: ['domain'],
},
+ {
+ title: 'Grounded shop assistant',
+ description: 'A shopper and ops assistant pair that only answers from retrieved evidence, validates every citation, and turns ungrounded questions into coverage-gap episodes the content team resolves.',
+ category: 'domain', status: 'available',
+ tags: ['domain', 'ecommerce'],
+ stack: ['TypeScript', 'Node.js', 'LiteLLM'],
+ repo: 'statewave-grounded-shop-assistant',
+ audience: 'Teams building customer-facing shopping or product-advisor assistants where wrong answers about stock, safety, or policy are costly.',
+ pageHref: '/use-cases/grounded-shop-assistant',
+ },
]
/* ─── Connector inventory (bootstrap patterns) ───────────────────────────── */
diff --git a/tests/benchmarks-page.test.tsx b/tests/benchmarks-page.test.tsx
new file mode 100644
index 0000000..e71b2ce
--- /dev/null
+++ b/tests/benchmarks-page.test.tsx
@@ -0,0 +1,200 @@
+/**
+ * /benchmarks: the LoCoMo + LongMemEval scoreboard page.
+ *
+ * These tests lock down the properties the page's credibility rests on, and
+ * the accessibility contract of its chart:
+ *
+ * - every rendered figure derives from the SYSTEMS table (no drift between
+ * the headline deltas and the underlying scores)
+ * - each chart bar is individually labeled, so identity and value are never
+ * carried by color alone
+ * - a legend names all three series
+ * - FAQ answers stay in the DOM while collapsed (crawlers + Ctrl-F)
+ * - the in-page section nav points at anchors that actually exist
+ */
+import { describe, it, expect, afterEach } from 'vitest'
+import { render, screen, cleanup, within, fireEvent } from '@testing-library/react'
+import { MemoryRouter } from 'react-router'
+import { ThemeProvider } from '../src/lib/theme'
+import { BenchmarksPage } from '../src/pages/BenchmarksPage'
+
+function renderPage() {
+ return render(
+
+
+
+
+ ,
+ )
+}
+
+afterEach(cleanup)
+
+describe('BenchmarksPage: figures', () => {
+ it('states the headline lead over the open-source peer consistently', () => {
+ const { container } = renderPage()
+ // 0.905 − 0.866 = +0.039 (LoCoMo), 0.967 − 0.833 = +0.134 (LongMemEval).
+ // Both are computed in the page, so a wrong score surfaces here.
+ expect(container.innerHTML).toContain('+0.039')
+ expect(container.innerHTML).toContain('+0.134')
+ })
+
+ it('never claims a reproduction of mem0’s published gpt-5 figures', () => {
+ const { container } = renderPage()
+ expect(container.innerHTML).toMatch(/not a reproduction/i)
+ })
+
+ it('discloses the zoomed axis rather than hiding it', () => {
+ renderPage()
+ expect(screen.getAllByText('0.80').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('1.00').length).toBeGreaterThan(0)
+ })
+})
+
+describe('BenchmarksPage: chart accessibility', () => {
+ it('labels every bar with its system and score, not color alone', () => {
+ renderPage()
+ // Two panels (LoCoMo + LongMemEval) x three systems = six labeled bars.
+ const bars = screen.getAllByRole('img')
+ expect(bars).toHaveLength(6)
+
+ expect(
+ screen.getByLabelText(/Statewave, LoCoMo score 0\.905/i),
+ ).toBeTruthy()
+ expect(
+ screen.getByLabelText(/mem0 OSS, LongMemEval score 0\.833.*leads by \+0\.134/i),
+ ).toBeTruthy()
+ })
+
+ it('renders a legend naming all three series', () => {
+ renderPage()
+ const legend = screen.getByRole('list', { name: /chart series/i })
+ expect(within(legend).getByText('Statewave')).toBeTruthy()
+ expect(within(legend).getByText('mem0 cloud')).toBeTruthy()
+ expect(within(legend).getByText('mem0 OSS')).toBeTruthy()
+ })
+})
+
+describe('BenchmarksPage: interaction', () => {
+ it('lets the reader collapse the zoomed axis back to full scale', () => {
+ renderPage()
+ const group = screen.getByRole('radiogroup', { name: /axis scale/i })
+ const zoomed = within(group).getByRole('radio', { name: '0.80–1.00' })
+ const full = within(group).getByRole('radio', { name: '0–1.00' })
+
+ // Zoomed is the default, but the honest full-scale view is one click away.
+ expect(zoomed).toHaveAttribute('aria-checked', 'true')
+ expect(full).toHaveAttribute('aria-checked', 'false')
+
+ fireEvent.click(full)
+ expect(full).toHaveAttribute('aria-checked', 'true')
+ expect(zoomed).toHaveAttribute('aria-checked', 'false')
+ // Axis ticks re-render for the new scale.
+ expect(screen.getAllByText('0.25').length).toBeGreaterThan(0)
+ })
+
+ it('keeps every printed score equal to its own label on both axes', () => {
+ renderPage()
+ const group = screen.getByRole('radiogroup', { name: /axis scale/i })
+
+ // Regression: the scores were briefly derived from their bar's position on
+ // the axis, so flipping the scale re-rendered against the new bounds while
+ // the bars were still travelling and printed figures nobody measured —
+ // 0.905 showing as 0.981. The axis changes how a score is drawn, never
+ // what it reads.
+ for (const axis of ['0–1.00', '0.80–1.00']) {
+ fireEvent.click(within(group).getByRole('radio', { name: axis }))
+ for (const bar of screen.getAllByRole('img')) {
+ const score = bar.getAttribute('aria-label')?.match(/score (\d\.\d{3})/)?.[1]
+ expect(score, `bar has no score in its label on the ${axis} axis`).toBeTruthy()
+ expect(bar.textContent, `printed score disagrees with its label on ${axis}`).toContain(
+ score,
+ )
+ }
+ }
+ })
+
+ it('isolates a single series from the legend, and releases it', () => {
+ renderPage()
+ const legend = screen.getByRole('list', { name: /chart series/i })
+ const statewave = within(legend).getByRole('button', { name: /Statewave/ })
+
+ expect(statewave).toHaveAttribute('aria-pressed', 'false')
+ fireEvent.click(statewave)
+ expect(statewave).toHaveAttribute('aria-pressed', 'true')
+ // Clicking the isolated series again clears the filter rather than
+ // trapping the reader in a single-series view.
+ fireEvent.click(statewave)
+ expect(statewave).toHaveAttribute('aria-pressed', 'false')
+ })
+
+ it('switches the reproduce command between backends', () => {
+ renderPage()
+ const tabs = screen.getByRole('tablist', { name: /backend/i })
+ const cloud = within(tabs).getByRole('tab', { name: /mem0 cloud/i })
+
+ // Statewave is selected first; its command is the one on screen.
+ expect(screen.getByRole('tabpanel').textContent).toContain('--backend statewave')
+
+ fireEvent.click(cloud)
+ expect(cloud).toHaveAttribute('aria-selected', 'true')
+ const panel = screen.getByRole('tabpanel')
+ expect(panel.textContent).toContain('--backend cloud')
+ expect(panel.textContent).not.toContain('--backend statewave')
+ })
+
+ it('shows the backend’s own published figures as the expected result', () => {
+ const { container } = renderPage()
+ const run = container.querySelector('#run')
+ expect(run, 'reproduce section must exist').toBeTruthy()
+
+ // Step 3 answers "did my run work?", so it has to track the command in
+ // step 2 rather than sitting on whichever backend rendered first.
+ expect(run!.textContent).toContain('0.905')
+ expect(run!.textContent).toContain('0.967')
+
+ const tabs = screen.getByRole('tablist', { name: /backend/i })
+ fireEvent.click(within(tabs).getByRole('tab', { name: /mem0 OSS/i }))
+ expect(run!.textContent).toContain('0.866')
+ expect(run!.textContent).toContain('0.833')
+ expect(run!.textContent).not.toContain('0.905')
+ })
+
+ it('scopes the retrieval caveat to the backend it qualifies', () => {
+ const { container } = renderPage()
+ const run = container.querySelector('#run')!
+
+ // The ≤20 cap is a mem0 OSS library default. On the Statewave tab it is
+ // noise; on the OSS tab it is material to reading the score.
+ expect(run.textContent).not.toMatch(/≤20 memories\/query/)
+
+ const tabs = screen.getByRole('tablist', { name: /backend/i })
+ fireEvent.click(within(tabs).getByRole('tab', { name: /mem0 OSS/i }))
+ expect(run.textContent).toMatch(/≤20 memories\/query/)
+ })
+})
+
+describe('BenchmarksPage: structure', () => {
+ it('keeps FAQ answers in the DOM while collapsed', () => {
+ renderPage()
+ // The last FAQ is closed by default; its answer text must still be
+ // present so search engines and in-page find can reach it.
+ expect(screen.getByText(/the layer that ingests, stores, and retrieves/i)).toBeTruthy()
+ })
+
+ it('points the section nav at anchors that exist on the page', () => {
+ const { container } = renderPage()
+ const nav = screen.getByRole('navigation', { name: /benchmark sections/i })
+ const links = within(nav).getAllByRole('link')
+ expect(links.length).toBeGreaterThan(0)
+
+ for (const link of links) {
+ const id = link.getAttribute('href')?.replace('#', '')
+ expect(id, 'nav link must have a hash href').toBeTruthy()
+ expect(
+ container.querySelector(`#${id}`),
+ `no section with id "${id}" for nav link`,
+ ).toBeTruthy()
+ }
+ })
+})
diff --git a/tests/routes.test.tsx b/tests/routes.test.tsx
index e857e27..8bbb542 100644
--- a/tests/routes.test.tsx
+++ b/tests/routes.test.tsx
@@ -62,6 +62,26 @@ describe('Route rendering', () => {
})
})
+ it('renders benchmarks page at /benchmarks', async () => {
+ renderApp('/benchmarks')
+ await waitFor(() => {
+ expect(screen.getByRole('main')).toBeInTheDocument()
+ })
+ await waitFor(() => {
+ expect(screen.getByText(/tops a memory benchmark/i)).toBeInTheDocument()
+ })
+ })
+
+ it('renders vs-mem0 comparison page at /vs/mem0', async () => {
+ renderApp('/vs/mem0')
+ await waitFor(() => {
+ expect(screen.getByRole('main')).toBeInTheDocument()
+ })
+ await waitFor(() => {
+ expect(screen.getByText(/statewave decides what.s delivered/i)).toBeInTheDocument()
+ })
+ })
+
it('renders 404 for unknown routes', async () => {
renderApp('/unknown-page')
await waitFor(() => {