From d6aa40d4272909cefc82643c71ff4767f5a227e7 Mon Sep 17 00:00:00 2001 From: jy7lsna Date: Mon, 3 Aug 2026 19:06:01 +0530 Subject: [PATCH 1/7] Add /benchmarks page: LoCoMo & LongMemEval results vs. mem0 Adds a detailed, reproducible benchmark writeup (scoreboard, methodology, run-it steps, scope, governance comparison, FAQ) run on mem0's own eval harness, plus the categorical chart palette and icon-only CodeCopyButton variant it needed. --- src/App.tsx | 2 + src/components/CodeCopyButton.tsx | 27 +- src/components/Footer.tsx | 1 + src/components/Navbar.tsx | 1 + src/index.css | 41 +- src/lib/seo-meta.ts | 14 + src/pages/BenchmarksPage.tsx | 2080 +++++++++++++++++++++++++++++ tests/benchmarks-page.test.tsx | 200 +++ tests/routes.test.tsx | 10 + 9 files changed, 2364 insertions(+), 12 deletions(-) create mode 100644 src/pages/BenchmarksPage.tsx create mode 100644 tests/benchmarks-page.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 04c98e2..9e34a00 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ const ConnectorsPage = lazy(() => import('./pages/ConnectorsPage').then(m => ({ const DevelopersPage = lazy(() => import('./pages/DevelopersPage').then(m => ({ default: m.DevelopersPage }))) const CookiesPage = lazy(() => import('./pages/CookiesPage').then(m => ({ default: m.CookiesPage }))) const LaunchPage = lazy(() => import('./pages/LaunchPage').then(m => ({ default: m.LaunchPage }))) +const BenchmarksPage = lazy(() => import('./pages/BenchmarksPage').then(m => ({ default: m.BenchmarksPage }))) const PressPage = lazy(() => import('./pages/PressPage').then(m => ({ default: m.PressPage }))) const WhitepaperPage = lazy(() => import('./pages/WhitepaperPage').then(m => ({ default: m.WhitepaperPage }))) const DemoPage = lazy(() => import('./pages/DemoPage').then(m => ({ default: m.DemoPage }))) @@ -39,6 +40,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/CodeCopyButton.tsx b/src/components/CodeCopyButton.tsx index 95e98f4..d7f7f9f 100644 --- a/src/components/CodeCopyButton.tsx +++ b/src/components/CodeCopyButton.tsx @@ -3,16 +3,29 @@ import React from 'react' /** * Floating "Copy" button for code snippet panels. Copies the given code on * click and flashes a "Copied" tag for ~1.5s. Falls back gracefully on - * insecure contexts where clipboard access is denied — the button still + * insecure contexts where clipboard access is denied; the button still * flashes feedback so the user knows something happened. + * + * `iconOnly` drops the visible "Copy"/"Copied" word for dense panel headers + * where the label is redundant next to the icon. The accessible name still + * comes through `aria-label`, and the live region still announces the state + * change. Only the visual text is suppressed. */ -export function CodeCopyButton({ code, label }: { code: string; label: string }) { +export function CodeCopyButton({ + code, + label, + iconOnly = false, +}: { + code: string + label: string + iconOnly?: boolean +}) { const [copied, setCopied] = React.useState(false) const handleCopy = async () => { try { await navigator.clipboard.writeText(code) } catch { - // ignored — clipboard can fail on insecure contexts or denied permissions + // ignored: clipboard can fail on insecure contexts or denied permissions } setCopied(true) window.setTimeout(() => setCopied(false), 1500) @@ -23,7 +36,9 @@ export function CodeCopyButton({ code, label }: { code: string; label: string }) onClick={handleCopy} aria-label={label} title={copied ? 'Copied' : label} - className="shrink-0 inline-flex items-center gap-1.5 rounded-md border border-transparent px-2 py-0.5 text-[10.5px] font-medium text-theme-muted/80 hover:text-accent hover:border-accent/30 focus-visible:text-accent focus:outline-none transition-colors" + className={`shrink-0 inline-flex items-center rounded-md border border-transparent text-[10.5px] font-medium text-theme-muted/80 hover:text-accent hover:border-accent/30 focus-visible:text-accent focus:outline-none transition-colors ${ + iconOnly ? 'p-1.5 hover:bg-accent/10' : 'gap-1.5 px-2 py-0.5' + }`} > {copied ? ( @@ -35,7 +50,9 @@ export function CodeCopyButton({ code, label }: { code: string; label: string }) )} - {copied ? 'Copied' : 'Copy'} + + {copied ? 'Copied' : 'Copy'} + ) } diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index ddfdcc9..d1e1f98 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -49,6 +49,7 @@ export function Footer() {
  • Python SDK
  • TypeScript SDK
  • White paper
  • +
  • Benchmarks
  • 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/index.css b/src/index.css index b5eae98..6358f47 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). 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 @@ -641,6 +657,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/seo-meta.ts b/src/lib/seo-meta.ts index 6d897e2..4b438ce 100644 --- a/src/lib/seo-meta.ts +++ b/src/lib/seo-meta.ts @@ -41,6 +41,7 @@ export type RouteKey = | '/' | '/product' | '/why' + | '/benchmarks' | '/use-cases' | '/use-cases/multi-agent-memory' | '/use-cases/personal-assistant-memory' @@ -58,6 +59,7 @@ export const PUBLIC_ROUTES: readonly RouteKey[] = [ '/', '/product', '/why', + '/benchmarks', '/use-cases', '/use-cases/multi-agent-memory', '/use-cases/personal-assistant-memory', @@ -117,6 +119,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: diff --git a/src/pages/BenchmarksPage.tsx b/src/pages/BenchmarksPage.tsx new file mode 100644 index 0000000..f1355a9 --- /dev/null +++ b/src/pages/BenchmarksPage.tsx @@ -0,0 +1,2080 @@ +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' + +/* ─── 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 ( +
    +
    + ) +} + +/** Compact "who leads" summary: the headline result before the full chart. */ +function HeroLeaderboard() { + return ( + + {SYSTEMS.map((s) => { + const lead = s.key === 'statewave' + return ( + +
    +
    + {/* 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. */} + +
    + ) +} + +/** + * 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 ( +
      + {SYSTEMS.map((s) => { + const isolated = focus === s.key + const dimmed = focus !== null && !isolated + 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 ( +
    + Axis +
    + {(['zoom', 'full'] as const).map((s) => ( + + ))} +
    +
    + ) +} + +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.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. */} + + ) +} + +/** + * 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 ( +
    setHovered(true)} + onMouseLeave={() => setHovered(false)} + onFocus={() => setHovered(true)} + onBlur={() => setHovered(false)} + tabIndex={0} + role="img" + aria-label={`${system.name}, ${METRICS[metric].label} score ${fmt(value)}${ + swDelta ? `, Statewave leads by ${swDelta}` : '' + }`} + > +
    + + + + {system.name} + + + + {fmt(value)} + +
    + + {/* 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 && ( +
    +
    + + {hovered && ( +
    +

    {system.name}

    +

    + {METRICS[metric].label} {fmt(value)} · {METRICS[metric].n} +

    +

    + {swDelta ? `Statewave leads by ${swDelta}` : 'Leads this benchmark'} +

    +
    + )} +
    + ) +} + +/* ─── Methodology ───────────────────────────────────────────────────────── */ + +function Methodology() { + return ( +
    +
    +

    + Methodology +

    + + One loop, one swap + +

    + 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. */} +

    +

    +
    + ))} +
    + + {/* 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 && ( + + )} + + {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 + +
    + {SYSTEMS.map((sys) => ( + + + ))} +
    +
    + ) : ( + /* 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. */} + + + + + + + + {/* 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. +

    + + Browse the harness on GitHub + + +
    + +
      + + + + + + + + + + + +
    +
    +
    + ) +} + +/** + * 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 && ( +
  • + ) +} + +/** + * 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 = ( +
    + {BACKEND_SNIPPETS.map((b, i) => { + const selected = b.series === active + return ( + + ) + })} +
    + ) + + return ( +
    + +

    + {current.label}{' '} + {current.note} +

    +
    + ) +} + +/** + * 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 ( +
    +
    + +
    + +
    +        {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 ( +
    + {!isContinuation ? ( + $ + ) : ( + + )} + {body} + {comment && {comment}} +
    + ) +} + +/* ─── Scope ─────────────────────────────────────────────────────────────── */ + +function Scope() { + return ( +
    +
    +
    +

    + Scope +

    + + What we claim, and what we don't + +

    + An honest benchmark is worth as much for where it stops as for what it shows. +

    +
    + + + + + +
    +
    + ) +} + +function ScopeList({ + heading, + tone, + items, +}: { + heading: string + tone: 'success' | 'muted' + items: readonly string[] +}) { + const good = tone === 'success' + return ( + +
    + + {heading} + +
    +
      + {items.map((item) => ( +
    • + +

      {item}

      +
    • + ))} +
    +
    + ) +} + +/* ─── 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. +

    +
    + +
    + + + + + + + + + + + + {RETRIEVAL_ROWS.map((r) => ( + + + + + ))} + + + {GOVERNANCE_ROWS.map((r) => ( + + + + + ))} + +
    + Statewave compared with mem0 cloud: benchmark retrieval scores, then governance + capabilities. +
    + Capability +
    +
    + +
    + +

    + mem0 cloud is the paid tier — their strongest showing on the retrieval rows. +

    +
    +
    + ) +} + +/** Series-colored column header. Swatch matches the scoreboard legend exactly. */ +function MatrixColumnHead({ + series, + name, + lead = false, +}: { + series: SeriesKey + name: string + lead?: boolean +}) { + return ( + + + + + ) +} + +/** Band divider naming what the rows beneath it are, and why they're there. */ +function MatrixGroupRow({ label, note }: { label: string; note: string }) { + return ( + + + + {label} + + {note} + + + ) +} + +function MatrixRow({ + title, + body, + children, +}: { + title: string + body: string + children: ReactNode +}) { + return ( + + + {title} + + {body} + + + {children} + + ) +} + +function ScoreCell({ value, lead = false }: { value: string; lead?: boolean }) { + return ( + + + {value} + + + ) +} + +/** + * 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 ( + + + {present ? 'Included' : 'Not offered'} + + ) +} + +/* ─── 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. +

    +
    + +
    + {FAQS.map((f, i) => ( +
    + +

    + {f.q} +

    + + + +
    +
    +
    +

    {f.a}

    +
    +
    + ))} +
    +
    +
    + ) +} + +/* ─── Closing CTA ───────────────────────────────────────────────────────── */ + +function ClosingCta() { + return ( +
    +
    + +
    + ) +} + +function GitHubIcon({ className }: { className?: string }) { + return ( + + ) +} 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..7b3b703 100644 --- a/tests/routes.test.tsx +++ b/tests/routes.test.tsx @@ -62,6 +62,16 @@ 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 404 for unknown routes', async () => { renderApp('/unknown-page') await waitFor(() => { From dd66124f45dff425703aa78c48cd248dd1ea623d Mon Sep 17 00:00:00 2001 From: jy7lsna Date: Wed, 5 Aug 2026 19:53:11 +0530 Subject: [PATCH 2/7] Add /use-cases/grounded-shop-assistant page Grounded product advisor + ops assistant use case: cited answers from retrieved evidence, citation validation against retrieved evidence, and ungrounded questions routed to a coverage-gap episode the content team resolves. - New GroundedShopAssistantPage with hero, why/how sections, ops console mockup, subjects table, API reference, and run instructions - Wires the route into App.tsx and seo-meta.ts - Registers the page in the use-case switcher/registry (use-case-pages.ts) - Adds the card to the UseCasesPage grid --- src/App.tsx | 2 + src/lib/seo-meta.ts | 11 + src/lib/use-case-pages.ts | 5 + src/pages/GroundedShopAssistantPage.tsx | 1227 +++++++++++++++++++++++ src/pages/UseCasesPage.tsx | 10 + 5 files changed, 1255 insertions(+) create mode 100644 src/pages/GroundedShopAssistantPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 04c98e2..fd77be2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ const UseCasesPage = lazy(() => import('./pages/UseCasesPage').then(m => ({ defa const MultiAgentMemoryPage = lazy(() => import('./pages/MultiAgentMemoryPage').then(m => ({ default: m.MultiAgentMemoryPage }))) const PersonalAssistantMemoryPage = lazy(() => import('./pages/PersonalAssistantMemoryPage').then(m => ({ default: m.PersonalAssistantMemoryPage }))) const MultiAgentSharedContextPage = lazy(() => import('./pages/MultiAgentSharedContextPage').then(m => ({ default: m.MultiAgentSharedContextPage }))) +const GroundedShopAssistantPage = lazy(() => import('./pages/GroundedShopAssistantPage').then(m => ({ default: m.GroundedShopAssistantPage }))) const ConnectorsPage = lazy(() => import('./pages/ConnectorsPage').then(m => ({ default: m.ConnectorsPage }))) const DevelopersPage = lazy(() => import('./pages/DevelopersPage').then(m => ({ default: m.DevelopersPage }))) const CookiesPage = lazy(() => import('./pages/CookiesPage').then(m => ({ default: m.CookiesPage }))) @@ -36,6 +37,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/lib/seo-meta.ts b/src/lib/seo-meta.ts index 6d897e2..1183a3b 100644 --- a/src/lib/seo-meta.ts +++ b/src/lib/seo-meta.ts @@ -45,6 +45,7 @@ export type RouteKey = | '/use-cases/multi-agent-memory' | '/use-cases/personal-assistant-memory' | '/use-cases/multi-agent-shared-context' + | '/use-cases/grounded-shop-assistant' | '/connectors' | '/developers' | '/about' @@ -62,6 +63,7 @@ export const PUBLIC_ROUTES: readonly RouteKey[] = [ '/use-cases/multi-agent-memory', '/use-cases/personal-assistant-memory', '/use-cases/multi-agent-shared-context', + '/use-cases/grounded-shop-assistant', '/connectors', '/developers', '/about', @@ -153,6 +155,15 @@ 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', + }, '/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/GroundedShopAssistantPage.tsx b/src/pages/GroundedShopAssistantPage.tsx new file mode 100644 index 0000000..5f362fc --- /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/Infrasity-Labs/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 ( +
    +
    + ) +} + +function HeroChatVisual() { + return ( +
    + + ) +} + +function ProofStrip() { + const stats = [ + { value: '25', label: 'unit tests' }, + { value: '42', label: 'eval assertions' }, + { value: '18/20/22', label: 'Node CI matrix' }, + { value: '0', label: 'API keys to run' }, + ] + + return ( +
    +
    + {stats.map((stat, i) => ( +
    + {i > 0 &&
    + ))} + +
    + +

    + 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 ( +
    +
    + ) +} + +/* ─── Built so every claim is checkable ──────────────────────────────────── */ + +function QADemoVisual() { + return ( +
    +
    + + Q + +

    + Are your terracotta pots frost-proof? +

    +
    +
    + + A + +

    + I don't have grounded information on frost tolerance for that item. +

    +
    +
    + + grounded: false · no guess emitted +
    +
    + ) +} + +function CitationDemoVisual() { + const rows = [ + { id: 'S1', target: 'PLT-001', kept: true }, + { id: 'S2', target: 'FAQ-014', kept: true }, + { id: 'S9', target: 'unknown', kept: false }, + ] + + return ( +
    + {rows.map((row) => ( +
    + + {row.kept ? '✓' : '×'} + + {row.id} + → {row.target} + + {row.kept ? 'kept' : 'dropped'} + +
    + ))} +
    + ) +} + +function GapDemoVisual() { + return ( +
    +
    + OPEN + ops:coverage-gaps +
    +

    + "Which planters suit a rooftop weight limit?" +

    +
    + + sourceId: GAP-2381 +
    +
    + ) +} + +function AppendOnlyVisual() { + const episodes = [ + { label: 'Episode v1', tag: 'PLT-001', current: false }, + { label: 'Episode v2', tag: 'PLT-001', current: false }, + { label: 'Episode v3', tag: 'current', current: true }, + ] + + return ( +
    +
    + +
    + ) +} + +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 ( +
    +
    + + Built So Every{' '} + Claim Is Checkable + + + + Jump to the API → + +
    + +
    + {WHY_CARDS.map((card, index) => { + const Visual = card.Visual + return ( + + +
    + ) +} + +/* ─── One pipeline, Episode to citation ──────────────────────────────────── */ + +const PIPELINE_CHIPS = [ + { label: 'Episode' }, + { label: 'compileSubject' }, + { label: 'getContext' }, + { label: 'grounded completion', active: true }, + { label: 'resolveCitations' }, +] + +function PipelineChips() { + return ( +
    + {PIPELINE_CHIPS.map((chip, i) => ( + + {i > 0 && ( + + )} + + {chip.label} + + + ))} +
    + ) +} + +interface FlowItem { + title: string + body: string + accent?: boolean + strong?: boolean + code?: boolean +} + +function ArchitectureFlow() { + const columns: { heading: string; items: FlowItem[] }[] = [ + { + heading: 'Source content', + items: [ + { title: 'catalog.json', body: 'Product data' }, + { title: 'service-content.json', body: 'FAQs and care guides' }, + ], + }, + { + heading: 'Ingestion', + items: [{ title: 'ingest job', body: 'Dedups by content hash, appends Episodes', accent: true }], + }, + { + heading: 'StatewaveStore', + items: [{ title: 'Episodes → compileSubject', body: 'Newest Episode per sourceId, persisted to db.json', strong: true }], + }, + { + heading: 'Subjects → Assistants', + items: [ + { title: 'Shopper Assistant', body: 'POST /api/chat' }, + { title: 'Ops Assistant', body: 'POST /api/ops/chat' }, + { title: 'completionFn', body: 'LiteLLM · OpenRouter · offline', code: true }, + ], + }, + ] + + return ( +
    +
    + {columns.map((col, i) => ( + + {i > 0 && ( +
    + → +
    + )} +
    +

    {col.heading}

    + {col.items.map((item) => ( +
    +

    + {item.title} +

    +

    + {item.body} +

    +
    + ))} +
    +
    + ))} +
    + +
    + + + +

    + 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. +

    +
    +
    + ) +} + +const TURN_STEPS = [ + { n: 1, label: 'Shopper → Widget', detail: '"Do you have anything for full shade?"', mono: false }, + { n: 2, label: 'Widget → Route', detail: 'POST /api/chat { sessionId, message }', mono: true }, + { n: 3, label: 'Route → Store', detail: 'getContext(readSubjects, query) → evidence + IDs', mono: true }, + { n: 4, label: 'Route → completionFn', detail: 'system + evidence + history → { answer, grounded, citationIds }', mono: true, code: true }, + { n: 5, label: 'Route → Store', detail: "Drops IDs not in evidence, runs resolveCitations(), appends the turn", mono: false }, +] + +function TurnSequence() { + return ( +
    +
    + One chat turn, end to end{' '} +   "Do you have anything for full shade?" +
    + +
    + {TURN_STEPS.map((step) => ( +
    +
    + + {step.n} + + {step.label} +
    +
    + {step.detail} +
    +
    + ))} +
    +
    + ) +} + +function ArchitectureSection() { + return ( +
    +
    + ) +} + +/* ─── 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 ( +
    + + ) +} + +function OpsConsoleSection() { + return ( +
    +
    + +

    + Ops Console +

    + + + 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. +

    + +
      + {OPS_BULLETS.map((bullet, i) => ( +
    • + + {bullet.text} +
    • + ))} +
    +
    + + + + +
    +
    + ) +} + +/* ─── Subjects table ──────────────────────────────────────────────────────── */ + +const SUBJECTS = [ + { subject: 'shop:products', writtenBy: <>ingestion job (catalog.json), readBy: 'shopper + ops assistants', tone: 'accent' }, + { subject: 'faq:service', writtenBy: <>ingestion job (service-content.json), readBy: 'shopper + ops assistants', tone: 'accent' }, + { subject: 'content:guides', writtenBy: <>ingestion job (service-content.json, guide docs), readBy: 'shopper assistant', tone: 'accent' }, + { subject: 'ops:coverage-gaps', writtenBy: 'shopper route, on an ungrounded answer', readBy: <>ops assistant, /api/ops/gaps, tone: 'amber' }, + { subject: 'shop:conversations', writtenBy: 'shopper route, every turn', readBy: 'audit trail', tone: 'neutral' }, + { subject: 'ops:conversations', writtenBy: 'ops route, every turn', readBy: 'audit trail', tone: 'neutral' }, +] + +function SubjectsSection() { + return ( +
    +

    + Data Model +

    + + + Subjects + + +

    + The compiled read models both assistants query. Each is written by + exactly one path and read where it makes sense. +

    + +
    + + + + + + + + + + {SUBJECTS.map((row, i) => ( + + + + + + ))} + +
    SubjectWritten byRead by
    + + {row.subject} + + {row.writtenBy}{row.readBy}
    +
    +
    + ) +} + +/* ─── A chat turn on the wire ─────────────────────────────────────────────── */ + +const REQUEST_JSON = `{ + "sessionId": "optional, generated if omitted", + "message": "Do you have anything for full shade?", + "readSubjects": ["optional override of the default subjects"], + "retrievalConfig": { "globalMaxTokens": 2000 } +}` + +const RESPONSE_JSON = `{ + "answer": "...", + "grounded": true, + "citations": [ + { "evidenceId": "S1", "subject": "shop:products", + "sourceId": "PLT-001", "label": "Hosta 'Blue Mouse Ears'" } + ], + "warnings": [], + "evidenceCount": 4 +}` + +function ApiCodeBlock({ label, tag, code }: { label: string; tag: string; code: string }) { + return ( +
    +
    + + {tag} + + {label} +
    + +
    +
    +
    {code}
    +
    + ) +} + +const API_ENDPOINTS = [ + { method: 'POST', path: '/api/chat', desc: 'Shopper-facing grounded chat turn' }, + { method: 'POST', path: '/api/ops/chat', desc: 'Ops chat turn, reads coverage gaps too' }, + { method: 'GET', path: '/api/ops/gaps', desc: 'Open and resolved coverage gaps' }, + { method: 'POST', path: '/api/ops/gaps/:sourceId/resolve', desc: 'Marks a coverage gap resolved' }, + { method: 'GET', path: '/healthz', desc: 'Liveness; reports the active completion mode' }, +] + +function ApiSection() { + return ( +
    +

    + Developer API +

    + + + A Chat Turn on the Wire + + +
    + + +
    + +
    + + i + +

    + grounded is + only true when the + model both claims groundedness and at least one citation survives + validation against the retrieved evidence. +

    +
    + +
    + {API_ENDPOINTS.map((ep) => ( +
    + + {ep.method} + +
    +

    {ep.path}

    +

    {ep.desc}

    +
    +
    + ))} +
    +
    + ) +} + +/* ─── Run the whole loop in two commands ─────────────────────────────────── */ + +type OS = 'macos' | 'linux' | 'windows' + +const OS_META: Record = { + macos: { title: 'greenhaven zsh', note: '# Requires Node.js 18+', prompt: '$', label: 'macOS' }, + linux: { title: 'greenhaven bash', note: '# Requires Node.js 18+', prompt: '$', label: 'Linux' }, + windows: { title: 'greenhaven powershell', note: '# Requires Node.js 18+ (PowerShell)', prompt: 'PS>', label: 'Windows' }, +} + +function RunItTerminal() { + const [os, setOs] = useState('macos') + const meta = OS_META[os] + + return ( +
    +
    +
    + + + +
    + {meta.title} +
    + {(Object.keys(OS_META) as OS[]).map((key) => ( + + ))} +
    +
    + +
    +
    {meta.note}
    +
    {meta.prompt} npm install
    +
    {meta.prompt} npm run dev
    +
    +
    Storefront   http://localhost:4000/
    +
    Ops console  http://localhost:4000/ops.html
    +
    completion   offline-rule-based (no API key)
    +
    +
    + ) +} + +function RunItSection() { + return ( +
    +
    + +
    + ) +} + +/* ─── Closing CTA ─────────────────────────────────────────────────────────── */ + +function CTASection() { + return ( +
    +
    +
    + ) +} + +/* ─── Page ───────────────────────────────────────────────────────────────── */ + +export function GroundedShopAssistantPage() { + usePageSEO({ + breadcrumb: false, + jsonLd: [ + breadcrumbJsonLd([ + { name: 'Home', path: '/' }, + { name: 'Use Cases', path: '/use-cases' }, + { name: 'Grounded Shop Assistant', path: '/use-cases/grounded-shop-assistant' }, + ]), + ], + }) + + return ( +
    + + + + + + + + + +
    + ) +} 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) ───────────────────────────── */ From 21b315323998be49829930aa42bd62b41e5ce40f Mon Sep 17 00:00:00 2001 From: jy7lsna Date: Fri, 7 Aug 2026 15:11:22 +0530 Subject: [PATCH 3/7] Add /vs/mem0 comparison page --- src/App.tsx | 2 + src/components/Footer.tsx | 1 + src/index.css | 27 + src/lib/seo-meta.ts | 11 + src/pages/StatewaveVsMem0Page.tsx | 1039 +++++++++++++++++++++++++++++ tests/routes.test.tsx | 10 + 6 files changed, 1090 insertions(+) create mode 100644 src/pages/StatewaveVsMem0Page.tsx diff --git a/src/App.tsx b/src/App.tsx index 04c98e2..c6ed987 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ const UseCasesPage = lazy(() => import('./pages/UseCasesPage').then(m => ({ defa const MultiAgentMemoryPage = lazy(() => import('./pages/MultiAgentMemoryPage').then(m => ({ default: m.MultiAgentMemoryPage }))) const PersonalAssistantMemoryPage = lazy(() => import('./pages/PersonalAssistantMemoryPage').then(m => ({ default: m.PersonalAssistantMemoryPage }))) const MultiAgentSharedContextPage = lazy(() => import('./pages/MultiAgentSharedContextPage').then(m => ({ default: m.MultiAgentSharedContextPage }))) +const StatewaveVsMem0Page = lazy(() => import('./pages/StatewaveVsMem0Page').then(m => ({ default: m.StatewaveVsMem0Page }))) const ConnectorsPage = lazy(() => import('./pages/ConnectorsPage').then(m => ({ default: m.ConnectorsPage }))) const DevelopersPage = lazy(() => import('./pages/DevelopersPage').then(m => ({ default: m.DevelopersPage }))) const CookiesPage = lazy(() => import('./pages/CookiesPage').then(m => ({ default: m.CookiesPage }))) @@ -36,6 +37,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index ddfdcc9..38e8d0d 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -27,6 +27,7 @@ export function Footer() {
  • How it works
  • Why Statewave
  • Use Cases
  • +
  • vs Mem0
  • Connectors
  • + +
  • + + + + + + + + + +
    + + ) +} + +function ArrowIcon() { + return ( + + ) +} + +function GitHubIcon() { + return ( + + ) +} + +function HeroBundleVisual() { + return ( +
    + + ) +} + +function HeroStatStrip() { + const stats = [ + { value: '0.905', label: 'LoCoMo, n=1,540' }, + { value: '4', label: 'ranking signals, deterministic' }, + { value: '708', label: 'unit tests · 56 evals' }, + { value: '0', label: 'API keys to run' }, + ] + + return ( +
    +
    + {stats.map((stat, i) => ( +
    + {i > 0 &&
    + ))} +
    +
    + ) +} + +/* ─── 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. */} +
    + ) +} + +/* ─── How order is decided ───────────────────────────────────────────────── */ + +const SIGNALS = [ + { label: 'KIND PRIORITY', range: '3–10', note: 'typed profile facts outrank raw episodes' }, + { label: 'RECENCY', range: '0–5', note: 'linear by age, newest scores highest' }, + { label: 'TASK RELEVANCE', range: '0–8', note: 'lexical overlap plus cosine similarity' }, + { label: 'TEMPORAL VALIDITY', range: '−4…+3', note: 'valid facts gain +3, expired ones lose 4' }, +] + +function SignalsStrip() { + return ( +
    +
    +
    + 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: 'Built-in entity linking; graph is a ranking signal, not traversable', 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. */} +
    + {rows.map((row) => ( +
    +
    {row.cap}
    +
    {row.m0}
    +
    {row.sw}
    +
    + ))} +
    + +
    + {rows.map((row) => ( +
    +

    {row.cap}

    +
    +
    +
    +
    {row.m0}
    +
    +
    +
    +
    {row.sw}
    +
    +
    +
    + ))} +
    + + ) +} + +function ComparisonSection() { + return ( +
    +
    + + Where each capability lives + +

    + 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 replaced external graph databases with built-in entity linking in + its v3 release (April 2026); the graph now feeds relevance scoring + rather than being a store you query. Rows reflect each product's + public docs 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. +

    +
    + +
    + +
    +
    + ) +} + +/* ─── Worked example ─────────────────────────────────────────────────────── */ + +function CodePanel({ + badge, + title, + lines, + calloutTone, + callout, +}: { + badge: React.ReactNode + title: string + lines: React.ReactNode[] + calloutTone: 'muted' | 'accent' + callout: React.ReactNode +}) { + return ( +
    +
    + {badge} + {title} +
    +
    + {lines.map((line, i) =>
    {line}
    )} +
    +
    + {callout} +
    +
    + ) +} + +function WorkedExampleSection() { + return ( +
    + + One returning customer, two runtimes + + +

    + 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 ( +
    +
    From 5a5097ac0c8e30c9aa8d01e138c808ebe62a45c3 Mon Sep 17 00:00:00 2001 From: smaramwbc <145447586+smaramwbc@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:02:07 +0100 Subject: [PATCH 7/7] Point the shop-assistant page at the repo under the statewave org The reference build now lives at smaramwbc/statewave-grounded-shop-assistant (TypeScript). Link there instead of the original development location. --- src/pages/GroundedShopAssistantPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/GroundedShopAssistantPage.tsx b/src/pages/GroundedShopAssistantPage.tsx index 5f362fc..864e36f 100644 --- a/src/pages/GroundedShopAssistantPage.tsx +++ b/src/pages/GroundedShopAssistantPage.tsx @@ -8,7 +8,7 @@ import { UseCaseSwitcher } from '../components/UseCaseSwitcher' import { usePageSEO } from '../lib/seo' import { breadcrumbJsonLd } from '../lib/seo-meta' -const REPO_URL = 'https://github.com/Infrasity-Labs/statewave-grounded-shop-assistant' +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